maestro/src/bridge/browser-session-api.test.ts
2026-06-03 05:08:00 +00:00

278 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import express from 'express';
import request from 'supertest';
import { mkdtempSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { Repository, BrowserSessionRepo } from '../db/repository.js';
import { createBrowserSessionApi } from './browser-session-api.js';
import { initMasterKey, generateUserDek, encryptUserDek } from '../crypto/sessions.js';
import type { SessionManager } from '../engine/browser-session.js';
interface TestContext {
app: express.Application;
repository: Repository;
sessRepo: BrowserSessionRepo;
tempDir: string;
}
function buildApp(userId: string): TestContext {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const db = repository.getDb();
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES (?, ?, 'active', 'active', datetime('now'), datetime('now'))`)
.run(userId, `${userId}@test`);
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
const master = initMasterKey(masterKeyPath);
// Pre-seed a DEK for the user
sessRepo.setUserDek(userId, encryptUserDek(master, generateUserDek()));
const app = express();
app.use(express.json());
// Stub req.user for auth-required tests
app.use((req, _res, next) => {
(req as { user?: unknown }).user = { id: userId, role: 'active' };
next();
});
app.use('/api/browser-sessions', createBrowserSessionApi({ sessRepo, sessionManager: null, masterKeyPath }));
return { app, repository, sessRepo, tempDir };
}
describe('browser-session-api', () => {
let ctx: TestContext | null = null;
afterEach(() => {
if (ctx) {
ctx.repository.close();
rmSync(ctx.tempDir, { recursive: true, force: true });
ctx = null;
}
});
it('lists empty for a new user', async () => {
ctx = buildApp('u1');
const res = await request(ctx.app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(200);
expect(res.body.profiles).toEqual([]);
});
it('creates a profile (status=pending, label echoes input)', async () => {
ctx = buildApp('u1');
const res = await request(ctx.app).post('/api/browser-sessions/profiles').send({
label: 'GitHub',
startUrl: 'https://github.com',
matchPatterns: ['https://github.com/**'],
storageOrigins: ['https://github.com'],
loginUrlPatterns: ['https://github.com/login**'],
});
expect(res.status).toBe(201);
expect(res.body.profile.status).toBe('pending');
expect(res.body.profile.label).toBe('GitHub');
// Must NOT leak the encrypted blob
expect(res.body.profile.encryptedStateBlob).toBeUndefined();
expect(res.body.profile.encrypted_state_blob).toBeUndefined();
});
it('deletes only profiles the user owns', async () => {
ctx = buildApp('u1');
// Owned profile: 200
const id = ctx.sessRepo.createProfile({
ownerId: 'u1', label: 'X', startUrl: 'https://x.com',
matchPatterns: [], storageOrigins: [], loginUrlPatterns: [],
});
const ok = await request(ctx.app).delete(`/api/browser-sessions/profiles/${id}`);
expect(ok.status).toBe(200);
// Non-existent: 404
const missing = await request(ctx.app).delete(`/api/browser-sessions/profiles/9999`);
expect(missing.status).toBe(404);
// Other user's profile: 404 (owner enforcement)
ctx.repository.getDb().prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES ('u2','u2@test','active','active',datetime('now'),datetime('now'))`).run();
const otherId = ctx.sessRepo.createProfile({
ownerId: 'u2', label: 'Other', startUrl: 'https://other.com',
matchPatterns: [], storageOrigins: [], loginUrlPatterns: [],
});
const forbidden = await request(ctx.app).delete(`/api/browser-sessions/profiles/${otherId}`);
expect(forbidden.status).toBe(404);
// Confirm it was NOT actually deleted (still exists for u2)
expect(ctx.sessRepo.getProfileById(otherId, 'u2')).not.toBeNull();
});
it('rejects unauthenticated requests', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-noauth-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const sessRepo = new BrowserSessionRepo(repository.getDb());
const masterKeyPath = join(tempDir, 'master.key');
const app = express();
app.use(express.json());
// No req.user middleware → unauthenticated
app.use('/api/browser-sessions', createBrowserSessionApi({ sessRepo, sessionManager: null, masterKeyPath }));
const res = await request(app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthenticated');
repository.close();
rmSync(tempDir, { recursive: true, force: true });
});
// ── P2b: authActive-aware gate ─────────────────────────────────────────────
it('authActive=true still rejects unauthenticated requests', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-auth-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const sessRepo = new BrowserSessionRepo(repository.getDb());
const masterKeyPath = join(tempDir, 'master.key');
const app = express();
app.use(express.json());
// authActive=true + no req.user → must still return 401
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: null,
masterKeyPath,
authActive: true,
}));
const res = await request(app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(401);
expect(res.body.error).toBe('Unauthenticated');
repository.close();
rmSync(tempDir, { recursive: true, force: true });
});
it('authActive=false falls back to synthetic local user (no-auth mode)', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-noauth-local-'));
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
const db = repository.getDb();
// Insert local user so FK constraints pass
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES ('local', 'local@localhost', 'active', 'active', datetime('now'), datetime('now'))`).run();
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
const app = express();
app.use(express.json());
// No req.user middleware, but authActive=false → should inject synthetic local user
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: null,
masterKeyPath,
authActive: false,
}));
const res = await request(app).get('/api/browser-sessions/profiles');
expect(res.status).toBe(200);
expect(res.body.profiles).toEqual([]);
repository.close();
rmSync(tempDir, { recursive: true, force: true });
});
});
describe('login + save flow', () => {
let tempDirsToClean: string[] = [];
let repositoryToClose: Repository | null = null;
afterEach(() => {
if (repositoryToClose) {
repositoryToClose.close();
repositoryToClose = null;
}
for (const d of tempDirsToClean) {
rmSync(d, { recursive: true, force: true });
}
tempDirsToClean = [];
});
it('starts a login session, then save captures storageState and encrypts it', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'maestro-bsapi-loginflow-'));
tempDirsToClean.push(tempDir);
const dbPath = join(tempDir, 'orchestrator.db');
const repository = new Repository(dbPath);
repositoryToClose = repository;
const db = repository.getDb();
db.prepare(`INSERT INTO users (id, email, role, status, created_at, updated_at)
VALUES (?, ?, 'active', 'active', datetime('now'), datetime('now'))`)
.run('u1', 'u1@test');
const sessRepo = new BrowserSessionRepo(db);
const masterKeyPath = join(tempDir, 'master.key');
// Intentionally NOT pre-seeding a DEK — /save should lazily create one via ensureUserDek.
const id = sessRepo.createProfile({
ownerId: 'u1',
label: 'X',
startUrl: 'https://example.com',
matchPatterns: [],
storageOrigins: [],
loginUrlPatterns: [],
});
const fakeContext = {
pages: () => [],
newPage: async () => ({ goto: async () => null }),
storageState: async () => ({ cookies: [{ name: 's', value: '1' }], origins: [] }),
};
const fake = {
createLoginSession: async (_opts: unknown) => ({
id: 'sess1',
kind: 'login',
profileId: id,
context: fakeContext,
browser: { isConnected: () => true },
display: ':99',
}),
getSession: () => ({
id: 'sess1',
kind: 'login',
profileId: id,
context: fakeContext,
}),
destroySession: async () => {},
};
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as { user?: unknown }).user = { id: 'u1', role: 'active' };
next();
});
app.use('/api/browser-sessions', createBrowserSessionApi({
sessRepo,
sessionManager: fake as unknown as SessionManager,
masterKeyPath,
}));
const start = await request(app).post(`/api/browser-sessions/profiles/${id}/login`);
expect(start.status).toBe(200);
expect(start.body.sessionId).toBe('sess1');
expect(start.body.novncPath).toContain('sess1');
const save = await request(app)
.post(`/api/browser-sessions/profiles/${id}/save`)
.send({ sessionId: 'sess1' });
expect(save.status).toBe(200);
const profile = sessRepo.getProfileById(id, 'u1')!;
expect(profile).not.toBeNull();
expect(profile.status).toBe('active');
expect(profile.encryptedStateBlob).not.toBeNull();
expect(profile.encryptedStateBlob!.length).toBeGreaterThan(16);
// Sanity: the API response itself must NOT leak the encrypted blob.
expect(save.body.profile.encryptedStateBlob).toBeUndefined();
expect(save.body.profile.encrypted_state_blob).toBeUndefined();
expect(save.body.profile.status).toBe('active');
});
});