diff --git a/src/stores.ts b/src/stores.ts index 0843ea8..4006a2b 100644 --- a/src/stores.ts +++ b/src/stores.ts @@ -384,11 +384,12 @@ function parseExemptionRecord(value: unknown): ExemptionRecord { throw new Error('invalid exemption record'); } - const { advertiserHost, policyIds, networkIds, grantedAt } = value; + const { advertiserHost, policyIds, networkIds, grantedAt, expiresAt } = value; if ( typeof advertiserHost !== 'string' || typeof grantedAt !== 'number' || + (expiresAt !== undefined && typeof expiresAt !== 'number') || !Array.isArray(policyIds) || !policyIds.every((id) => typeof id === 'string') || !Array.isArray(networkIds) || @@ -397,12 +398,18 @@ function parseExemptionRecord(value: unknown): ExemptionRecord { throw new Error('invalid exemption record'); } - return { + const record: ExemptionRecord = { advertiserHost, policyIds: [...policyIds], networkIds: [...networkIds], grantedAt, }; + + if (expiresAt !== undefined) { + record.expiresAt = expiresAt; + } + + return record; } function parseSessionRecord(value: unknown): SessionRecord { diff --git a/tests/session.test.ts b/tests/session.test.ts index 7099f47..29b8738 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -9,6 +9,7 @@ import { validatePolicy, } from '../src'; import { cjPolicy } from '../src/policies'; +import { SessionStorageStateStore, type WebStorageLike } from '../src/stores'; const behaviors = [ 'suppress-prompts', @@ -404,6 +405,40 @@ describe('StanddownSession', () => { ).resolves.toMatchObject({ standDown: false }); }); + it('bounds a session self-exemption by the default TTL across a persisted reload', async () => { + const storage = new FakeWebStorage(); + const selfPatterns = [ + { name: 'cjevent', value: 'own', match: 'equals' as const, policyId: 'cj' }, + ]; + + const first = new StanddownSession(new SessionStorageStateStore(storage), { + selfExemptionScope: 'session', + }); + await first.ingest( + { url: 'https://merchant.example/?cjevent=own', now: 0, selfPatterns }, + [cjPolicy], + ); + + // A fresh session over the same persisted state: the exemption is reloaded + // through the store's parser rather than held in memory. + const revived = new StanddownSession(new SessionStorageStateStore(storage), { + selfExemptionScope: 'session', + }); + + // Past the 30-minute default TTL the exemption has lapsed, so the lingering + // cookie stands down exactly as it does without persistence. + await expect( + revived.ingest( + { + url: 'https://merchant.example/checkout', + now: 1_800_001, + cookieNames: ['cjevent_dc'], + }, + [cjPolicy], + ), + ).resolves.toMatchObject({ standDown: true, policyId: 'cj' }); + }); + it('validates the test policy fixture', () => { expect(() => validatePolicy( @@ -607,3 +642,19 @@ function testPolicy(standdown: { function clonePolicy(policy: StanddownPolicy): StanddownPolicy { return JSON.parse(JSON.stringify(policy)) as StanddownPolicy; } + +class FakeWebStorage implements WebStorageLike { + readonly #items = new Map(); + + getItem(key: string): string | null { + return this.#items.get(key) ?? null; + } + + setItem(key: string, value: string): void { + this.#items.set(key, value); + } + + removeItem(key: string): void { + this.#items.delete(key); + } +} diff --git a/tests/stores.test.ts b/tests/stores.test.ts index e148548..616c85d 100644 --- a/tests/stores.test.ts +++ b/tests/stores.test.ts @@ -189,6 +189,32 @@ describe('StateStore implementations', () => { ]); }); + it('round-trips a session exemption expiresAt through persistence', async () => { + const local = new FakeChromeStorageArea(); + await new ChromeLocalStateStore(local, { + sessionId: 'browser-session-1', + now: () => 0, + }).save({ + ...stateWithSessionAndInactivityRecords(), + exemptions: { + 'session.example': { + advertiserHost: 'session.example', + policyIds: ['alfa'], + networkIds: ['alfa'], + grantedAt: 0, + expiresAt: 1_800_000, + }, + }, + }); + + const loaded = await new ChromeLocalStateStore(local, { + sessionId: 'browser-session-1', + now: () => 1_000, + }).load(); + + expect(loaded?.exemptions?.['session.example']?.expiresAt).toBe(1_800_000); + }); + it('drops session exemptions when the browser session changes', async () => { const local = new FakeChromeStorageArea(); await new ChromeLocalStateStore(local, {