370 lines
15 KiB
TypeScript
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();
|
|
}
|
|
});
|
|
});
|