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'); }); });