1254 lines
51 KiB
TypeScript
1254 lines
51 KiB
TypeScript
import { request as httpRequest } from 'node:http';
|
||
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
||
import { BrowserKVStore } from '../src/kv/browser.js';
|
||
import type { DeviceSafeKVStore, KVStore, LocalKVStore } from '../src/kv/types.js';
|
||
import { KVScopeViolationError } from '../src/kv/types.js';
|
||
import { kvPersistStorage } from '../src/zustand/persist.js';
|
||
import { HttpAccountKV, HttpKVStore, HttpKVStoreError } from '../src/kv/http.js';
|
||
import { runKVStoreContract } from './kv-contract.js';
|
||
import { MemoryStorage } from './setup.js';
|
||
import { startKvConformanceServer, type KvConformanceServer } from './kv-conformance-server.js';
|
||
|
||
let server: KvConformanceServer;
|
||
let namespace = 0;
|
||
|
||
/**
|
||
* Send a raw HTTP request, so a body can ride on a bodyless method — something
|
||
* `fetch` refuses to construct but a real HTTP client (or an attacker) can put
|
||
* on the wire, and exactly the shape the server must defend against.
|
||
*/
|
||
function rawRequest(
|
||
baseUrl: string,
|
||
options: { method: string; path: string; headers: Record<string, string>; body?: string },
|
||
): Promise<{ status: number; json: unknown }> {
|
||
const url = new URL(baseUrl);
|
||
// An explicit Content-Length (not chunked framing) so the server flushes its
|
||
// full response body back on a bodyless-method rejection.
|
||
const headers =
|
||
options.body === undefined
|
||
? options.headers
|
||
: { ...options.headers, 'content-length': String(Buffer.byteLength(options.body)) };
|
||
return new Promise((resolve, reject) => {
|
||
const req = httpRequest(
|
||
{
|
||
hostname: url.hostname,
|
||
port: url.port,
|
||
method: options.method,
|
||
path: options.path,
|
||
headers,
|
||
},
|
||
(res) => {
|
||
const chunks: Buffer[] = [];
|
||
res.on('data', (chunk: Buffer) => chunks.push(chunk));
|
||
res.on('end', () => {
|
||
const text = Buffer.concat(chunks).toString('utf8');
|
||
resolve({
|
||
status: res.statusCode ?? 0,
|
||
json: text === '' ? undefined : (JSON.parse(text) as unknown),
|
||
});
|
||
});
|
||
},
|
||
);
|
||
req.on('error', reject);
|
||
if (options.body !== undefined) req.write(options.body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Bind a real loopback conformance server, run `fn` against it, and always close
|
||
* it — skipping the test where the sandbox forbids binding a listener. Dedups the
|
||
* bind / EPERM-skip / close lifecycle the network-backed tests all repeat.
|
||
*/
|
||
async function withNetworkServer(
|
||
skip: (reason: string) => void,
|
||
fn: (server: KvConformanceServer) => Promise<void>,
|
||
): Promise<void> {
|
||
let server: KvConformanceServer;
|
||
try {
|
||
server = await startKvConformanceServer();
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
|
||
skip('sandbox does not permit binding a 127.0.0.1 listener');
|
||
return;
|
||
}
|
||
throw error;
|
||
}
|
||
try {
|
||
await fn(server);
|
||
} finally {
|
||
await server.close();
|
||
}
|
||
}
|
||
|
||
function makeStore(storageNamespace = `kv-${namespace++}`): HttpKVStore {
|
||
return new HttpKVStore({
|
||
baseUrl: server.baseUrl,
|
||
fetch: server.fetch,
|
||
headers: () => ({ 'x-storage-namespace': storageNamespace }),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
server = await startKvConformanceServer({ listen: false });
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await server.close();
|
||
});
|
||
|
||
runKVStoreContract('HTTP (account) + local (device)', () => makeStore());
|
||
|
||
describe('HttpKVStore device-scope invariant', () => {
|
||
test('a device scope is a type error at a direct call on the transport', () => {
|
||
const account = new HttpAccountKV({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () => {
|
||
throw new Error('fetch must not be called');
|
||
},
|
||
});
|
||
|
||
// Never invoked — these exist to be type-checked. The scope parameter is
|
||
// declared as `'account'` precisely so a literal `'device'` cannot be
|
||
// written here; if that narrowing is ever widened the directives go unused
|
||
// and `tsc` fails the build, so the guard cannot rot silently. It only
|
||
// covers *direct* calls, which is why the runtime refusal below exists too.
|
||
const deviceCalls = (): unknown[] => [
|
||
// @ts-expect-error this transport serves the account scope only.
|
||
account.set('theme', 'dark', 'device'),
|
||
// @ts-expect-error this transport serves the account scope only.
|
||
account.get('theme', 'device'),
|
||
// @ts-expect-error this transport serves the account scope only.
|
||
account.remove('theme', 'device'),
|
||
// @ts-expect-error this transport serves the account scope only.
|
||
account.keys('ui:', 'device'),
|
||
// An options-shaped scope is no more expressible than a positional one:
|
||
// the parameter is `'account'`, not an object with a scope in it.
|
||
// @ts-expect-error there is no options form that carries a scope.
|
||
account.set('theme', 'dark', { scope: 'device' }),
|
||
];
|
||
|
||
expect(typeof deviceCalls).toBe('function');
|
||
});
|
||
|
||
test('an HttpKVStore cannot be built without somewhere local to keep device values', () => {
|
||
const withoutDeviceStore = (): HttpKVStore =>
|
||
// @ts-expect-error `deviceStore` is required: there is no default, and no
|
||
// configuration in which device values fall through to the server.
|
||
new HttpKVStore({ baseUrl: 'https://kv.invalid' });
|
||
|
||
expect(typeof withoutDeviceStore).toBe('function');
|
||
});
|
||
|
||
// The transport's missing scope parameter only protects the *positional*
|
||
// call. What actually keeps device values off the wire is that a networked
|
||
// store cannot be the deviceStore at all — and that has to be checked, because
|
||
// `HttpAccountKV` satisfies `KVStore` structurally (its methods are the same
|
||
// methods minus an optional parameter), so before the `LocalKVStore` brand it
|
||
// could be injected as the device backend with zero type errors.
|
||
test('a networked store cannot be injected as the device backend', () => {
|
||
const remote = new HttpAccountKV({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () => new Response(null, { status: 204 }),
|
||
});
|
||
|
||
const injectTransport = (): HttpKVStore =>
|
||
new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
// @ts-expect-error a remote transport is not a LocalKVStore.
|
||
deviceStore: remote,
|
||
});
|
||
// Assignability to the *bare* `KVStore` is what made this reachable, so the
|
||
// probe goes through a `KVStore` annotation as well.
|
||
const asPlainKvStore: KVStore = remote;
|
||
const injectViaKVStore = (): HttpKVStore =>
|
||
new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
// @ts-expect-error `KVStore` is not `LocalKVStore` either.
|
||
deviceStore: asPlainKvStore,
|
||
});
|
||
|
||
// And the cast that would erase both type errors is refused at runtime.
|
||
expect(injectTransport).toThrow(/must be a local KVStore/);
|
||
expect(injectViaKVStore).toThrow(/must be a local KVStore/);
|
||
expect(
|
||
() =>
|
||
new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
deviceStore: remote as unknown as LocalKVStore,
|
||
}),
|
||
).toThrow(/must be a local KVStore/);
|
||
});
|
||
|
||
test('an HttpKVStore cannot be nested as its own device backend', () => {
|
||
const outer = makeStore();
|
||
|
||
expect(
|
||
() =>
|
||
new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () => new Response(null, { status: 204 }),
|
||
deviceStore: outer as unknown as LocalKVStore,
|
||
}),
|
||
).toThrow(/must be a local KVStore/);
|
||
});
|
||
|
||
// The transport is publicly exported and structurally a `KVStore`, so its
|
||
// missing scope parameter was never "inexpressible" — the extra argument was
|
||
// accepted by the language and dropped on the floor. Both sequences below
|
||
// compiled without a cast and put a device value on the wire.
|
||
test('the transport refuses a device scope handed to it through a KVStore reference', async () => {
|
||
const sent: string[] = [];
|
||
const account = new HttpAccountKV({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input, init) => {
|
||
sent.push(`${String(input)} ${String(init?.body ?? '')}`);
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
});
|
||
// Assignable, because TypeScript compares method parameters bivariantly.
|
||
const asKvStore: KVStore = account;
|
||
|
||
await expect(asKvStore.set('theme', 'DEVICE-ONLY', 'device')).rejects.toBeInstanceOf(
|
||
KVScopeViolationError,
|
||
);
|
||
await expect(asKvStore.get('theme', 'device')).rejects.toThrow(/account scope only/);
|
||
await expect(asKvStore.remove('theme', 'device')).rejects.toThrow(/account scope only/);
|
||
await expect(asKvStore.keys('ui:', 'device')).rejects.toThrow(/account scope only/);
|
||
|
||
expect(sent).toEqual([]);
|
||
});
|
||
|
||
test('the transport still serves an explicitly account-scoped call', async () => {
|
||
const paths: string[] = [];
|
||
const account = new HttpAccountKV({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input) => {
|
||
// The raw request string the client built — not `new URL(...).pathname`,
|
||
// which normalizes the `..` away and hides what the client emitted.
|
||
paths.push(String(input).replace('https://kv.invalid', ''));
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
});
|
||
|
||
await account.set('k', 'v', 'account');
|
||
await account.remove('k', 'account');
|
||
expect(paths).toEqual(['/kv/entries/k', '/kv/entries/k']);
|
||
});
|
||
|
||
test('the persist adapter refuses to pair a device scope with a pure account transport', () => {
|
||
const sent: string[] = [];
|
||
const account = new HttpAccountKV({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input, init) => {
|
||
sent.push(`${String(input)} ${String(init?.body ?? '')}`);
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
});
|
||
|
||
// The documented persist wiring, and the shortest path to a device value on
|
||
// the wire: the adapter takes the store and the scope as separate arguments,
|
||
// so nothing else was checking that they belong together. HttpAccountKV has
|
||
// no local device backend, so a device value handed to it goes to the server
|
||
// — it must stay rejected even though the device-safe composite is now allowed.
|
||
const pairing = (): unknown =>
|
||
// @ts-expect-error the `device` overload requires a DeviceSafeKVStore.
|
||
kvPersistStorage(account, 'device');
|
||
expect(pairing).toThrow(/servesDeviceScopeLocally/);
|
||
|
||
// And the cast that erases the type error is refused at runtime.
|
||
expect(() => kvPersistStorage(account as unknown as DeviceSafeKVStore, 'device')).toThrow(
|
||
KVScopeViolationError,
|
||
);
|
||
expect(sent).toEqual([]);
|
||
});
|
||
|
||
// cosarah's finding: a full HttpKVStore routes `device` to its required local
|
||
// backend and never puts it on the wire, so it is safe to persist device-scoped
|
||
// state through — the earlier guard wrongly conflated "fully local" with "safe
|
||
// for device" and rejected this legitimate composite case.
|
||
test('the persist adapter accepts a device-routing composite for the device scope', async () => {
|
||
const deviceBacking = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
let fetchCalls = 0;
|
||
const composite = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () => {
|
||
fetchCalls += 1;
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
deviceStore: deviceBacking,
|
||
});
|
||
|
||
const deviceStorage = kvPersistStorage<{ theme: string }>(composite, 'device');
|
||
await deviceStorage.setItem('settings-storage', { state: { theme: 'dark' } });
|
||
expect(await deviceStorage.getItem('settings-storage')).toEqual({ state: { theme: 'dark' } });
|
||
await deviceStorage.removeItem('settings-storage');
|
||
expect(await deviceStorage.getItem('settings-storage')).toBeNull();
|
||
|
||
// The value reached the injected device backend directly...
|
||
await deviceStorage.setItem('settings-storage', { state: { theme: 'light' } });
|
||
expect(await deviceBacking.get('settings-storage', 'device')).toEqual({
|
||
state: { theme: 'light' },
|
||
});
|
||
// ...and it is device-scoped there, not account-scoped.
|
||
expect(await deviceBacking.get('settings-storage', 'account')).toBeNull();
|
||
// ...and no HTTP request was ever made.
|
||
expect(fetchCalls).toBe(0);
|
||
});
|
||
|
||
test('the persist adapter still accepts the legitimate pairings', async () => {
|
||
const local = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
const deviceStorage = kvPersistStorage<{ theme: string }>(local, 'device');
|
||
await deviceStorage.setItem('settings-storage', { state: { theme: 'dark' } });
|
||
expect(await deviceStorage.getItem('settings-storage')).toEqual({ state: { theme: 'dark' } });
|
||
|
||
// A remote store with the account scope is exactly what the adapter is for.
|
||
expect(() => kvPersistStorage(makeStore(), 'account')).not.toThrow();
|
||
expect(() => kvPersistStorage(makeStore())).not.toThrow();
|
||
});
|
||
|
||
test('a hand-rolled store lying about being local is the only way through', async () => {
|
||
// Documented, not endorsed: the brand stops accidents and casts, not an
|
||
// author who writes the lie out. The point of the assertion is that the lie
|
||
// has to be *authored* — nothing in the package hands it to you.
|
||
const sent: string[] = [];
|
||
const liar = {
|
||
isLocalKVStore: true as const,
|
||
servesDeviceScopeLocally: true as const,
|
||
get: async () => null,
|
||
set: async (key: string, value: unknown) => {
|
||
sent.push(`${key}=${JSON.stringify(value)}`);
|
||
},
|
||
remove: async () => undefined,
|
||
keys: async () => [],
|
||
} satisfies LocalKVStore;
|
||
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () => {
|
||
throw new Error('fetch must not be called');
|
||
},
|
||
deviceStore: liar,
|
||
});
|
||
await store.set('theme', 'dark', 'device');
|
||
|
||
expect(sent).toEqual(['theme="dark"']);
|
||
});
|
||
|
||
test('device reads and writes issue no request at all', async () => {
|
||
const requests: string[] = [];
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input, init) => {
|
||
requests.push(`${init?.method ?? 'GET'} ${String(input)} ${String(init?.body ?? '')}`);
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await store.set('theme', { mode: 'dark' }, 'device');
|
||
await store.set('layout', 'wide', 'device');
|
||
expect(await store.get('theme', 'device')).toEqual({ mode: 'dark' });
|
||
expect(await store.keys('', 'device')).toEqual(['theme', 'layout']);
|
||
await store.remove('theme', 'device');
|
||
expect(await store.get('theme', 'device')).toBeNull();
|
||
|
||
expect(requests).toEqual([]);
|
||
});
|
||
|
||
test('a device value never appears in traffic, even alongside account traffic', async () => {
|
||
const traffic: string[] = [];
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input, init) => {
|
||
traffic.push(
|
||
[
|
||
init?.method ?? 'GET',
|
||
String(input),
|
||
JSON.stringify(init?.headers ?? {}),
|
||
String(init?.body ?? ''),
|
||
].join(' '),
|
||
);
|
||
const body = String(input).includes('/kv/keys') ? [] : { value: null };
|
||
return new Response(JSON.stringify(body), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
});
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
const secret = 'device-only-secret-value';
|
||
await store.set('shared-key', secret, 'device');
|
||
await store.set('shared-key', 'account-value', 'account');
|
||
await store.get('shared-key');
|
||
await store.keys();
|
||
await store.remove('shared-key');
|
||
|
||
expect(traffic.length).toBeGreaterThan(0);
|
||
for (const line of traffic) {
|
||
expect(line).not.toContain(secret);
|
||
expect(line).not.toContain('device');
|
||
}
|
||
});
|
||
|
||
test('the account contract exposes no scope on the wire', async () => {
|
||
const paths: string[] = [];
|
||
const account = new HttpAccountKV({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input) => {
|
||
paths.push(new URL(String(input)).pathname + new URL(String(input)).search);
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
});
|
||
|
||
await account.set('k', 'v');
|
||
await account.remove('k');
|
||
expect(paths).toEqual(['/kv/entries/k', '/kv/entries/k']);
|
||
});
|
||
});
|
||
|
||
// Coverage matrix — every operation × every channel a scope could hide in must
|
||
// be closed. A gap here is not academic: the reason this matrix exists is that
|
||
// the bodyless GET routes once ignored their body, so `GET /kv/keys` with a
|
||
// `{"scope":"device"}` body returned 200 [] — one uncovered cell shipping the
|
||
// exact silent-discard the contract forbids.
|
||
const SCOPE_OPERATIONS = [
|
||
{ name: 'get', method: 'GET', entryPath: true, writesValue: false },
|
||
{ name: 'set', method: 'PUT', entryPath: true, writesValue: true },
|
||
{ name: 'remove', method: 'DELETE', entryPath: true, writesValue: false },
|
||
{ name: 'keys', method: 'GET', entryPath: false, writesValue: false },
|
||
] as const;
|
||
|
||
// Every header spelling that would carry a scope. Enumerated in the test so a
|
||
// gap in the server's set (which once had only `x-scope`) shows up as a failing
|
||
// cell rather than a silently-accepted scope.
|
||
const SCOPE_HEADER_SPELLINGS = ['scope', 'x-scope', 'kv-scope', 'x-kv-scope'] as const;
|
||
|
||
describe('scope-rejection matrix: {get,set,remove,keys} × {path,query,header spellings}', () => {
|
||
// Every non-body channel a scope could hide in, run in-process against the
|
||
// injected server. The body channel needs a client that can put a body on a
|
||
// bodyless method, which `fetch` refuses to construct — that cell is covered by
|
||
// the raw-request matrix below.
|
||
const channels = ['path', 'query', ...SCOPE_HEADER_SPELLINGS] as const;
|
||
const cells = SCOPE_OPERATIONS.flatMap((op) =>
|
||
channels.map((channel) => [`${op.name} via ${channel}`, op, channel] as const),
|
||
);
|
||
|
||
test.each(cells)('rejects a device scope on %s', async (_title, op, channel) => {
|
||
const ns = `scope-matrix-${namespace++}`;
|
||
const entryTail = op.entryPath ? '/entries/mk' : '/keys';
|
||
// The scope-path-segment cell injects a scope as the segment after `kv`
|
||
// (`/kv/device/…`); the others keep the contract path and hide the scope in
|
||
// the query or a header.
|
||
let path =
|
||
channel === 'path'
|
||
? `${server.baseUrl}/kv/device${entryTail}`
|
||
: `${server.baseUrl}/kv${entryTail}`;
|
||
const headers: Record<string, string> = { 'x-storage-namespace': ns };
|
||
if (channel === 'query') path += '?scope=device';
|
||
if ((SCOPE_HEADER_SPELLINGS as readonly string[]).includes(channel))
|
||
headers[channel] = 'device';
|
||
const init: RequestInit = { method: op.method, headers };
|
||
if (op.writesValue) {
|
||
headers['content-type'] = 'application/json';
|
||
init.body = JSON.stringify({ value: 1 });
|
||
}
|
||
|
||
const response = await server.fetch(path, init);
|
||
expect(response.status, `${op.name} via ${channel}`).toBe(400);
|
||
await expect(response.json()).resolves.toMatchObject({
|
||
error: { code: 'VALIDATION_FAILED', message: expect.stringContaining('scope') },
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('scope-rejection matrix: {get,set,remove,keys} × body', () => {
|
||
// The body channel, exercised by a raw client — precisely the non-conforming
|
||
// client the finding described, since `fetch` cannot put a body on a GET. For
|
||
// the bodyless methods the whole body is refused (that is where a scope would
|
||
// hide); for `set`, which legitimately carries a body, a `scope` field in it
|
||
// is refused. One test per operation (not `test.each`) so each can consult the
|
||
// per-test `skip` for sandboxes that cannot bind a loopback listener.
|
||
for (const op of SCOPE_OPERATIONS) {
|
||
test(`rejects a scope in the body of ${op.name}`, async ({ skip }) =>
|
||
withNetworkServer(skip, async (networkServer) => {
|
||
const body = op.writesValue
|
||
? JSON.stringify({ value: 1, scope: 'device' })
|
||
: JSON.stringify({ scope: 'device' });
|
||
const { status, json } = await rawRequest(networkServer.baseUrl, {
|
||
method: op.method,
|
||
path: op.entryPath ? '/kv/entries/mk' : '/kv/keys',
|
||
headers: { 'content-type': 'application/json', 'x-storage-namespace': 'scope-raw' },
|
||
body,
|
||
});
|
||
|
||
expect(status, op.name).toBe(400);
|
||
expect(json).toMatchObject({ error: { code: 'VALIDATION_FAILED' } });
|
||
}));
|
||
}
|
||
});
|
||
|
||
describe('HttpKVStore opaque keys and transport limits', () => {
|
||
test('round-trips an empty key through the real server', async () => {
|
||
// The key domain is opaque, so an empty key is legitimate. It rides as a
|
||
// trailing-slash path segment and the server keeps it as the empty map key.
|
||
const remote = makeStore(`kv-empty-${namespace++}`);
|
||
await remote.set('', { ok: 1 });
|
||
expect(await remote.get('')).toEqual({ ok: 1 });
|
||
await remote.remove('');
|
||
expect(await remote.get('')).toBeNull();
|
||
});
|
||
|
||
test('round-trips a NUL-containing key through the real server', async () => {
|
||
// NUL is a legitimate opaque key: it percent-encodes to `%00`, which the URL
|
||
// parser preserves and the server decodes back. No key-domain rejection.
|
||
const remote = makeStore(`kv-nul-${namespace++}`);
|
||
const key = 'ns:before\u0000after';
|
||
await remote.set(key, { ok: 1 });
|
||
expect(await remote.get(key)).toEqual({ ok: 1 });
|
||
expect(await remote.keys('ns:')).toContain(key);
|
||
});
|
||
|
||
test('round-trips an arbitrarily long key — there is no length ceiling', async () => {
|
||
// No DoS ceiling in the key domain: a long id is just as valid. (A deployment
|
||
// may still cap the request-target at its own transport layer; that is a
|
||
// deployment concern, documented, not a key-domain rule.)
|
||
const remote = makeStore(`kv-long-${namespace++}`);
|
||
const key = `editor-current-scene:${'s'.repeat(5000)}`;
|
||
await remote.set(key, { sceneId: null });
|
||
expect(await remote.get(key)).toEqual({ sceneId: null });
|
||
expect(await remote.keys('editor-current-scene:')).toContain(key);
|
||
});
|
||
|
||
test('round-trips a key containing separators through the real server', async () => {
|
||
// A stageId of `stage/one` is a legitimate id, so the composed key must
|
||
// survive the wire. The client percent-encodes it into one path segment and
|
||
// the server stores the decoded key as an opaque value.
|
||
const storageNamespace = `kv-opaque-${namespace++}`;
|
||
const remote = new HttpKVStore({
|
||
baseUrl: server.baseUrl,
|
||
fetch: server.fetch,
|
||
headers: () => ({ 'x-storage-namespace': storageNamespace }),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
const key = 'editor-current-scene:stage/one';
|
||
await remote.set(key, { sceneId: null });
|
||
expect(await remote.get(key)).toEqual({ sceneId: null });
|
||
expect(await remote.keys('editor-current-scene:')).toContain(key);
|
||
await remote.remove(key);
|
||
expect(await remote.get(key)).toBeNull();
|
||
});
|
||
|
||
test('a lone-surrogate key is an HTTP transport limit, not a key-domain rejection', async () => {
|
||
// An unpaired UTF-16 surrogate has no percent-encoding, so the URL-path HTTP
|
||
// transport structurally cannot carry it — the client surfaces a clear
|
||
// transport error (not a key-domain "invalid key"). The browser primitive,
|
||
// which uses no URL, stores the very same key.
|
||
const http = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () => {
|
||
throw new Error('fetch must not be called');
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
const failure = http.set('\uD800', 'v');
|
||
await expect(failure).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' });
|
||
await expect(failure).rejects.toThrow(/cannot be carried over the HTTP URL transport/);
|
||
|
||
// Opaque on the browser primitive — stored and read back.
|
||
const browser = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
await browser.set('\uD800', 'v', 'device');
|
||
expect(await browser.get('\uD800', 'device')).toBe('v');
|
||
});
|
||
|
||
test('the server accepts an opaque prefix containing a separator', async () => {
|
||
// A separator in a prefix is data, not structure: the client encodes it and
|
||
// the server matches it literally. It must not be rejected.
|
||
const storageNamespace = `kv-prefix-${namespace++}`;
|
||
await server.fetch(`${server.baseUrl}/kv/entries/${encodeURIComponent('a/one')}`, {
|
||
method: 'PUT',
|
||
headers: { 'content-type': 'application/json', 'x-storage-namespace': storageNamespace },
|
||
body: JSON.stringify({ value: 1 }),
|
||
});
|
||
|
||
const response = await server.fetch(
|
||
`${server.baseUrl}/kv/keys?prefix=${encodeURIComponent('a/')}`,
|
||
{ headers: { 'x-storage-namespace': storageNamespace } },
|
||
);
|
||
expect(response.status).toBe(200);
|
||
await expect(response.json()).resolves.toEqual(['a/one']);
|
||
});
|
||
|
||
test('a percent-encoded traversal is stored as an opaque key, not a path', async () => {
|
||
// Neutralized by encoding, not by rejection: the client single-segments it,
|
||
// and the server keeps it as a plain map key that traverses nothing.
|
||
const storageNamespace = `kv-traversal-${namespace++}`;
|
||
const remote = new HttpKVStore({
|
||
baseUrl: server.baseUrl,
|
||
fetch: server.fetch,
|
||
headers: () => ({ 'x-storage-namespace': storageNamespace }),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
const key = '../../etc/passwd';
|
||
await remote.set(key, 'harmless');
|
||
expect(await remote.get(key)).toBe('harmless');
|
||
// It is one opaque key, not a walk up the tree — a sibling with the same
|
||
// trailing segment is a different key.
|
||
expect(await remote.get('passwd')).toBeNull();
|
||
});
|
||
|
||
test('a raw (unencoded) traversal path does not reach the entries route', async () => {
|
||
// Belt to the encoding's braces: a malicious client that puts literal
|
||
// separators on the wire produces extra path segments, which match no route.
|
||
const response = await server.fetch(`${server.baseUrl}/kv/entries/../../etc/passwd`, {
|
||
method: 'DELETE',
|
||
headers: { 'x-storage-namespace': `kv-raw-${namespace++}` },
|
||
});
|
||
expect(response.status).toBe(404);
|
||
await expect(response.json()).resolves.toMatchObject({
|
||
error: { code: 'ROUTE_NOT_FOUND' },
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('HttpKVStore error-table coverage', () => {
|
||
function respondWith(status: number, code: string): HttpKVStore {
|
||
return new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () =>
|
||
new Response(JSON.stringify({ error: { code, message: `${code} from server` } }), {
|
||
status,
|
||
headers: { 'content-type': 'application/json' },
|
||
}),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
}
|
||
|
||
test('a 404 ROUTE_NOT_FOUND throws instead of masquerading as a missing key', async () => {
|
||
// The MUST a status-only client would break: both rows are 404, and only
|
||
// KEY_NOT_FOUND means "no such entry".
|
||
const failure = respondWith(404, 'ROUTE_NOT_FOUND').get('k');
|
||
await expect(failure).rejects.toBeInstanceOf(HttpKVStoreError);
|
||
await expect(failure).rejects.toMatchObject({ status: 404, code: 'ROUTE_NOT_FOUND' });
|
||
});
|
||
|
||
test.each([
|
||
[401, 'UNAUTHENTICATED'],
|
||
[403, 'FORBIDDEN_KV'],
|
||
[413, 'PAYLOAD_TOO_LARGE'],
|
||
[500, 'INTERNAL_ERROR'],
|
||
])('maps %i %s to a typed error', async (status, code) => {
|
||
await expect(respondWith(status, code).get('k')).rejects.toMatchObject({ status, code });
|
||
});
|
||
|
||
test('an unauthenticated deployment answers 401 on the contract routes', async () => {
|
||
const guarded = await startKvConformanceServer({
|
||
listen: false,
|
||
authenticate: (req) => req.headers.authorization !== undefined,
|
||
});
|
||
try {
|
||
const store = new HttpKVStore({
|
||
baseUrl: guarded.baseUrl,
|
||
fetch: guarded.fetch,
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
// Not null: an authentication failure must never look like a missing key.
|
||
await expect(store.get('k')).rejects.toMatchObject({
|
||
status: 401,
|
||
code: 'UNAUTHENTICATED',
|
||
});
|
||
} finally {
|
||
await guarded.close();
|
||
}
|
||
});
|
||
|
||
test('a denied principal receives 403 rather than a missing key', async () => {
|
||
const guarded = await startKvConformanceServer({
|
||
listen: false,
|
||
authorize: (_req, area) => area !== 'kv',
|
||
});
|
||
try {
|
||
const store = new HttpKVStore({
|
||
baseUrl: guarded.baseUrl,
|
||
fetch: guarded.fetch,
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
await expect(store.get('k')).rejects.toMatchObject({ status: 403, code: 'FORBIDDEN_KV' });
|
||
} finally {
|
||
await guarded.close();
|
||
}
|
||
});
|
||
|
||
test('a write past the body ceiling is rejected with 413', async () => {
|
||
const bounded = await startKvConformanceServer({ listen: false, maxBodyBytes: 8 });
|
||
try {
|
||
const store = new HttpKVStore({
|
||
baseUrl: bounded.baseUrl,
|
||
fetch: bounded.fetch,
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
await expect(store.set('k', 'a value well past eight bytes')).rejects.toMatchObject({
|
||
status: 413,
|
||
code: 'PAYLOAD_TOO_LARGE',
|
||
});
|
||
} finally {
|
||
await bounded.close();
|
||
}
|
||
});
|
||
|
||
test('a 2xx with an unparseable body becomes a typed malformed response', async () => {
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () =>
|
||
new Response('<html>a proxy error page</html>', {
|
||
status: 200,
|
||
headers: { 'content-type': 'text/html' },
|
||
}),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
const failure = store.get('k');
|
||
await expect(failure).rejects.not.toBeInstanceOf(SyntaxError);
|
||
await expect(failure).rejects.toMatchObject({ code: 'MALFORMED_RESPONSE' });
|
||
});
|
||
});
|
||
|
||
describe('HttpKVStore transport semantics', () => {
|
||
test('maps a missing key to null rather than an error', async () => {
|
||
const store = makeStore();
|
||
await expect(store.get('absent')).resolves.toBeNull();
|
||
});
|
||
|
||
test('rejects values JSON cannot carry faithfully', async () => {
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () => {
|
||
throw new Error('fetch must not be called');
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
// Narrower than BrowserKVStore on purpose: structured values that survive
|
||
// `localStorage` round-trips only because `JSON.stringify` silently drops or
|
||
// rewrites them must fail loud before they are sent.
|
||
const circular: Record<string, unknown> = {};
|
||
circular.self = circular;
|
||
const rejected: [string, unknown][] = [
|
||
['Map', new Map([['k', 'v']])],
|
||
['Set', new Set(['v'])],
|
||
['Date', new Date('2026-01-01T00:00:00.000Z')],
|
||
['NaN', Number.NaN],
|
||
['negative zero', -0],
|
||
['nested undefined', { nested: undefined }],
|
||
['NUL string', 'before\u0000after'],
|
||
['unpaired surrogate', '\uD800'],
|
||
// The three a `JSON.stringify` pre-flight would mishandle: two throw a
|
||
// native TypeError before the gate can produce its message, and the third
|
||
// stringifies to `undefined` and would be reclassified as a delete.
|
||
['bigint', BigInt(1)],
|
||
['circular reference', circular],
|
||
['toJSON returning undefined', { toJSON: () => undefined }],
|
||
];
|
||
|
||
for (const [name, value] of rejected) {
|
||
const failure = store.set('k', value);
|
||
await expect(failure, name).rejects.toThrow(/not a plain JSON value/);
|
||
await expect(failure, name).rejects.not.toBeInstanceOf(TypeError);
|
||
}
|
||
});
|
||
|
||
test('the JSON gate runs before anything reads the value', async () => {
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () => {
|
||
throw new Error('fetch must not be called');
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
// A pre-flight `JSON.stringify` would invoke this before the gate looked,
|
||
// letting caller code run — and change its mind — between the check and the
|
||
// serialization the check was supposed to cover.
|
||
let reads = 0;
|
||
const spy = {
|
||
get value() {
|
||
reads += 1;
|
||
return reads;
|
||
},
|
||
};
|
||
|
||
await expect(store.set('k', spy)).rejects.toThrow(/not a plain JSON value/);
|
||
expect(reads).toBe(0);
|
||
});
|
||
|
||
test.each([
|
||
['undefined', undefined],
|
||
['a function', () => 'x'],
|
||
['a symbol', Symbol('s')],
|
||
])('treats %s as a delete, matching the browser backend', async (_name, value) => {
|
||
const store = makeStore();
|
||
await store.set('k', 'present');
|
||
await store.set('k', value);
|
||
|
||
expect(await store.get('k')).toBeNull();
|
||
expect(await store.keys()).not.toContain('k');
|
||
});
|
||
|
||
test('does not reject opaque keys — it encodes and sends them', async () => {
|
||
// The client applies no key-domain rules: a NUL and a separator are both
|
||
// encoded and sent (proven by the raw request target the client builds).
|
||
const targets: string[] = [];
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input) => {
|
||
// The raw string the client passed — not `new URL(...).pathname`, which
|
||
// would normalize a dot segment and hide what was actually emitted.
|
||
targets.push(String(input).replace('https://kv.invalid', ''));
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await store.set('a\u0000b', 'v');
|
||
await store.set('a/b', 'v');
|
||
// A key that merely *contains* a dot is an ordinary segment — no special case.
|
||
await store.set('a.b', 'v');
|
||
expect(targets).toEqual(['/kv/entries/a%00b', '/kv/entries/a%2Fb', '/kv/entries/a.b']);
|
||
});
|
||
|
||
test('a whole-key "." or ".." is an HTTP transport limit, not a key-domain rejection', async () => {
|
||
// The data-correctness case: `encodeURIComponent('.')` is `.`, so a standard
|
||
// `fetch` would normalize `/kv/entries/.` to `/kv/entries/` (the empty key)
|
||
// and `/kv/entries/..` up a level — silently reading, overwriting, or
|
||
// deleting a *different* entry. The client refuses these before the request
|
||
// is built, exactly as it refuses an unencodable surrogate. The browser
|
||
// primitive stores them opaquely, unchanged.
|
||
const fetchCalls: string[] = [];
|
||
const http = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input) => {
|
||
fetchCalls.push(String(input));
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
for (const key of ['.', '..']) {
|
||
await expect(http.get(key), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' });
|
||
await expect(http.set(key, 'v'), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' });
|
||
await expect(http.remove(key), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' });
|
||
}
|
||
// No request was ever built — nothing aliased to another entry.
|
||
expect(fetchCalls).toEqual([]);
|
||
|
||
// Opaque on the browser primitive: stored and read back, distinct from the
|
||
// empty key.
|
||
const browser = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
await browser.set('.', 'dot', 'device');
|
||
await browser.set('..', 'dotdot', 'device');
|
||
await browser.set('', 'empty', 'device');
|
||
expect(await browser.get('.', 'device')).toBe('dot');
|
||
expect(await browser.get('..', 'device')).toBe('dotdot');
|
||
expect(await browser.get('', 'device')).toBe('empty');
|
||
});
|
||
|
||
test('reports a malformed get response instead of inventing a value', async () => {
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () =>
|
||
new Response(JSON.stringify({ notValue: 1 }), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
}),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await expect(store.get('k')).rejects.toMatchObject({
|
||
name: 'HttpKVStoreError',
|
||
code: 'MALFORMED_RESPONSE',
|
||
});
|
||
});
|
||
|
||
test.each([
|
||
['a non-array body', { keys: [] }],
|
||
['a non-string member', ['ok', 7]],
|
||
])('reports %s from keys() as a malformed response', async (_name, body) => {
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () =>
|
||
new Response(JSON.stringify(body), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
}),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await expect(store.keys()).rejects.toMatchObject({ code: 'MALFORMED_RESPONSE' });
|
||
});
|
||
|
||
test('surfaces a server failure as a typed error', async () => {
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () =>
|
||
new Response(JSON.stringify({ error: { code: 'INTERNAL_ERROR', message: 'boom' } }), {
|
||
status: 500,
|
||
headers: { 'content-type': 'application/json' },
|
||
}),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
const failure = store.get('k');
|
||
await expect(failure).rejects.toBeInstanceOf(HttpKVStoreError);
|
||
await expect(failure).rejects.toMatchObject({ status: 500, code: 'INTERNAL_ERROR' });
|
||
});
|
||
|
||
test('sends a prefix query only when a prefix is given', async () => {
|
||
const paths: string[] = [];
|
||
const account = new HttpAccountKV({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input) => {
|
||
const url = new URL(String(input));
|
||
paths.push(url.pathname + url.search);
|
||
return new Response(JSON.stringify([]), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
});
|
||
},
|
||
});
|
||
|
||
await account.keys();
|
||
await account.keys('ui:');
|
||
expect(paths).toEqual(['/kv/keys', '/kv/keys?prefix=ui%3A']);
|
||
});
|
||
|
||
test.each([
|
||
['file:', 'file:///etc'],
|
||
['ftp:', 'ftp://host/x'],
|
||
])('refuses a %s base url at construction', (_name, baseUrl) => {
|
||
const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(/is not http\(s\)/);
|
||
});
|
||
|
||
test('refuses a scheme-without-authority base url', () => {
|
||
const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
expect(
|
||
() => new HttpKVStore({ baseUrl: 'https:api.example/persistence', deviceStore }),
|
||
).toThrow(/scheme:\/\/host/);
|
||
});
|
||
|
||
test.each([
|
||
['a query', 'https://kv.invalid/api?v=1'],
|
||
['a fragment', 'https://kv.invalid/api#frag'],
|
||
])('refuses a base url carrying %s', (_name, baseUrl) => {
|
||
const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(/query or fragment/);
|
||
});
|
||
|
||
test('omits credentials entirely when the deployment does not ask for them', async () => {
|
||
let hadKey = true;
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (_input, init) => {
|
||
hadKey = init !== undefined && 'credentials' in init;
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await store.remove('k');
|
||
expect(hadKey).toBe(false);
|
||
});
|
||
|
||
test.each([
|
||
['protocol-relative', '//evil.example'],
|
||
['backslash authority', '/\\evil.example'],
|
||
])('refuses a %s base url posing as a path', (_name, baseUrl) => {
|
||
const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(
|
||
/not a reference to another origin/,
|
||
);
|
||
});
|
||
|
||
test.each([
|
||
['a tab', '/api\u0009/persistence'],
|
||
['a NUL', '/api\u0000/persistence'],
|
||
])('refuses a base url containing %s', (_name, baseUrl) => {
|
||
const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(/control characters/);
|
||
});
|
||
|
||
test.each([
|
||
['a bare query separator', 'https://kv.invalid/api?'],
|
||
['a bare fragment separator', '/api/persistence#'],
|
||
])('refuses a base url ending in %s', (_name, baseUrl) => {
|
||
const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(/query or fragment/);
|
||
});
|
||
|
||
test.each([
|
||
['userinfo', 'https://user:pass@kv.invalid'],
|
||
['a trailing space', 'https://kv.invalid/api '],
|
||
])('refuses a base url carrying %s', (_name, baseUrl) => {
|
||
const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
expect(() => new HttpKVStore({ baseUrl, deviceStore })).toThrow(
|
||
/must not carry userinfo|whitespace/,
|
||
);
|
||
});
|
||
|
||
test('passes credentials through to fetch for a cross-origin cookie deployment', async () => {
|
||
let seen: RequestCredentials | undefined;
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
credentials: 'include',
|
||
fetch: async (_input, init) => {
|
||
seen = init?.credentials;
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await store.remove('k');
|
||
expect(seen).toBe('include');
|
||
});
|
||
|
||
test('a headers hook cannot describe the request body', async () => {
|
||
// The hook attaches authentication headers; the body is always JSON, so a
|
||
// hook that sets Content-Type is misconfigured and fails loud.
|
||
let called = false;
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () => {
|
||
called = true;
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
headers: () => ({ 'Content-Type': 'text/plain' }),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await expect(store.set('k', 'v')).rejects.toThrow(/must not set Content-Type/);
|
||
expect(called).toBe(false);
|
||
// Reads carry no body, so there is nothing for a hook to misdescribe.
|
||
await expect(store.remove('k')).resolves.toBeUndefined();
|
||
});
|
||
|
||
test('requires a non-empty base url and a usable fetch implementation', () => {
|
||
const deviceStore = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
expect(() => new HttpKVStore({ baseUrl: '', deviceStore })).toThrow(
|
||
/baseUrl must be non-empty/,
|
||
);
|
||
expect(
|
||
() =>
|
||
new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: {} as unknown as typeof globalThis.fetch,
|
||
deviceStore,
|
||
}),
|
||
).toThrow(/requires a fetch implementation/);
|
||
});
|
||
});
|
||
|
||
describe('HttpKVStore cache and not-found identity', () => {
|
||
test.each([
|
||
['a read of one entry', (kv: HttpKVStore) => kv.get('k')],
|
||
['a key listing', (kv: HttpKVStore) => kv.keys()],
|
||
])('%s is never served from a cache', async (_name, operation) => {
|
||
// `account` values are precisely the ones another device may have changed a
|
||
// moment ago, and this client cannot invalidate a cache entry when it does.
|
||
let seen: RequestCache | undefined;
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (input, init) => {
|
||
seen = init?.cache;
|
||
const body = String(input).includes('/kv/keys') ? [] : { value: 'v' };
|
||
return new Response(JSON.stringify(body), {
|
||
status: 200,
|
||
headers: { 'content-type': 'application/json' },
|
||
});
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await operation(store);
|
||
expect(seen).toBe('no-store');
|
||
});
|
||
|
||
test('a write is not marked no-store, having nothing to read from a cache', async () => {
|
||
let seen: RequestCache | undefined = 'no-store';
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async (_input, init) => {
|
||
seen = init?.cache;
|
||
return new Response(null, { status: 204 });
|
||
},
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await store.set('k', 'v');
|
||
expect(seen).toBeUndefined();
|
||
});
|
||
|
||
test.each([
|
||
[401, 'the credential expired'],
|
||
[403, 'a gateway denied the request'],
|
||
[500, 'the origin fell over'],
|
||
])('throws when a %i answer carries the not-found code', async (status, why) => {
|
||
// `null` here is indistinguishable from a legitimate miss, so reading an
|
||
// outage as one would present it to the caller as data loss.
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () =>
|
||
new Response(JSON.stringify({ error: { code: 'KEY_NOT_FOUND', message: why } }), {
|
||
status,
|
||
headers: { 'content-type': 'application/json' },
|
||
}),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
const failure = store.get('k');
|
||
await expect(failure).rejects.toBeInstanceOf(HttpKVStoreError);
|
||
await expect(failure).rejects.toMatchObject({ status, code: 'KEY_NOT_FOUND' });
|
||
});
|
||
|
||
test('still maps a genuine 404 KEY_NOT_FOUND to null', async () => {
|
||
const store = new HttpKVStore({
|
||
baseUrl: 'https://kv.invalid',
|
||
fetch: async () =>
|
||
new Response(JSON.stringify({ error: { code: 'KEY_NOT_FOUND', message: 'absent' } }), {
|
||
status: 404,
|
||
headers: { 'content-type': 'application/json' },
|
||
}),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await expect(store.get('k')).resolves.toBeNull();
|
||
});
|
||
});
|
||
|
||
describe('conformance server read-cache and delete hygiene', () => {
|
||
test.each([
|
||
['a kv entry read', (base: string) => `${base}/kv/entries/cached`],
|
||
['a kv key listing', (base: string) => `${base}/kv/keys`],
|
||
])('%s is served no-store', async (_name, url) => {
|
||
// The contract requires it, so the harness has to send it — otherwise the
|
||
// suite passes against exactly the implementation the requirement exists to
|
||
// catch.
|
||
const storageNamespace = `no-store-${namespace++}`;
|
||
const store = makeStore(storageNamespace);
|
||
await store.set('cached', 'v');
|
||
|
||
const response = await server.fetch(url(server.baseUrl), {
|
||
headers: { 'x-storage-namespace': storageNamespace },
|
||
});
|
||
expect(response.status).toBe(200);
|
||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||
});
|
||
|
||
test('a delete carrying a body is refused', async () => {
|
||
// The body was the one channel left through which a delete could still
|
||
// describe a scope; query and header were already covered.
|
||
const response = await server.fetch(`${server.baseUrl}/kv/entries/k`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
'x-storage-namespace': `delete-body-${namespace++}`,
|
||
},
|
||
body: JSON.stringify({ scope: 'device' }),
|
||
});
|
||
|
||
expect(response.status).toBe(400);
|
||
await expect(response.json()).resolves.toMatchObject({
|
||
error: { code: 'VALIDATION_FAILED', message: expect.stringContaining('body') },
|
||
});
|
||
});
|
||
|
||
test('a bodyless delete still succeeds', async () => {
|
||
const storageNamespace = `delete-clean-${namespace++}`;
|
||
const store = makeStore(storageNamespace);
|
||
await store.set('k', 'v');
|
||
await expect(store.remove('k')).resolves.toBeUndefined();
|
||
expect(await store.get('k')).toBeNull();
|
||
});
|
||
});
|
||
|
||
test('real fetch reaches the listening conformance server over loopback', async ({ skip }) =>
|
||
withNetworkServer(skip, async (networkServer) => {
|
||
const store = new HttpKVStore({
|
||
baseUrl: networkServer.baseUrl,
|
||
headers: () => ({ 'x-storage-namespace': 'real-network' }),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
await store.set('provider', { name: 'demo', models: ['a', 'b'] });
|
||
await expect(store.get('provider')).resolves.toEqual({ name: 'demo', models: ['a', 'b'] });
|
||
await expect(store.keys()).resolves.toEqual(['provider']);
|
||
await store.remove('provider');
|
||
await expect(store.get('provider')).resolves.toBeNull();
|
||
}));
|
||
|
||
// The key domain is opaque and unbounded, and that holds over a *real* HTTP
|
||
// connection, not just the in-process harness: an arbitrarily long non-ASCII key
|
||
// and a NUL-containing key both round-trip, with no ceiling and no NUL rejection.
|
||
// The browser primitive accepts the same keys — parity on validity. A key the
|
||
// URL transport genuinely cannot carry (an unpaired surrogate) is a transport
|
||
// limit surfaced by the HTTP client, tested in the key-constraints suite.
|
||
test('arbitrary opaque keys round-trip over a real HTTP connection', async ({ skip }) =>
|
||
withNetworkServer(skip, async (networkServer) => {
|
||
const http = new HttpKVStore({
|
||
baseUrl: networkServer.baseUrl,
|
||
headers: () => ({ 'x-storage-namespace': 'opaque-real' }),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
const browser = new BrowserKVStore({ storage: new MemoryStorage() });
|
||
|
||
const keys = [
|
||
// Long and non-ASCII: 5000 × U+20AC (encoded ~45 KB would exceed a URL, so
|
||
// keep it long but within a real request-target — the point is "no domain
|
||
// ceiling", and a real transport still has its own separate limit).
|
||
`settings:${'€'.repeat(300)}`,
|
||
// NUL, which percent-encodes to %00 and is preserved end to end.
|
||
'ns:before\u0000after',
|
||
// A separator, the original regression.
|
||
'editor-current-scene:stage/one',
|
||
];
|
||
|
||
for (const [index, key] of keys.entries()) {
|
||
// The value is plain (the key may contain NUL; a *value* may not, per the
|
||
// JSON/jsonb value gate — a separate axis from the opaque key domain).
|
||
const value = { stored: index };
|
||
await http.set(key, value);
|
||
expect(await http.get(key), key).toEqual(value);
|
||
// The browser primitive stores the identical key — validity parity.
|
||
await browser.set(key, value, 'device');
|
||
expect(await browser.get(key, 'device'), key).toEqual(value);
|
||
}
|
||
}));
|
||
|
||
test("a whole-key '.'/'..' never pollutes the empty-key entry over a real server", async ({
|
||
skip,
|
||
}) =>
|
||
withNetworkServer(skip, async (networkServer) => {
|
||
const http = new HttpKVStore({
|
||
baseUrl: networkServer.baseUrl,
|
||
headers: () => ({ 'x-storage-namespace': 'dot-alias' }),
|
||
deviceStore: new BrowserKVStore({ storage: new MemoryStorage() }),
|
||
});
|
||
|
||
// The empty key holds a distinct value; `.`/`..` would alias to it (or to a
|
||
// bad route) if the client sent them, corrupting or deleting it.
|
||
await http.set('', { entry: 'empty' });
|
||
for (const key of ['.', '..']) {
|
||
await expect(http.get(key), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' });
|
||
await expect(http.set(key, { entry: key }), key).rejects.toMatchObject({
|
||
code: 'KEY_NOT_ENCODABLE',
|
||
});
|
||
await expect(http.remove(key), key).rejects.toMatchObject({ code: 'KEY_NOT_ENCODABLE' });
|
||
}
|
||
// The empty-key entry is exactly as it was — never read, overwritten, or deleted.
|
||
expect(await http.get('')).toEqual({ entry: 'empty' });
|
||
expect(await http.keys()).toEqual(['']);
|
||
}));
|