From 04597c20cdf3d202f1584ffdd34037941ea6a5f6 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 03:30:17 +0700 Subject: [PATCH 1/6] csp: allow data: and blob: images so Safari and Firefox can draw The Content-Security-Policy had no img-src, so default-src 'self' governed images and refused the data: URIs the Guacamole client draws every tile from on browsers without WebCodecs ImageDecoder. Safari and Firefox rendered SSH and RDP sessions black with only rect fills and the cursor cell showing; Chrome decodes straight from the stream and never fetches a URL, which hid it. The same directive blocks the remote mouse cursor (cursor: url(data:...)) and blob: image URLs. Move the policy into a documented constant, add img-src 'self' data: blob:, and add a unit test that fails if img-src loses either source. Document the proxy-side header replacement for releases up to v1.10.5. Fixes #249 --- docs/reverse-proxies.md | 33 +++++++++++++++++++++++++++++++++ src/main.rs | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/reverse-proxies.md b/docs/reverse-proxies.md index 5fc65aae..1b9d58c6 100644 --- a/docs/reverse-proxies.md +++ b/docs/reverse-proxies.md @@ -218,3 +218,36 @@ trusted_proxies = ["127.0.0.1/32"] Without this setting, all requests from behind the proxy appear to come from the proxy's own IP. + +## Content-Security-Policy + +rustguac sends its own `Content-Security-Policy` on every response. Leave +it alone: the Guacamole client needs `img-src 'self' data: blob:` because +browsers without WebCodecs `ImageDecoder` (Safari, Firefox) draw every +image tile from a `data:` URI. If your proxy sets a CSP of its own, keep +that directive, or SSH/RDP sessions render black in those browsers while +Chrome looks fine. + +Releases up to v1.10.5 shipped a policy with no `img-src`, which is +exactly that failure (#249). If you can't upgrade, have the proxy replace +the header. The whole policy has to be repeated because the header is +replaced, not merged: + +HAProxy (backend section): + +``` +http-response set-header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" +``` + +nginx: + +```nginx +proxy_hide_header Content-Security-Policy; +add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" always; +``` + +Caddy: + +``` +header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:; img-src 'self' data: blob:" +``` diff --git a/src/main.rs b/src/main.rs index 4bd2991b..06f018b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -653,6 +653,23 @@ fn cmd_map_group(database: &Db, group: &str, role: &str, force: bool) { #[derive(Clone)] struct TlsEnabled(bool); +/// Content-Security-Policy sent with every response. +/// +/// `img-src` must allow `data:` and `blob:`. The Guacamole client draws each +/// `img` stream by loading a `data:image/...;base64,...` URI into an `Image` +/// (the `DataURIReader` path in `Display.drawStream`) on browsers without +/// WebCodecs `ImageDecoder`, which is Safari and Firefox. Without `img-src` +/// the `default-src 'self'` fallback refuses those loads, the client silently +/// skips the draw, and SSH/RDP sessions render black with only rect fills +/// showing (#249). Chromium decodes straight from the stream bytes and never +/// fetches a URL, so it hid the problem. The remote mouse cursor +/// (`cursor: url(data:...)`) and blob: image URLs need the same allowance. +const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \ + script-src 'self' 'unsafe-inline'; \ + style-src 'self' 'unsafe-inline'; \ + connect-src 'self' wss: ws:; \ + img-src 'self' data: blob:"; + async fn security_headers( tls: Extension, request: Request, @@ -672,7 +689,7 @@ async fn security_headers( ); headers.insert( "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss: ws:".parse().unwrap(), + CONTENT_SECURITY_POLICY.parse().unwrap(), ); if tls.0 .0 { headers.insert( @@ -1818,6 +1835,24 @@ mod tests { use super::*; + #[test] + fn test_csp_allows_data_and_blob_images() { + // Safari and Firefox draw Guacamole image streams through data: URIs + // (#249). Chromium uses ImageDecoder and would not catch a regression. + let directives: Vec<&str> = CONTENT_SECURITY_POLICY.split(';').map(str::trim).collect(); + let img_src = directives + .iter() + .find(|d| d.starts_with("img-src ")) + .expect("CSP must carry an explicit img-src directive"); + let sources: Vec<&str> = img_src.split_whitespace().skip(1).collect(); + assert!(sources.contains(&"'self'"), "img-src must allow 'self'"); + assert!(sources.contains(&"data:"), "img-src must allow data: URIs"); + assert!(sources.contains(&"blob:"), "img-src must allow blob: URLs"); + // The string continuation must not leave stray whitespace runs. + assert!(!CONTENT_SECURITY_POLICY.contains(" ")); + assert!(!CONTENT_SECURITY_POLICY.contains('\n')); + } + #[test] fn test_version_assets_rewrites_local_js_and_css() { // Tests run from the crate root, so this is the real static tree. From ee03b3e65cf9c2848fc2d8eea67395bc32c8d3b9 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 07:00:08 +0700 Subject: [PATCH 2/6] client: keep the fullscreen gesture press off the remote, release all input after the transition With fullscreen-on-connect, the first mousedown after CONNECTED is the browser's fullscreen gesture, but Guacamole.Mouse forwarded it to guacd as well, and the transition that followed swallowed its release. guacd was left with the button held and the terminal selected text on every pointer move; the same with a key press as the gesture. Stop the gesture event before the display's handlers see it, and on entering fullscreen release everything explicitly: keyboard.reset() and a mouse state with no buttons at the last pointer position, so a release lost for any other reason during the transition cannot stick either. Fixes #256 --- static/client.html | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/static/client.html b/static/client.html index cf477195..d5f71664 100644 --- a/static/client.html +++ b/static/client.html @@ -1466,14 +1466,37 @@

