This commit is contained in:
@@ -3,7 +3,7 @@ import express from 'express';
|
||||
import request from 'supertest';
|
||||
import { Repository } from '../db/repository.js';
|
||||
import { runMigrations } from '../db/migrate.js';
|
||||
import { createBrowserApi } from './browser-api.js';
|
||||
import { createBrowserApi, unavailableReason } from './browser-api.js';
|
||||
import {
|
||||
type SessionManager,
|
||||
type BrowserSession,
|
||||
@@ -23,6 +23,17 @@ vi.mock('./novnc-proxy.js', async () => {
|
||||
});
|
||||
const novncProxyMock = await import('./novnc-proxy.js');
|
||||
|
||||
// browser.displayMode を per-test で切り替えられるようにする。
|
||||
// createBrowserApi の各ハンドラは loadConfig().browser?.displayMode を読む。
|
||||
let mockDisplayMode: 'headless' | 'novnc' | undefined = 'novnc';
|
||||
vi.mock('../config.js', async () => {
|
||||
const actual = await vi.importActual<typeof import('../config.js')>('../config.js');
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: vi.fn(() => ({ browser: { displayMode: mockDisplayMode } })),
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 2026-05 redesign: API は CAPTCHA Pool (admin only) と Task Session
|
||||
* (visibility ベース) を分離。テストダブルも kind / taskId / captchaPending
|
||||
@@ -115,6 +126,7 @@ describe('Browser API', () => {
|
||||
const dbPath = './_test_browser_api.db';
|
||||
|
||||
beforeEach(() => {
|
||||
mockDisplayMode = 'novnc'; // 既存テストは session が見える前提なので novnc 既定
|
||||
repo = new Repository(dbPath);
|
||||
runMigrations(repo.getDb());
|
||||
});
|
||||
@@ -130,7 +142,7 @@ describe('Browser API', () => {
|
||||
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
|
||||
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false });
|
||||
expect(res.body).toEqual({ available: false, reason: 'no_session' });
|
||||
});
|
||||
|
||||
it('returns pool info to admin', async () => {
|
||||
@@ -180,7 +192,7 @@ describe('Browser API', () => {
|
||||
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
|
||||
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false });
|
||||
expect(res.body).toEqual({ available: false, reason: 'no_session' });
|
||||
});
|
||||
|
||||
it('owner can see their own task session', async () => {
|
||||
@@ -201,7 +213,7 @@ describe('Browser API', () => {
|
||||
const app = makeApp(sm, repo, { id: 'bob', role: 'user' });
|
||||
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false });
|
||||
expect(res.body).toEqual({ available: false, reason: 'no_session' });
|
||||
});
|
||||
|
||||
it('admin can see any task session', async () => {
|
||||
@@ -240,6 +252,108 @@ describe('Browser API', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('unavailableReason helper', () => {
|
||||
it('returns headless_mode when displayMode is not novnc', () => {
|
||||
expect(unavailableReason('headless', true)).toBe('headless_mode');
|
||||
expect(unavailableReason('headless', false)).toBe('headless_mode');
|
||||
expect(unavailableReason(undefined, true)).toBe('headless_mode'); // default headless
|
||||
});
|
||||
|
||||
it('returns display_unavailable when novnc but sessionManager absent', () => {
|
||||
expect(unavailableReason('novnc', false)).toBe('display_unavailable');
|
||||
});
|
||||
|
||||
it('returns no_session when novnc and sessionManager present', () => {
|
||||
expect(unavailableReason('novnc', true)).toBe('no_session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reason codes on available:false responses', () => {
|
||||
it('headless mode → task-session returns reason:headless_mode (even with sessionManager)', async () => {
|
||||
mockDisplayMode = 'headless';
|
||||
const sm = new FakeSessionManager();
|
||||
const taskId = await createTask(repo, 'alice');
|
||||
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
|
||||
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false, reason: 'headless_mode' });
|
||||
});
|
||||
|
||||
it('headless mode → captcha-pool returns reason:headless_mode', async () => {
|
||||
mockDisplayMode = 'headless';
|
||||
const sm = new FakeSessionManager();
|
||||
const app = makeApp(sm, repo, { id: 'admin-1', role: 'admin' });
|
||||
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false, reason: 'headless_mode' });
|
||||
});
|
||||
|
||||
it('novnc + sessionManager null → task-session returns reason:display_unavailable', async () => {
|
||||
mockDisplayMode = 'novnc';
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as unknown as { user: unknown }).user = {
|
||||
id: 'alice', role: 'user', status: 'active', orgIds: [],
|
||||
email: '[email protected]', name: 'alice', avatarUrl: null,
|
||||
defaultVisibility: 'private', defaultVisibilityOrgId: null,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use('/api/local/browser/sessions', createBrowserApi(null, repo));
|
||||
const taskId = await createTask(repo, 'alice');
|
||||
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false, reason: 'display_unavailable' });
|
||||
});
|
||||
|
||||
it('novnc + sessionManager null → captcha-pool returns reason:display_unavailable', async () => {
|
||||
mockDisplayMode = 'novnc';
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as unknown as { user: unknown }).user = {
|
||||
id: 'admin-1', role: 'admin', status: 'active', orgIds: [],
|
||||
email: '[email protected]', name: 'admin', avatarUrl: null,
|
||||
defaultVisibility: 'private', defaultVisibilityOrgId: null,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use('/api/local/browser/sessions', createBrowserApi(null, repo));
|
||||
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false, reason: 'display_unavailable' });
|
||||
});
|
||||
|
||||
it('headless + sessionManager null → reason:headless_mode', async () => {
|
||||
mockDisplayMode = 'headless';
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as unknown as { user: unknown }).user = {
|
||||
id: 'admin-1', role: 'admin', status: 'active', orgIds: [],
|
||||
email: '[email protected]', name: 'admin', avatarUrl: null,
|
||||
defaultVisibility: 'private', defaultVisibilityOrgId: null,
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use('/api/local/browser/sessions', createBrowserApi(null, repo));
|
||||
const res = await request(app).get('/api/local/browser/sessions/captcha-pool');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false, reason: 'headless_mode' });
|
||||
});
|
||||
|
||||
it('novnc + sessionManager present + no session → reason:no_session', async () => {
|
||||
mockDisplayMode = 'novnc';
|
||||
const sm = new FakeSessionManager();
|
||||
const taskId = await createTask(repo, 'alice');
|
||||
const app = makeApp(sm, repo, { id: 'alice', role: 'user' });
|
||||
const res = await request(app).get(`/api/local/browser/sessions/task-session/${taskId}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ available: false, reason: 'no_session' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /task-session/:taskId/release', () => {
|
||||
it('owner can release their own task session', async () => {
|
||||
const sm = new FakeSessionManager();
|
||||
|
||||
@@ -4,6 +4,25 @@ import type { Repository } from '../db/repository.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { buildNovncPath, isNovncStaticInstalled } from './novnc-proxy.js';
|
||||
import { canUserSeeTask, canEditEntity } from './visibility.js';
|
||||
import { loadConfig } from '../config.js';
|
||||
|
||||
/**
|
||||
* `available: false` なレスポンスに添える reason コードを決める純関数。
|
||||
* 優先順位:
|
||||
* 1. displayMode !== 'novnc' → 'headless_mode' (config で機能 OFF)
|
||||
* 2. SessionManager が null → 'display_unavailable' (noVNC スタック未導入)
|
||||
* 3. それ以外 → 'no_session' (まだ session が無いだけ)
|
||||
*
|
||||
* どのバイナリ (Xvfb/x11vnc/websockify) が欠けているかは漏らさない。
|
||||
*/
|
||||
export function unavailableReason(
|
||||
displayMode: string | undefined,
|
||||
sessionManagerPresent: boolean,
|
||||
): 'headless_mode' | 'display_unavailable' | 'no_session' {
|
||||
if ((displayMode ?? 'headless') !== 'novnc') return 'headless_mode';
|
||||
if (!sessionManagerPresent) return 'display_unavailable';
|
||||
return 'no_session';
|
||||
}
|
||||
|
||||
/**
|
||||
* 2026-05 redesign: CAPTCHA Pool (admin 専用) と Task Session (タスク
|
||||
@@ -102,10 +121,12 @@ export function createBrowserApi(sessionManager: SessionManager | null, repo: Re
|
||||
// は available: false を返したい。503 にしてしまうと UI 側でエラー扱いされてしまう。
|
||||
if (!sessionManager) {
|
||||
router.get('/captcha-pool', (_req: Request, res: Response) => {
|
||||
res.json({ available: false });
|
||||
const reason = unavailableReason(loadConfig().browser?.displayMode, false);
|
||||
res.json({ available: false, reason });
|
||||
});
|
||||
router.get('/task-session/:taskId', (_req: Request, res: Response) => {
|
||||
res.json({ available: false });
|
||||
const reason = unavailableReason(loadConfig().browser?.displayMode, false);
|
||||
res.json({ available: false, reason });
|
||||
});
|
||||
router.all('*', (_req: Request, res: Response) => {
|
||||
res.status(503).json({ error: 'Browser sessions not available (missing system dependencies)' });
|
||||
@@ -122,7 +143,8 @@ export function createBrowserApi(sessionManager: SessionManager | null, repo: Re
|
||||
}
|
||||
const pool = sessionManager.getSession(CAPTCHA_POOL_SESSION_ID);
|
||||
if (!pool) {
|
||||
res.json({ available: false });
|
||||
const reason = unavailableReason(loadConfig().browser?.displayMode, true);
|
||||
res.json({ available: false, reason });
|
||||
return;
|
||||
}
|
||||
if (!isNovncStaticInstalled()) {
|
||||
@@ -162,12 +184,15 @@ export function createBrowserApi(sessionManager: SessionManager | null, repo: Re
|
||||
.listSessions()
|
||||
.find((s) => s.kind === 'task' && s.taskId === taskId);
|
||||
if (!session) {
|
||||
res.json({ available: false });
|
||||
const reason = unavailableReason(loadConfig().browser?.displayMode, true);
|
||||
res.json({ available: false, reason });
|
||||
return;
|
||||
}
|
||||
if (!(await canViewSession(req, session, repo))) {
|
||||
// 認可失敗は available: false にして session 存在情報を漏らさない
|
||||
res.json({ available: false });
|
||||
// 認可失敗は available: false にして session 存在情報を漏らさない。
|
||||
// reason も session が無いとき相当 (存在を漏らさない) で返す。
|
||||
const reason = unavailableReason(loadConfig().browser?.displayMode, true);
|
||||
res.json({ available: false, reason });
|
||||
return;
|
||||
}
|
||||
if (!isNovncStaticInstalled()) {
|
||||
|
||||
Reference in New Issue
Block a user