Files
makelore/tests/unit/preview-data-session.test.ts

370 lines
15 KiB
TypeScript

import { EventEmitter, once } from 'node:events';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { startHostApiServer } from '@electron/api/server';
import type { HostApiContext } from '@electron/api/context';
import type { AgentBrowserLifecycleEvent } from '@electron/agent-browser/module';
import {
PREVIEW_DATA_BUCKET_CAPACITY,
PREVIEW_DATA_MAX_REQUEST_BYTES,
createPreviewDataSessionManager,
type PreviewDataSessionManager,
} from '@electron/services/preview-data-session';
import type { DataServiceOperations } from '@electron/services/data-service-client';
import {
clearWorksSquareSession,
resetWorksSquareSessionForTests,
storeWorksSquareSession,
} from '@electron/services/works-square-session';
import type { DataServiceDocument, DataServiceHostResult } from '../../shared/data-service';
const roots: string[] = [];
function success<T>(data: T, status = 200): DataServiceHostResult<T> {
return { success: true, status, code: null, error: null, retryable: false, data };
}
function operationSet(overrides: Partial<Record<keyof DataServiceOperations, ReturnType<typeof vi.fn>>> = {}) {
return {
configure: vi.fn().mockResolvedValue(success(null)),
inspect: vi.fn().mockResolvedValue(success(null)),
listProjects: vi.fn().mockResolvedValue(success(null)),
getDocument: vi.fn().mockResolvedValue(success(null)),
listDocuments: vi.fn().mockResolvedValue(success(null)),
putDocument: vi.fn().mockResolvedValue(success(null)),
deleteDocument: vi.fn().mockResolvedValue(success(null, 204)),
removeCollection: vi.fn().mockResolvedValue(success(null)),
reset: vi.fn().mockResolvedValue(success(null)),
removeProject: vi.fn().mockResolvedValue(success(null)),
...overrides,
} as unknown as DataServiceOperations;
}
function request(method: string, origin: string, token: string): import('node:http').IncomingMessage {
const req = new EventEmitter();
Object.assign(req, {
method,
headers: {
origin,
authorization: `Bearer ${token}`,
host: '127.0.0.1',
},
});
return req as import('node:http').IncomingMessage;
}
async function createManager(
operations = operationSet(),
now: () => number = () => 0,
): Promise<{ manager: PreviewDataSessionManager; operations: DataServiceOperations; projectPath: string }> {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-preview-data-'));
roots.push(projectPath);
const projects = {
requireActiveRealProjectWithIdentity: vi.fn(async () => ({
project: { path: projectPath },
path: projectPath,
projectId: '11111111-1111-4111-8111-111111111111',
})),
};
return {
manager: createPreviewDataSessionManager({ projects, now }),
operations,
projectPath,
};
}
async function startServer(manager: PreviewDataSessionManager, operations: DataServiceOperations) {
const context = {
previewDataSession: manager,
codingProducts: { dataService: operations, previewDataSession: manager },
} as unknown as HostApiContext;
const server = startHostApiServer(context, 0);
await once(server, 'listening');
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Preview test server failed to bind');
return {
baseUrl: `http://127.0.0.1:${address.port}`,
port: address.port,
close: async () => await new Promise<void>((resolve, reject) => {
server.closeAllConnections();
server.close((error) => error ? reject(error) : resolve());
}),
};
}
afterEach(async () => {
resetWorksSquareSessionForTests();
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
describe('preview data session manager', () => {
it('creates a fresh base64url capability bound to the active project and Origin', async () => {
const { manager, projectPath } = await createManager();
const session = await manager.open({
projectPath,
origin: 'http://127.0.0.1:13210',
browserGeneration: 4,
});
const value = manager.getInjectionValue(13210);
expect(session).toMatchObject({
projectPath,
projectId: '11111111-1111-4111-8111-111111111111',
origin: 'http://127.0.0.1:13210',
browserGeneration: 4,
});
expect(value).toMatchObject({
endpoint: 'http://127.0.0.1:13210/api/runtime/data/v1',
contractVersion: 1,
});
expect(value?.token).toMatch(/^[A-Za-z0-9_-]{43}$/);
expect(manager.getInjectionValue(0)).toBeNull();
manager.dispose();
expect(manager.getSnapshot()).toBeNull();
});
it('accepts HTTP/HTTPS loopback Origins, including their default ports', async () => {
const { manager, projectPath } = await createManager();
for (const origin of [
'http://localhost',
'http://localhost:80',
'https://localhost',
'https://localhost:443',
'http://127.0.0.1',
'https://127.0.0.1:443',
'http://[::1]',
'https://[::1]:443',
]) {
await expect(manager.open({ projectPath, origin, browserGeneration: 1 }))
.resolves.toMatchObject({ origin: new URL(origin).origin });
}
await expect(manager.open({
projectPath,
origin: 'https://example.com:443',
browserGeneration: 1,
})).rejects.toMatchObject({ code: 'invalid_origin' });
await expect(manager.open({
projectPath,
origin: 'http://localhost:80/path',
browserGeneration: 1,
})).rejects.toMatchObject({ code: 'invalid_origin' });
});
it('requires matching browser generation before invalidating lifecycle events', async () => {
const { manager, projectPath } = await createManager();
await manager.open({ projectPath, origin: 'http://127.0.0.1:13210', browserGeneration: 2 });
manager.handleAgentBrowserLifecycle({
type: 'closed',
projectId: '11111111-1111-4111-8111-111111111111',
projectPath,
generation: 1,
url: 'http://127.0.0.1:13210/',
});
expect(manager.getSnapshot()).not.toBeNull();
manager.handleAgentBrowserLifecycle({
type: 'closed',
projectId: '11111111-1111-4111-8111-111111111111',
projectPath,
generation: 2,
url: 'http://127.0.0.1:13210/',
});
expect(manager.getSnapshot()).toBeNull();
});
it('invalidates on browser lifecycle events from the current generation', async () => {
const { manager, projectPath } = await createManager();
const lifecycleTypes: AgentBrowserLifecycleEvent['type'][] = [
'cross-origin-navigation',
'generation-replaced',
'detached',
'crashed',
'closed',
];
for (const type of lifecycleTypes) {
await manager.open({ projectPath, origin: 'http://127.0.0.1:13210', browserGeneration: 1 });
manager.handleAgentBrowserLifecycle({
type,
projectId: '11111111-1111-4111-8111-111111111111',
projectPath,
generation: 1,
url: 'http://127.0.0.1:13210/',
});
expect(manager.getSnapshot()).toBeNull();
}
});
it('charges authorized operations with a burst-30, five-per-second bucket while OPTIONS is free', async () => {
const { manager, projectPath } = await createManager(undefined, () => 0);
await manager.open({ projectPath, origin: 'http://127.0.0.1:13210', browserGeneration: 1 });
const token = manager.getInjectionValue(13210)?.token;
if (!token) throw new Error('Preview token was not created');
const preflight = request('OPTIONS', 'http://127.0.0.1:13210', token);
for (let index = 0; index < 10; index += 1) {
expect(manager.authorizeRequest(preflight)).toMatchObject({ ok: true });
}
for (let index = 0; index < PREVIEW_DATA_BUCKET_CAPACITY; index += 1) {
expect(manager.authorizeRequest(request('GET', 'http://127.0.0.1:13210', token))).toMatchObject({ ok: true });
}
expect(manager.authorizeRequest(request('GET', 'http://127.0.0.1:13210', token))).toMatchObject({
ok: false,
status: 429,
code: 'rate_limited',
});
});
it('invalidates on account replacement and session clear while retaining same-account refresh', async () => {
storeWorksSquareSession({ accessToken: 'one', accountPartitionKey: 'a'.repeat(64) });
const { manager, projectPath } = await createManager();
await manager.open({ projectPath, origin: 'http://127.0.0.1:13210', browserGeneration: 1 });
storeWorksSquareSession({ accessToken: 'two', accountPartitionKey: 'a'.repeat(64) });
expect(manager.getSnapshot()).not.toBeNull();
storeWorksSquareSession({ accessToken: 'three', accountPartitionKey: 'b'.repeat(64) });
expect(manager.getSnapshot()).toBeNull();
await manager.open({ projectPath, origin: 'http://127.0.0.1:13210', browserGeneration: 2 });
clearWorksSquareSession();
expect(manager.getSnapshot()).toBeNull();
});
});
describe('preview data loopback routes', () => {
const document: DataServiceDocument = {
id: 'todo-1',
data: { done: false },
revision: 7,
created_at: '2026-08-26T08:00:00Z',
updated_at: '2026-08-26T08:00:00Z',
};
it('dispatches direct DTO data operations before the Host gates and keeps general routes isolated', async () => {
const operations = operationSet({
getDocument: vi.fn().mockResolvedValue(success(document)),
listDocuments: vi.fn().mockResolvedValue(success({ items: [document], next_cursor: null, limit: 50 })),
putDocument: vi.fn().mockResolvedValue(success({ ...document, revision: 8 })),
deleteDocument: vi.fn().mockResolvedValue(success(null, 204)),
});
const { manager, projectPath } = await createManager(operations);
const server = await startServer(manager, operations);
const origin = server.baseUrl;
await manager.open({ projectPath, origin, browserGeneration: 1 });
const value = manager.getInjectionValue(server.port);
if (!value) throw new Error('Preview token was not created');
const headers = { Origin: origin, Authorization: `Bearer ${value.token}` };
try {
const get = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, { headers });
expect(get.status).toBe(200);
expect(get.headers.get('access-control-allow-origin')).toBe(origin);
expect(get.headers.get('access-control-allow-credentials')).toBeNull();
expect(get.headers.get('etag')).toBe('"7"');
await expect(get.json()).resolves.toEqual(document);
const list = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents?limit=50`, { headers });
expect(list.status).toBe(200);
await expect(list.json()).resolves.toEqual({ items: [document], next_cursor: null, limit: 50 });
const put = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, {
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json', 'If-Match': '"7"' },
body: JSON.stringify({ data: { done: true } }),
});
expect(put.status).toBe(200);
expect(operations.putDocument).toHaveBeenCalledWith({
collection: 'todos', document_id: 'todo-1', data: { done: true }, if_revision: 7,
}, projectPath);
const deleted = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, {
method: 'DELETE', headers,
});
expect(deleted.status).toBe(204);
expect(operations.deleteDocument).toHaveBeenCalledWith({
collection: 'todos', document_id: 'todo-1', confirmed: true,
}, projectPath);
const options = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, {
method: 'OPTIONS',
headers: {
Origin: origin,
'Access-Control-Request-Method': 'PUT',
'Access-Control-Request-Headers': 'authorization, content-type, if-match',
},
});
expect(options.status).toBe(204);
expect(options.headers.get('access-control-allow-methods')).toBe('GET, PUT, DELETE, OPTIONS');
expect(options.headers.get('access-control-allow-headers')).toBe('Authorization, Content-Type, If-Match');
const general = await fetch(`${origin}/api/works/data-service/projects`, { headers });
expect(general.status).toBe(401);
const projectIdInLocalPath = await fetch(
`${origin}/api/runtime/data/v1/projects/11111111-1111-4111-8111-111111111111`,
{ headers },
);
expect(projectIdInLocalPath.status).toBe(404);
} finally {
manager.dispose();
await server.close();
}
});
it('rejects wrong Origin, malformed headers, oversized bodies, and oversized responses locally', async () => {
const oversized = { data: { payload: 'x'.repeat(1_400_000) } };
const operations = operationSet({
getDocument: vi.fn().mockResolvedValue(success({ ...document, ...oversized })),
});
const { manager, projectPath } = await createManager(operations);
const server = await startServer(manager, operations);
const origin = server.baseUrl;
await manager.open({ projectPath, origin, browserGeneration: 1 });
const token = manager.getInjectionValue(server.port)?.token;
if (!token) throw new Error('Preview token was not created');
try {
const wrongOrigin = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, {
headers: { Origin: 'http://127.0.0.1:9999', Authorization: `Bearer ${token}` },
});
expect(wrongOrigin.status).toBe(403);
expect(wrongOrigin.headers.get('access-control-allow-origin')).toBeNull();
const malformedHeader = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, {
headers: {
Origin: origin,
Authorization: `Bearer ${token}`,
'X-Not-Allowed': '1',
},
});
expect(malformedHeader.status).toBe(400);
expect(operations.getDocument).not.toHaveBeenCalled();
const tooLarge = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, {
method: 'PUT',
headers: {
Origin: origin,
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ data: { payload: 'x'.repeat(PREVIEW_DATA_MAX_REQUEST_BYTES) } }),
});
expect(tooLarge.status).toBe(413);
const responseTooLarge = await fetch(`${origin}/api/runtime/data/v1/collections/todos/documents/todo-1`, {
headers: { Origin: origin, Authorization: `Bearer ${token}` },
});
expect(responseTooLarge.status).toBe(502);
await expect(responseTooLarge.json()).resolves.toMatchObject({
detail: { code: 'upstream_invalid_response' },
});
} finally {
manager.dispose();
await server.close();
}
});
});