Closes the three remaining items from the storage audit backlog:
- entries(): introspection snapshot of every live entry with
{ key, fullKey, adapter }. Useful for devtools panels and audit logs.
Read-only metadata — no entry handles exposed.
- registeredDefaults refcount: the conflict registry now tracks how many
live entries point at each (adapter, fullKey) and forgets the slot when
the count drops to zero. Re-creating a key with a different default
after the previous owner cleaned up no longer emits a stale warning.
When at least one owner remains, the diagnostic still fires.
- Client cookieAdapter() coverage: 8 tests with a mocked `document` (no
jsdom dependency) covering setItem/getItem/removeItem, encode/decode
defaults, custom encoder pair, Set-Cookie attribute building (path,
samesite, secure, max-age, domain), sameSite=none auto-secure and
multi-cookie parsing.
Cookie polling for onChange remains intentionally out of scope —
documented in README under "Cross-tab sync".
746 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
master
parent
585906e170
commit
7dfa530a47
@ -0,0 +1,186 @@
|
||||
/**
|
||||
* cookieAdapter() — client-side tests with mocked `document`.
|
||||
*
|
||||
* The default Vitest project is node, so `document` is undefined and the
|
||||
* adapter no-ops on every call. We install a tiny stub that mirrors the
|
||||
* subset of `document.cookie` semantics the adapter touches: a single
|
||||
* string holding a serialized cookie jar, plus a setter that merges new
|
||||
* declarations.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SyncStorageAdapter } from '../types';
|
||||
|
||||
interface CookieJarStub {
|
||||
get cookie(): string;
|
||||
set cookie(value: string);
|
||||
}
|
||||
|
||||
function installFakeDocument(): {
|
||||
doc: CookieJarStub;
|
||||
jar: Map<string, string>;
|
||||
restore: () => void;
|
||||
} {
|
||||
const jar = new Map<string, string>();
|
||||
|
||||
const doc: CookieJarStub = {
|
||||
get cookie() {
|
||||
return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
},
|
||||
set cookie(value: string) {
|
||||
// Browsers parse the first `name=value`; remaining segments are
|
||||
// attributes. Honor `max-age=0` as deletion.
|
||||
const head = value.split(';')[0].trim();
|
||||
const eq = head.indexOf('=');
|
||||
if (eq < 0) return;
|
||||
const name = head.slice(0, eq);
|
||||
const val = head.slice(eq + 1);
|
||||
const isDeletion = /max-age\s*=\s*0\b/i.test(value);
|
||||
if (isDeletion) {
|
||||
jar.delete(name);
|
||||
return;
|
||||
}
|
||||
jar.set(name, val);
|
||||
}
|
||||
};
|
||||
|
||||
const original = (globalThis as { document?: unknown }).document;
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: doc,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
|
||||
return {
|
||||
doc,
|
||||
jar,
|
||||
restore: () => {
|
||||
if (original === undefined) {
|
||||
delete (globalThis as { document?: unknown }).document;
|
||||
} else {
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: original,
|
||||
writable: true,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let env: ReturnType<typeof installFakeDocument>;
|
||||
let cookieAdapter: typeof import('../adapters/cookie').cookieAdapter;
|
||||
|
||||
beforeEach(async () => {
|
||||
env = installFakeDocument();
|
||||
// Re-import so the module-level `isBrowser` flag re-evaluates against the
|
||||
// freshly installed `document`. Vitest caches modules per test file by
|
||||
// default, so `vi.resetModules()` is required.
|
||||
vi.resetModules();
|
||||
cookieAdapter = (await import('../adapters/cookie')).cookieAdapter;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
env.restore();
|
||||
});
|
||||
|
||||
describe('cookieAdapter() — client', () => {
|
||||
it('writes and reads back a value via document.cookie', () => {
|
||||
const adapter: SyncStorageAdapter = cookieAdapter();
|
||||
adapter.setItem('theme', 'forest');
|
||||
expect(env.jar.get('theme')).toBe('forest');
|
||||
expect(adapter.getItem('theme')).toBe('forest');
|
||||
});
|
||||
|
||||
it('returns null for missing keys', () => {
|
||||
const adapter = cookieAdapter();
|
||||
expect(adapter.getItem('missing')).toBeNull();
|
||||
});
|
||||
|
||||
it('encodes special characters by default (encodeURIComponent)', () => {
|
||||
const adapter = cookieAdapter();
|
||||
adapter.setItem('user name', 'Ada Lovelace');
|
||||
// The jar stores the encoded form.
|
||||
expect(env.jar.get('user%20name')).toBe('Ada%20Lovelace');
|
||||
// Reading via the adapter returns the decoded form.
|
||||
expect(adapter.getItem('user name')).toBe('Ada Lovelace');
|
||||
});
|
||||
|
||||
it('honors a custom encode/decode pair (identity)', () => {
|
||||
const adapter = cookieAdapter({ encode: (s) => s, decode: (s) => s });
|
||||
adapter.setItem('locale', 'es-MX');
|
||||
expect(env.jar.get('locale')).toBe('es-MX');
|
||||
expect(adapter.getItem('locale')).toBe('es-MX');
|
||||
});
|
||||
|
||||
it('removeItem deletes the cookie via max-age=0', () => {
|
||||
const adapter = cookieAdapter();
|
||||
adapter.setItem('theme', 'forest');
|
||||
expect(env.jar.has('theme')).toBe(true);
|
||||
adapter.removeItem('theme');
|
||||
expect(env.jar.has('theme')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds Set-Cookie attributes (path, samesite, secure, max-age)', () => {
|
||||
// Spy on the setter to capture the full Set-Cookie string.
|
||||
const writes: string[] = [];
|
||||
Object.defineProperty(env.doc, 'cookie', {
|
||||
get() {
|
||||
return [...env.jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
},
|
||||
set(value: string) {
|
||||
writes.push(value);
|
||||
const head = value.split(';')[0].trim();
|
||||
const eq = head.indexOf('=');
|
||||
if (eq >= 0) env.jar.set(head.slice(0, eq), head.slice(eq + 1));
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
|
||||
const adapter = cookieAdapter({
|
||||
path: '/admin',
|
||||
sameSite: 'strict',
|
||||
secure: true,
|
||||
maxAge: 3600,
|
||||
domain: 'example.com'
|
||||
});
|
||||
adapter.setItem('theme', 'forest');
|
||||
|
||||
expect(writes).toHaveLength(1);
|
||||
const written = writes[0].toLowerCase();
|
||||
expect(written).toContain('path=/admin');
|
||||
expect(written).toContain('samesite=strict');
|
||||
expect(written).toContain('secure');
|
||||
expect(written).toContain('max-age=3600');
|
||||
expect(written).toContain('domain=example.com');
|
||||
});
|
||||
|
||||
it('forces secure when sameSite is none', () => {
|
||||
const writes: string[] = [];
|
||||
Object.defineProperty(env.doc, 'cookie', {
|
||||
get() {
|
||||
return [...env.jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
|
||||
},
|
||||
set(value: string) {
|
||||
writes.push(value);
|
||||
},
|
||||
configurable: true
|
||||
});
|
||||
|
||||
const adapter = cookieAdapter({ sameSite: 'none' });
|
||||
adapter.setItem('cross', 'yes');
|
||||
|
||||
expect(writes[0].toLowerCase()).toContain('secure');
|
||||
});
|
||||
|
||||
it('parses multi-cookie strings without bleeding values', () => {
|
||||
env.jar.set('theme', 'forest');
|
||||
env.jar.set('locale', 'es');
|
||||
env.jar.set('cart-size', '7');
|
||||
|
||||
const adapter = cookieAdapter();
|
||||
expect(adapter.getItem('theme')).toBe('forest');
|
||||
expect(adapter.getItem('locale')).toBe('es');
|
||||
expect(adapter.getItem('cart-size')).toBe('7');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in new issue