278 lines
10 KiB
TypeScript
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');
|
|
});
|
|
});
|