sync: update from private repo (3385c80)
CI / build-and-test (push) Has been cancelled

This commit is contained in:
oss-sync
2026-06-08 05:00:15 +00:00
parent 38bd874366
commit ee062050e0
13 changed files with 295 additions and 44 deletions
+118 -4
View File
@@ -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();
+31 -6
View File
@@ -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()) {