Files
openmaic/OpenMAIC/packages/@openmaic/storage/test/http-document-store.test.ts
2026-08-16 14:58:47 +08:00

332 lines
12 KiB
TypeScript

import type { IncomingMessage, RequestListener, ServerResponse } from 'node:http';
import { IDBFactory } from 'fake-indexeddb';
import { DSL_VERSION_KEY } from '@openmaic/dsl';
import { describe, expect, test } from 'vitest';
import { BrowserDocumentStore } from '../src/document/browser.js';
import { DocumentVersionError } from '../src/document/types.js';
import { HttpDocumentStore, HttpDocumentStoreError } from '../src/document/http.js';
import type { StageValidator } from '../src/document/types.js';
import { BrowserRuntimeStore } from '../src/runtime/browser.js';
import { createStorageHttpHandler } from '../src/server/index.js';
import { makeDocument, runDocumentStoreContract, slideScene } from './document-contract.js';
const BASE_URL = 'http://storage-reference.invalid';
function handlerFetch(handler: RequestListener): typeof globalThis.fetch {
return async (input, init) => {
const request = new Request(input, init);
const url = new URL(request.url);
const body = await request.text();
const headers = Object.fromEntries(request.headers.entries());
headers.authorization ??= 'Bearer document-contract';
const fakeRequest = {
method: request.method,
url: `${url.pathname}${url.search}`,
headers,
async *[Symbol.asyncIterator]() {
if (body !== '') yield Buffer.from(body);
},
} as unknown as IncomingMessage;
return new Promise<Response>((resolve, reject) => {
let status = 200;
let responseHeaders: Record<string, string> = {};
let responseBody: string | undefined;
let headersSent = false;
const fakeResponse = {
get headersSent() {
return headersSent;
},
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
status = nextStatus;
responseHeaders = nextHeaders ?? {};
headersSent = true;
return this;
},
end(chunk?: string | Buffer) {
responseBody = chunk === undefined ? undefined : chunk.toString();
resolve(
new Response(status === 204 ? null : responseBody, {
status,
headers: responseHeaders,
}),
);
return this;
},
destroy(error?: Error) {
reject(error ?? new Error('response destroyed'));
return this;
},
} as unknown as ServerResponse;
handler(fakeRequest, fakeResponse);
});
};
}
async function reStampStage(
idb: IDBFactory,
dbName: string,
stageId: string,
version: string | undefined,
): Promise<void> {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = idb.open(dbName);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction('stages', 'readwrite');
const stages = transaction.objectStore('stages');
const request = stages.get(stageId);
request.onsuccess = () => {
const row = request.result as Record<string, unknown>;
if (version === undefined) delete row[DSL_VERSION_KEY];
else row[DSL_VERSION_KEY] = version;
stages.put(row);
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
db.close();
}
let harnessDb = 0;
function makeHarness(
authorizeDocuments: () => boolean = () => true,
options: { maxBodyBytes?: number; validateStage?: StageValidator } = {},
) {
const idb = new IDBFactory();
const dbName = `http-document-contract-${harnessDb++}`;
const documents = new BrowserDocumentStore({ indexedDB: idb, dbName });
const runtime = new BrowserRuntimeStore({ indexedDB: new IDBFactory() });
const handler = createStorageHttpHandler(runtime, documents, {
authenticate: async (req) => {
const authorization = req.headers.authorization;
return typeof authorization === 'string' && authorization.startsWith('Bearer ')
? { learnerKey: 'author' }
: undefined;
},
authorizeDocuments: async () => authorizeDocuments(),
...(options.maxBodyBytes === undefined ? {} : { maxBodyBytes: options.maxBodyBytes }),
...(options.validateStage === undefined ? {} : { validateStage: options.validateStage }),
});
const fetch = handlerFetch(handler);
return {
documents,
fetch,
client: new HttpDocumentStore({ baseUrl: BASE_URL, fetch }),
seedStoredVersion: (stageId: string, version: string | undefined) =>
reStampStage(idb, dbName, stageId, version),
};
}
runDocumentStoreContract('reference HTTP', () => {
const harness = makeHarness();
return {
store: harness.client,
seedStoredVersion: harness.seedStoredVersion,
};
});
describe('HttpDocumentStore contract mapping', () => {
test('uses injected request headers and the reference server requires authentication', async () => {
const { fetch } = makeHarness();
const unauthenticated = await fetch(`${BASE_URL}/documents`, {
headers: { authorization: '' },
});
expect(unauthenticated.status).toBe(401);
let context: { method: string; path: string } | undefined;
const client = new HttpDocumentStore({
baseUrl: BASE_URL,
fetch,
headers: (next) => {
context = next;
return { authorization: 'Bearer author' };
},
});
await client.listDocuments();
expect(context).toEqual({ method: 'GET', path: '/documents' });
});
test('maps document authorization denial to a typed 403', async () => {
const { client } = makeHarness(() => false);
await expect(client.listDocuments()).rejects.toMatchObject({
name: 'HttpDocumentStoreError',
status: 403,
code: 'FORBIDDEN_DOCUMENTS',
});
});
test('maps malformed request JSON to VALIDATION_FAILED', async () => {
const { fetch } = makeHarness();
const response = await fetch(`${BASE_URL}/documents/stage-1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: '{',
});
expect(response.status).toBe(400);
await expect(response.json()).resolves.toMatchObject({
error: { code: 'VALIDATION_FAILED' },
});
});
test('rejects oversized bodies with 413 while allowing an under-limit request', async () => {
const { fetch } = makeHarness(() => true, { maxBodyBytes: 256 });
const overLimit = await fetch(`${BASE_URL}/documents/stage-1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ padding: 'x'.repeat(300) }),
});
expect(overLimit.status).toBe(413);
await expect(overLimit.json()).resolves.toMatchObject({
error: { code: 'PAYLOAD_TOO_LARGE' },
});
const underLimit = await fetch(`${BASE_URL}/documents/stage-1/stage`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: 'stage-1',
name: 'Missing parent',
createdAt: 1,
updatedAt: 2,
}),
});
expect(underLimit.status).toBe(404);
});
test('fails loud when a structured-clone document is not JSON-safe on read', async () => {
const { documents, fetch } = makeHarness();
const document = makeDocument();
document.outline = { generatedAt: new Date('2026-01-01T00:00:00.000Z') };
await documents.saveDocument(document);
const response = await fetch(`${BASE_URL}/documents/stage-1`);
expect(response.status).toBe(500);
await expect(response.json()).resolves.toMatchObject({
error: {
code: 'NOT_JSON_SAFE',
message: expect.stringMatching(/document response.*outline.*Date/i),
},
});
});
test('maps missing required parents to typed 404 errors with browser wording', async () => {
const { client } = makeHarness();
const failure = client.putStage('ghost', {
id: 'ghost',
name: 'Ghost',
createdAt: 1,
updatedAt: 2,
});
await expect(failure).rejects.toBeInstanceOf(HttpDocumentStoreError);
await expect(failure).rejects.toMatchObject({ status: 404, code: 'DOCUMENT_NOT_FOUND' });
await expect(failure).rejects.toThrow(/missing document/);
});
test.each([
[
'putStage',
(client: HttpDocumentStore) =>
client.putStage('stage-1', {
id: 'stage-1',
name: 'Stale update',
createdAt: 1,
updatedAt: 2,
}),
],
[
'putScene',
(client: HttpDocumentStore) =>
client.putScene('stage-1', slideScene('stage-1', 'scene-c', 2)),
],
['deleteScene', (client: HttpDocumentStore) => client.deleteScene('stage-1', 'scene-a')],
])('maps unversioned %s to 400 VALIDATION_FAILED', async (_operation, write) => {
const { client, seedStoredVersion } = makeHarness();
await client.saveDocument(makeDocument());
await seedStoredVersion('stage-1', undefined);
await expect(write(client)).rejects.toMatchObject({
status: 400,
code: 'VALIDATION_FAILED',
});
});
test('maps a future-version save to FUTURE_VERSION without overwriting current data', async () => {
const { client } = makeHarness();
await client.saveDocument(makeDocument());
const future = makeDocument();
future.dslVersion = '99.0.0';
future.stage.name = 'Future';
const failure = client.saveDocument(future);
await expect(failure).rejects.toBeInstanceOf(DocumentVersionError);
await expect(failure).rejects.toMatchObject({
stageId: 'stage-1',
kind: 'future',
storedVersion: '99.0.0',
});
await expect(failure).rejects.toThrow(
`@openmaic/storage: refusing to save document "stage-1" — it was written at DSL version ` +
`"99.0.0", newer than this client's`,
);
expect((await client.loadDocument('stage-1'))!.stage.name).toBe('Intro Course');
});
test('rematerializes a typed future-version error classified from the backing store', async () => {
const { client, seedStoredVersion } = makeHarness();
await client.saveDocument(makeDocument());
await seedStoredVersion('stage-1', '99.0.0');
const replacement = makeDocument();
replacement.stage.name = 'Old Client Overwrite';
const failure = client.saveDocument(replacement);
await expect(failure).rejects.toBeInstanceOf(DocumentVersionError);
await expect(failure).rejects.toMatchObject({
stageId: 'stage-1',
kind: 'future',
storedVersion: '99.0.0',
});
await expect(failure).rejects.toThrow(/refusing to overwrite document/);
});
test('client-side validators fail before fetch', async () => {
let calls = 0;
const client = new HttpDocumentStore({
baseUrl: BASE_URL,
fetch: async () => {
calls += 1;
return new Response(null, { status: 204 });
},
});
const bad = makeDocument();
delete (bad.stage as { name?: string }).name;
await expect(client.saveDocument(bad)).rejects.toThrow(/invalid stage/);
expect(calls).toBe(0);
});
test('retains structured validator details on HttpDocumentStoreError', async () => {
const details = [
{ path: '/name', message: 'name is invalid' },
{ path: '/updatedAt', message: 'updatedAt is invalid' },
];
const { client } = makeHarness(() => true, {
validateStage: () => ({ valid: false, errors: details }),
});
await expect(
client.putStage('stage-1', {
id: 'stage-1',
name: 'Rejected by server validator',
createdAt: 1,
updatedAt: 2,
}),
).rejects.toMatchObject({
status: 400,
code: 'VALIDATION_FAILED',
details,
});
});
});