688 lines
24 KiB
TypeScript
688 lines
24 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
|
|
import { RUNTIME_DSL_VERSION } from '@openmaic/dsl';
|
|
import type { RuntimePayload, RuntimeRecord, RuntimeRecordInit } from '@openmaic/dsl';
|
|
import { HttpRuntimeStore, HttpRuntimeStoreError } from '../src/runtime/http.js';
|
|
import type { RuntimeSessionInit } from '../src/runtime/types.js';
|
|
import { runRuntimeStoreContract } from './runtime-contract.js';
|
|
import {
|
|
startHttpConformanceServer,
|
|
type HttpConformanceServer,
|
|
} from './http-conformance-server.js';
|
|
|
|
const T0 = '2026-01-01T00:00:00.000Z';
|
|
let server: HttpConformanceServer;
|
|
let namespace = 0;
|
|
|
|
function makeSession(id: string, overrides: Partial<RuntimeSessionInit> = {}): RuntimeSessionInit {
|
|
return {
|
|
id,
|
|
kind: 'playback',
|
|
stageId: 'stage-1',
|
|
learnerKey: 'learner-1',
|
|
status: 'active',
|
|
createdAt: T0,
|
|
updatedAt: T0,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeRecord(sessionId: string, payload: unknown = { value: 'ok' }): RuntimeRecordInit {
|
|
return {
|
|
id: `record-${namespace++}`,
|
|
sessionId,
|
|
createdAt: T0,
|
|
payload: payload as RuntimePayload,
|
|
};
|
|
}
|
|
|
|
function fakeJsonResponse(body: unknown, status = 200): Response {
|
|
return {
|
|
ok: status >= 200 && status < 300,
|
|
status,
|
|
json: async () => body,
|
|
} as Response;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
server = await startHttpConformanceServer({ listen: false });
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.close();
|
|
});
|
|
|
|
runRuntimeStoreContract('HTTP', () => {
|
|
const storeId = `contract-${namespace++}`;
|
|
return new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
});
|
|
|
|
describe('HttpRuntimeStore error mapping', () => {
|
|
test.each([
|
|
['empty', undefined],
|
|
['unparseable', '{'],
|
|
['null', 'null'],
|
|
])('maps a %s JSON request body to a 400 validation failure', async (_name, body) => {
|
|
const response = await server.fetch(`${server.baseUrl}/runtime/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
...(body === undefined ? {} : { body }),
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: { code: 'VALIDATION_FAILED' },
|
|
});
|
|
});
|
|
|
|
test('reconstitutes a 400 validation failure as an Error with its code and message', async () => {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': `errors-${namespace++}` }),
|
|
});
|
|
|
|
const failure = store.createSession({
|
|
id: 'invalid',
|
|
kind: 'chat',
|
|
stageId: 'stage-1',
|
|
learnerKey: '',
|
|
status: 'active',
|
|
createdAt: T0,
|
|
updatedAt: T0,
|
|
});
|
|
|
|
await expect(failure).rejects.toMatchObject({
|
|
name: 'HttpRuntimeStoreError',
|
|
status: 400,
|
|
code: 'VALIDATION_FAILED',
|
|
});
|
|
await expect(failure).rejects.toThrow(/learnerKey/);
|
|
});
|
|
|
|
test('reconstitutes a 404 missing-session response with browser-store semantics', async () => {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': `errors-${namespace++}` }),
|
|
});
|
|
|
|
const failure = store.setSessionStatus('ghost', 'completed', T0);
|
|
await expect(failure).rejects.toMatchObject({
|
|
name: 'HttpRuntimeStoreError',
|
|
status: 404,
|
|
code: 'SESSION_NOT_FOUND',
|
|
});
|
|
await expect(failure).rejects.toThrow(/no session/i);
|
|
});
|
|
|
|
test('reconstitutes a 409 future-version response with fail-loud semantics', async () => {
|
|
const fetch: typeof globalThis.fetch = async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
error: {
|
|
code: 'FUTURE_VERSION',
|
|
message:
|
|
'@openmaic/storage: session "future" was written at runtime DSL version "99.0.0", newer than this client\'s 1.0.0',
|
|
},
|
|
}),
|
|
{ status: 409, headers: { 'content-type': 'application/json' } },
|
|
);
|
|
const store = new HttpRuntimeStore({ baseUrl: 'https://runtime.invalid', fetch });
|
|
|
|
const failure = store.setSessionStatus('future', 'completed', T0);
|
|
await expect(failure).rejects.toBeInstanceOf(HttpRuntimeStoreError);
|
|
await expect(failure).rejects.toMatchObject({ status: 409, code: 'FUTURE_VERSION' });
|
|
await expect(failure).rejects.toThrow(/newer than this client's/);
|
|
});
|
|
|
|
test('maps concurrent duplicate session creation to one success and one 409', async () => {
|
|
const storeId = `duplicate-race-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
|
|
const results = await Promise.allSettled([
|
|
store.createSession(makeSession('duplicate-race')),
|
|
store.createSession(makeSession('duplicate-race')),
|
|
]);
|
|
const fulfilled = results.filter((result) => result.status === 'fulfilled');
|
|
const rejected = results.filter((result) => result.status === 'rejected');
|
|
|
|
expect(fulfilled).toHaveLength(1);
|
|
expect(rejected).toHaveLength(1);
|
|
expect(rejected[0]).toMatchObject({
|
|
reason: expect.objectContaining({
|
|
status: 409,
|
|
code: 'SESSION_ALREADY_EXISTS',
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('maps invalid kind-specific append payloads to 400 validation failures', async () => {
|
|
const storeId = `payload-gate-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
await store.createSession(makeSession('chat-session', { kind: 'chat' }));
|
|
await store.createSession(makeSession('quiz-session', { kind: 'quizAttempt' }));
|
|
|
|
await expect(
|
|
store.appendRecord(makeRecord('chat-session', { role: 'tool', content: 'invalid' })),
|
|
).rejects.toMatchObject({ status: 400, code: 'VALIDATION_FAILED' });
|
|
await expect(
|
|
store.appendRecord(makeRecord('quiz-session', { phase: 'graded', answers: {} })),
|
|
).rejects.toMatchObject({ status: 400, code: 'VALIDATION_FAILED' });
|
|
});
|
|
|
|
test('maps empty merge learner keys to 400 validation failures', async () => {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': `merge-validation-${namespace++}` }),
|
|
});
|
|
|
|
await expect(store.mergeLearner('', 'learner-2')).rejects.toMatchObject({
|
|
status: 400,
|
|
code: 'VALIDATION_FAILED',
|
|
});
|
|
await expect(store.mergeLearner('learner-1', '')).rejects.toMatchObject({
|
|
status: 400,
|
|
code: 'VALIDATION_FAILED',
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
['createSession id', '/runtime/sessions', makeSession('.')],
|
|
['createSession stageId', '/runtime/sessions', makeSession('dot-stage', { stageId: '..' })],
|
|
[
|
|
'createSession learnerKey',
|
|
'/runtime/sessions',
|
|
makeSession('dot-learner', { learnerKey: '.' }),
|
|
],
|
|
[
|
|
'mergeLearner target',
|
|
'/runtime/learners/merge',
|
|
{ fromLearnerKey: 'learner-1', toLearnerKey: '..' },
|
|
],
|
|
])('maps a dot-only %s body field to a 400 validation failure', async (_name, path, body) => {
|
|
const response = await server.fetch(`${server.baseUrl}${path}`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: {
|
|
code: 'VALIDATION_FAILED',
|
|
message: expect.stringContaining('URL path segment must not be'),
|
|
},
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
['NUL', 'from\u0000learner', 'to-learner'],
|
|
['unpaired surrogate', 'from-learner', '\uD800'],
|
|
])(
|
|
'maps a merge learner key containing %s to a 400 validation failure',
|
|
async (_name, fromLearnerKey, toLearnerKey) => {
|
|
const response = await server.fetch(`${server.baseUrl}/runtime/learners/merge`, {
|
|
method: 'POST',
|
|
headers: { 'content-type': 'application/json' },
|
|
body: JSON.stringify({ fromLearnerKey, toLearnerKey }),
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: { code: 'VALIDATION_FAILED' },
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe('HttpRuntimeStore HTTP hardening', () => {
|
|
test('validates append envelopes before sending a request', async () => {
|
|
let requestCount = 0;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => {
|
|
requestCount += 1;
|
|
return fakeJsonResponse({}, 201);
|
|
},
|
|
});
|
|
|
|
const init = { ...makeRecord('session'), createdAt: 'not-iso' };
|
|
let failure: unknown;
|
|
try {
|
|
await store.appendRecord(init);
|
|
} catch (error) {
|
|
failure = error;
|
|
}
|
|
|
|
expect(failure).toBeInstanceOf(HttpRuntimeStoreError);
|
|
expect(failure).toMatchObject({ status: 400, code: 'VALIDATION_FAILED' });
|
|
expect((failure as Error).message).toBe(
|
|
`@openmaic/storage: invalid runtime record ${JSON.stringify(init.id)}: /createdAt: expected ISO 8601 \`createdAt\``,
|
|
);
|
|
expect(requestCount).toBe(0);
|
|
});
|
|
|
|
test('rejects payload values that cannot be represented faithfully by JSON', async () => {
|
|
const storeId = `json-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
await store.createSession(makeSession('json-session'));
|
|
|
|
const sparse = [, 'present'];
|
|
const circular: Record<string, unknown> = {};
|
|
circular.self = circular;
|
|
const symbolKeyed = { [Symbol('k')]: 1 };
|
|
const nonEnumerable = Object.defineProperty({}, 'k', { value: 1, enumerable: false });
|
|
const arrayWithExtraProperty = Object.assign([1, 2], { meta: 'x' });
|
|
const enumerableAccessor = Object.defineProperty({}, 'value', {
|
|
enumerable: true,
|
|
get: () => 'x',
|
|
});
|
|
const ArraySubclass = class extends Array {};
|
|
const arraySubclass = Object.assign(new ArraySubclass(), { 0: 'x', length: 1 });
|
|
const fakeDense = Object.setPrototypeOf([, 1], { 0: 'x' });
|
|
const rejected: [string, unknown][] = [
|
|
['Map', new Map([['key', 'value']])],
|
|
['Set', new Set(['value'])],
|
|
['Date', new Date(T0)],
|
|
['NaN', Number.NaN],
|
|
['negative zero', -0],
|
|
['nested undefined', { nested: undefined }],
|
|
['bigint', (globalThis as unknown as { BigInt(value: number): unknown }).BigInt(1)],
|
|
['NUL', `before\u0000after`],
|
|
['NUL object key', { 'bad key\u0000': 1 }],
|
|
['unpaired surrogate string', '\uD800'],
|
|
['unpaired surrogate object key', { ['\uD800']: 1 }],
|
|
['symbol key', symbolKeyed],
|
|
['non-enumerable property', nonEnumerable],
|
|
['array extra own property', arrayWithExtraProperty],
|
|
['enumerable accessor property', enumerableAccessor],
|
|
['Array subclass', arraySubclass],
|
|
['prototype-provided array index', fakeDense],
|
|
['circular reference', circular],
|
|
];
|
|
|
|
await expect(store.appendRecord(makeRecord('json-session', sparse))).rejects.toThrow(
|
|
/sparse array hole/,
|
|
);
|
|
|
|
for (const [name, payload] of rejected) {
|
|
await expect(store.appendRecord(makeRecord('json-session', payload)), name).rejects.toThrow(
|
|
/not a plain JSON value/,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('accepts JSON strings containing line and paragraph separators', async () => {
|
|
const storeId = `json-separators-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
await store.createSession(makeSession('json-separators'));
|
|
|
|
await expect(
|
|
store.appendRecord(makeRecord('json-separators', `before\u2028after`)),
|
|
).resolves.toMatchObject({ payload: `before\u2028after` });
|
|
await expect(
|
|
store.appendRecord(makeRecord('json-separators', `before\u2029after`)),
|
|
).resolves.toMatchObject({ payload: `before\u2029after` });
|
|
});
|
|
|
|
test('accepts a valid surrogate pair string', async () => {
|
|
const storeId = `json-surrogate-pair-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
await store.createSession(makeSession('json-surrogate-pair'));
|
|
|
|
await expect(store.appendRecord(makeRecord('json-surrogate-pair', '𐀀'))).resolves.toMatchObject(
|
|
{ payload: '𐀀' },
|
|
);
|
|
});
|
|
|
|
test('rejects non-JSON properties and NUL ids across full write envelopes', async () => {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => {
|
|
throw new Error('fetch must not be called');
|
|
},
|
|
});
|
|
const sessionWithDate = Object.assign(makeSession('session-date'), { extra: new Date(T0) });
|
|
const recordWithDate = Object.assign(makeRecord('session'), { extra: new Date(T0) });
|
|
|
|
await expect(store.createSession(sessionWithDate)).rejects.toThrow(/not a plain JSON value/);
|
|
await expect(store.appendRecord(recordWithDate)).rejects.toThrow(/not a plain JSON value/);
|
|
await expect(store.createSession(makeSession('bad\u0000session'))).rejects.toThrow(
|
|
/not a plain JSON value/,
|
|
);
|
|
await expect(
|
|
store.appendRecord({ ...makeRecord('session'), id: 'bad\u0000record' }),
|
|
).rejects.toThrow(/not a plain JSON value/);
|
|
});
|
|
|
|
test('rejects dot-only path segments before URL construction', async () => {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => {
|
|
throw new Error('fetch must not be called');
|
|
},
|
|
});
|
|
|
|
await expect(store.getSession('.')).rejects.toThrow(/must not be ['"]\.['"]/);
|
|
await expect(store.getSession('..')).rejects.toThrow(/must not be ['"]\.\.['"]/);
|
|
});
|
|
|
|
test.each([
|
|
['session id', (store: HttpRuntimeStore) => store.createSession(makeSession('.'))],
|
|
[
|
|
'stage id',
|
|
(store: HttpRuntimeStore) => store.createSession(makeSession('dot-stage', { stageId: '..' })),
|
|
],
|
|
[
|
|
'session learner key',
|
|
(store: HttpRuntimeStore) =>
|
|
store.createSession(makeSession('dot-learner', { learnerKey: '.' })),
|
|
],
|
|
['merge target learner key', (store: HttpRuntimeStore) => store.mergeLearner('from', '..')],
|
|
])('rejects a dot-only %s body field before sending a request', async (_name, operation) => {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => {
|
|
throw new Error('fetch must not be called');
|
|
},
|
|
});
|
|
|
|
await expect(operation(store)).rejects.toThrow(/URL path segment must not be/);
|
|
});
|
|
|
|
test.each([
|
|
['from NUL', 'from\u0000learner', 'to-learner'],
|
|
['to NUL', 'from-learner', 'to\u0000learner'],
|
|
['from unpaired surrogate', '\uD800', 'to-learner'],
|
|
['to unpaired surrogate', 'from-learner', '\uD800'],
|
|
])(
|
|
'rejects a merge learner key containing %s before sending a request',
|
|
async (_name, fromLearnerKey, toLearnerKey) => {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => {
|
|
throw new Error('fetch must not be called');
|
|
},
|
|
});
|
|
|
|
await expect(store.mergeLearner(fromLearnerKey, toLearnerKey)).rejects.toThrow(
|
|
/not a plain JSON value/,
|
|
);
|
|
},
|
|
);
|
|
|
|
test('treats a top-level undefined record anchor identically to an omitted anchor', async () => {
|
|
const storeId = `undefined-anchor-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
await store.createSession(makeSession('undefined-anchor'));
|
|
|
|
const explicit = await store.appendRecord({
|
|
...makeRecord('undefined-anchor', { source: 'explicit' }),
|
|
sceneId: undefined,
|
|
});
|
|
const omitted = await store.appendRecord(makeRecord('undefined-anchor', { source: 'omitted' }));
|
|
|
|
expect('sceneId' in explicit).toBe(false);
|
|
expect('sceneId' in omitted).toBe(false);
|
|
await expect(store.listRecords('undefined-anchor')).resolves.toEqual([explicit, omitted]);
|
|
});
|
|
|
|
test('rejects an unknown top-level record field that is explicitly undefined', async () => {
|
|
const storeId = `undefined-ext-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
await store.createSession(makeSession('undefined-ext'));
|
|
|
|
await expect(
|
|
store.appendRecord({
|
|
...makeRecord('undefined-ext', { source: 'extension' }),
|
|
ext: undefined,
|
|
} as never),
|
|
).rejects.toThrow(/undefined member/);
|
|
});
|
|
|
|
test('maps non-array list response containers to malformed-response errors', async () => {
|
|
const sessionsStore = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => fakeJsonResponse({ sessions: [] }, 206),
|
|
});
|
|
const recordsStore = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => fakeJsonResponse({ records: [] }, 202),
|
|
});
|
|
|
|
await expect(sessionsStore.listSessions('stage', 'learner')).rejects.toMatchObject({
|
|
status: 206,
|
|
code: 'MALFORMED_RESPONSE',
|
|
});
|
|
await expect(recordsStore.listRecords('session')).rejects.toMatchObject({
|
|
status: 202,
|
|
code: 'MALFORMED_RESPONSE',
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
['non-numeric', '1'],
|
|
['non-finite', Number.POSITIVE_INFINITY],
|
|
['negative', -3],
|
|
['fractional', 2.5],
|
|
])('maps a %s merge moved field to a malformed-response error', async (_name, moved) => {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => fakeJsonResponse({ moved }, 201),
|
|
});
|
|
|
|
await expect(store.mergeLearner('from', 'to')).rejects.toMatchObject({
|
|
status: 201,
|
|
code: 'MALFORMED_RESPONSE',
|
|
});
|
|
});
|
|
|
|
test('validates append responses and every listed record, including seq', async () => {
|
|
const baseRecord = {
|
|
id: 'record',
|
|
sessionId: 'session',
|
|
createdAt: T0,
|
|
payload: null,
|
|
};
|
|
const invalidRecords: unknown[] = [
|
|
{ ...baseRecord, seq: -1 },
|
|
{ ...baseRecord, seq: Number.NaN },
|
|
{ ...baseRecord, seq: 0.5 },
|
|
baseRecord,
|
|
];
|
|
|
|
for (const invalid of invalidRecords) {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => fakeJsonResponse(invalid, 201),
|
|
});
|
|
await expect(store.appendRecord(makeRecord('session', null))).rejects.toThrow(/seq/);
|
|
}
|
|
|
|
const listStore = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => fakeJsonResponse([{ ...baseRecord, seq: 1 }, invalidRecords[0]]),
|
|
});
|
|
await expect(listStore.listRecords('session')).rejects.toThrow(/seq/);
|
|
});
|
|
|
|
test('reports null session and record response bodies as readable storage errors', async () => {
|
|
const nullStore = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => fakeJsonResponse(null),
|
|
});
|
|
const nullListStore = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => fakeJsonResponse([null]),
|
|
});
|
|
const operations: [string, () => Promise<unknown>][] = [
|
|
['session', () => nullStore.getSession('session')],
|
|
['session', () => nullStore.createSession(makeSession('session'))],
|
|
['record', () => nullStore.appendRecord(makeRecord('session'))],
|
|
['record', () => nullListStore.listRecords('session')],
|
|
];
|
|
|
|
for (const [kind, operation] of operations) {
|
|
const error = await operation().then(
|
|
() => undefined,
|
|
(reason: unknown) => reason,
|
|
);
|
|
expect(error).toBeInstanceOf(Error);
|
|
expect(error).not.toBeInstanceOf(TypeError);
|
|
expect(error).toMatchObject({
|
|
message: expect.stringContaining(`invalid stored runtime ${kind}`),
|
|
});
|
|
}
|
|
});
|
|
|
|
test('sorts validated listRecords responses by seq ascending', async () => {
|
|
const records: RuntimeRecord[] = [
|
|
{ id: 'two', sessionId: 'session', seq: 2, createdAt: T0, payload: null },
|
|
{ id: 'zero', sessionId: 'session', seq: 0, createdAt: T0, payload: null },
|
|
{ id: 'one', sessionId: 'session', seq: 1, createdAt: T0, payload: null },
|
|
];
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch: async () => fakeJsonResponse(records),
|
|
});
|
|
|
|
await expect(store.listRecords('session')).resolves.toMatchObject([
|
|
{ seq: 0 },
|
|
{ seq: 1 },
|
|
{ seq: 2 },
|
|
]);
|
|
});
|
|
|
|
test('normalizes a Headers instance without consulting the global Headers constructor', async () => {
|
|
const HeadersConstructor = globalThis.Headers;
|
|
const suppliedHeaders = new HeadersConstructor([['X-Test-Header', 'present']]);
|
|
const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'Headers');
|
|
const fetch: typeof globalThis.fetch = async (_input, init) => {
|
|
expect(init?.headers).toEqual({ 'x-test-header': 'present' });
|
|
return fakeJsonResponse(undefined, 204);
|
|
};
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: 'https://runtime.invalid',
|
|
fetch,
|
|
headers: () => suppliedHeaders,
|
|
});
|
|
|
|
Reflect.deleteProperty(globalThis, 'Headers');
|
|
try {
|
|
await store.deleteStageRuntime('stage');
|
|
} finally {
|
|
if (descriptor) Object.defineProperty(globalThis, 'Headers', descriptor);
|
|
}
|
|
});
|
|
|
|
test('caller-controlled error substrings do not hijack structured status classification', async () => {
|
|
const storeId = `malicious-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
const maliciousId = `no session already exists newer than this client's`;
|
|
|
|
const failure = store.createSession(makeSession(maliciousId, { learnerKey: '' }));
|
|
await expect(failure).rejects.toMatchObject({ status: 400, code: 'VALIDATION_FAILED' });
|
|
});
|
|
|
|
test('preserves empty segments and keeps empty-key deletes idempotent and route-local', async () => {
|
|
const storeId = `empty-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
await store.createSession(
|
|
makeSession('route-sentinel', { stageId: 'learners', learnerKey: 'sentinel' }),
|
|
);
|
|
|
|
await expect(store.getSession('')).resolves.toBeUndefined();
|
|
await expect(store.deleteSession('')).resolves.toBeUndefined();
|
|
await expect(store.deleteLearnerRuntime('', '')).resolves.toBeUndefined();
|
|
await expect(store.getSession('route-sentinel')).resolves.toMatchObject({
|
|
id: 'route-sentinel',
|
|
});
|
|
});
|
|
|
|
test('ignores client-submitted runtimeDslVersion and seq in favor of store values', async () => {
|
|
const storeId = `assigned-${namespace++}`;
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: server.baseUrl,
|
|
fetch: server.fetch,
|
|
headers: () => ({ 'x-runtime-store-id': storeId }),
|
|
});
|
|
const session = await store.createSession({
|
|
...makeSession('assigned-session'),
|
|
runtimeDslVersion: '99.0.0',
|
|
} as RuntimeSessionInit);
|
|
const record = await store.appendRecord({
|
|
...makeRecord('assigned-session'),
|
|
seq: 99,
|
|
} as RuntimeRecordInit);
|
|
|
|
expect(session.runtimeDslVersion).toBe(RUNTIME_DSL_VERSION);
|
|
expect(record.seq).toBe(0);
|
|
});
|
|
});
|
|
|
|
test('real fetch reaches the listening conformance server over loopback', async ({ skip }) => {
|
|
let networkServer: HttpConformanceServer;
|
|
try {
|
|
networkServer = await startHttpConformanceServer();
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === 'EPERM') {
|
|
skip('sandbox does not permit binding a 127.0.0.1 listener');
|
|
}
|
|
throw error;
|
|
}
|
|
try {
|
|
const store = new HttpRuntimeStore({
|
|
baseUrl: networkServer.baseUrl,
|
|
headers: () => ({ 'x-runtime-store-id': 'real-network' }),
|
|
});
|
|
await store.createSession(makeSession('network-session'));
|
|
await store.appendRecord(makeRecord('network-session'));
|
|
await expect(store.listRecords('network-session')).resolves.toMatchObject([
|
|
{ sessionId: 'network-session', seq: 0 },
|
|
]);
|
|
} finally {
|
|
await networkServer.close();
|
|
}
|
|
});
|