mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
fix(html-view): address review — scope, source view, tests, docs
- Refuse to mint for an HTML file in a hidden directory or directly in a broad root (/, home, tmpdir, cases and user-space roots): the capability serves the directory tree recursively to the page's own scripts. - Keep HTML's source view and editor: a Page pill (per-device htmlRendered, like MD) toggles to the file-content text path, and a refused mint (remote case, broad root, hidden dir) falls through to it automatically. - Tests: html-view auth exemption edges, revocation on logout / admin logout / user deletion, out-of-workspace mint (blocked tree, confinement), hidden and broad roots. - Docs: Working-With-Files, architecture-invariants, security-architecture, the file-routes comment and CLAUDE.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
56978f2cc9
commit
91bdaee724
11 files changed
+275
-27
No files matched your search
@@ -293,7 +293,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
**Attachments** (live external document references; all wiring in `file-routes.ts`): a **registry** maps a stable `attachmentId` to a realpath-resolved, extension-allowlisted absolute path, so browser requests never carry arbitrary absolute paths. ⚠️ The **magic-link scanner** (`codeman://attach?...` in terminal output) is **prompt-injectable**, so its scan path is force-confined to the session workspace; a hostile prompt could otherwise exfiltrate arbitrary host files over SSE. The security gate is an extension **allowlist**, not a blocklist. `document-conversion-limiter.ts` caps converter spawns globally: without it, N large docs detected at once fork N multi-minute processes, which is a resource-exhaustion vector. → [architecture-invariants#attachments](docs/architecture-invariants.md#attachments)
|
||||
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API), and only non-dot, asset-allowlisted files under that directory are served (GET only; capabilities revoked with the web-tab ones on logout). Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
**File-path links (terminal + chat)**: a path an agent prints is clickable on BOTH surfaces and opens the file-preview overlay. ⚠️ ONE pattern (`FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` in constants.js) feeds the xterm link provider AND `_linkifyFilePaths()`, a fresh instance per call (`lastIndex`). The chat linkifier walks TEXT NODES with DOM APIs, never rebuilds sanitized markup as a string. ⚠️ An out-of-workspace path goes through the ATTACHMENT routes (`POST /api/sessions/:id/attachments` with `notify: false`), never by widening `file-content`/`file-raw` or `file-stream-manager`'s `tail -f` allowlist. ⚠️ `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (never a second list), and widening READ must never widen RUN: `html`/`htm`/`svg` stay download-only, other text is inert `text/plain`+`nosniff`. ⚠️ The ONE place HTML renders is `/html-view/<cap>/...` (`html-view-routes.ts`, `html-view-capabilities.ts`): `POST /api/sessions/:id/html-view` mints a capability for the file's own directory, every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin` (opaque origin even as a top-level tab, so the page can never read Codeman or drive its API), and only non-dot, asset-allowlisted files under that directory are served (GET/HEAD only; capabilities revoked with the web-tab ones on logout). That tree is served recursively, so minting is refused for a hidden directory or a broad root (`isBroadHtmlViewRoot`); the preview renders by default and the **Page** pill (or a refused mint) falls through to the source view and its Edit button. Never serve HTML same-origin anywhere else. Tests: `test/routes/html-view-routes.test.ts`. Media extensions are single-sourced in `attachment-registry.ts`. → [architecture-invariants#file-path-links-terminal--response-viewer](docs/architecture-invariants.md#file-path-links-terminal--response-viewer)
|
||||
|
||||
**Filesystem path picker** (Link Existing "Browse" + the mobile keyboard's `📁 Path` key): lazy one-directory browsing via `GET /api/filesystem/browse`, with `GET /api/filesystem/preview` for the tapped file. Inserts the path **without** Enter, so the prompt is never submitted; the sibling `⌫ All` key clears only the unsent prompt and must never send the agent's `/clear`. ⚠️ This is a **second file-serving surface and inherits neither the attachment confinement nor its ownership scoping** — it allowlists Home, `CASES_DIR`, `/mnt/d` and `CODEMAN_FILE_PICKER_ROOTS`, blocks sensitive trees, and rejects symlink escapes **after** `realpath`. ⚠️ The optional `sessionId` is an ownership boundary that must be `canAccessOwned`-checked by hand (it does not go through `findSessionOrFail`), and in multi-user mode a non-admin gets only their own `userSpacePath` as a root: per-user spaces live INSIDE `homedir()`, so a `Home` root exposes every other user's workspace. Previews go through the same global conversion limiter, and Markdown/TXT/JSON are served as inert `text/plain`. → [architecture-invariants#filesystem-path-picker](docs/architecture-invariants.md#filesystem-path-picker)
|
||||
|
||||
|
||||
@@ -365,9 +365,9 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli
|
||||
|
||||
**Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term.
|
||||
|
||||
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow); nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
|
||||
⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except `.html`/`.htm`, which always go to the preview so they render as a page (see below); nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path.
|
||||
|
||||
**Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so.
|
||||
**Text reuses the edit-mode allowlist; markup stays download-only.** `TEXT_ATTACHMENT_EXTENSIONS` IS `EDITABLE_EXTENSIONS` (`config/file-editing.ts`) rather than a second curated list that would drift from it: if the viewer would open a file for editing inside the workspace, the same file outside it can be read. The justification for widening is that the agent in the session can already `cat` any of these and the picker already previews them, so the suffix was never the confidentiality gate; the path guard is (sensitive-file blocklist, `/root` and `/etc` trees, realpath first). ⚠️ Two consequences had to be handled at the same time: `~/.codeman*/state.json` joined `isSensitivePath` (it persists `SessionState.envOverrides`, and the env allowlist admits key-shaped names like `GEMINI_API_KEY`, so it can hold a live credential), and `html`/`htm` joined `svg` in `serveRawFile`'s **download-only** branch so that widening what can be READ never widens what can RUN on our own origin. Text with no dedicated MIME entry goes out as inert `text/plain; charset=utf-8` + `nosniff`, matching the picker. ⚠️ **The one place HTML renders is `/html-view/<cap>/...`** (`html-view-routes.ts`, `html-view-capabilities.ts`), never these routes: `POST /api/sessions/:id/html-view` mints a capability for the HTML file's own directory, and every response carries `Content-Security-Policy: sandbox` WITHOUT `allow-same-origin`, so the page is opaque-origin even as a top-level tab and cannot read Codeman or drive its API. The directory is served recursively to the page's scripts (non-dot, asset-allowlisted, realpath-confined, blocklist applied, GET/HEAD only), which is why minting is refused for an HTML file in a hidden directory or directly in a broad root (`/`, home, the temp dir, the cases and user-space roots; `isBroadHtmlViewRoot`). The preview renders it by default; the **Page** pill (`htmlRendered`, per-device like `mdRendered`) and any refused mint fall through to the source view with its Edit button. Tests: `test/routes/html-view-routes.test.ts`, the html-view block in `test/webview-auth-exemption.test.ts`, `test/webview-capability-revocation.test.ts`. The by-id text preview is bounded like the workspace one: a `Range` request for the first 512KB (a real partial read, not a discarded 50MB download) plus a 500-line cap, with the footer saying so.
|
||||
|
||||
**Media is single-sourced across the two preview paths.** `VIDEO_ATTACHMENT_EXTENSIONS` / `AUDIO_ATTACHMENT_EXTENSIONS` live in `attachment-registry.ts` and are imported by `file-content`'s media classification, so a clip plays identically whether it is in the workspace or reached by id from outside it. They diverged first: the workspace path had its own inline sets and the registry allowlist had no media at all, so a video an agent wrote to `/tmp` was refused as an unsupported type while the same file inside the repo played. ⚠️ Three things have to line up for a player rather than a dead frame: the extension in the allowlist, a **real MIME entry** in `MIME_TYPES` (a `<video>` refuses to decode `application/octet-stream`, which presents as a player that renders and then does nothing), and the range-aware body (`serveRawFile` → `sendFileBody`) that makes the scrub bar work. `getAttachmentType()` returns the `video`/`audio` members of `AttachmentDetectedType` for them; the attachment card has no per-type CSS and its thumbnail falls back to the type label, since `generateFirstPageThumbnail` has no media branch and answers 204. ⚠️ The image-watcher keeps its OWN narrow detection list (`png/pdf/docx/pptx`), so this does not start popping cards for every video an agent writes.
|
||||
|
||||
|
||||
@@ -528,6 +528,7 @@ A saved dashboard URL renders as a tab, served through Codeman's own origin at `
|
||||
- **The proxy is exempt from cookie auth and the Origin/CSRF guard, and that is deliberate.** The iframe is sandboxed without `allow-same-origin`, so it is opaque‑origin: its requests are cross‑site, meaning the `SameSite=lax` session cookie is never attached and its writes and WS upgrades arrive with `Origin: null`. The credential is instead a 192‑bit capability in the path, minted only by an authenticated `POST /api/webviews/:id/open`, held in memory (a restart invalidates every one), rolling TTL, bound to the minting user, and granting nothing but "relay bytes to this one saved URL". ⚠️ **The Host allowlist is NOT bypassed**, so DNS‑rebinding protection is unaffected. A second `Referer`‑keyed form exists for root‑absolute assets and is the only exemption decided by a request‑supplied header, so it is fenced to safe methods on non‑`/api`, non‑`/ws`, non‑`/q` paths. Edges pinned by `test/webview-auth-exemption.test.ts`.
|
||||
- **The lost‑frame recovery page is the third unauthenticated 200, and the only one decided by request headers alone.** The proxy's runtime shim masks `/webview/<cap>/` off the page's own URL so a single‑page app routes on the path it expects; a navigation the page then starts itself (`location.reload()`, a root‑absolute `location.href`) lands on Codeman's root with no capability anywhere, no cookie (opaque origin) and a Referer naming the masked page. `serveLostWebviewFrame()` in `middleware/auth.ts` recognises it by shape (`GET`/`HEAD`, `Sec-Fetch-Dest: iframe` or `frame`, `Accept: text/html`, `Sec-Fetch-Mode: navigate` or absent) and answers, BEFORE the credential checks and without counting an auth failure, with a static page whose only content is a `postMessage` of the lost path to the parent tab (`default-src 'none'` plus the hash of that one script, `no-store`, `referrer: no-referrer`, no reflected input). It is fenced to paths that are NOT registered routes and never `/api/`, `/ws/` or `/q/`, with one carve‑out: `/` itself, because the landing page masks to exactly `/` and its reload otherwise rendered Codeman's app shell inside the web tab. `/` is admitted only when the request carries neither the `codeman_session` cookie nor an `Authorization` header: nothing in Codeman frames its own root and a sandboxed frame has neither, while a framed `/` that does carry credentials still gets the shell. On a passwordless install no auth hook runs, so the index route applies the same test itself (`isLostWebviewRootFrame`). ⚠️ Known property, accepted rather than mitigated: those headers are trivially set by a non‑browser client, so an unauthenticated caller can distinguish a registered route (401) from a non‑route (200) and enumerate the route table; the routes are public in `docs/api-reference.md`, so nothing is learned. Pinned by `test/webview-auth-exemption.test.ts` (password) and `test/webview-lost-root-frame.test.ts` (passwordless).
|
||||
- **Sandboxed by default; `allow-same-origin` is an explicit per‑dashboard opt‑in.** A proxied page is same‑origin with Codeman, so without the sandbox its JavaScript could read the Codeman document and call the agent‑spawning API. ⚠️ In BOTH modes the `Authorization` header and the `codeman_session` cookie are stripped before the upstream request, because a trusted (same‑origin) frame makes the browser attach Codeman's own Basic‑auth credentials to every proxied request; forwarding them would hand `CODEMAN_PASSWORD` to the dashboard.
|
||||
- **Rendered HTML files (`/html-view/<capability>/`) are the other capability-gated route that skips cookie auth.** A clicked `.html` file renders through it so an agent's report can be viewed from any device. The exemption is narrower than the proxy's: `GET`/`HEAD` only, the capability in the path only (no `Referer` form), and it is a separate store, so a web-tab capability never opens a page and vice versa. The capability is minted by an authenticated `POST /api/sessions/:id/html-view` (session ownership via `findSessionOrFail`, the attachment guard for out-of-workspace files), held in memory with a rolling TTL and revoked with the web-tab ones on logout, admin logout and user deletion. Every response carries `Content-Security-Policy: sandbox` without `allow-same-origin`, so the page is opaque-origin even when opened as a top-level tab and cannot read Codeman's document, cookies or API. It may read its own directory tree (non-dot, asset-allowlisted, realpath-confined, blocklist applied), so minting is refused for an HTML file in a hidden directory or directly in a broad root (`/`, home, the temp dir, the cases and user-space roots). Pinned by `test/routes/html-view-routes.test.ts` and the html-view block in `test/webview-auth-exemption.test.ts`.
|
||||
- **Not an open relay, and not a privilege boundary.** `resolveUpstreamUrl()` refuses anything leaving the saved origin, and cross‑origin redirects are handed back unchanged rather than followed. The proxy does reach whatever the SERVER can reach, which is not an escalation for someone who already commands `--dangerously-skip-permissions` agents, but in multi‑user mode it means a non‑admin's dashboard is fetched from the server's network position. Saved URLs are validated to plain http(s) with no embedded credentials, and there is deliberately **no magic‑link path**: terminal output can never create a webview (the mistake the attachment scanner had to be walled off from). The one refused destination class is link‑local and cloud‑metadata addresses (`169.254.0.0/16`, `fe80::/10`, `fd00:ec2::254`, `168.63.129.16`, `100.100.100.200`, `metadata.google.internal`): `webview-egress-policy.ts` refuses them at save time, and `webview-egress.ts` re‑judges the RESOLVED address at connect time through a `lookup` hook on the proxy's undici Agent and on its WebSocket client, so a DNS name pointing into those ranges is refused as well. Loopback and RFC1918 stay allowed on purpose. Capabilities are revoked on logout, admin logout and user deletion, and proxied responses carry `Referrer-Policy: same-origin` so a dashboard cannot hand the capability‑bearing URL to a third‑party host it links.
|
||||
|
||||
---
|
||||
|
||||
@@ -100,8 +100,16 @@ Outside the workspace the allowlist is images, video, audio, PDF, Office documen
|
||||
files, where "text" is the same list the viewer will let you edit: code, config, logs, csv,
|
||||
markdown. The reasoning is that a session can already `cat` any of those, so the file suffix
|
||||
was never what kept anything secret; the path guard is. Types outside the list (`.svg`,
|
||||
`.bmp`) say so rather than failing silently, and `.html` previews as source rather than being
|
||||
rendered, so nothing served this way can execute in the page.
|
||||
`.bmp`) say so rather than failing silently.
|
||||
|
||||
An `.html` file opens as a rendered page by default, with its relative CSS, scripts, images and
|
||||
data files. The page is served from a separate `/html-view/...` address in a sandbox with no
|
||||
origin of its own, so its scripts can never read Codeman's page, cookies or API. The **Page**
|
||||
pill in the viewer header switches to the source view (where **Edit** works as for any text
|
||||
file), and the viewer falls back to source on its own when a page cannot be rendered: a remote
|
||||
(SSH) case, an HTML file inside a hidden folder, or one sitting directly in a broad folder such
|
||||
as your home folder or `/tmp` (the page may read the files in its own folder tree, so move it
|
||||
into a folder of its own). The raw file routes still serve HTML as a download only.
|
||||
|
||||
Text previews are capped at the first 500 lines, fetched as a partial read, so clicking a
|
||||
one-gigabyte log does not try to paint one.
|
||||
|
||||
@@ -587,6 +587,7 @@
|
||||
<span class="file-preview-title" id="filePreviewTitle">file.ts</span>
|
||||
<div class="file-preview-actions">
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewMdBtn" onclick="app.toggleFilePreviewMd()" title="Rendered markdown" aria-label="Rendered markdown" aria-pressed="true" hidden>MD</button>
|
||||
<button class="btn-icon-sm file-preview-pill" id="filePreviewPageBtn" onclick="app.toggleFilePreviewPage()" title="Rendered page" aria-label="Rendered page" aria-pressed="true" hidden>Page</button>
|
||||
<button class="btn-icon-sm" id="filePreviewLinesBtn" onclick="app.toggleFilePreviewLines()" title="Line numbers" aria-label="Line numbers" aria-pressed="false" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><path d="M4 6h1v4"/><path d="M4 10h2"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg></button>
|
||||
<button class="btn-icon-sm" id="filePreviewWrapBtn" onclick="app.toggleFilePreviewWrap()" title="Wrap lines" aria-label="Wrap lines" aria-pressed="true" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="3" y1="6" x2="21" y2="6"/><path d="M3 12h15a3 3 0 1 1 0 6h-4"/><polyline points="16 16 14 18 16 20"/><line x1="3" y1="18" x2="10" y2="18"/></svg></button>
|
||||
<button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button>
|
||||
|
||||
+52
-18
@@ -24,16 +24,18 @@ const TEXT_PREVIEW_MAX_LINES = 500;
|
||||
// cap: a rendered README cut mid-way reads as the whole document.
|
||||
const MARKDOWN_PREVIEW_MAX_LINES = 10000;
|
||||
const MARKDOWN_EXTS = new Set(['md', 'markdown']);
|
||||
const HTML_PAGE_EXTS = new Set(['html', 'htm']);
|
||||
// File Viewer text-view prefs: per-device, in their own localStorage keys for
|
||||
// the same reason as FILE_BROWSER_SHOW_HIDDEN_KEY (the app-settings object is
|
||||
// rebuilt from the settings modal on save, so a key toggled from the viewer
|
||||
// would be dropped on the next save).
|
||||
const FILE_PREVIEW_PREF_KEYS = {
|
||||
mdRendered: 'codeman:filePreviewMdRendered',
|
||||
htmlRendered: 'codeman:filePreviewHtmlRendered',
|
||||
lineNumbers: 'codeman:filePreviewLineNumbers',
|
||||
wrap: 'codeman:filePreviewWrap',
|
||||
};
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, lineNumbers: false, wrap: true };
|
||||
const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true };
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
@@ -4106,6 +4108,8 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Same for the text-view toggles: they act on the text this load has not
|
||||
// fetched yet, and an image or PDF has nothing for them to toggle.
|
||||
this.filePreviewText = null;
|
||||
this.filePreviewPage = null;
|
||||
this.filePreviewPageRefused = false;
|
||||
this._updateFilePreviewToolbar('none');
|
||||
|
||||
// Show overlay with loading state
|
||||
@@ -4119,25 +4123,22 @@ Object.assign(CodemanApp.prototype, {
|
||||
// HTML renders as a page: the server mints a sandboxed /html-view URL for the
|
||||
// file's directory (html-view-routes.ts), so relative CSS, scripts and images
|
||||
// resolve, and the page runs in an opaque origin that cannot reach Codeman.
|
||||
// This branch comes first because the routes below serve HTML download-only.
|
||||
if (!attachmentId && (ext === 'html' || ext === 'htm')) {
|
||||
footerEl.textContent = 'HTML';
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/html-view`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: filePath }),
|
||||
});
|
||||
const result = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !result.success) throw new Error(result.error || 'Failed to open page');
|
||||
const pageUrl = CodemanBase.url(result.data.url);
|
||||
// The Page pill switches to the source view below (with its Edit button), and
|
||||
// a refused mint (remote case, broad folder, hidden directory) falls through
|
||||
// to that source view on its own.
|
||||
if (!attachmentId && HTML_PAGE_EXTS.has(ext) && this._filePreviewPref('htmlRendered')) {
|
||||
const pageUrl = await this._mintHtmlViewUrl(filePath, sessionId);
|
||||
if (pageUrl) {
|
||||
footerEl.textContent = 'HTML';
|
||||
this.filePreviewPage = { filePath, sessionId };
|
||||
this.filePreviewDetachUrl = pageUrl;
|
||||
if (detachBtn) detachBtn.hidden = false;
|
||||
bodyEl.innerHTML = `<iframe src="${escapeHtml(pageUrl)}" title="${escapeHtml(filePath)}" sandbox="allow-scripts allow-forms allow-popups allow-modals allow-downloads"></iframe>`;
|
||||
} catch (err) {
|
||||
bodyEl.innerHTML = `<div class="binary-message">Error: ${escapeHtml(err.message)}</div>`;
|
||||
this._updateFilePreviewToolbar('page');
|
||||
return;
|
||||
}
|
||||
return;
|
||||
// The pill would only retry a page the server just refused.
|
||||
this.filePreviewPageRefused = true;
|
||||
}
|
||||
|
||||
// Out-of-workspace path: mint an attachment id up front. Every branch below
|
||||
@@ -4545,8 +4546,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* Show the toggles that apply to the current view: MD for a markdown file in
|
||||
* either view, Lines/Wrap for the plain-text view only; `'none'` (loading,
|
||||
* image, media, PDF, edit mode) hides all three.
|
||||
* either view, Page for an HTML file in either view (`'page'` = rendered),
|
||||
* Lines/Wrap for the plain-text view only; `'none'` (loading, image, media,
|
||||
* PDF, edit mode) hides them all.
|
||||
*/
|
||||
_updateFilePreviewToolbar(view) {
|
||||
const set = (id, shown, pressed) => {
|
||||
@@ -4557,10 +4559,42 @@ Object.assign(CodemanApp.prototype, {
|
||||
};
|
||||
const isMarkdown = view !== 'none' && MARKDOWN_EXTS.has(this.filePreviewText?.ext || '');
|
||||
set('filePreviewMdBtn', isMarkdown, view === 'markdown');
|
||||
// Page: shown on a rendered HTML page, and on its source view when that view
|
||||
// can be re-rendered (a bare attachment-card name has no path to mint from).
|
||||
const text = this.filePreviewText;
|
||||
const htmlSource =
|
||||
view === 'text' &&
|
||||
!this.filePreviewPageRefused &&
|
||||
HTML_PAGE_EXTS.has(text?.ext || '') &&
|
||||
(!text.attachmentId || text.filePath.startsWith('/'));
|
||||
set('filePreviewPageBtn', view === 'page' || htmlSource, view === 'page');
|
||||
set('filePreviewLinesBtn', view === 'text', this._filePreviewPref('lineNumbers'));
|
||||
set('filePreviewWrapBtn', view === 'text', this._filePreviewPref('wrap'));
|
||||
},
|
||||
|
||||
/** Mint the sandboxed page URL for an HTML file; null when the server refuses. */
|
||||
async _mintHtmlViewUrl(filePath, sessionId) {
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/html-view`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: filePath }),
|
||||
});
|
||||
const result = await res.json().catch(() => ({}));
|
||||
return res.ok && result.success ? CodemanBase.url(result.data.url) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Page pill: flip between the rendered page and the source view of an HTML file. */
|
||||
toggleFilePreviewPage() {
|
||||
const target = this.filePreviewPage || this.filePreviewText;
|
||||
if (!target) return;
|
||||
this._setFilePreviewPref('htmlRendered', !this._filePreviewPref('htmlRendered'));
|
||||
this.openFilePreview(target.filePath, target.sessionId);
|
||||
},
|
||||
|
||||
toggleFilePreviewMd() {
|
||||
this._setFilePreviewPref('mdRendered', !this._filePreviewPref('mdRendered'));
|
||||
this._renderFilePreviewText();
|
||||
|
||||
@@ -241,7 +241,9 @@ async function serveRawFile(
|
||||
// would be stored XSS. SVG was always here; HTML/HTM join it now that the text
|
||||
// family is servable, so widening what can be READ never widened what can RUN.
|
||||
// The preview overlay reads these through `fetch()`, which ignores the
|
||||
// disposition, so a clicked .html still shows its source.
|
||||
// disposition, so the source view of a clicked .html still works. Rendering it
|
||||
// as a page is html-view-routes.ts's job (sandboxed, opaque origin), never this
|
||||
// route's.
|
||||
const markupOnly = extension === 'svg' || extension === 'html' || extension === 'htm';
|
||||
if (download || markupOnly) {
|
||||
reply.header(
|
||||
|
||||
@@ -18,16 +18,21 @@
|
||||
* capability then serves ONLY the files in that file's own directory tree, filtered
|
||||
* by the same blocklist plus an asset-extension allowlist and a no-dotfile rule, so
|
||||
* a page cannot use its own directory to pull a `.env` out through the browser.
|
||||
* Because that tree is served recursively, minting is refused for an HTML file in a
|
||||
* hidden directory or directly in a broad root (`/`, home, the temp dir, the cases
|
||||
* and user-space roots), see {@link isBroadHtmlViewRoot}.
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { createReadStream, realpathSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { basename, dirname, extname, isAbsolute, resolve, sep } from 'node:path';
|
||||
import { ApiErrorCode, createErrorResponse } from '../../types.js';
|
||||
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
|
||||
import { findSessionOrFail, ownerFor, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { CASES_DIR, findSessionOrFail, ownerFor, validateSessionFilePath } from '../route-helpers.js';
|
||||
import { getUserSpacesDir } from '../../config/multiuser.js';
|
||||
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
|
||||
import type { SessionPort } from '../ports/index.js';
|
||||
|
||||
@@ -77,6 +82,32 @@ function extensionOf(path: string): string {
|
||||
return extname(path).toLowerCase().replace(/^\./, '');
|
||||
}
|
||||
|
||||
/** realpath when the path exists, else the plain resolved path. */
|
||||
function canonical(path: string): string {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch {
|
||||
return resolve(path);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a directory is too broad to hand to a page as its asset root. The
|
||||
* capability serves the HTML file's directory RECURSIVELY to the page's own
|
||||
* scripts, so `/tmp/report.html` would expose every other session's scratch
|
||||
* output under `/tmp`, and `~/report.html` the home tree. Refuse those roots
|
||||
* (and the cases / user-space roots, which hold every workspace) outright.
|
||||
*/
|
||||
export function isBroadHtmlViewRoot(dir: string): boolean {
|
||||
const target = canonical(dir);
|
||||
const userSpaces = canonical(getUserSpacesDir());
|
||||
const broad = [sep, homedir(), tmpdir(), CASES_DIR, userSpaces].map(canonical);
|
||||
if (broad.includes(target)) return true;
|
||||
// A user's own space (`<spaces>/<user>`) and its cases dir hold every workspace of that user.
|
||||
if (dirname(target) === userSpaces) return true;
|
||||
return basename(target) === 'cases' && dirname(dirname(target)) === userSpaces;
|
||||
}
|
||||
|
||||
export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort): void {
|
||||
app.post('/api/sessions/:id/html-view', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
@@ -128,6 +159,27 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
|
||||
return;
|
||||
}
|
||||
|
||||
// The serving route never descends into a dot segment below the capability
|
||||
// root; the HTML file's own path gets the same rule, or `.cache/r.html` would
|
||||
// mint a capability over a hidden directory and serve its `token.json`.
|
||||
if (resolvedPath.split(sep).some((segment) => segment.startsWith('.'))) {
|
||||
reply
|
||||
.code(403)
|
||||
.send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'HTML files in hidden directories cannot be rendered'));
|
||||
return;
|
||||
}
|
||||
if (isBroadHtmlViewRoot(dirname(resolvedPath))) {
|
||||
reply
|
||||
.code(403)
|
||||
.send(
|
||||
createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'This folder is too broad to render a page from; move the HTML file into its own folder'
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const cap = htmlViewCapabilities.mint(dirname(resolvedPath), ownerFor(req));
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { homedir, tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
import { registerHtmlViewRoutes } from '../../src/web/routes/html-view-routes.js';
|
||||
import { isBroadHtmlViewRoot, registerHtmlViewRoutes } from '../../src/web/routes/html-view-routes.js';
|
||||
import { capabilityFromHtmlViewPath, htmlViewCapabilities } from '../../src/html-view-capabilities.js';
|
||||
|
||||
describe('html-view routes', () => {
|
||||
@@ -106,4 +106,70 @@ describe('html-view routes', () => {
|
||||
it('reuses one capability per directory', async () => {
|
||||
expect(await openUrl()).toBe(await openUrl());
|
||||
});
|
||||
describe('outside the workspace', () => {
|
||||
let outsideDir: string;
|
||||
const saved = {
|
||||
confine: process.env.CODEMAN_ATTACHMENT_CONFINE,
|
||||
blocked: process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
outsideDir = join(root, 'reports');
|
||||
mkdirSync(outsideDir);
|
||||
writeFileSync(join(outsideDir, 'report.html'), '<p>report</p>');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of [
|
||||
['CODEMAN_ATTACHMENT_CONFINE', saved.confine],
|
||||
['CODEMAN_ATTACHMENT_BLOCKED_PATHS', saved.blocked],
|
||||
] as const) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
it('mints for a page under the attachment guard', async () => {
|
||||
expect((await open(join(outsideDir, 'report.html'))).statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('refuses a page in a blocked tree', async () => {
|
||||
process.env.CODEMAN_ATTACHMENT_BLOCKED_PATHS = outsideDir;
|
||||
expect((await open(join(outsideDir, 'report.html'))).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('refuses every out-of-workspace page when attachments are confined to the workspace', async () => {
|
||||
process.env.CODEMAN_ATTACHMENT_CONFINE = '1';
|
||||
expect((await open(join(outsideDir, 'report.html'))).statusCode).toBe(403);
|
||||
// The workspace itself is still fine.
|
||||
expect((await open(join(workspace, 'index.html'))).statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses an HTML file inside a hidden directory, in or out of the workspace', async () => {
|
||||
mkdirSync(join(workspace, '.cache'));
|
||||
writeFileSync(join(workspace, '.cache', 'r.html'), '<p>r</p>');
|
||||
writeFileSync(join(workspace, '.cache', 'token.json'), '{}');
|
||||
expect((await open(join(workspace, '.cache', 'r.html'))).statusCode).toBe(403);
|
||||
mkdirSync(join(root, '.hidden'));
|
||||
writeFileSync(join(root, '.hidden', 'r.html'), '<p>r</p>');
|
||||
expect((await open(join(root, '.hidden', 'r.html'))).statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it('treats the filesystem root, home and the temp dir as too broad to serve', () => {
|
||||
expect(isBroadHtmlViewRoot('/')).toBe(true);
|
||||
expect(isBroadHtmlViewRoot(homedir())).toBe(true);
|
||||
expect(isBroadHtmlViewRoot(tmpdir())).toBe(true);
|
||||
expect(isBroadHtmlViewRoot(workspace)).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a page sitting directly in the temp dir', async () => {
|
||||
const page = join(tmpdir(), `html-view-broad-${process.pid}.html`);
|
||||
writeFileSync(page, '<p>broad</p>');
|
||||
try {
|
||||
expect((await open(page)).statusCode).toBe(403);
|
||||
} finally {
|
||||
rmSync(page, { force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyCookie from '@fastify/cookie';
|
||||
import { registerAuthMiddleware, registerHostGuard, registerSecurityHeaders } from '../src/web/middleware/auth.js';
|
||||
import { webviewCapabilities } from '../src/webview-capabilities.js';
|
||||
import { htmlViewCapabilities } from '../src/html-view-capabilities.js';
|
||||
import type { HostPolicy } from '../src/web/network-auth-policy.js';
|
||||
|
||||
const POLICY: HostPolicy = { allowedHosts: [], allowLan: true };
|
||||
@@ -21,6 +22,7 @@ const PASSWORD = 'test-password';
|
||||
|
||||
let app: FastifyInstance;
|
||||
let capability: string;
|
||||
let htmlCapability: string;
|
||||
let savedPassword: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -29,6 +31,7 @@ beforeEach(async () => {
|
||||
process.env.CODEMAN_PASSWORD = PASSWORD;
|
||||
|
||||
capability = webviewCapabilities.mint('webview-under-test', undefined);
|
||||
htmlCapability = htmlViewCapabilities.mint('/srv/report-under-test', undefined);
|
||||
|
||||
app = Fastify({ logger: false });
|
||||
await app.register(fastifyCookie);
|
||||
@@ -48,6 +51,8 @@ beforeEach(async () => {
|
||||
app.all('/q/:token', async () => ({ qr: true }));
|
||||
app.get('/', async () => 'app shell');
|
||||
app.get('/webviewfoo/bar', async () => 'lookalike');
|
||||
app.all('/html-view/:cap/*', async () => ({ page: true }));
|
||||
app.all('/html-viewx/*', async () => 'lookalike');
|
||||
// Stand-in for @fastify/static mounted at '/', which is what actually serves
|
||||
// /static/app.js in production. It matches EVERY path, so the fence must treat a
|
||||
// root catch-all as "no real route" or the Referer form could never apply at all.
|
||||
@@ -58,6 +63,7 @@ beforeEach(async () => {
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
webviewCapabilities.revokeWebview('webview-under-test');
|
||||
htmlViewCapabilities.revokeOwner(undefined);
|
||||
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
|
||||
else process.env.CODEMAN_PASSWORD = savedPassword;
|
||||
});
|
||||
@@ -319,3 +325,42 @@ describe('a lost web-tab frame', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// The rendered-HTML route (/html-view/<cap>/...) has its own, narrower exemption:
|
||||
// read-only, capability in the path only, no Referer form.
|
||||
describe('the html-view exemption', () => {
|
||||
it('lets an unauthenticated GET and HEAD through with a live capability', async () => {
|
||||
for (const method of ['GET', 'HEAD'] as const) {
|
||||
const res = await app.inject({ method, url: `/html-view/${htmlCapability}/index.html` });
|
||||
expect(res.statusCode, method).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('never exempts a write on the capability path', async () => {
|
||||
for (const method of ['POST', 'PUT'] as const) {
|
||||
const res = await app.inject({ method, url: `/html-view/${htmlCapability}/index.html`, payload: {} });
|
||||
expect(res.statusCode, method).toBe(401);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects an unknown capability', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/html-view/AAAAAAAAAAAAAAAAAAAAAAAA/index.html' });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a revoked capability immediately', async () => {
|
||||
htmlViewCapabilities.revokeOwner(undefined);
|
||||
const res = await app.inject({ method: 'GET', url: `/html-view/${htmlCapability}/index.html` });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('does not match a lookalike prefix', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: `/html-viewx/${htmlCapability}/index.html` });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it('is not a web-tab capability, and a web-tab capability is not an html-view one', async () => {
|
||||
expect((await app.inject({ method: 'GET', url: `/webview/${htmlCapability}/x` })).statusCode).toBe(401);
|
||||
expect((await app.inject({ method: 'GET', url: `/html-view/${capability}/x.html` })).statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,12 @@
|
||||
* exempt from cookie auth, with a rolling TTL refreshed on every use, so a leaked
|
||||
* proxy URL stayed valid indefinitely. These tests pin every call site:
|
||||
* `POST /api/logout` (own identity), the admin forced logout, and user deletion.
|
||||
* The rendered-HTML capabilities (`htmlViewCapabilities`) are pinned at the same sites.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { WebviewCapabilityStore, webviewCapabilities } from '../src/webview-capabilities.js';
|
||||
import { HtmlViewCapabilityStore, htmlViewCapabilities } from '../src/html-view-capabilities.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './routes/_route-test-utils.js';
|
||||
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
|
||||
import { registerAdminRoutes } from '../src/web/routes/admin-routes.js';
|
||||
@@ -40,6 +42,28 @@ describe('WebviewCapabilityStore.revokeOwner', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('HtmlViewCapabilityStore.revokeOwner', () => {
|
||||
it('revokes exactly the identity asked for, the single-user `undefined` identity included', () => {
|
||||
const store = new HtmlViewCapabilityStore();
|
||||
const solo = store.mint('/srv/solo', undefined);
|
||||
const alice = store.mint('/srv/alice', 'alice');
|
||||
const bob = store.mint('/srv/bob', 'bob');
|
||||
|
||||
expect(store.revokeOwner('alice')).toBe(1);
|
||||
expect(store.resolve(alice)).toBeUndefined();
|
||||
expect(store.resolve(bob)).toBeDefined();
|
||||
expect(store.resolve(solo)).toBeDefined();
|
||||
|
||||
expect(store.revokeOwner(undefined)).toBe(1);
|
||||
expect(store.resolve(solo)).toBeUndefined();
|
||||
expect(store.resolve(bob)).toBeDefined();
|
||||
|
||||
// Re-opening mints a NEW token rather than resurrecting the revoked one.
|
||||
expect(store.mint('/srv/alice', 'alice')).not.toBe(alice);
|
||||
store.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/logout', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
@@ -53,11 +77,14 @@ describe('POST /api/logout', () => {
|
||||
|
||||
it('single-user: every outstanding capability dies with the login', async () => {
|
||||
const cap = webviewCapabilities.mint('wv-logout-solo', undefined);
|
||||
const page = htmlViewCapabilities.mint('/srv/logout-solo', undefined);
|
||||
expect(webviewCapabilities.resolve(cap)).toBeDefined();
|
||||
expect(htmlViewCapabilities.resolve(page)).toBeDefined();
|
||||
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/logout' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(webviewCapabilities.resolve(cap)).toBeUndefined();
|
||||
expect(htmlViewCapabilities.resolve(page)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,12 +107,17 @@ describe('POST /api/logout in multi-user mode', () => {
|
||||
it("revokes only the caller's capabilities, never another user's", async () => {
|
||||
const mine = webviewCapabilities.mint('wv-peon-own', 'peon');
|
||||
const theirs = webviewCapabilities.mint('wv-boss-own', 'boss');
|
||||
const myPage = htmlViewCapabilities.mint('/srv/peon-own', 'peon');
|
||||
const theirPage = htmlViewCapabilities.mint('/srv/boss-own', 'boss');
|
||||
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/logout' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(webviewCapabilities.resolve(mine)).toBeUndefined();
|
||||
expect(webviewCapabilities.resolve(theirs)).toBeDefined();
|
||||
expect(htmlViewCapabilities.resolve(myPage)).toBeUndefined();
|
||||
expect(htmlViewCapabilities.resolve(theirPage)).toBeDefined();
|
||||
webviewCapabilities.revokeWebview('wv-boss-own');
|
||||
htmlViewCapabilities.revokeOwner('boss');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,19 +146,26 @@ describe('admin routes (multi-user)', () => {
|
||||
it('a forced logout revokes the target user (normalised) and leaves the admin alone', async () => {
|
||||
const peon = webviewCapabilities.mint('wv-peon-forced', 'peon');
|
||||
const boss = webviewCapabilities.mint('wv-boss-forced', 'boss');
|
||||
const peonPage = htmlViewCapabilities.mint('/srv/peon-forced', 'peon');
|
||||
const bossPage = htmlViewCapabilities.mint('/srv/boss-forced', 'boss');
|
||||
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/admin/users/PEON/logout' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(webviewCapabilities.resolve(peon)).toBeUndefined();
|
||||
expect(webviewCapabilities.resolve(boss)).toBeDefined();
|
||||
expect(htmlViewCapabilities.resolve(peonPage)).toBeUndefined();
|
||||
expect(htmlViewCapabilities.resolve(bossPage)).toBeDefined();
|
||||
webviewCapabilities.revokeWebview('wv-boss-forced');
|
||||
htmlViewCapabilities.revokeOwner('boss');
|
||||
});
|
||||
|
||||
it('deleting a user revokes whatever that user had open', async () => {
|
||||
const peon = webviewCapabilities.mint('wv-peon-deleted', 'peon');
|
||||
const peonPage = htmlViewCapabilities.mint('/srv/peon-deleted', 'peon');
|
||||
|
||||
const res = await harness.app.inject({ method: 'DELETE', url: '/api/admin/users/peon' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(webviewCapabilities.resolve(peon)).toBeUndefined();
|
||||
expect(htmlViewCapabilities.resolve(peonPage)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user