Session Ended

if (x) { try { x.call(document); } catch (e) {} } } function rgArmFullscreenGesture() { - var fire = function() { + var fire = function(e) { document.removeEventListener('mousedown', fire, true); document.removeEventListener('keydown', fire, true); + // This press is the browser's fullscreen gesture, not + // input for the remote. The transition it starts re-lays + // out the display and swallows the matching release, and + // a press guacd never sees released turns every later + // pointer move into a drag selection (#256). Keep it from + // the display's handlers; rgReleaseAllInput() covers a + // release lost for any other reason once fullscreen lands. + e.stopPropagation(); + e.preventDefault(); rgEnterFullscreen(); }; document.addEventListener('mousedown', fire, true); document.addEventListener('keydown', fire, true); } + // Tell the remote that nothing is held: every key the keyboard + // tracks as down, and all mouse buttons at the last pointer + // position. Used after a fullscreen transition, during which the + // browser may not deliver the releases to the display element. + function rgReleaseAllInput() { + try { if (keyboard) keyboard.reset(); } catch (e) {} + try { + if (!mouse || !mouse.currentState) return; + var st = new Guacamole.Mouse.State(mouse.currentState); + st.left = st.middle = st.right = st.up = st.down = false; + if (fillX !== 1 || fillY !== 1) { st.x /= fillX; st.y /= fillY; } + client.sendMouseState(st, true); + } catch (e) {} + } function rgShowEscNoticeOnce() { if (sessionStorage.getItem('rustguac_esc_notice_seen')) return; sessionStorage.setItem('rustguac_esc_notice_seen', '1'); @@ -1526,6 +1549,7 @@

Session Ended

function rgOnFullscreenChange() { var inFs = !!rgFullscreenEl(); var bar = document.getElementById('fs-bar'); + if (inFs) rgReleaseAllInput(); if (bar) { if (inFs) { bar.classList.add('visible'); From e7358ab9c98beb15dfc114655abbc556c65ac7a1 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 04:11:10 +0700 Subject: [PATCH 3/6] connections: fit the entries table on 1710px-wide displays The 18px table with 20px cell padding and a separate cell each for edit, clone and delete overran the main column by ~300px on a 1710px MacBook display, cutting "delete" off the right edge. Put the three admin actions in one cell, and add a max-width: 1800px breakpoint on the connections page: 20px page padding, 240-280px sidebar, 16px cells with tighter padding, 14px headers, a 110px Connect button and a smaller folder title. The icon-only "open in new window" button keeps its compact size. Nothing is hidden; at 1710px the table fits with about 250px to spare. Fixes #252 --- static/connections.html | 37 +++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/static/connections.html b/static/connections.html index 8802f42e..a75bc525 100644 --- a/static/connections.html +++ b/static/connections.html @@ -117,6 +117,9 @@ .entries-table tbody tr:last-child td { border-bottom: none; } .entries-table td:last-child { text-align: right; white-space: nowrap; } + .entries-table .actions-cell { white-space: nowrap; text-align: right; } + .entries-table .actions-cell .btn-small { margin-left: var(--s-1); } + /* Bulk selection (admins). */ .entries-table .sel-cell { width: 2em; text-align: center; padding-right: 0; } .entries-table .sel-cell input { width: auto; margin: 0; cursor: pointer; } @@ -165,6 +168,27 @@ color: var(--text-muted); font-size: var(--fz-md); font-style: italic; } + /* Narrow desktops (a 1710px-wide MacBook display, 1600px monitors): + the 18px monospace table plus three padded action cells overran + the main column by ~300px, with "delete" cut off at the right. + Tighten the chrome rather than hide columns: smaller side + padding and sidebar, 16px cells with less padding, a narrower + Connect button. Everything then fits with ~250px to spare. */ + @media (max-width: 1800px) { + body { padding: var(--s-4) var(--s-4); } + .layout { gap: var(--s-4); } + .sidebar { min-width: 240px; max-width: 280px; padding-right: var(--s-3); } + .entries-table th, .entries-table td { + padding: var(--s-2) var(--s-3); + font-size: var(--fz-sm); + } + .entries-table th { font-size: var(--fz-xs); letter-spacing: 0.08em; } + .btn-connect { min-width: 110px; padding: 0 var(--s-3); } + .btn-connect-win { min-width: 0; padding: 0 var(--s-1); } + .folder-actions strong { font-size: var(--fz-lg); } + .folder-actions .desc { font-size: var(--fz-sm); } + } + /* Active sessions strip */ .active-sessions { margin-bottom: var(--s-5); } .session-grid { display: flex; gap: var(--s-3); overflow-x: auto; padding-bottom: var(--s-2); } @@ -2394,7 +2418,7 @@

Enter Credentials

var html = ''; if (isAdmin) html += ''; html += ''; - if (isAdmin) html += ''; + if (isAdmin) html += ''; html += ''; entries.forEach(function(e, idx) { @@ -2435,9 +2459,14 @@

Enter Credentials

html += ''; if (isAdmin) { - html += ''; - html += ''; - html += ''; + // One cell for the three admin actions: three cells cost + // two extra runs of cell padding on every row, which is + // what pushed "delete" off the right edge at 1710px wide. + html += ''; } html += ''; }); From 1b93b0c8c1843145a3330f7368cd2e5be19c567c Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 07:08:23 +0700 Subject: [PATCH 4/6] reports, recordings: fit the tables on 1710px-wide displays The 18px tables with 20px cell padding ran past the right edge on a 1710px MacBook display, the recording link cut off. Same treatment as the connections table: 16px cells with tighter padding, 14px headers, actions kept on one line. Nothing is hidden. Part of #254 --- static/recordings.html | 13 +++++++++++++ static/reports.html | 15 +++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/static/recordings.html b/static/recordings.html index f9aae965..64366c3c 100644 --- a/static/recordings.html +++ b/static/recordings.html @@ -102,6 +102,19 @@ #player-time { color: var(--text-muted); font-size: var(--fz-sm); white-space: nowrap; } .btn-delete { color: var(--primary); } .btn-delete:hover { color: var(--primary-hover); } + /* Narrow desktops (a 1710px MacBook display): the 18px table with + 20px cell padding ran past the right edge. Same treatment as the + connections table: 16px cells, tighter padding, 14px headers, and + the actions kept on one line. Nothing is hidden. */ + @media (max-width: 1800px) { + table th, table td { + padding: var(--s-2) var(--s-3); + font-size: var(--fz-sm); + } + table th { font-size: var(--fz-xs); letter-spacing: 0.08em; } + table td:last-child { white-space: nowrap; } + .btn-small { padding: 0 var(--s-1); } + } diff --git a/static/reports.html b/static/reports.html index 6b9715ce..fe7e78c2 100644 --- a/static/reports.html +++ b/static/reports.html @@ -48,6 +48,21 @@ font-size: var(--fz-sm); } .panel th { background: transparent; padding-top: 0; } + /* Narrow desktops (a 1710px MacBook display): the 18px table with + 20px cell padding ran past the right edge. Same treatment as the + connections table: 16px cells, tighter padding, 14px headers, and + the actions kept on one line. Nothing is hidden. */ + @media (max-width: 1800px) { + #history-table th, #history-table td, + #recordings-table th, #recordings-table td, + table.list th, table.list td { + padding: var(--s-2) var(--s-3); + font-size: var(--fz-sm); + } + #history-table th, #recordings-table th, table.list th { font-size: var(--fz-xs); letter-spacing: 0.08em; } + #history-table td:last-child, #recordings-table td:last-child { white-space: nowrap; } + .btn-small { padding: 0 var(--s-1); } + } From b9db3ca2d26f98bf2a54da0ed6257a13632ff3f2 Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Sun, 11 Oct 2026 06:05:33 +0700 Subject: [PATCH 5/6] css: share the narrow-desktop page padding across pages The 1800px breakpoint set a tighter page gutter on the connections page only, so switching to Sessions or any other tab snapped the content sideways. Move it to rustguac.css so every page moves together. Part of #254 --- static/connections.html | 3 ++- static/rustguac.css | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/static/connections.html b/static/connections.html index a75bc525..ef9561a0 100644 --- a/static/connections.html +++ b/static/connections.html @@ -175,7 +175,8 @@ padding and sidebar, 16px cells with less padding, a narrower Connect button. Everything then fits with ~250px to spare. */ @media (max-width: 1800px) { - body { padding: var(--s-4) var(--s-4); } + /* page padding at this width lives in rustguac.css, shared by + every page, so switching tabs does not shift the gutter */ .layout { gap: var(--s-4); } .sidebar { min-width: 240px; max-width: 280px; padding-right: var(--s-3); } .entries-table th, .entries-table td { diff --git a/static/rustguac.css b/static/rustguac.css index 1b384eeb..22b865a4 100644 --- a/static/rustguac.css +++ b/static/rustguac.css @@ -57,6 +57,13 @@ body { margin: 0; } +/* Narrow desktops (a 1710px MacBook display, 1600px monitors): a tighter + page gutter. Shared here so every page shifts together and switching + tabs does not snap the content sideways. */ +@media (max-width: 1800px) { + body { padding: var(--s-4) var(--s-4); } +} + h1 { color: var(--primary); margin: 0; font-size: 30px; letter-spacing: 0.02em; font-weight: bold; } h2 { color: var(--primary); font-size: var(--fz-lg); margin-top: var(--s-5); letter-spacing: 0.02em; } h3 { color: var(--text); font-size: var(--fz-md); margin-top: var(--s-4); letter-spacing: 0.02em; } From 4ae28efec0410660d3b1a3debfcab1d2952f275e Mon Sep 17 00:00:00 2001 From: Jad Seifeddine Date: Mon, 12 Oct 2026 03:42:26 +0000 Subject: [PATCH 6/6] tests: Playwright end-to-end harness, with a 1710px layout spec A browser test harness under tests/e2e: serve.mjs serves the real static tree over HTTP and lib/api.mjs answers /api/* from a small fixture address book, recording every request the page makes, so the shipped pages run against data the test controls without Vault, guacd or a session. CI gains an e2e job that installs Chromium and runs the suite. The first spec pins #252: at 1710px the connections, recordings and reports tables fit inside the viewport and nothing scrolls sideways. --- .github/workflows/ci.yml | 30 ++++ tests/e2e/.gitignore | 3 + tests/e2e/README.md | 40 +++++ tests/e2e/lib/api.mjs | 216 +++++++++++++++++++++++++++ tests/e2e/package-lock.json | 76 ++++++++++ tests/e2e/package.json | 13 ++ tests/e2e/playwright.config.mjs | 31 ++++ tests/e2e/serve.mjs | 52 +++++++ tests/e2e/specs/tables-1710.spec.mjs | 56 +++++++ 9 files changed, 517 insertions(+) create mode 100644 tests/e2e/.gitignore create mode 100644 tests/e2e/README.md create mode 100644 tests/e2e/lib/api.mjs create mode 100644 tests/e2e/package-lock.json create mode 100644 tests/e2e/package.json create mode 100644 tests/e2e/playwright.config.mjs create mode 100644 tests/e2e/serve.mjs create mode 100644 tests/e2e/specs/tables-1710.spec.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba70c923..81dd1bf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,3 +65,33 @@ jobs: run: | $HOME/.cargo/bin/cargo install cargo-audit $HOME/.cargo/bin/cargo audit + + e2e: + name: Browser tests + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install Playwright + working-directory: tests/e2e + run: | + npm ci + npx playwright install --with-deps chromium + + - name: End-to-end tests + # The real static pages against a stubbed API: see tests/e2e/README.md. + working-directory: tests/e2e + run: npm test + + - name: Keep traces of failures + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-traces + path: tests/e2e/test-results/ + if-no-files-found: ignore diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore new file mode 100644 index 00000000..dbd64df8 --- /dev/null +++ b/tests/e2e/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +test-results/ +playwright-report/ diff --git a/tests/e2e/README.md b/tests/e2e/README.md new file mode 100644 index 00000000..7b44a3a6 --- /dev/null +++ b/tests/e2e/README.md @@ -0,0 +1,40 @@ +# End-to-end tests + +Playwright specs that drive the real pages in `static/` against a stubbed +API. Nothing here needs Vault, guacd, Docker or a browser session: `serve.mjs` +serves the static tree over HTTP and every spec installs `lib/api.mjs`, which +answers `/api/*` from a small fixture address book (two named tenants and +twenty generic ones, two sessions, two recordings) and records every request +the page makes so a spec can assert on what was sent. + +```sh +cd tests/e2e +npm ci +npx playwright install chromium # once +npm test # all specs +npx playwright test specs/global-search.spec.mjs +npx playwright test --ui # step through a spec +``` + +`PW_CHROMIUM=/path/to/chrome` runs against an already installed Chromium +instead of the one Playwright downloads. CI runs the suite in the `e2e` job. + +One spec per change, named after the branch that introduced it: + +| spec | pins | +|---|---| +| `tables-1710` | #252: no horizontal overflow, admin table fits at 1710px | +| `connections-sidebar` | #254: independent sidebar scroll, selected row marked and kept in view | +| `connections-folder-filter` | #254: live filter box, ARIA tree, arrow keys, `f` shortcut | +| `connections-app-shell` | #254: clipped body, two scroll panes, folder change scrolls the pane not the page | +| `role-nav` | one nav on every page, gated by `data-role-level` | +| `sessions-adhoc-modal` | ad-hoc form opens as a modal | +| `facet-filters` | shared facets narrow lists, persist, combine with search | +| `folder-display-name` | display name over the key, modal field round-trips | +| `themed-dialogs` | `rgConfirm`/`rgAlert` replace native dialogs | +| `global-search` | `/` opens the overlay on every page, folder view untouched | +| `search-under-modals` | a page modal covers the search box | +| `zero-click-fullscreen` | #257: same-tab iframe sized from the measured viewport | + +The fixture suppresses the first-visit onboarding tour (`options.onboarding` +re-enables it) and defaults to the admin role; pass `role` to test gating. diff --git a/tests/e2e/lib/api.mjs b/tests/e2e/lib/api.mjs new file mode 100644 index 00000000..b9c8a8f3 --- /dev/null +++ b/tests/e2e/lib/api.mjs @@ -0,0 +1,216 @@ +/* + * Stubbed rustguac API for the end-to-end tests. + * + * installApi(page, options) routes every /api/* request the page makes to a + * JSON answer built from a small fixture address book, so the real pages run + * against data the test controls and nothing needs Vault, guacd or a browser + * session. Every request is recorded in the returned `calls` array so a spec + * can assert on what the page sent (method, path, JSON body). + * + * options.role 'admin' (default) | 'poweruser' | 'operator' | 'viewer' + * options.folders replace the fixture folders (see fixtureFolders()) + * options.sessions /api/sessions list (default: two sessions) + * options.recordings /api/recordings list + * options.routes { 'METHOD /api/path': body | (req, url) => body|Response-like } + * exact-path overrides, checked before the defaults + */ + +export function entry(name, session_type, extra = {}) { + return { + name, + session_type, + hostname: extra.hostname ?? (session_type === 'web' ? null : `${name}.acme.internal`), + port: extra.port ?? (session_type === 'ssh' ? 22 : session_type === 'rdp' ? 3389 : null), + username: extra.username ?? (session_type === 'web' ? null : 'root'), + url: extra.url ?? (session_type === 'web' ? `https://${name}.example.com/` : null), + display_name: extra.display_name ?? null, + domain: null, + security: null, + ignore_cert: null, + has_credentials: extra.has_credentials ?? true, + inherited_credentials: extra.inherited_credentials ?? false, + fullscreen_on_connect: extra.fullscreen_on_connect ?? false, + ...extra.fields + }; +} + +export function folder(name, opts = {}) { + return { + name, + scope: opts.scope ?? 'shared', + path: opts.path ?? name, + description: opts.description ?? '', + display_name: opts.display_name ?? '', + allowed_groups: opts.allowed_groups ?? [], + has_children: opts.has_children ?? false, + entries: opts.entries ?? [] + }; +} + +/* Two named tenants plus twenty generic ones, so the sidebar is long enough + * for the scrolling, filtering and keyboard tests to mean something. */ +export function fixtureFolders() { + const folders = [ + folder('acme', { + display_name: "Dani & Fiori's Gourmet Pizzeria", + description: 'Acme tenant (NetBox)', + entries: [ + entry('web01', 'ssh'), + entry('dc01', 'rdp', { username: 'administrator' }), + entry('kiosk', 'rdp', { username: 'kiosk', fullscreen_on_connect: true }), + entry('portal', 'web') + ] + }), + folder('beta', { + description: 'Beta Industries', + entries: [entry('app01', 'ssh'), entry('files', 'rdp', { has_credentials: false })] + }) + ]; + for (let i = 1; i <= 20; i++) { + const n = `tenant-${String(i).padStart(2, '0')}`; + folders.push(folder(n, { description: `Tenant ${i}`, entries: [entry(`${n}-ssh`, 'ssh')] })); + } + return folders; +} + +export function fixtureSessions() { + return [ + { session_id: '11111111-1111-4111-8111-111111111111', session_type: 'ssh', status: 'Connected', + created_at: '2026-10-11T01:00:00Z', client_url: '/client.html?session=1111', ws_url: '/ws/1111', + hostname: 'web01.acme.internal', username: 'root', active_connections: 1, created_by: 'alice@example.com' }, + { session_id: '22222222-2222-4222-8222-222222222222', session_type: 'rdp', status: 'Pending', + created_at: '2026-10-11T01:05:00Z', client_url: '/client.html?session=2222', ws_url: '/ws/2222', + hostname: 'dc01.acme.internal', username: 'administrator', active_connections: 0, created_by: 'bob@example.com' } + ]; +} + +export function fixtureRecordings() { + return [ + { name: 'ssh-web01-20261011-010000.guac', modified: '2026-10-11T01:30:00Z', size_bytes: 120_000, + session_type: 'ssh', user: 'alice@example.com', folder: 'shared/acme', address_book_entry: 'web01', + entry_display_name: 'web01 (Acme)' }, + { name: 'rdp-dc01-20261011-020000.guac', modified: '2026-10-11T02:30:00Z', size_bytes: 9_000_000, + session_type: 'rdp', user: 'bob@example.com', folder: 'shared/acme', address_book_entry: 'dc01', + entry_display_name: 'dc01 (Acme)' } + ]; +} + +function json(body, status = 200) { + return { status, contentType: 'application/json', body: JSON.stringify(body) }; +} + +export async function installApi(page, options = {}) { + const role = options.role ?? 'admin'; + const folders = options.folders ?? fixtureFolders(); + const sessions = options.sessions ?? fixtureSessions(); + const recordings = options.recordings ?? fixtureRecordings(); + const state = { version: 1, folders, sessions, recordings, calls: [] }; + + // The first visit to Connections opens the onboarding tour over the page; + // mark it seen so it never sits over the elements a test clicks. + if (!options.onboarding) { + await page.addInitScript(() => { + try { localStorage.setItem('rustguac_onboarding_dismissed', '1'); } catch (e) { /* no storage */ } + }); + } + + const findFolder = (scope, path) => folders.find(f => f.scope === scope && (f.path ?? f.name) === path); + const searchIndex = () => ({ + entries: folders.flatMap(f => f.entries.map(e => ({ + scope: f.scope, folder_path: f.path ?? f.name, folder_display_name: f.display_name || '', entry: e + }))) + }); + + await page.route('**/api/**', async (route) => { + const req = route.request(); + const url = new URL(req.url()); + const path = url.pathname; + const method = req.method(); + let body = null; + try { body = req.postDataJSON(); } catch { body = req.postData(); } + state.calls.push({ method, path, query: Object.fromEntries(url.searchParams), body }); + + const key = `${method} ${path}`; + if (options.routes && key in options.routes) { + const v = options.routes[key]; + const out = typeof v === 'function' ? await v(req, url, state) : v; + if (out && typeof out === 'object' && 'status' in out && 'body' in out) return route.fulfill(out); + return route.fulfill(json(out)); + } + + // ── identity ── + if (path === '/api/me') return route.fulfill(json({ + name: 'tester@example.com', role, groups: ['everyone'], vault_enabled: true, vault_configured: true + })); + if (path === '/api/auth/status') return route.fulfill(json({ + oidc_enabled: false, drive_configured: false, theme: 'dark' + })); + if (path === '/api/me/credentials') return route.fulfill(json({ credentials: {} })); + if (path === '/api/credential-variables' || path === '/api/login-scripts' || path === '/api/auth/known-groups') + return route.fulfill(json([])); + + // ── address book ── + if (path === '/api/addressbook') return route.fulfill(json({ folders, unavailable_scopes: [] })); + if (path === '/api/addressbook/version') return route.fulfill(json({ version: state.version, changed_at: 0 })); + if (path === '/api/addressbook/search-index') return route.fulfill(json(searchIndex())); + if (path === '/api/addressbook/folder-paths') return route.fulfill(json({ + paths: folders.map(f => ({ scope: f.scope, path: f.path ?? f.name })) + })); + if (path === '/api/addressbook/folders' && method === 'GET') return route.fulfill(json({ folders })); + let m = path.match(/^\/api\/addressbook\/folders\/([^/]+)\/(.+?)\/(entries|config|defaults|subfolders)$/); + if (m) { + const f = findFolder(decodeURIComponent(m[1]), decodeURIComponent(m[2])); + if (!f) return route.fulfill(json({ error: 'no such folder' }, 404)); + if (m[3] === 'entries') return route.fulfill(json(f.entries)); + if (m[3] === 'subfolders') return route.fulfill(json([])); + if (m[3] === 'config') return route.fulfill(json({ + allowed_groups: f.allowed_groups, allowed_users: [], description: f.description, display_name: f.display_name + })); + if (m[3] === 'defaults') return route.fulfill(json({ defaults: {} })); + } + m = path.match(/^\/api\/addressbook\/folders\/([^/]+)\/(.+?)\/entries\/([^/]+)\/connect$/); + if (m && method === 'POST') { + const id = '33333333-3333-4333-8333-333333333333'; + return route.fulfill(json({ + session_id: id, client_url: `/client.html?session=${id}`, ws_url: `/ws/${id}`, session_type: 'rdp' + })); + } + if (path.startsWith('/api/addressbook/') && method !== 'GET') { + state.version += 1; + return route.fulfill(json({ ok: true })); + } + + // ── sessions / recordings / reports ── + if (path === '/api/sessions' && method === 'GET') return route.fulfill(json(sessions)); + if (path.startsWith('/api/sessions/') && method === 'DELETE') return route.fulfill(json({ ok: true })); + if (path === '/api/vdi/containers') return route.fulfill(json([])); + if (path === '/api/recordings' && method === 'GET') return route.fulfill(json(recordings)); + if (path.startsWith('/api/recordings/') && method === 'DELETE') return route.fulfill(json({ ok: true })); + if (path === '/api/typescripts') return route.fulfill(json({ items: [] })); + if (path === '/api/reports/summary') return route.fulfill(json({ + total_sessions: 42, total_hours: 17.5, unique_users: 3, active_now: 1 + })); + if (path === '/api/reports/sessions') return route.fulfill(json({ + total: 2, limit: 100, offset: 0, + sessions: [ + { session_id: '1111', session_type: 'ssh', hostname: 'web01.acme.internal', username: 'root', + created_by: 'alice@example.com', status: 'Ended', started_at: '2026-10-11T01:00:00Z', + ended_at: '2026-10-11T01:30:00Z', duration_secs: 1800, entry_name: 'web01 (Acme)' }, + { session_id: '2222', session_type: 'rdp', hostname: 'dc01.acme.internal', username: 'administrator', + created_by: 'bob@example.com', status: 'Ended', started_at: '2026-10-11T02:00:00Z', + ended_at: '2026-10-11T02:30:00Z', duration_secs: 1800, entry_name: 'dc01 (Acme)' } + ] + })); + if (path === '/api/reports/top-connections' || path === '/api/reports/top-users') return route.fulfill(json([])); + + return route.fulfill(json({})); + }); + return state; +} + +/* Wait until the page has finished its initial loads: no pending fetches for + * a short while and no spinner-type skeletons left. */ +export async function settle(page, ms = 250) { + await page.waitForLoadState('networkidle'); + await page.waitForTimeout(ms); +} diff --git a/tests/e2e/package-lock.json b/tests/e2e/package-lock.json new file mode 100644 index 00000000..a94a60d6 --- /dev/null +++ b/tests/e2e/package-lock.json @@ -0,0 +1,76 @@ +{ + "name": "rustguac-e2e", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rustguac-e2e", + "devDependencies": { + "@playwright/test": "1.58.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/tests/e2e/package.json b/tests/e2e/package.json new file mode 100644 index 00000000..88ea545f --- /dev/null +++ b/tests/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "rustguac-e2e", + "private": true, + "description": "Playwright end-to-end tests for the rustguac pages, run against the static tree with a stubbed API.", + "type": "module", + "scripts": { + "test": "playwright test", + "serve": "node serve.mjs" + }, + "devDependencies": { + "@playwright/test": "1.58.2" + } +} diff --git a/tests/e2e/playwright.config.mjs b/tests/e2e/playwright.config.mjs new file mode 100644 index 00000000..bb0ad7d1 --- /dev/null +++ b/tests/e2e/playwright.config.mjs @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +const PORT = process.env.E2E_PORT || '8098'; +const baseURL = `http://127.0.0.1:${PORT}`; + +export default defineConfig({ + testDir: './specs', + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + workers: process.env.CI ? 2 : undefined, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['list'], ['github']] : 'list', + use: { + baseURL, + colorScheme: 'dark', + // The 1710px MacBook display the layout issues were reported on. + viewport: { width: 1710, height: 1112 }, + trace: 'retain-on-failure', + ...(process.env.PW_CHROMIUM ? { launchOptions: { executablePath: process.env.PW_CHROMIUM } } : {}) + }, + webServer: { + command: `node serve.mjs ${PORT}`, + url: `${baseURL}/index.html`, + reuseExistingServer: !process.env.CI, + timeout: 20_000 + }, + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'], viewport: { width: 1710, height: 1112 } } } + ] +}); diff --git a/tests/e2e/serve.mjs b/tests/e2e/serve.mjs new file mode 100644 index 00000000..2191d3a3 --- /dev/null +++ b/tests/e2e/serve.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +/* + * Serves the real static tree (../../static) over HTTP for the end-to-end + * tests. No API: each spec stubs /api/* in the browser with page.route(), + * so the pages run exactly as shipped against data the test controls. + * + * Usage: node tests/e2e/serve.mjs [port] (default 8098) + */ + +import { createServer } from 'node:http'; +import { createReadStream } from 'node:fs'; +import { stat } from 'node:fs/promises'; +import { dirname, resolve, join, extname, normalize } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../static'); +const port = parseInt(process.argv[2] || '8098', 10); + +const TYPES = { + '.html': 'text/html; charset=utf-8', + '.js' : 'text/javascript; charset=utf-8', + '.mjs' : 'text/javascript; charset=utf-8', + '.css' : 'text/css; charset=utf-8', + '.svg' : 'image/svg+xml', + '.png' : 'image/png', + '.ico' : 'image/x-icon', + '.json': 'application/json', + '.woff2': 'font/woff2', + '.woff': 'font/woff' +}; + +createServer(async (req, res) => { + const url = new URL(req.url, 'http://localhost'); + let path = url.pathname === '/' ? '/index.html' : url.pathname; + const file = join(root, normalize(path).replace(/^(\.\.[/\\])+/, '')); + if (!file.startsWith(root)) { res.writeHead(403); res.end(); return; } + try { + const s = await stat(file); + if (!s.isFile()) throw new Error('not a file'); + } catch { + res.writeHead(404, { 'Content-Type': 'text/plain' }); + res.end('not found: ' + path); + return; + } + res.writeHead(200, { + 'Content-Type': TYPES[extname(file)] || 'application/octet-stream', + 'Cache-Control': 'no-store' + }); + createReadStream(file).pipe(res); +}).listen(port, '127.0.0.1', () => { + console.log(`e2e static server: http://127.0.0.1:${port}/ (root ${root})`); +}); diff --git a/tests/e2e/specs/tables-1710.spec.mjs b/tests/e2e/specs/tables-1710.spec.mjs new file mode 100644 index 00000000..689239f0 --- /dev/null +++ b/tests/e2e/specs/tables-1710.spec.mjs @@ -0,0 +1,56 @@ +// #252: the admin table overran the page at 1710px wide and the page scrolled +// sideways. Each list page must fit its table inside the viewport at 1710px +// with nothing cut off, and the page must not scroll horizontally. +import { test, expect } from '@playwright/test'; +import { installApi, settle } from '../lib/api.mjs'; + +test.use({ viewport: { width: 1710, height: 1112 } }); + +async function noHorizontalOverflow(page) { + const m = await page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + bodyScroll: document.body.scrollWidth + })); + expect(m.scrollWidth, 'document must not be wider than the viewport').toBeLessThanOrEqual(m.clientWidth); + expect(m.bodyScroll).toBeLessThanOrEqual(m.clientWidth); +} + +async function tableFits(page, selector) { + const table = page.locator(selector).first(); + await expect(table).toBeVisible(); + const box = await table.boundingBox(); + const vw = page.viewportSize().width; + expect(box.x + box.width, `${selector} right edge must be inside the viewport`).toBeLessThanOrEqual(vw + 0.5); +} + +test('connections: the entries table with admin actions fits at 1710px', async ({ page }) => { + await installApi(page, { role: 'admin' }); + await page.goto('/connections.html'); + await settle(page); + // Open the first tenant folder so the admin table (edit/clone/delete) renders. + await page.locator('.folder-row').filter({ hasText: /beta/ }).first().click(); + await expect(page.locator('#entries-title')).toHaveText(/beta/); + await expect(page.locator('.entries-table')).toBeVisible(); + await expect(page.locator('.entries-table tbody tr').first()).toBeVisible(); + await tableFits(page, '.entries-table'); + await noHorizontalOverflow(page); +}); + +test('recordings: the table fits at 1710px', async ({ page }) => { + await installApi(page); + await page.goto('/recordings.html'); + await settle(page); + await expect(page.locator('#recording-list tbody tr').first()).toBeVisible(); + await tableFits(page, '#recording-list'); + await noHorizontalOverflow(page); +}); + +test('reports: the session history table fits at 1710px', async ({ page }) => { + await installApi(page); + await page.goto('/reports.html'); + await settle(page); + await expect(page.locator('#history-body tr').first()).toBeVisible(); + await tableFits(page, '#history-table'); + await noHorizontalOverflow(page); +});
NameTypeHostUser
' + '' + + '' + + '' + + '' + + '