diff --git a/src/servers/permissions/registry.test.ts b/src/servers/permissions/registry.test.ts index c25e78ed..1d9a44c1 100644 --- a/src/servers/permissions/registry.test.ts +++ b/src/servers/permissions/registry.test.ts @@ -18,7 +18,11 @@ import { assertPermissionTotality, isExemptApiPath } from './totality'; // `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below. `vault` left // on 2026-08-18 with `plugins/vault/`, taking its `/vault` api prefix and its ws provider with it: a // plugin's routes are mounted behind the account gate like any other, so neither needs an exemption now. -const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'desktop', 'sidecar']; +// `desktop` is NOT here: server.tsx's handlers map has it commented out with the rest of the plugin, +// switched off 2026-08-13. It was listed here until 2026-08-18 and made this file's own totality tests +// fail for five days — the mirror claiming a provider the server does not serve, which is precisely the +// drift the comment above warns about, caught by the check and then ignored because it was "the baseline". +const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'sidecar']; const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))]; const surface = () => ({ apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/sidecar'], @@ -44,10 +48,13 @@ describe('totality', () => { expect(() => assertPermissionTotality(s)).toThrow(/websocket provider 'newsocket' is served/); }); + // Used `/gitea`, which claims no API prefix any more (unmounted 2026-08-13), so removing it from the + // served list produced no error and the test could not detect what it was written to detect. `/tasks` + // is core, mounted, and claimed — so withdrawing it is a real "a permission points at nothing" case. test('refuses a claim on a router that no longer exists', () => { const s = surface(); - s.apiPrefixes = s.apiPrefixes.filter((p) => p !== '/gitea'); - expect(() => assertPermissionTotality(s)).toThrow(/claims \/api\/gitea, which nothing mounts/); + s.apiPrefixes = s.apiPrefixes.filter((p) => p !== '/tasks'); + expect(() => assertPermissionTotality(s)).toThrow(/claims \/api\/tasks, which nothing mounts/); }); test('refuses a claim on a socket that is not served', () => { @@ -74,9 +81,14 @@ describe('totality', () => { }); describe('path → permission', () => { + // These used to read `/api/gitea`, which stopped resolving on 2026-08-13 when gitea's router was + // unmounted pending its own extraction — the permission still exists and still gates the FRONTEND route, + // but `permissionForApiPath` answers about mounted API prefixes and there is no longer one. Re-pointed + // at `/tasks`, which is core and mounted, so the test exercises the resolver rather than the state of + // whichever feature happens to be mid-extraction. test('resolves a prefix and its descendants', () => { - expect(permissionForApiPath('/api/gitea')?.key).toBe('gitea'); - expect(permissionForApiPath('/api/gitea/repos/a/b')?.key).toBe('gitea'); + expect(permissionForApiPath('/api/tasks')?.key).toBe('tasks'); + expect(permissionForApiPath('/api/tasks/pipeline/ws')?.key).toBe('tasks'); }); test('does not match a prefix that is merely a string prefix', () => { @@ -85,9 +97,15 @@ describe('path → permission', () => { expect(permissionForApiPath('/api/username')).toBeNull(); }); - test('longest prefix wins, so /dav and /caldav do not fight', () => { - expect(permissionForApiPath('/api/caldav/x')?.key).toBe('calendar'); - expect(permissionForApiPath('/api/dav/x')?.key).toBe('calendar'); + // Was `/dav` vs `/caldav`, both of which are unmounted now — and both claimed by the same permission, + // so it never demonstrated the property anyway. `/user` (account) against `/users` (user-admin) is the + // live overlap AND a real privilege boundary: resolving `/api/users` to `account` would hand the + // ordinary-account permission the user-administration API. + test('longest prefix wins, so /user and /users do not fight', () => { + expect(permissionForApiPath('/api/user')?.key).toBe('account'); + expect(permissionForApiPath('/api/user/settings')?.key).toBe('account'); + expect(permissionForApiPath('/api/users')?.key).toBe('user-admin'); + expect(permissionForApiPath('/api/users/3/permissions')?.key).toBe('user-admin'); }); test('an unclaimed path resolves to nothing rather than to something permissive', () => { @@ -236,7 +254,25 @@ describe('kinds', () => { expect(GRANTABLE_PERMISSIONS.some((c) => c.key === 'gitea')).toBe(true); }); - test('every permission declares at least one api prefix', () => { - for (const permission of PERMISSIONS) expect(permission.api.length).toBeGreaterThan(0); + /* + Was `every permission declares at least one api prefix`, and that stopped being true on 2026-08-13 + rather than becoming wrong: eleven permissions now declare none, because their routers were unmounted + pending plugin extraction while the permission stayed to keep the frontend route gated and existing + role grants meaningful. The test went red and was carried as "baseline" for five days. + + What it was actually protecting is still worth protecting — a permission that gates NOTHING can be + granted and revoked to no effect, which is worse than not existing because it reads as a control. So + the invariant becomes "gates something a client can reach": an API prefix, or a frontend route. + */ + test('every permission gates something a client can reach', () => { + // `notify` is the one that genuinely gates nothing today: no API (unmounted with its plugin) and no + // screen of its own by design — it produces notifications FOR other features. Named here rather than + // silently tolerated, so it is a known state and not a hole the test stopped being able to see. + const DORMANT = new Set(['notify']); + for (const permission of PERMISSIONS) { + if (DORMANT.has(permission.key)) continue; + const gates = permission.api.length + (permission.routes?.length ?? 0); + expect(gates).toBeGreaterThan(0); + } }); }); diff --git a/src/servers/sidecar/pty/index.test.ts b/src/servers/sidecar/pty/index.test.ts index 8fdac1c9..d76c3f60 100644 --- a/src/servers/sidecar/pty/index.test.ts +++ b/src/servers/sidecar/pty/index.test.ts @@ -117,7 +117,12 @@ describe('pty sidecar', () => { // The sidecar chooses the shell and the home itself, so pin both rather than spawning the owner's // interactive zsh, which would read their rc files and their history. SHELL: '/bin/sh', - HOME_DIR: '/tmp', + // `HOME`, not `HOME_DIR`. This said `HOME_DIR: '/tmp'` until 2026-08-18 and had silently done + // nothing since 2026-08-13, when that variable was removed from the platform entirely (the install + // root is derived now — see `data-path.ts`). `sessions.mjs` resolves `~` with node's `homedir()`, + // which on POSIX reads `$HOME`, so the shell opened in the OWNER's real home and `pwd` never + // printed `/tmp`. The test then waited the full 15s for output that could not arrive. + HOME: '/tmp', ENV: '/dev/null', }, stdout: 'ignore', @@ -132,7 +137,7 @@ describe('pty sidecar', () => { const a = client(wsBase, `sessionId=${sessionId}&cwd=~&cols=80&rows=24`); await a.open(); - // `~` is resolved by the sidecar against its own HOME_DIR. + // `~` is resolved by the sidecar against its own home — `homedir()` in sessions.mjs, pinned above. a.send({ type: 'input', data: 'pwd\n' }); await a.await(sawOutput('/tmp'), 'pwd output');