Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d81b7ed5ae | ||
|
|
22c9e02f20 | ||
|
|
138c3c6db5 | ||
|
|
70f26e9c9a | ||
|
|
2bc75d779e | ||
|
|
17e22edaee | ||
|
|
66d02c7b6a | ||
|
|
20fe7b7b9b | ||
|
|
4190ee01ba | ||
|
|
62d1e77847 | ||
|
|
a23fd997db | ||
|
|
f367756b8c | ||
|
|
4a055463bd | ||
|
|
0222f0dcad | ||
|
|
4fea636f24 | ||
|
|
5ca3e0b17a | ||
|
|
a32ef74a9d | ||
|
|
d292c76530 | ||
|
|
e3fc4b2f52 | ||
|
|
b5f8be7645 | ||
|
|
8f552e2e29 | ||
|
|
1f18575abe | ||
|
|
6a5c1bb237 | ||
|
|
58a896345f | ||
|
|
bd904fe4ac | ||
|
|
09c271384c | ||
|
|
05763450ff | ||
|
|
aa0129f024 | ||
|
|
2bdc048440 | ||
|
|
7bd157dd81 | ||
|
|
2535e98c7e | ||
|
|
2853b9b148 | ||
|
|
29cb5584fc | ||
|
|
f36d8709c9 | ||
|
|
f7b2aa52a8 | ||
|
|
a1d3e48419 | ||
|
|
ea73118fca | ||
|
|
c629eb0b6c | ||
|
|
571ee7bc88 | ||
|
|
12cf7745f6 | ||
|
|
0f6581b6d7 | ||
|
|
46a1d5d7ec | ||
|
|
226efa841f | ||
|
|
dd130238be | ||
|
|
d8c803ef5f | ||
|
|
5e3738b73f | ||
|
|
289eca19d4 |
No files matched your search
@@ -72,6 +72,9 @@ jobs:
|
||||
- name: Acceptance (${{ matrix.browser }})
|
||||
env:
|
||||
QQQ_ACCEPTANCE_BROWSERS: ${{ matrix.browser }}
|
||||
QQQ_ACCEPTANCE_TRANSPORT_TRACE: '1'
|
||||
# Fixed NAV023/NAV056 ownership capture only; original assertions and diagnostics remain strict.
|
||||
QQQ_ACCEPTANCE_BROWSER_TRACE: ${{ (matrix.browser == 'webkit' || matrix.browser == 'mobile,tablet') && 'navigation' || '0' }}
|
||||
# Retain native browser crashes alongside traces (QQQ#904, QQQ#960).
|
||||
DEBUG: pw:browser
|
||||
run: node scripts/acceptance.mjs --skip-build
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A metadata-driven admin UI for the QQQ low-code application framework, rewritten from React + Material UI to Next.js 16 + Tailwind CSS + shadcn/ui. The UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded in the frontend.
|
||||
|
||||
[Next UI **1.0.0-RC.9** is published](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.9) for application testing. Public Maven and AMD64/ARM64 container delivery are verified; requirements, verification scope and known gaps are in the [RC9 notes](docs/releases/1.0.0-RC.9.md). Final 1.0 remains held for owner application testing and explicit approval.
|
||||
**This checkout targets Next UI 1.0.0-RC.11**, with searchable-selector and script-completion input-race corrections. Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing; see the [RC11 notes](docs/releases/1.0.0-RC.11.md) for the preparation checkpoint and verification scope. [RC10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) was the previously verified public candidate at that checkpoint and remains immutable. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
**Current roadmap and known issues:** [Next UI 1.0 tracker](https://github.com/QRun-IO/qqq/issues/713). GitHub issues are the source of truth for status and remaining work; see [issue tracking](docs/ISSUE-TRACKING.md).
|
||||
|
||||
@@ -14,11 +14,11 @@ The dashboard is the default admin UI for QQQ 4.1+. Add the jar (managed by `qqq
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.9</version>
|
||||
<version>1.0.0-RC.11</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
The explicit version overrides an older BOM-managed frontend. RC9 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
|
||||
The explicit version overrides an older BOM-managed frontend. RC11 requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for the verified backend commit. Select Next explicitly with `-Dqqq.javalin.frontend=next`. This does not release QQQ 4.1.
|
||||
|
||||
The jar holds the static export (`pnpm build:export`) under `next-dashboard/`. Deep links such as `/app/person/1` are served from placeholder pages and read their route from the browser path, so no Node.js server is involved. The Material Dashboard remains available: select it with `withServeFrontendMaterialDashboard(true)` or `-Dqqq.javalin.frontend=material`. Material routes are `/<app>/<table>/<id>`; Next routes are `/app/<table>/<id>`.
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ COPY --from=frontend --chown=node:node /app/.next/static ./.next/static
|
||||
COPY --from=frontend --chown=node:node /app/public ./public
|
||||
# server.js wrapped with the dashboard security headers (QRun-IO/qqq#734); add origins the
|
||||
# dashboard must reach (QuickSight, custom component bundles) with QQQ_DASHBOARD_CSP_SOURCES
|
||||
COPY --from=frontend --chown=node:node /app/standalone/qqq-server.mjs /app/standalone/security-headers.mjs /app/standalone/backend-prefixes.json ./
|
||||
COPY --from=frontend --chown=node:node /app/standalone/qqq-server.mjs /app/standalone/security-headers.mjs /app/standalone/branding-assets.mjs /app/standalone/backend-prefixes.json ./
|
||||
COPY LICENSE ./LICENSE
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# Next UI agent guide
|
||||
|
||||
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
|
||||
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
|
||||
|
||||
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the latest published prerelease is [1.0.0-RC.9](../releases/1.0.0-RC.9.md) at `c029ad2`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
|
||||
This is an implementation playbook for coding agents working on the QQQ Next UI. Read the [developer guide](./next-ui-developer-guide.md) for user and integrator behavior, the repository [AGENTS.md](../../AGENTS.md) and [CLAUDE.md](../../CLAUDE.md) for scoped conventions, and the [real-server feature matrix](../acceptance/feature-matrix.md) plus [Material parity ledger](../acceptance/material-parity.md) before claiming compatibility. This guide reflects the `feature/next-1.0` release worktree as of 2026-10-01; the previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) at `12cf774`, and final `1.0.0` remains on hold. Later branch changes must not be attributed to that immutable release. Some ledger entries predate integrated changes, so reconcile them with source and real-server evidence. A green subset of tests is evidence for that subset, not proof of complete Material parity or a published 1.0 release.
|
||||
|
||||
## Operating rule: metadata is the product boundary
|
||||
|
||||
@@ -223,7 +225,7 @@ Google Drive picker changes span the frontend component/SDK loader and Javalin's
|
||||
and v1 public-environment allow-list (#704; backend PR #908 merged to develop as `bac663044`). The frontend needs those backend changes to receive its public configuration and load under the configured CSP. PRC-065 tests controlled SDK responses with
|
||||
real process execution and SQL; PRC-039 is the approved real-account exclusion. Preserve
|
||||
the shared-drive view, folder-only validation, cancellation and token expiry handling.
|
||||
Never log or persist the access token, and never publish OAuth client secrets in metadata. Javalin conditional CSP covers its static export only; standalone Node/container deployments also require runtime `QQQ_DASHBOARD_CSP_SOURCES` using the [documented Google directives](./next-ui-developer-guide.md#google-drive-process-configuration). Check the actual dashboard HTML response from the selected host. RC1 remains immutable and retains its historical placeholder; these changes belong to the ongoing final 1.0 work.
|
||||
Never log or persist the access token, and never publish OAuth client secrets in metadata. Javalin applies conditional CSP to its static export; standalone Node/container deployments derive the same sources from backend `dashboardCspSources` authentication metadata. Older backends that omit that optional field require runtime `QQQ_DASHBOARD_CSP_SOURCES` using the [documented Google directives](./next-ui-developer-guide.md#google-drive-process-configuration). Check the actual dashboard HTML response from the selected host. RC1 remains immutable and retains its historical placeholder; these changes belong to the ongoing final 1.0 work.
|
||||
|
||||
### Script editor suggestions
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# Next UI developer guide
|
||||
|
||||
> **This checkout targets RC11.** Confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. [RC11 notes](../releases/1.0.0-RC.11.md) record the preparation checkpoint and verification scope; RC10 was the previously verified public candidate at that checkpoint. Earlier versioned receipts remain historical evidence; final 1.0 remains held.
|
||||
|
||||
> **RC9:** application code is unchanged from RC8. See [RC9 notes](../releases/1.0.0-RC.9.md) and [published releases](https://github.com/QRun-IO/qqq-frontend-next/releases) for versioned verification and public delivery status. Earlier RC8 receipts below remain historical evidence.
|
||||
|
||||
> **Live roadmap and known issues:** [QRun-IO/qqq#713](https://github.com/QRun-IO/qqq/issues/713). GitHub issues own current status and remaining work. Follow the [issue-tracking policy](../ISSUE-TRACKING.md) when reporting or resolving findings; keep this guide focused on usage and evidence.
|
||||
|
||||
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The latest published prerelease is [1.0.0-RC.9](../releases/1.0.0-RC.9.md) (commit `c029ad2`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
|
||||
This guide describes the `feature/next-1.0` release worktree as of 2026-10-01. It explains the current frontend and backend metadata contract. The previously verified public candidate at the RC11 preparation checkpoint was [1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) (commit `12cf774`); final `1.0.0` remains on hold. Later branch changes are not retroactive changes to that immutable release. For behavior proven against a real server use the [feature matrix](../acceptance/feature-matrix.md); for the full Material inventory use the [parity ledger](../acceptance/material-parity.md). Some ledger rows predate recent work, so verify an individual status against current source and acceptance results before making a release claim.
|
||||
|
||||
The visual baseline is the September 25 Next UI design (`e42ad2b2`). Material remains the reference for backend contracts and functional behavior. The layout defaults below describe current source; they do not certify a completed visual review.
|
||||
|
||||
@@ -33,7 +35,7 @@ The preferred QQQ-hosted distribution is the `com.kingsrook.qqq:qqq-frontend-nex
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qqq</groupId>
|
||||
<artifactId>qqq-frontend-next</artifactId>
|
||||
<version>1.0.0-RC.5</version>
|
||||
<version>1.0.0-RC.11</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
@@ -46,7 +48,7 @@ pnpm build:export
|
||||
pnpm build:jar
|
||||
```
|
||||
|
||||
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). Use version `1.0.0-RC.8` for the published prerelease, overriding an older BOM-managed frontend. It requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for its verified backend commit. Use a matching local backend/frontend build to test later branch changes.
|
||||
The mock setting is for isolated development. Set `NEXT_PUBLIC_MOCK_API=false` and connect a real QQQ backend for integration. `build:export` writes `out/`; `build:jar` packages it with Maven. The Maven POM enforces the presence of static placeholder pages. The default `pnpm build` instead creates a standalone Node server, whose `QQQ_BACKEND_URL` build setting adds same-origin rewrites. For the container path see [`docker/quickstart/Dockerfile`](../../docker/quickstart/Dockerfile). This checkout targets `1.0.0-RC.11`, overriding an older BOM-managed frontend; confirm its availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. It requires the QQQ 4.1.0-SNAPSHOT development backend including PRs #913 and #908; see the release notes for its verified backend commit. Use a matching local backend/frontend build to test later branch changes.
|
||||
|
||||
| Setting | Behavior in this checkout |
|
||||
|---|---|
|
||||
@@ -457,8 +459,9 @@ Client secrets are not browser configuration and are not published in v1 metadat
|
||||
The Next UI middleware must include the #704 changes (backend PR #908, merged to develop as `bac663044`) that publish these two settings
|
||||
and permit Google's SDK and picker origins in CSP only when both are configured.
|
||||
Those conditional headers apply to the Javalin-hosted static export. The standalone
|
||||
Node/container host must receive `QQQ_DASHBOARD_CSP_SOURCES` at runtime; it does not derive
|
||||
Google allowances from instance metadata. Merge these sources with any other deployment
|
||||
Node/container host also derives them when the backend publishes `dashboardCspSources`
|
||||
in authentication metadata. With an older backend that omits that optional field, supply
|
||||
`QQQ_DASHBOARD_CSP_SOURCES` at runtime. Merge these sources with any other deployment
|
||||
additions and start through [`standalone/qqq-server.mjs`](../../standalone/qqq-server.mjs):
|
||||
|
||||
```bash
|
||||
|
||||
@@ -10,11 +10,11 @@ RC1 remains immutable: [v1.0.0-RC.1](https://github.com/QRun-IO/qqq-frontend-nex
|
||||
|
||||
The owner authorizes publishing new RCs at verified stability or daily milestones. Each RC gets a new immutable version and notes describing its fixes, backend prerequisites, verification, and known gaps. RC publication does not authorize final 1.0.
|
||||
|
||||
Latest published candidate: **[1.0.0-RC.9](1.0.0-RC.9.md)**. Its notes record installation, backend requirements, exact artifact verification and known failures. RC1 through RC8 remain available and unchanged.
|
||||
Previously verified public candidate at the RC11 preparation checkpoint: **[1.0.0-RC.10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10)**. Its public release entry records exact artifact verification, backend requirements and known limits. RC1 through RC9 remain available and unchanged.
|
||||
|
||||
## Fixes during testing
|
||||
|
||||
Browser stability work continues on `feature/browser-stability`, tracked in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) and the [unreleased changelog](unreleased.md). These changes are not present in the published RC9 artifacts. Earlier PRs #14 and #16 are merged. Check the version of each preview separately; a local preview may intentionally preserve an earlier design checkpoint.
|
||||
This checkout targets [RC11](1.0.0-RC.11.md); confirm availability in the [RC11 GitHub release](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11) before installing. The candidate adds searchable-selector and script-completion input-race corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24). These changes are absent from immutable RC10. Candidate notes distinguish versioned local checks from public delivery and retain the historical browser failures and remaining RC limits. Local previews may intentionally preserve an earlier version.
|
||||
|
||||
RC9's immutable-tag acceptance run completed with known Firefox and tablet failures; its release notes preserve the exact results. Follow [qqq#985](https://github.com/QRun-IO/qqq/issues/985) for browser investigations and [qqq#713](https://github.com/QRun-IO/qqq/issues/713) for the complete backlog. The [original Next visual review](../acceptance/next-ui-visual-review.md) and owner approval remain separate requirements.
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Next UI 1.0.0-RC.10 — preparation
|
||||
|
||||
RC10 is being prepared for application testing and **is not yet published**. [RC9](1.0.0-RC.9.md) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.10` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.10`. Public checksums, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Changes from RC9
|
||||
|
||||
- Fix section-hash loss and stale immediate URLs when changing record tabs or views (#989), preserving URL context, return links, focus and history. The baseline eventually restored the tab; this is not a claim of proven tab loss.
|
||||
- Preserve search-dialog keyboard focus (#950), WebKit possible-value selections (#978), and authentication loading through logout/provider departure (#984).
|
||||
- Add ESB developer views and overview/management controls, with real-backend acceptance. See the [ESB contract](../acceptance/esb.md).
|
||||
- Improve browser readiness and diagnostic checks while preserving assertions. The pinned Playwright runner contains the corrected Linux WebKit library; stable-runner migration remains #973. See [change details](unreleased.md).
|
||||
|
||||
## Verification
|
||||
|
||||
Verification uses source `5e3738b73f14d7ed25580560f3c97b91aed58fce` ([PR21](https://github.com/QRun-IO/qqq-frontend-next/pull/21)) with RC10 metadata and QQQ backend `55b33e6bd7f8cc2aef3b41546ef8409ad307ff05`.
|
||||
|
||||
- **1,979 unit tests / 184 files**, typecheck, lint/license (zero errors, five existing warnings), static export, Maven packaging and bundle budgets pass.
|
||||
- The actual RC10 JAR passes **85/85** auth/focus/selection/ESB real-backend cases: Chromium, Firefox and WebKit 21 each; mobile and tablet 11 each.
|
||||
- Four navigation regressions pass **8/8 in Firefox/WebKit**, using the same JAR's assets with mocked APIs. These are separate from real-backend API coverage.
|
||||
|
||||
Both browser reports have zero failures, skips, flaky outcomes or report errors, with retries disabled. All 192 dashboard files are packaged without a mock service worker; Maven and compiled frontend versions are RC10. Served JavaScript and font bytes match the JAR. The primary fixture serves the JAR directly; authentication variants use verified assets extracted from it. These are local candidate checks, not public delivery receipts. PR23 changes test selection and documentation only; production code and test assertions are unchanged from this verified candidate.
|
||||
|
||||
## Browser coverage and remaining RC limits
|
||||
|
||||
The historical [PR18 run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) failed its touch-coverage gate despite all 2,972 executed cases passing: ESB-001–010 were required but not selected on phone and tablet. The original failed result is retained under [#990](https://github.com/QRun-IO/qqq/issues/990).
|
||||
|
||||
[PR23](https://github.com/QRun-IO/qqq-frontend-next/pull/23) corrects selection by adding touch markers to the ten existing ESB tests, preserving production code and every assertion. Its separate focused real-broker run on the PR18 export and QQQ `55b33e6` passed all 20 phone/tablet cases with zero retries, failures, skips, flaky outcomes or report errors. This focused result is not a fresh full hosted matrix pass; full hosted verification remains pending at this preparation checkpoint.
|
||||
|
||||
The secondary-browser/tablet RC exception remains documented in [#985](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5943724086) and [#798](https://github.com/QRun-IO/qqq/issues/798#issuecomment-5943457468). No gate, test requirement or exclusion was weakened. This exception does not waive product defects or authorize GA.
|
||||
|
||||
Remaining parity, real Auth0/Drive/QuickSight checks, browser investigations, visual review and application soak stay tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#985](https://github.com/QRun-IO/qqq/issues/985), [#711](https://github.com/QRun-IO/qqq/issues/711) and [#712](https://github.com/QRun-IO/qqq/issues/712). The container backend origin is fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin. This candidate does not itself publish QQQ 4.1.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Next UI 1.0.0-RC.11 — preparation
|
||||
|
||||
At this preparation checkpoint, RC11 is being prepared for application testing and **is not yet published**. [RC10](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) remains the latest verified public candidate and is immutable. Planned artifacts are `com.kingsrook.qqq:qqq-frontend-next:1.0.0-RC.11` and `ghcr.io/qrun-io/qqq-frontend-next:1.0.0-RC.11`. Public hashes, signatures and image digests will be recorded after delivery verification. Final 1.0 remains held for application soak and explicit owner approval.
|
||||
|
||||
## Changes from RC10
|
||||
|
||||
[PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24), merged source `a1d3e48419812fcb8d47091cb4af315cad4e76e9` (tree-identical to reviewed head `ea73118fcabdf3c08ea8614c8bd0ad63749fb598`), invalidates stale searchable-selector results immediately when input changes (#970), and keeps script-completion suggestions open when a delayed scroll event reports an unchanged position (#954). Actual position changes still dismiss suggestions. No acceptance assertion or gate is weakened.
|
||||
|
||||
## Verification checkpoint
|
||||
|
||||
PR24's [hosted primary acceptance](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36954433088) passes on its exact source: 614 cases and 401 required rows. The actual merged source above has the same tree. With RC11 metadata, fresh verification passes **1,984 unit tests / 184 files**, typecheck, lint/license (zero errors, five existing warnings), static export, Maven packaging and bundle budgets. All **192 dashboard files** match the export byte-for-byte, with no mock service worker and matching RC11 frontend/Maven versions.
|
||||
|
||||
The actual RC11 JAR passes **125/125 real-backend cases** against QQQ `55b33e6bd7f8cc2aef3b41546ef8409ad307ff05`: 25 each in Chromium, Firefox, WebKit, phone and tablet. These include the 105 auth/focus/selection/ESB cases and 20 selector/completion regression cases. A separate **8/8 record-navigation regressions in Firefox/WebKit** use the same JAR assets with mocked APIs. Both reports have zero failures, skips, flaky outcomes or report errors, with retries disabled. Served versioned JavaScript and font bytes match the JAR. The scoped matrix check passes only the exercised rows; it is not a full hosted matrix result. These local candidate checks are separate from eventual public-artifact verification; no RC10 test count or artifact digest is attributed to RC11.
|
||||
|
||||
## Remaining RC limits
|
||||
|
||||
Historical observations remain preserved: REC-054 on mobile light expected three completion options but found none; PRC-061 in WebKit left the Casey selection at its placeholder. See [#954](https://github.com/QRun-IO/qqq/issues/954), [#970](https://github.com/QRun-IO/qqq/issues/970) and [RC10 delivery notes](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10). The original [PR18 touch-coverage failure](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) remains historical evidence. [#990 is complete](https://github.com/QRun-IO/qqq/issues/990#issuecomment-5944836159): PR23’s complete touch report covers all 401 required IDs on each profile, with zero missing, and all ten ESB scenarios pass first attempt on both phone and tablet. That report records **1,149 passes and one unrelated tablet NAV-056 failure**; its full touch gate and whole workflow remain **failed**. [NAV-056 diagnostics remain under investigation](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5944830383), provisionally tracked in [#904](https://github.com/QRun-IO/qqq/issues/904#issuecomment-5944558037), without claiming a shared historical cause. Scoped #990 completion does not imply an all-browser pass or waive that failure.
|
||||
|
||||
The documented secondary-browser/tablet RC exception remains under [#985](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5943724086) and [#798](https://github.com/QRun-IO/qqq/issues/798#issuecomment-5943457468); it does not waive new product defects or authorize GA. Material parity, sub-path hosting, real Auth0/Drive/QuickSight verification, visual review and application soak remain tracked in [#713](https://github.com/QRun-IO/qqq/issues/713), [#711](https://github.com/QRun-IO/qqq/issues/711) and [#712](https://github.com/QRun-IO/qqq/issues/712). The image backend origin remains fixed at build time to `host.docker.internal:8000`; use the JAR or rebuild for another origin. This candidate does not publish QQQ 4.1.
|
||||
@@ -1,9 +1,12 @@
|
||||
# Next UI — unreleased
|
||||
|
||||
The browser stability changes in [PR #18](https://github.com/QRun-IO/qqq-frontend-next/pull/18) are under review and verification. No new version or release is declared here. Published [RC9](1.0.0-RC.9.md) remains immutable and does not contain these fixes. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
|
||||
[RC11 is published and immutable](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.11). It includes the searchable-selector and script-completion corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24); its release details remain in [RC11 notes](1.0.0-RC.11.md). Current main also contains the later menu, copy-focus and grid fixes, which are not in that published artifact. Central tracking: [qqq#713](https://github.com/QRun-IO/qqq/issues/713), [browser investigations #985](https://github.com/QRun-IO/qqq/issues/985), and [QQQ Roadmap Project 12](https://github.com/orgs/QRun-IO/projects/12).
|
||||
|
||||
## Product fixes
|
||||
|
||||
- Clear remembered query filters and saved-view selections on logout or a detected user identity change. Same-user resumes keep their query state; global appearance, density and legacy column preferences remain intact. This correction awaits its own branch acceptance. [#1006](https://github.com/QRun-IO/qqq/issues/1006)
|
||||
- In searchable possible-value fields, invalidate old results immediately when the search term changes, so a debounced request cannot remove an option during its selection. [#970](https://github.com/QRun-IO/qqq/issues/970)
|
||||
- Keep code suggestions open when a delayed textarea scroll event reports the position already used for the popup; dismiss them when the scroll position actually changes. [#954](https://github.com/QRun-IO/qqq/issues/954)
|
||||
- Preserve focus on the search dialog's Clear control when keyboard interaction occurs before its scheduled initial focus. [#950](https://github.com/QRun-IO/qqq/issues/950)
|
||||
- Preserve a clicked possible-value option in WebKit when blurring its search input would scroll the form between mousedown and mouseup. The selected value now reaches process submission and generated output. [#978](https://github.com/QRun-IO/qqq/issues/978)
|
||||
- Keep authentication loading until explicit logout finishes, so a late logout response cannot erase cookies from a subsequent successful sign-in. [#984](https://github.com/QRun-IO/qqq/issues/984)
|
||||
@@ -16,9 +19,35 @@ The browser stability changes in [PR #18](https://github.com/QRun-IO/qqq-fronten
|
||||
- Verify Linux WebKit's downloaded filename normalization while retaining file-content and cleanup assertions. [#979](https://github.com/QRun-IO/qqq/issues/979)
|
||||
- Verify actual browser refusal of disallowed framing with a control that renders when the protective headers are removed. [#981](https://github.com/QRun-IO/qqq/issues/981)
|
||||
- Recognize Firefox's network-error diagnostic for the gallery's intentionally missing extension only after that same script request returns HTTP 404; preserve the raw evidence and reject unrelated failures. [#983](https://github.com/QRun-IO/qqq/issues/983)
|
||||
- Select all ten ESB scenarios for both phone and tablet acceptance, retaining real broker delivery, lifecycle, replay, SQL and permission checks. Matrix references now use the central QQQ tickets. [#986](https://github.com/QRun-IO/qqq/issues/986), [#990](https://github.com/QRun-IO/qqq/issues/990)
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
Each linked issue and PR records the source commit, reproduction and scoped verification. Preserve failed full-run snapshots separately from later focused passes; no corrected all-browser pass or release approval is claimed by this changelog. The original causes of the same-user session stall and legacy process-navigation stall remain unresolved in [#952](https://github.com/QRun-IO/qqq/issues/952) and [#960](https://github.com/QRun-IO/qqq/issues/960).
|
||||
Current main `5ca3e0b` has a complete [3,010-case first-attempt acceptance pass](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36989591498): 620 per desktop browser and 575 per touch profile. That run qualifies the main tree before the query-state correction above. The earlier checkpoints below are retained separately.
|
||||
|
||||
The [combined acceptance run](https://github.com/QRun-IO/qqq-frontend-next/actions/runs/36942612338) on head `7ff90f1` (the same tree as merge `289eca19`) passed all 2,972 executed cases: 614 each in Chromium, Firefox and WebKit, and 565 each on phone and tablet. All desktop gates covered 401 required rows. The workflow still failed its touch coverage gate because ten ESB rows were not selected; that original failure remains retained under [#990](https://github.com/QRun-IO/qqq/issues/990). After adding the selection markers, a separate native Linux run passed all 20 ESB phone/tablet cases, with zero retries, skips, flaky outcomes or report errors. Its frozen frontend export is unchanged; its freshly rebuilt backend is `55b33e6`. Complete touch collection is 575 cases per profile. The subsequent [complete PR23 outcome](https://github.com/QRun-IO/qqq/issues/990#issuecomment-5944836159) covers all 401 required IDs on each profile with zero missing and all 20 ESB touch cases passing, completing #990. Touch totals are 1,149 passes and one unrelated tablet NAV-056 failure; the full gate and workflow remain failed. [NAV-056 investigation](https://github.com/QRun-IO/qqq/issues/985#issuecomment-5944830383) remains separate, with no shared-cause claim or new waiver.
|
||||
|
||||
Each linked issue and PR records the source commit, reproduction and scoped verification. Preserve failed full-run snapshots separately from later focused passes. The broader historical navigation inventory [#904](https://github.com/QRun-IO/qqq/issues/904), same-user session stall [#952](https://github.com/QRun-IO/qqq/issues/952), legacy process-navigation stall [#960](https://github.com/QRun-IO/qqq/issues/960), and original toast-paint uncertainty [#967](https://github.com/QRun-IO/qqq/issues/967) remain open. The [RC10 preparation notes](1.0.0-RC.10.md) retain their historical checkpoint; its [public release entry](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10) records final delivery verification. See [RC11 notes](1.0.0-RC.11.md) for the successor candidate. No final-release approval is claimed by these results.
|
||||
|
||||
Final 1.0 remains held for real-application soak and explicit owner approval under [#712](https://github.com/QRun-IO/qqq/issues/712). This work does not authorize a QQQ 4.1 release. See the [testing-period policy](1.0-testing.md) and [issue lifecycle](../ISSUE-TRACKING.md).
|
||||
|
||||
## Pending standalone branding compatibility (QQQ #1009)
|
||||
|
||||
The standalone Node server can route the exact root-relative `branding.logo` and
|
||||
`branding.icon` paths published by authentication metadata through fixed backend
|
||||
image roles. Local Node `public` files keep precedence; external image URLs and
|
||||
Java dashboard serving are unchanged. This does not expose the whole application
|
||||
overlay or add a general proxy.
|
||||
|
||||
This requires **a QQQ middleware-javalin build containing QQQ #1009's
|
||||
`/qqq/branding/logo` and `/qqq/branding/icon` endpoints**. They are absent from
|
||||
published **4.1.0-RC.1**. The minimum numbered release is not yet assigned; a
|
||||
source build must record the exact backend commit/JAR hashes. Older deployments
|
||||
must supply branding through their Node `public` files or an existing external
|
||||
image URL. No automatic compatibility with old relative backend assets is claimed.
|
||||
|
||||
The preserved standalone qualification against public RC1 remains **5 passed,
|
||||
38 failed** out of 43 cases because of missing branding assets. The original
|
||||
staging failure is also retained. The corrected cross-repository pairing has
|
||||
not yet received its browser qualification; neither #1009 nor #734 is closed by
|
||||
focused unit/HTTP results.
|
||||
@@ -1,5 +1,49 @@
|
||||
# Next UI security review (QRun-IO/qqq#696)
|
||||
|
||||
## Current status — October 4, 2026
|
||||
|
||||
This addendum supersedes the historical disposition statements below where noted.
|
||||
The September 25 review remains intact as evidence of its original source and scope.
|
||||
Current findings and release decisions are tracked in
|
||||
[QQQ #696](https://github.com/QRun-IO/qqq/issues/696#issuecomment-5982040326).
|
||||
This is a scoped security review, not a certification of ASVS Level 2 compliance.
|
||||
|
||||
| Finding | Current disposition |
|
||||
| --- | --- |
|
||||
| NUI-04: dashboard headers | [#695](https://github.com/QRun-IO/qqq/issues/695) is closed for the Javalin static-export scope, with current-source provider tests and browser evidence. Standalone qualification is separate. |
|
||||
| NUI-05: readable session cookie | [#733](https://github.com/QRun-IO/qqq/issues/733) is closed. Next resumes through the backend-owned cookie; the legacy endpoint correction is merged. Material's explicit readable-cookie compatibility mode remains supported. |
|
||||
| NUI-06: previous-user browser state | The additional query-filter and saved-view-pointer retention finding is fixed in [#1006](https://github.com/QRun-IO/qqq/issues/1006), merged in frontend `f367756`. Global visual preferences are preserved. |
|
||||
| NUI-09: standalone headers | [#734](https://github.com/QRun-IO/qqq/issues/734) and the metadata-derived CSP correction [#1017](https://github.com/QRun-IO/qqq/issues/1017) are closed after scoped qualification and integration. The paired implementation passed 48 standalone first-attempt cases across five profiles. [Frontend PR #40](https://github.com/QRun-IO/qqq-frontend-next/pull/40) merged as `138c3c6d`, tree `328bc322`, exactly matching the qualified combined candidate. Its separate full Javalin acceptance passed 3,015/3,015 first attempts against backend `1d16a70c`. HEAD/304, bounded metadata and Java-customizer limits remain documented in the closeout. |
|
||||
| NUI-11: custom CSS | Current code treats application `customCss` as trusted developer configuration and inserts it with `textContent`. It is not filtered by the historical deny-list or sanitized by DOMPurify. CSP and application configuration remain separate controls. |
|
||||
| Auth0 token verification | [#1008](https://github.com/QRun-IO/qqq/issues/1008) confirms issuer/audience validation and cache-boundary findings. [Backend PR #1012](https://github.com/QRun-IO/qqq/pull/1012) contains the correction; 164 focused tests passed on its combined backend tree. The exact-head CI passed and the correction merged into backend develop as `fc67393b`. An unconfigured audience remains supported; no audience enforcement is claimed in that configuration. |
|
||||
| Signed back-channel logout | [#909](https://github.com/QRun-IO/qqq/issues/909) remains open for stable release delivery. [Backend PR #1021](https://github.com/QRun-IO/qqq/pull/1021) merged the qualified correction into stable main as `9168e434`; its merge tree equals the qualified candidate and the automatic post-merge reactor/sample/static gates passed. Published 4.0.0 remains affected; planned 4.0.1 is not published or artifact-qualified. PR #1018 is closed as superseded, not merged; its original Mongo failure is retained. The corrected 4.1 RC line remains distinct. |
|
||||
|
||||
The other scoped finding dispositions remain as reconciled in #696. NUI-10's
|
||||
runtime-style exception remains deliberate; it does not allow inline scripts.
|
||||
Negative security tests intentionally produce denied-resource and CSP diagnostics.
|
||||
A passing test run does not mean those diagnostic arrays are all empty.
|
||||
|
||||
### Evidence limits
|
||||
|
||||
The reference remains ASVS 4.0.3. Owned identity-provider fixtures do not qualify a
|
||||
real Auth0 tenant, MFA or step-up policy, provider-grant revocation, or deployment
|
||||
TLS, HSTS and proxy trust. The approved real-provider exclusion remains explicit.
|
||||
Configurable TABLE_BASED idle expiration does not prove a 12-hour active / 30-minute
|
||||
idle policy. Cookie-prefix and shared-host suitability, generic CSRF/Origin
|
||||
protection, all-session revocation, and application-sensitive storage/cache policy
|
||||
also remain outside the demonstrated guarantees. These limits are not newly
|
||||
confirmed High/Critical exploits and must not be presented as universal compliance.
|
||||
|
||||
The 48-case standalone result covers its selected security scope and paired
|
||||
candidate backend. Its analytics case uses an owned plugin through the real
|
||||
application hook, not a vendor SDK or real account. It does not
|
||||
replace full-browser acceptance. The separate 3,015-case Javalin gate qualifies
|
||||
the merged PR40 tree; neither result resolves separately tracked browser failures
|
||||
or certifies a real provider or deployment. Final 1.0 still requires owner visual
|
||||
acceptance and real-application soak.
|
||||
|
||||
## Historical review — September 25, 2026
|
||||
|
||||
Independent review of the QQQ Next admin UI against OWASP ASVS 4.0.3 Level 2, for the
|
||||
Next UI 1.0 milestone (epic QRun-IO/qqq#713). Reviewed on 2026-09-25 at
|
||||
`feature/GH-649-next-acceptance` (`2b6569f`, release 0.2.0) with the backend at
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "qqq-frontend-next",
|
||||
"version": "1.0.0-RC.9",
|
||||
"version": "1.0.0-RC.11",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@9.15.9",
|
||||
"scripts": {
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</developers>
|
||||
|
||||
<properties>
|
||||
<revision>1.0.0-RC.9</revision>
|
||||
<revision>1.0.0-RC.11</revision>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<project.build.outputTimestamp>2026-09-24T00:00:00Z</project.build.outputTimestamp>
|
||||
</properties>
|
||||
|
||||
@@ -18,7 +18,7 @@ rmSync(classes, { recursive: true, force: true })
|
||||
mkdirSync(classes, { recursive: true })
|
||||
const fixtureDirectory = path.resolve('tests/acceptance/fixture')
|
||||
const sources = readdirSync(fixtureDirectory).filter((name) => name.endsWith('.java')).map((name) => path.join(fixtureDirectory, name))
|
||||
const javac = spawnSync('javac', ['-proc:none', '-encoding', 'UTF-8', '-cp', fixtureClasspath, '-d', classes, ...sources], { stdio: 'inherit' })
|
||||
const javac = spawnSync('javac', ['-proc:none', '-sourcepath', '', '-encoding', 'UTF-8', '-cp', fixtureClasspath, '-d', classes, ...sources], { stdio: 'inherit' })
|
||||
if (javac.status !== 0) {
|
||||
console.error('Acceptance fixture compilation failed.')
|
||||
process.exit(1)
|
||||
|
||||
@@ -11,6 +11,7 @@ import { spawn } from 'node:child_process'
|
||||
import { cpSync, existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { BUILD_MARKER } from './acceptance-paths.mjs'
|
||||
import { assertExplicitCspSources } from './acceptance-standalone.mjs'
|
||||
|
||||
const backend = `http://127.0.0.1:${process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? '18765'}`
|
||||
const standalone = path.resolve('.next/standalone')
|
||||
@@ -19,16 +20,16 @@ if (!existsSync(path.join(standalone, 'server.js')) || !existsSync(BUILD_MARKER)
|
||||
console.error(`No production build for ${backend}; run node scripts/acceptance.mjs.`)
|
||||
process.exit(1)
|
||||
}
|
||||
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true })
|
||||
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true })
|
||||
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'backend-prefixes.json']) {
|
||||
// Use the JS copy path for owner-readable files on Docker Desktop mounts.
|
||||
cpSync(path.resolve('.next/static'), path.join(standalone, '.next/static'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
cpSync(path.resolve('public'), path.join(standalone, 'public'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'branding-assets.mjs', 'backend-prefixes.json']) {
|
||||
cpSync(path.resolve('standalone', file), path.join(standalone, file))
|
||||
}
|
||||
|
||||
/**
|
||||
* The application's policy additions (its QuickSight embed, custom component bundles and
|
||||
* the fixture's loopback service), which the QQQ server derives in javalin mode and a
|
||||
* container deployment sets in QQQ_DASHBOARD_CSP_SOURCES.
|
||||
* Only the fixture's explicit owned frame/image/media overrides. Application
|
||||
* metadata sources must arrive through the production metadata contract.
|
||||
*
|
||||
* @returns {Promise<string>} The sources, once the acceptance backend is up.
|
||||
*/
|
||||
@@ -47,6 +48,7 @@ async function dashboardCspSources() {
|
||||
}
|
||||
|
||||
const sources = await dashboardCspSources()
|
||||
assertExplicitCspSources(sources)
|
||||
const server = spawn('node', ['qqq-server.mjs'], {
|
||||
cwd: standalone,
|
||||
stdio: 'inherit',
|
||||
|
||||
@@ -40,3 +40,11 @@ export const BUILD_MARKER = path.resolve('.next/acceptance-backend.txt')
|
||||
export const EXPORT_CLASSPATH = path.resolve('test-results/acceptance/export-classpath')
|
||||
|
||||
export const ACCEPTANCE_MODE = process.env.QQQ_ACCEPTANCE_MODE === 'standalone' ? 'standalone' : 'javalin'
|
||||
|
||||
/** Separate backend, browser and provider origins for authentication variants. */
|
||||
export const SECURITY_BACKEND_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT ?? Number(process.env.QQQ_ACCEPTANCE_BACKEND_PORT ?? 18765) + 10)
|
||||
export const SECURITY_FRONTEND_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_FRONTEND_PORT ?? Number(process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? 13765) + 20)
|
||||
export const SECURITY_IDP_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_IDP_PORT ?? Number(process.env.QQQ_ACCEPTANCE_FRONTEND_PORT ?? 13765) + 10)
|
||||
export const SECURITY_BACKEND_URL = `http://127.0.0.1:${SECURITY_BACKEND_PORT}`
|
||||
export const SECURITY_UI_URL = ACCEPTANCE_MODE === 'standalone' ? `http://127.0.0.1:${SECURITY_FRONTEND_PORT}` : SECURITY_BACKEND_URL
|
||||
export const SECURITY_STANDALONE = path.resolve('test-results/acceptance/security-standalone')
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/** @file Stages and validates an immutable standalone build for an authentication backend. */
|
||||
import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
const prefixes = createRequire(path.resolve('package.json'))('./standalone/backend-prefixes.json')
|
||||
|
||||
/** Rejects a missing or differently routed build, regardless of any retained Javalin export. */
|
||||
export function assertStandaloneTarget(directory, backend) {
|
||||
if (!existsSync(path.join(directory, 'server.js')) || !existsSync(path.join(directory, 'qqq-server.mjs'))
|
||||
|| readFileSync(path.join(directory, 'acceptance-backend.txt'), 'utf8').trim() !== backend) {
|
||||
throw new Error(`No standalone security build for ${backend}; run acceptance without --skip-build.`)
|
||||
}
|
||||
const { rewrites } = JSON.parse(readFileSync(path.join(directory, '.next/routes-manifest.json'), 'utf8'))
|
||||
const routes = Array.isArray(rewrites) ? rewrites : [...rewrites.beforeFiles, ...rewrites.afterFiles, ...rewrites.fallback]
|
||||
for (const prefix of prefixes) {
|
||||
if (!routes.some(({ source, destination }) => source === `/${prefix}/:path*` && destination === `${backend}/${prefix}/:path*`)) {
|
||||
throw new Error(`Standalone security build has the wrong backend rewrite for ${prefix}.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the same standalone payload as the container image, before the next clean build. */
|
||||
export function stageStandalone(directory, backend, source = process.cwd()) {
|
||||
rmSync(directory, { recursive: true, force: true })
|
||||
// The JS copy path preserves readable files on Docker Desktop mounts; relative links
|
||||
// must remain relative so deleting the original build cannot invalidate this stage.
|
||||
cpSync(path.join(source, '.next/standalone'), directory, { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
cpSync(path.join(source, '.next/static'), path.join(directory, '.next/static'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
cpSync(path.join(source, 'public'), path.join(directory, 'public'), { recursive: true, verbatimSymlinks: true, filter: () => true })
|
||||
for (const file of ['qqq-server.mjs', 'security-headers.mjs', 'branding-assets.mjs', 'backend-prefixes.json']) {
|
||||
cpSync(path.join(source, 'standalone', file), path.join(directory, file))
|
||||
}
|
||||
writeFileSync(path.join(directory, 'acceptance-backend.txt'), `${backend}\n`)
|
||||
assertStandaloneTarget(directory, backend)
|
||||
}
|
||||
|
||||
/** Prevents the fixture from supplying the metadata admissions the production wrapper must derive. */
|
||||
export function assertExplicitCspSources(text) {
|
||||
for (const part of text.split(';')) {
|
||||
const [directive, ...sources] = part.trim().split(/\s+/).filter(Boolean)
|
||||
if (!directive) continue
|
||||
if (!['frame-src', 'img-src', 'media-src'].includes(directive) || sources.length !== 1) {
|
||||
throw new Error('Only explicit owned-service CSP overrides are allowed')
|
||||
}
|
||||
let origin
|
||||
try { origin = new URL(sources[0]) } catch { /* rejected below */ }
|
||||
if (!origin || !['http:', 'https:'].includes(origin.protocol) || origin.origin !== sources[0] || sources[0].includes('*')) {
|
||||
throw new Error('Only explicit owned-service CSP overrides are allowed')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,9 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar } from './acceptance-paths.mjs'
|
||||
import { ACCEPTANCE_MODE, BUILD_MARKER, EXPORT_CLASSPATH, resolveSampleJar, SECURITY_BACKEND_URL, SECURITY_STANDALONE } from './acceptance-paths.mjs'
|
||||
|
||||
import { stageStandalone } from './acceptance-standalone.mjs'
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const skipBuild = args.includes('--skip-build')
|
||||
@@ -38,7 +40,12 @@ if (ACCEPTANCE_MODE === 'javalin') {
|
||||
cpSync('out', path.join(EXPORT_CLASSPATH, 'next-dashboard'), { recursive: true, filter: (source) => !source.endsWith('mockServiceWorker.js') })
|
||||
}
|
||||
if (!skipBuild && ACCEPTANCE_MODE === 'standalone') {
|
||||
const status = run('pnpm', ['build'], { QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
|
||||
// A second immutable build is required: Next bakes the rewrite destination at build time.
|
||||
const securityStatus = run('pnpm', ['build'], { QQQ_NEXT_OUTPUT: 'standalone', QQQ_BACKEND_URL: SECURITY_BACKEND_URL, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
|
||||
if (securityStatus !== 0) process.exit(securityStatus)
|
||||
stageStandalone(SECURITY_STANDALONE, SECURITY_BACKEND_URL)
|
||||
rmSync('.next', { recursive: true, force: true })
|
||||
const status = run('pnpm', ['build'], { QQQ_NEXT_OUTPUT: 'standalone', QQQ_BACKEND_URL: backend, NEXT_PUBLIC_MOCK_API: 'false', NEXT_TELEMETRY_DISABLED: '1' })
|
||||
if (status !== 0) process.exit(status)
|
||||
writeFileSync(BUILD_MARKER, backend + '\n')
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { act, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { userEvent } from '@testing-library/user-event'
|
||||
import { useForm } from 'react-hook-form'
|
||||
|
||||
@@ -416,6 +416,38 @@ describe('PossibleValueSelect — option selection', () => {
|
||||
})
|
||||
|
||||
describe('PossibleValueSelect — search', () => {
|
||||
it.each(['loaded', 'in flight'])('offers only the current search results when the prior request is %s', async (prior) => {
|
||||
const user = userEvent.setup()
|
||||
let finishPrior!: (values: QPossibleValue[]) => void
|
||||
let finishSearch!: (values: QPossibleValue[]) => void
|
||||
mockFetchTable.mockReset()
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { finishPrior = resolve }))
|
||||
.mockImplementationOnce(() => new Promise((resolve) => { finishSearch = resolve }))
|
||||
render(<Wrapper />)
|
||||
await user.click(screen.getByRole('combobox', { name: 'Person' }))
|
||||
const search = screen.getByRole('textbox', { name: 'Search Person options' })
|
||||
if (prior === 'loaded') await act(async () => finishPrior(OPTIONS))
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
fireEvent.change(search, { target: { value: 'Bo' } })
|
||||
if (prior === 'in flight') await act(async () => finishPrior(OPTIONS))
|
||||
// Old options must not be clickable while a new search is debouncing:
|
||||
// starting its request would remove the option between pointer down/up.
|
||||
expect(screen.queryAllByRole('option')).toEqual([])
|
||||
fireEvent.keyDown(search, { key: 'Enter' })
|
||||
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveTextContent('-- Select Person --')
|
||||
await act(async () => vi.advanceTimersByTimeAsync(300))
|
||||
expect(mockFetchTable).toHaveBeenLastCalledWith('person', 'person', { searchTerm: 'Bo', formValues: { testField: null } })
|
||||
await act(async () => finishSearch([OPTIONS[1]]))
|
||||
fireEvent.click(screen.getByRole('option', { name: 'Bob' }))
|
||||
expect(screen.getByRole('combobox', { name: 'Person' })).toHaveTextContent('Bob')
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a search input when the dropdown is open', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Wrapper />)
|
||||
|
||||
@@ -204,6 +204,10 @@ export function PossibleValueSelect({
|
||||
const debouncedFetch = useCallback(
|
||||
(term: string) => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current)
|
||||
// Old results must not remain selectable until the debounce removes them mid-click.
|
||||
requestSequence.current += 1
|
||||
setOptions([])
|
||||
setIsLoading(true)
|
||||
debounceRef.current = setTimeout(() => fetchOptions(term), 300)
|
||||
},
|
||||
[fetchOptions]
|
||||
|
||||
@@ -72,12 +72,19 @@ const itemClass = cn(
|
||||
* @returns The menu.
|
||||
*/
|
||||
export function ColumnHeaderMenu({ column, sort, pinned, hasRows, onSort, onFilter, onHide, onPin, onCopyPageValues, onCopyFullQueryValues, onColumnStats }: ColumnHeaderMenuProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null)
|
||||
const noRows = hasRows ? undefined : 'There are no rows to copy from'
|
||||
const id = (action: string) => `column-menu-${action}`
|
||||
return (
|
||||
<DropdownMenuPrimitive.Root modal={false}>
|
||||
<DropdownMenuPrimitive.Root modal={false} open={open} onOpenChange={(nextOpen) => {
|
||||
// Focus survives scrolling; restore the reference before Radix measures its menu.
|
||||
if (nextOpen) triggerRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' })
|
||||
setOpen(nextOpen)
|
||||
}}>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-60 hover:text-foreground hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-ring group-hover:opacity-100 data-[state=open]:opacity-100 pointer-coarse:opacity-100"
|
||||
|
||||
@@ -16,18 +16,19 @@
|
||||
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QRecord, QTableMetaData } from '@/types'
|
||||
import { DataGrid } from './DataGrid'
|
||||
|
||||
const cellRenders = vi.fn()
|
||||
vi.mock('./DataCell', () => ({
|
||||
DataCell: ({ value }: { value: unknown }) => {
|
||||
vi.mock('./DataCell', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./DataCell')>()
|
||||
return { DataCell: (props: React.ComponentProps<typeof actual.DataCell>) => {
|
||||
cellRenders()
|
||||
return <span>{String(value ?? '')}</span>
|
||||
},
|
||||
}))
|
||||
return <actual.DataCell {...props} />
|
||||
} }
|
||||
})
|
||||
const router = { push: vi.fn() }
|
||||
vi.mock('next/navigation', () => ({ useRouter: () => router }))
|
||||
|
||||
@@ -137,3 +138,89 @@ describe('DataGrid compact headers (QRun-IO/qqq#728)', () => {
|
||||
expect(screen.getByRole('button', { name: 'Column statistics for Name' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('DataGrid stable headers (QRun-IO/qqq#994)', () => {
|
||||
const scrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
|
||||
beforeAll(() => Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }))
|
||||
afterAll(() => {
|
||||
if (scrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', scrollIntoView)
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
|
||||
})
|
||||
const menu = () => ({ onHide: vi.fn(), onPin: vi.fn(), onCopyPageValues: vi.fn(), onCopyFullQueryValues: vi.fn() })
|
||||
|
||||
it('retains a focused menu trigger across nonstructural preview updates', () => {
|
||||
const { rerender } = render(grid({ columnMenu: menu() }))
|
||||
const trigger = screen.getByRole('button', { name: 'Name column menu' })
|
||||
trigger.focus()
|
||||
rerender(grid({ columnMenu: menu(), isFetching: true }))
|
||||
expect(screen.getByRole('button', { name: 'Name column menu' })).toBe(trigger)
|
||||
expect(trigger).toHaveFocus()
|
||||
})
|
||||
|
||||
it('keeps the same header while updating metadata, sort and menu callbacks', () => {
|
||||
const initialMenu = menu()
|
||||
const updatedMenu = menu()
|
||||
const onSortChange = vi.fn()
|
||||
const { rerender } = render(grid({ columnMenu: initialMenu }))
|
||||
const trigger = screen.getByRole('button', { name: 'Name column menu' })
|
||||
const tableMetaData = { ...TABLE, fields: { ...TABLE.fields, name: { ...TABLE.fields.name, label: 'Current name' } } }
|
||||
rerender(grid({ tableMetaData, columnMenu: updatedMenu, sortOrder: [{ fieldName: 'name', isAscending: true }], onSortChange }))
|
||||
expect(screen.getByRole('button', { name: 'Current name column menu' })).toBe(trigger)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort by Current name' }))
|
||||
expect(onSortChange).toHaveBeenCalledWith([{ fieldName: 'name', isAscending: false }])
|
||||
fireEvent.keyDown(trigger, { key: 'Enter' })
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Copy page values' }))
|
||||
expect(updatedMenu.onCopyPageValues).toHaveBeenCalledWith('name')
|
||||
expect(initialMenu.onCopyPageValues).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the selection header and updates its current selection handler', () => {
|
||||
const onRowSelectionChange = vi.fn()
|
||||
const { rerender } = render(grid())
|
||||
const checkbox = screen.getByRole('checkbox', { name: 'Select all rows on this page' })
|
||||
checkbox.focus()
|
||||
rerender(grid({ rowSelection: { 1: true, 2: true, 3: true }, onRowSelectionChange }))
|
||||
expect(screen.getByRole('checkbox', { name: 'Select all rows on this page' })).toBe(checkbox)
|
||||
expect(checkbox).toHaveFocus()
|
||||
expect(checkbox).toBeChecked()
|
||||
fireEvent.click(checkbox)
|
||||
expect(onRowSelectionChange).toHaveBeenCalledWith({})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('DataGrid stable cells (QRun-IO/qqq#995)', () => {
|
||||
const menu = () => ({ onHide: vi.fn(), onPin: vi.fn(), onCopyPageValues: vi.fn(), onCopyFullQueryValues: vi.fn() })
|
||||
|
||||
it('retains the focused selection checkbox and uses current selection callbacks', () => {
|
||||
const initialHandler = vi.fn()
|
||||
const currentHandler = vi.fn()
|
||||
const { rerender } = render(grid({ columnMenu: menu(), onRowSelectionChange: initialHandler }))
|
||||
const checkbox = screen.getAllByRole('checkbox', { name: 'Select record' })[0]
|
||||
checkbox.focus()
|
||||
rerender(grid({ columnMenu: menu(), rowSelection: { 1: true }, onRowSelectionChange: currentHandler }))
|
||||
expect(screen.getAllByRole('checkbox', { name: 'Select record' })[0]).toBe(checkbox)
|
||||
expect(checkbox).toHaveFocus()
|
||||
expect(checkbox).toBeChecked()
|
||||
fireEvent.click(checkbox)
|
||||
expect(currentHandler).toHaveBeenCalledWith({})
|
||||
expect(initialHandler).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains revealed cell state and focus while using updated field values', () => {
|
||||
const tableMetaData = { ...TABLE, fields: { ...TABLE.fields, name: { ...TABLE.fields.name, adornments: [{ type: 'REVEAL' }] } } } as QTableMetaData
|
||||
const { rerender } = render(grid({ tableMetaData, columnMenu: menu() }))
|
||||
const reveal = screen.getAllByRole('button', { name: 'Reveal name' })[0]
|
||||
reveal.focus()
|
||||
fireEvent.click(reveal)
|
||||
expect(reveal).toHaveAccessibleName('Hide name')
|
||||
expect(reveal).toHaveTextContent('Ada')
|
||||
rerender(grid({ tableMetaData, columnMenu: menu(), isFetching: true }))
|
||||
expect(screen.getAllByRole('button', { name: 'Hide name' })[0]).toBe(reveal)
|
||||
expect(reveal).toHaveFocus()
|
||||
rerender(grid({ tableMetaData, columnMenu: menu(), records: [record(1, 'Current value'), ...RECORDS.slice(1)] }))
|
||||
expect(reveal).toHaveTextContent('Current value')
|
||||
expect(reveal).toHaveAccessibleName('Hide name')
|
||||
})
|
||||
})
|
||||
@@ -26,7 +26,6 @@ import React, { useMemo, useRef, useCallback, useEffect, useLayoutEffect, useSta
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type SortingState,
|
||||
@@ -818,9 +817,13 @@ export function DataGrid({
|
||||
style={{ width: `${header.getSize()}px`, ...(pinned?.style ?? {}) }}
|
||||
aria-sort={ariaSortValue}
|
||||
>
|
||||
{/* These locally defined headers are pure render callbacks. Calling them
|
||||
preserves their child identity when current props rebuild the columns. */}
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
: typeof header.column.columnDef.header === 'function'
|
||||
? header.column.columnDef.header(header.getContext())
|
||||
: header.column.columnDef.header}
|
||||
|
||||
{/* Column resize handle */}
|
||||
{!isSelectCol && (() => {
|
||||
@@ -998,7 +1001,11 @@ const GridRow = React.memo(function GridRow({ row, isSelected, rowClass, cellCla
|
||||
onCellKeyDown(e)
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
{/* Local pure render callbacks must not become new component types when
|
||||
current grid props rebuild columns: retain focused and stateful children. */}
|
||||
{typeof cell.column.columnDef.cell === 'function'
|
||||
? cell.column.columnDef.cell(cell.getContext())
|
||||
: cell.column.columnDef.cell}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -414,7 +414,6 @@ function RecordViewContent({
|
||||
}) {
|
||||
const searchParams = useSearchParams()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { preferences } = useUserPreferences()
|
||||
|
||||
// Persist tab and view mode in URL so back navigation restores state
|
||||
@@ -441,15 +440,16 @@ function RecordViewContent({
|
||||
* @param defaultValue - The "default" value; when matched the key is removed.
|
||||
*/
|
||||
const updateUrlParam = useCallback((key: string, value: string, defaultValue: string) => {
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
const url = new URL(window.location.href)
|
||||
const params = url.searchParams
|
||||
if (value === defaultValue) {
|
||||
params.delete(key)
|
||||
} else {
|
||||
params.set(key, value)
|
||||
}
|
||||
const qs = params.toString()
|
||||
router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false })
|
||||
}, [searchParams, pathname, router])
|
||||
// Make URL state durable before another click, reload, or departure, without an RSC navigation.
|
||||
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Switches the active tab by updating the `tab` URL parameter.
|
||||
|
||||
@@ -99,6 +99,42 @@ describe('script code completions', () => {
|
||||
expect(editor).toHaveValue('api.qu')
|
||||
})
|
||||
|
||||
it('keeps current suggestions when a delayed scroll event reports their existing position', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Editor initial={'const result = api.query("person");'} />)
|
||||
const editor = screen.getByRole('textbox', { name: 'Code' }) as HTMLTextAreaElement
|
||||
await user.click(editor)
|
||||
editor.scrollLeft = 4
|
||||
fireEvent.scroll(editor)
|
||||
editor.scrollLeft = 0
|
||||
fireEvent.change(editor, { target: { value: 'api.bu' } })
|
||||
editor.setSelectionRange(6, 6)
|
||||
await user.keyboard('{Control>} {/Control}{ArrowDown}')
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3)
|
||||
|
||||
fireEvent.scroll(editor)
|
||||
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3)
|
||||
expect(screen.getByRole('option', { name: /api.bulkUpdate/ })).toHaveAttribute('aria-selected', 'true')
|
||||
await user.keyboard('{Tab}')
|
||||
expect(editor).toHaveValue('api.bulkUpdate(')
|
||||
expect(editor).toHaveFocus()
|
||||
})
|
||||
|
||||
it.each(['scrollLeft', 'scrollTop'] as const)('dismisses suggestions when %s changes after they open', async (direction) => {
|
||||
const user = userEvent.setup()
|
||||
render(<Editor />)
|
||||
const editor = screen.getByRole('textbox', { name: 'Code' }) as HTMLTextAreaElement
|
||||
await user.type(editor, 'api.bu')
|
||||
expect(screen.getAllByRole('option')).toHaveLength(3)
|
||||
|
||||
editor[direction] = 4
|
||||
fireEvent.scroll(editor)
|
||||
|
||||
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
|
||||
expect(editor).toHaveValue('api.bu')
|
||||
})
|
||||
|
||||
it.each([[0, 0], [0, 6]])('rejects a stale suggestion after selection changes to %s–%s', async (start, end) => {
|
||||
const user = userEvent.setup()
|
||||
render(<Editor />)
|
||||
|
||||
@@ -174,7 +174,7 @@ export function CodeEditor({
|
||||
}: CodeEditorProps) {
|
||||
const movesFocus = useSyncExternalStore(subscribeTabMode, readTabMode, readServerTabMode)
|
||||
const [released, setReleased] = useState(false)
|
||||
const [completion, setCompletion] = useState<(CodeCompletionMatch & { selected: number; top: number; left: number; height: number; caret: number; source: string }) | null>(null)
|
||||
const [completion, setCompletion] = useState<(CodeCompletionMatch & { selected: number; top: number; left: number; height: number; caret: number; source: string; scrollTop: number; scrollLeft: number }) | null>(null)
|
||||
const composing = useRef(false)
|
||||
const metricsRef = useRef<HTMLSpanElement>(null)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
@@ -211,7 +211,7 @@ export function CodeEditor({
|
||||
const top = caretTop + lineHeight + height <= textarea.clientHeight ? caretTop + lineHeight : Math.max(0, caretTop - height)
|
||||
const column = before.at(-1)!.replace(/\t/g, ' ').length
|
||||
const left = Math.min(Math.max(0, 12 + column * characterWidth - textarea.scrollLeft), Math.max(0, textarea.clientWidth - 320))
|
||||
setCompletion({ ...match, selected: 0, top, left, height, caret: textarea.selectionStart, source: textarea.value })
|
||||
setCompletion({ ...match, selected: 0, top, left, height, caret: textarea.selectionStart, source: textarea.value, scrollTop: textarea.scrollTop, scrollLeft: textarea.scrollLeft })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -382,7 +382,11 @@ export function CodeEditor({
|
||||
value={value}
|
||||
onChange={(event) => { onChange?.(event.target.value); suggest(event.currentTarget) }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onScroll={() => { syncScroll(); setCompletion(null) }}
|
||||
onScroll={(event) => {
|
||||
syncScroll()
|
||||
// A deferred browser event can report the position already used to place suggestions.
|
||||
if (shown && (shown.scrollTop !== event.currentTarget.scrollTop || shown.scrollLeft !== event.currentTarget.scrollLeft)) setCompletion(null)
|
||||
}}
|
||||
onClick={() => setCompletion(null)}
|
||||
onCompositionStart={() => { composing.current = true; setCompletion(null) }}
|
||||
onCompositionEnd={(event) => { composing.current = false; suggest(event.currentTarget) }}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
@@ -30,6 +30,7 @@ vi.mock('@/lib/api/widgets', async (importOriginal) => {
|
||||
import type { QWidgetMetaData, WidgetData } from '@/types'
|
||||
import { fetchWidgetData, WidgetRequestError } from '@/lib/api/widgets'
|
||||
import { ConnectedWidget, SeededWidget } from './ConnectedWidget'
|
||||
import * as widgetUtils from './widget-utils'
|
||||
|
||||
const fetchMock = vi.mocked(fetchWidgetData)
|
||||
|
||||
@@ -69,6 +70,106 @@ describe('ConnectedWidget', () => {
|
||||
fetchMock.mockImplementation(async (_name, params) => controlsPayload(params))
|
||||
})
|
||||
|
||||
it('hydrates backend date and choice defaults without persisting caller defaults', async () => {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
const day = await screen.findByLabelText('Select Day')
|
||||
await waitFor(() => expect(day).toHaveValue('2026-01-15'))
|
||||
expect(screen.getByLabelText('Select Choice')).toHaveValue('Alpha')
|
||||
const sent = new Date(2026, 0, 15).toLocaleDateString()
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha', accDate: sent }))
|
||||
expect(await screen.findByText(`choice=alpha; day=${sent}`)).toBeInTheDocument()
|
||||
expect(localStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('uses the configured date wire normalization for a backend default', async () => {
|
||||
const normalize = vi.spyOn(widgetUtils, 'normalizeDropdownDate').mockImplementation((value) => value)
|
||||
try {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
await waitFor(() => expect(screen.getByLabelText('Select Day')).toHaveValue('2026-01-15'))
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha', accDate: '2026-01-15' }))
|
||||
expect(await screen.findByText('choice=alpha; day=2026-01-15')).toBeInTheDocument()
|
||||
expect(localStorage.length).toBe(0)
|
||||
} finally {
|
||||
normalize.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([undefined, null, '', 'not-a-date'])('leaves a missing or invalid date default blank (%s)', async (value) => {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', value],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
await waitFor(() => expect(screen.getByLabelText('Select Choice')).toHaveValue('Alpha'))
|
||||
expect(screen.getByLabelText('Select Day')).toHaveValue('')
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha' }))
|
||||
})
|
||||
|
||||
it('keeps a user date when a late reload supplies a different default', async () => {
|
||||
const user = userEvent.setup()
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
const day = await screen.findByLabelText('Select Day')
|
||||
await waitFor(() => expect(day).toHaveValue('2026-01-15'))
|
||||
fireEvent.change(day, { target: { value: '2026-01-20' } })
|
||||
const sent = new Date(2026, 0, 20).toLocaleDateString()
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenLastCalledWith('accControls', { accChoice: 'alpha', accDate: sent }))
|
||||
expect(await screen.findByText(`choice=alpha; day=${sent}`)).toBeInTheDocument()
|
||||
let finishReload!: (value: WidgetData) => void
|
||||
fetchMock.mockImplementationOnce(() => new Promise((resolve) => { finishReload = resolve }))
|
||||
await user.click(screen.getByLabelText('Reload Owned Controls'))
|
||||
await waitFor(() => expect(finishReload).toBeDefined())
|
||||
finishReload({ ...controlsPayload({ accChoice: 'alpha', accDate: sent }), dropdownDefaultValueList: ['alpha', '2026-01-25'] })
|
||||
await waitFor(() => expect(screen.getByLabelText('Reload Owned Controls')).not.toBeDisabled())
|
||||
expect(day).toHaveValue('2026-01-20')
|
||||
})
|
||||
|
||||
it('preserves an explicit clear when the backend still supplies a date default', async () => {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
const day = await screen.findByLabelText('Select Day')
|
||||
await waitFor(() => expect(day).toHaveValue('2026-01-15'))
|
||||
fireEvent.change(day, { target: { value: '' } })
|
||||
await waitFor(() => expect(screen.getByText('Please select a Day')).toBeInTheDocument())
|
||||
expect(day).toHaveValue('')
|
||||
expect(localStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves a stored date instead of replacing it with a server default', async () => {
|
||||
localStorage.setItem('qqq.widgets.dropdownData.accControls.accDate', JSON.stringify({ id: '2026-01-10' }))
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
renderWidget()
|
||||
await waitFor(() => expect(screen.getByLabelText('Select Choice')).toHaveValue('Alpha'))
|
||||
expect(screen.getByLabelText('Select Day')).toHaveValue('2026-01-10')
|
||||
expect(JSON.parse(localStorage.getItem('qqq.widgets.dropdownData.accControls.accDate')!)).toEqual({ id: '2026-01-10' })
|
||||
expect(localStorage.getItem('qqq.widgets.dropdownData.accControls.accChoice')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not carry one caller default into a fresh caller widget without saved dates', async () => {
|
||||
fetchMock.mockImplementation(async (_name, params) => ({
|
||||
...controlsPayload(params), dropdownDefaultValueList: ['alpha', '2026-01-15'],
|
||||
}))
|
||||
const first = renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
await waitFor(() => expect(screen.getByLabelText('Select Day')).toHaveValue('2026-01-15'))
|
||||
first.unmount()
|
||||
fetchMock.mockImplementation(async (_name, params) => controlsPayload(params))
|
||||
renderWidget({ ...controls, storeDropdownSelections: false })
|
||||
expect(await screen.findByText('Please select a Choice and Day')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('Select Day')).toHaveValue('')
|
||||
expect(localStorage.length).toBe(0)
|
||||
})
|
||||
|
||||
it('renders payload dropdowns, sends selections under their parameter names and persists them', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWidget()
|
||||
|
||||
@@ -39,7 +39,7 @@ import { WidgetRenderer } from './WidgetRenderer'
|
||||
import { ChildRecordExportButton } from './ChildRecordListWidget'
|
||||
import type { ChildRecordListPayload } from './ChildRecordListWidget'
|
||||
import {
|
||||
CUSTOM_TIMEFRAME, TIMEFRAME_DROPDOWN, downloadText, dropdownStorageKey, storedDropdownParams, widgetCsvToString,
|
||||
CUSTOM_TIMEFRAME, TIMEFRAME_DROPDOWN, downloadText, dropdownStorageKey, normalizeDropdownDate, parseDropdownDate, storedDropdownParams, widgetCsvToString,
|
||||
widgetExportFileName, writeStoredSelection,
|
||||
} from './widget-utils'
|
||||
import { tableExportCsv } from './table-widget-utils'
|
||||
@@ -174,9 +174,15 @@ export function ConnectedWidget({
|
||||
names.forEach((name, index) => {
|
||||
if (typeof name !== 'string') return
|
||||
const meta = widgetMetaData.dropdowns?.[index]
|
||||
if (meta?.type === 'DATE_PICKER') return
|
||||
const ids = (Array.isArray(lists[index]) ? lists[index] : []).map((option: { id?: unknown }) => String(option?.id))
|
||||
const selected = current[name]
|
||||
if (meta?.type === 'DATE_PICKER') {
|
||||
const value = defaults[index]
|
||||
if (selected === undefined && typeof value === 'string' && parseDropdownDate(value)) {
|
||||
next = { ...next, [name]: normalizeDropdownDate(value) }
|
||||
}
|
||||
return
|
||||
}
|
||||
const ids = (Array.isArray(lists[index]) ? lists[index] : []).map((option: { id?: unknown }) => String(option?.id))
|
||||
// a custom timeframe range (custom,<start>,<end>) is offered by the timeframe's custom option
|
||||
const offered = selected && (ids.includes(selected) || (name === TIMEFRAME_DROPDOWN && ids.includes(CUSTOM_TIMEFRAME) && selected.startsWith(`${CUSTOM_TIMEFRAME},`)))
|
||||
if (selected && !offered) {
|
||||
|
||||
@@ -15,10 +15,18 @@
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it, vi, beforeAll, afterAll } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
// jsdom does not implement native viewport scrolling; real menu geometry is covered in acceptance.
|
||||
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
|
||||
beforeAll(() => Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }))
|
||||
afterAll(() => {
|
||||
if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView)
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
import type { QTableMetaData, QWidgetMetaData } from '@/types'
|
||||
import type { WidgetFormContext } from './widget-types'
|
||||
|
||||
|
||||
@@ -15,10 +15,18 @@
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll, afterAll } from 'vitest'
|
||||
import { render, screen, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
// jsdom does not implement native viewport scrolling; real menu geometry is covered in acceptance.
|
||||
const originalScrollIntoView = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'scrollIntoView')
|
||||
beforeAll(() => Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { configurable: true, value: vi.fn() }))
|
||||
afterAll(() => {
|
||||
if (originalScrollIntoView) Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', originalScrollIntoView)
|
||||
else Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView')
|
||||
})
|
||||
|
||||
const { previewRecords } = vi.hoisted(() => ({ previewRecords: [] as QRecord[] }))
|
||||
|
||||
vi.mock('@/lib/hooks/use-metadata', () => ({ useTableMetaData: vi.fn(), useMetaData: () => ({ data: undefined }) }))
|
||||
|
||||
@@ -15,6 +15,12 @@
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
readCurrentSavedViewId,
|
||||
readStoredQueryView,
|
||||
writeCurrentSavedViewId,
|
||||
writeStoredQueryView,
|
||||
} from '@/lib/utils/query-view-storage'
|
||||
import {
|
||||
claimClientData,
|
||||
clearUserClientData,
|
||||
@@ -97,6 +103,57 @@ describe('auth-storage', () => {
|
||||
expect(localStorage.getItem('accessToken')).toBeNull()
|
||||
})
|
||||
|
||||
it('clears every table\'s remembered filters and saved-view selection without resetting machine preferences (QRun-IO/qqq#1006)', () => {
|
||||
for (const table of ['person', 'pet']) {
|
||||
writeStoredQueryView(table, {
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
viewIdentity: 'empty',
|
||||
})
|
||||
writeCurrentSavedViewId(table, 7)
|
||||
expect(readStoredQueryView(table)).toMatchObject({
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
})
|
||||
expect(readCurrentSavedViewId(table)).toBe(7)
|
||||
}
|
||||
const preferences = {
|
||||
'qqq.density': 'compact',
|
||||
'qqq-dark-mode': 'true',
|
||||
'qqq-person-column-widths': '{"name":240}',
|
||||
'qqq.recordQueryViews.person': 'unrelated plural key',
|
||||
'another-application': 'keep',
|
||||
}
|
||||
for (const [key, value] of Object.entries(preferences)) localStorage.setItem(key, value)
|
||||
|
||||
clearUserClientData()
|
||||
|
||||
for (const table of ['person', 'pet']) {
|
||||
expect(readStoredQueryView(table)).toBeNull()
|
||||
expect(readCurrentSavedViewId(table)).toBeNull()
|
||||
}
|
||||
for (const [key, value] of Object.entries(preferences)) expect(localStorage.getItem(key)).toBe(value)
|
||||
})
|
||||
|
||||
it('keeps remembered query state for the same identity and clears it before another identity claims the browser (QRun-IO/qqq#1006)', () => {
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.test' })
|
||||
writeStoredQueryView('person', {
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
viewIdentity: 'savedView:7',
|
||||
})
|
||||
writeCurrentSavedViewId('person', 7)
|
||||
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.test' })
|
||||
expect(readStoredQueryView('person')).toMatchObject({
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Private Alice'] }] },
|
||||
viewIdentity: 'savedView:7',
|
||||
})
|
||||
expect(readCurrentSavedViewId('person')).toBe(7)
|
||||
|
||||
claimClientData({ name: 'Bob', email: 'bob@example.test' })
|
||||
expect(readStoredQueryView('person')).toBeNull()
|
||||
expect(readCurrentSavedViewId('person')).toBeNull()
|
||||
expect(localStorage.getItem('qqq.clientDataOwner')).toBe('bob@example.test')
|
||||
})
|
||||
|
||||
it('clears the previous user\'s data when someone else signs in in this browser (QRun-IO/qqq#696)', () => {
|
||||
claimClientData({ name: 'Alice', email: 'alice@example.com' })
|
||||
localStorage.setItem('qqq-recent-records', JSON.stringify([{ path: '/app/pet/1', recordLabel: 'Alice private' }]))
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
* re-authentication loop guard, and clearing per-user client data on sign-out.
|
||||
*/
|
||||
|
||||
import { clearAllStoredQueryState } from '@/lib/utils/query-view-storage-cleanup'
|
||||
import { clearRecentRecords } from '@/lib/utils/recent-records'
|
||||
|
||||
const SIGNED_OUT_KEY = 'qqq.signedOut'
|
||||
@@ -192,9 +193,10 @@ export function resetReauthAttempts(): void {
|
||||
storage('session')?.removeItem(REAUTH_KEY)
|
||||
}
|
||||
|
||||
/** Removes per-user data kept in the browser (recently viewed records, stored identity and session values). */
|
||||
/** Removes per-user browser data: recent records, query views, stored identity and session values. */
|
||||
export function clearUserClientData(): void {
|
||||
clearRecentRecords()
|
||||
clearAllStoredQueryState()
|
||||
storeUser(null)
|
||||
storage('local')?.removeItem(SESSION_VALUES_KEY)
|
||||
storage('local')?.removeItem('accessToken')
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// Tests for useRecordQuery hook
|
||||
|
||||
import { setTimeout as realSetTimeout } from 'node:timers'
|
||||
import { act, cleanup, fireEvent, render, renderHook, screen, within } from '@testing-library/react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QTableMetaData } from '@/types'
|
||||
import { RecordQueryContent } from '@/components/query/RecordQueryContent'
|
||||
import { queryClient, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import { qInstance } from '@/mocks/fixtures/q-instance'
|
||||
import { server } from '@/mocks/node'
|
||||
import { useRecordQuery } from './use-record-query'
|
||||
|
||||
const table: QTableMetaData = { ...qInstance.tables.person, capabilities: ['TABLE_QUERY', 'TABLE_COUNT'] }
|
||||
const options = { tableName: table.name, tableMetaData: table, allTables: { [table.name]: table } }
|
||||
const records = [{ tableName: table.name, recordLabel: 'Recovered row', values: { id: 1, firstName: 'Recovered row' } }]
|
||||
|
||||
function Wrapper({ children }: { children: React.ReactNode }) {
|
||||
// Use the production client, including its retry predicate, delay and error cache callbacks.
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
}
|
||||
|
||||
function QueryContent() {
|
||||
const rq = useRecordQuery(options)
|
||||
return <RecordQueryContent
|
||||
tableName={table.name} tableMetaData={table} viewMode="card" activeFilterCount={0}
|
||||
{...rq.data} {...rq.pagination} {...rq.columns}
|
||||
sortOrder={rq.filter.sortOrder} onSortChange={rq.filter.setSort}
|
||||
onResetFilter={rq.filter.resetFilter} quickSearchTerm={rq.filter.quickSearchTerm}
|
||||
rowSelection={rq.selection.rowSelection} onRowSelectionChange={rq.selection.setRowSelection}
|
||||
onColumnWidthChange={rq.columns.setColumnWidth} density={rq.density}
|
||||
onPageChange={rq.pagination.setPage} onPageSizeChange={rq.pagination.setPageSize}
|
||||
/>
|
||||
}
|
||||
|
||||
// Hold each real HTTP response independently, including the last automatic attempt.
|
||||
function controlledEndpoint(endpoint: 'query' | 'count') {
|
||||
const responses: Array<(response: Response) => void> = []
|
||||
server.use(http.post(`*/qqq/v1/table/${table.name}/${endpoint}`, () =>
|
||||
new Promise<Response>((resolve) => { responses.push(resolve) })))
|
||||
return responses
|
||||
}
|
||||
|
||||
// Flush HTTP/React work without advancing the retry clock. Real IO can require
|
||||
// another event-loop turn; only zero-duration fake timers (query notifications) run.
|
||||
async function settle(assertion: () => void) {
|
||||
for (let turn = 0; turn < 100; turn++) {
|
||||
await act(async () => {
|
||||
await new Promise<void>((resolve) => { realSetTimeout(resolve, 1) })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
try { assertion(); return } catch (error) { if (turn === 99) throw error }
|
||||
}
|
||||
}
|
||||
|
||||
async function advance(milliseconds: number) {
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(milliseconds) })
|
||||
}
|
||||
|
||||
function failureCount(endpoint: 'query' | 'count') {
|
||||
return queryClient.getQueryCache().findAll({ queryKey: queryKeys.tableRecords(table.name) })
|
||||
.find((query) => query.queryKey[3] === endpoint)?.state.fetchFailureCount
|
||||
}
|
||||
|
||||
async function exhaustServerErrors(responses: Array<(response: Response) => void>, endpoint: 'query' | 'count', message: string) {
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await settle(() => { expect(responses).toHaveLength(attempt + 1) })
|
||||
responses[attempt](HttpResponse.json({ error: message }, { status: 500 }))
|
||||
await settle(() => { expect(failureCount(endpoint)).toBe(attempt + 1) })
|
||||
if (attempt < 3) await advance([1000, 2000, 4000][attempt])
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
|
||||
queryClient.clear()
|
||||
localStorage.clear()
|
||||
server.use(
|
||||
http.post(`*/qqq/v1/table/${table.name}/query`, () => HttpResponse.json({ records })),
|
||||
http.post(`*/qqq/v1/table/${table.name}/count`, () => HttpResponse.json({ count: 1 })),
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
queryClient.clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('record query production retry lifecycle', () => {
|
||||
it('waits for the first attempt and three retries before an accessible terminal error, then recovers through Retry', async () => {
|
||||
const responses = controlledEndpoint('query')
|
||||
render(<QueryContent />, { wrapper: Wrapper })
|
||||
for (const [attempt, delay] of [1000, 2000, 4000].entries()) {
|
||||
await settle(() => { expect(responses).toHaveLength(attempt + 1) })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
responses[attempt](HttpResponse.json({ error: 'Database query unavailable' }, { status: 500 }))
|
||||
await settle(() => { expect(failureCount('query')).toBe(attempt + 1) })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
await advance(delay - 1)
|
||||
expect(responses).toHaveLength(attempt + 1)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
await advance(1)
|
||||
}
|
||||
await settle(() => { expect(responses).toHaveLength(4) })
|
||||
// A slow final response is still pending; reaching attempt four is not terminal.
|
||||
await advance(5000)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('No records found')).not.toBeInTheDocument()
|
||||
responses[3](HttpResponse.json({ error: 'Database query unavailable' }, { status: 500 }))
|
||||
await settle(() => { expect(screen.getByRole('alert')).toHaveTextContent('Database query unavailable') })
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(within(alert).getByRole('button', { name: 'Dismiss' })).toBeEnabled()
|
||||
await advance(8000)
|
||||
expect(responses).toHaveLength(4)
|
||||
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
|
||||
await settle(() => { expect(responses).toHaveLength(5) })
|
||||
responses[4](HttpResponse.json({ records }))
|
||||
await settle(() => { expect(screen.getByRole('heading', { name: 'Recovered row' })).toBeInTheDocument() })
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('dismisses only the current terminal error without retrying or hiding a later distinct failure', async () => {
|
||||
const responses = controlledEndpoint('query')
|
||||
const view = render(<QueryContent />, { wrapper: Wrapper })
|
||||
await exhaustServerErrors(responses, 'query', 'First backend failure')
|
||||
await settle(() => { expect(screen.getByRole('alert')).toHaveTextContent('First backend failure') })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }))
|
||||
view.rerender(<QueryContent />)
|
||||
await advance(8000)
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
expect(responses).toHaveLength(4)
|
||||
act(() => { void queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(table.name) }) })
|
||||
await settle(() => { expect(responses).toHaveLength(5) })
|
||||
// Keep the same controlled handler and collect subsequent retry responses.
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
await settle(() => { expect(responses).toHaveLength(5 + attempt) })
|
||||
responses[4 + attempt](HttpResponse.json({ error: 'Later backend failure' }, { status: 500 }))
|
||||
await settle(() => { expect(failureCount('query')).toBe(attempt + 1) })
|
||||
if (attempt < 3) await advance([1000, 2000, 4000][attempt])
|
||||
}
|
||||
await settle(() => { expect(screen.getByRole('alert')).toHaveTextContent('Later backend failure') })
|
||||
expect(screen.queryByText('First backend failure')).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps successful records when the separate count exhausts its server-error retries', async () => {
|
||||
const responses = controlledEndpoint('count')
|
||||
const { result } = renderHook(() => useRecordQuery(options), { wrapper: Wrapper })
|
||||
await settle(() => { expect(result.current.data.records).toEqual(records) })
|
||||
await exhaustServerErrors(responses, 'count', 'Count temporarily unavailable')
|
||||
await settle(() => { expect(result.current.data.countError?.message).toBe('Count temporarily unavailable') })
|
||||
expect(result.current.data.records).toEqual(records)
|
||||
expect(result.current.data.isError).toBe(false)
|
||||
expect(result.current.data.error).toBeNull()
|
||||
expect(result.current.data.isLoading).toBe(false)
|
||||
expect(getErrorStatusCode(result.current.data.countError)).toBe(500)
|
||||
await advance(8000)
|
||||
expect(responses).toHaveLength(4)
|
||||
})
|
||||
|
||||
it.each([403, 404])('shows HTTP %i immediately without an automatic retry', async (status) => {
|
||||
const responses = controlledEndpoint('query')
|
||||
render(<QueryContent />, { wrapper: Wrapper })
|
||||
await settle(() => { expect(responses).toHaveLength(1) })
|
||||
responses[0](HttpResponse.json({ error: 'Backend access detail' }, { status }))
|
||||
await settle(() => { expect(screen.getByRole('alert')).toHaveTextContent('Backend access detail') })
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(status === 403
|
||||
? 'You do not have permission to view these records.' : 'This table could not be found.')
|
||||
await advance(7000)
|
||||
expect(responses).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Query-view storage keys and cleanup, independent of view parsing and API utilities. */
|
||||
|
||||
/** Root of the per-table key for the last view. */
|
||||
export const VIEW_STORAGE_KEY_ROOT = 'qqq.recordQueryView'
|
||||
/** Root of the per-table key for the last saved view's id. */
|
||||
export const CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT = 'qqq.currentSavedViewId'
|
||||
|
||||
/** Forgets every table's remembered view and saved-view selection, preserving machine preferences. */
|
||||
export function clearAllStoredQueryState(): void {
|
||||
try {
|
||||
for (let index = localStorage.length - 1; index >= 0; index--) {
|
||||
const key = localStorage.key(index)
|
||||
if (key?.startsWith(`${VIEW_STORAGE_KEY_ROOT}.`) || key?.startsWith(`${CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT}.`)) {
|
||||
localStorage.removeItem(key)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// persistence is best-effort
|
||||
}
|
||||
}
|
||||
@@ -23,11 +23,9 @@
|
||||
*/
|
||||
|
||||
import { parseViewJson, type RecordQueryView } from './saved-view-utils'
|
||||
import { CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT, VIEW_STORAGE_KEY_ROOT } from './query-view-storage-cleanup'
|
||||
|
||||
/** Root of the per-table key for the last view. */
|
||||
export const VIEW_STORAGE_KEY_ROOT = 'qqq.recordQueryView'
|
||||
/** Root of the per-table key for the last saved view's id. */
|
||||
export const CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT = 'qqq.currentSavedViewId'
|
||||
export { CURRENT_SAVED_VIEW_ID_STORAGE_KEY_ROOT, VIEW_STORAGE_KEY_ROOT } from './query-view-storage-cleanup'
|
||||
/** The (global) row density key. */
|
||||
export const DENSITY_STORAGE_KEY = 'qqq.density'
|
||||
/** The `viewIdentity` of an ad-hoc (unsaved) view. */
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Exact public branding aliases for the standalone Node server (QQQ #1009). */
|
||||
import { realpath, stat } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { BACKEND_PREFIXES } from './security-headers.mjs'
|
||||
|
||||
const reserved = new Set(['app', 'login', 'callback', '_next', ...BACKEND_PREFIXES].map((part) => part.toLowerCase()))
|
||||
const imageSuffix = /\.(png|jpe?g|gif|webp|avif|ico|svg)$/i
|
||||
|
||||
/** Canonical decoded root-relative image path, rejecting traversal before normalization. */
|
||||
export function brandingImagePath(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return null
|
||||
const raw = value.split(/[?#]/, 1)[0]
|
||||
if (/%(2f|5c)/i.test(raw)) return null
|
||||
let decoded
|
||||
try { decoded = decodeURIComponent(raw) } catch { return null }
|
||||
if (/[\u0000-\u001f\u007f\ufffd\\%?#]/.test(decoded)) return null
|
||||
const parts = decoded.slice(1).split('/')
|
||||
if (parts.some((part) => !part || part === '.' || part === '..') || reserved.has(parts[0].toLowerCase())) return null
|
||||
return imageSuffix.test(decoded) ? decoded : null
|
||||
}
|
||||
|
||||
/** Keep existing regular public files with Next; never inspect a path outside that root. */
|
||||
async function hasPublicFile(directory, image) {
|
||||
try {
|
||||
const root = await realpath(directory)
|
||||
const file = await realpath(path.join(root, image.slice(1)))
|
||||
return file.startsWith(root + path.sep) && (await stat(file)).isFile()
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps at most the two public metadata fields to fixed backend roles. An expired cache
|
||||
* refreshes before use; failed reads cannot keep stale asset authority. The caller bounds
|
||||
* the metadata fetch. No browser URL, cookie, or body is passed to that fetch.
|
||||
*/
|
||||
export function createBrandingAssetAlias(loadAuthentication, publicDirectory) {
|
||||
let aliases = new Map()
|
||||
let expires = 0
|
||||
let loading = null
|
||||
const refresh = () => {
|
||||
loading ??= Promise.resolve().then(loadAuthentication).then((authentication) => {
|
||||
const next = new Map()
|
||||
for (const role of ['logo', 'icon']) {
|
||||
const image = brandingImagePath(authentication?.branding?.[role])
|
||||
if (image && !next.has(image)) next.set(image, `/qqq/branding/${role}`)
|
||||
}
|
||||
aliases = next
|
||||
expires = Date.now() + 60_000
|
||||
}).catch(() => {
|
||||
aliases = new Map()
|
||||
expires = Date.now() + 5_000
|
||||
}).finally(() => { loading = null })
|
||||
return loading
|
||||
}
|
||||
return async (method, url) => {
|
||||
if (method !== 'GET' && method !== 'HEAD') return null
|
||||
const image = brandingImagePath(url)
|
||||
if (!image || await hasPublicFile(publicDirectory, image)) return null
|
||||
if (Date.now() >= expires) await refresh()
|
||||
return aliases.get(image) ?? null
|
||||
}
|
||||
}
|
||||
@@ -19,19 +19,20 @@
|
||||
* server (`server.js`, generated by `next build` next to this file) with the security headers
|
||||
* the QQQ server sends with the export it hosts (QRun-IO/qqq#734, see security-headers.mjs).
|
||||
*
|
||||
* The identity provider origin comes from the backend's `GET /qqq/v1/metaData/authentication`
|
||||
* (read through this server's own API rewrites). Other origins the dashboard must reach (a
|
||||
* QuickSight embed, custom component bundles, image or media hosts) are added with
|
||||
* `QQQ_DASHBOARD_CSP_SOURCES`, for example
|
||||
* `frame-src https://*.quicksight.aws.amazon.com; script-src https://cdn.example.com`.
|
||||
* Identity provider and application CSP sources come from the backend's
|
||||
* `GET /qqq/v1/metaData/authentication`, through this server's own API rewrites.
|
||||
* Additional operator-owned sources (or application sources on older backends) use
|
||||
* `QQQ_DASHBOARD_CSP_SOURCES`, for example `img-src https://images.example.com`.
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { createBrandingAssetAlias } from './branding-assets.mjs'
|
||||
import {
|
||||
applyDashboardSecurityHeaders,
|
||||
buildContentSecurityPolicy,
|
||||
createIdentityProviderOrigins,
|
||||
createDashboardPolicyInputs,
|
||||
isBackendPath,
|
||||
parseSourceAdditions,
|
||||
} from './security-headers.mjs'
|
||||
@@ -40,12 +41,20 @@ const additions = parseSourceAdditions(process.env.QQQ_DASHBOARD_CSP_SOURCES)
|
||||
const port = parseInt(process.env.PORT ?? '', 10) || 3000
|
||||
const listenHost = process.env.HOSTNAME || '0.0.0.0'
|
||||
const selfHost = ['0.0.0.0', '::', ''].includes(listenHost) ? '127.0.0.1' : listenHost.includes(':') ? `[${listenHost}]` : listenHost
|
||||
const identityProviderOrigins = createIdentityProviderOrigins(async () => {
|
||||
const dashboardPolicyInputs = createDashboardPolicyInputs(async () => {
|
||||
const response = await fetch(`http://${selfHost}:${port}/qqq/v1/metaData/authentication`, { signal: AbortSignal.timeout(5_000) })
|
||||
if (!response.ok) throw new Error(`GET /qqq/v1/metaData/authentication answered ${response.status}`)
|
||||
return response.json()
|
||||
})
|
||||
|
||||
// These two fields are already public before sign-in. The fixed role endpoint requires
|
||||
// a backend containing QQQ #1009; old backends retain their real 404, never a fallback UI.
|
||||
const brandingAlias = createBrandingAssetAlias(async () => {
|
||||
const response = await fetch(`http://${selfHost}:${port}/qqq/v1/metaData/authentication`, { signal: AbortSignal.timeout(5_000) })
|
||||
if (!response.ok) throw new Error('Could not load public branding metadata')
|
||||
return response.json()
|
||||
}, fileURLToPath(new URL('./public', import.meta.url)))
|
||||
|
||||
// server.js creates one HTTP server; its request listener is wrapped to add the headers
|
||||
const createServer = http.createServer
|
||||
http.createServer = function (...args) {
|
||||
@@ -54,9 +63,11 @@ http.createServer = function (...args) {
|
||||
if (index < 0) throw new Error('The Next standalone server created its HTTP server without a request listener; the dashboard security headers cannot be added.')
|
||||
const listener = args[index]
|
||||
args[index] = async function (req, res) {
|
||||
const asset = await brandingAlias(req.method, req.url ?? '/')
|
||||
if (asset) req.url = asset
|
||||
if (!isBackendPath(req.url ?? '/')) {
|
||||
const connectOrigins = await identityProviderOrigins()
|
||||
applyDashboardSecurityHeaders(req, res, (scriptHashes) => buildContentSecurityPolicy({ connectOrigins, additions, scriptHashes }))
|
||||
const policyInputs = await dashboardPolicyInputs()
|
||||
applyDashboardSecurityHeaders(req, res, (scriptHashes) => buildContentSecurityPolicy({ ...policyInputs, additions, scriptHashes }))
|
||||
}
|
||||
return listener.call(this, req, res)
|
||||
}
|
||||
|
||||
@@ -148,6 +148,48 @@ export function inlineScriptHashes(html) {
|
||||
return [...hashes]
|
||||
}
|
||||
|
||||
const SOURCE_FIELDS = { connectSrc: 'connect-src', scriptSrc: 'script-src', frameSrc: 'frame-src', styleSrc: 'style-src' }
|
||||
const SPECIAL_SOURCES = {
|
||||
connectSrc: new Set(['https://*.google-analytics.com', 'https://*.analytics.google.com', 'https://accounts.google.com/gsi/']),
|
||||
scriptSrc: new Set(['https://accounts.google.com/gsi/client']),
|
||||
frameSrc: new Set(['https://*.quicksight.aws.amazon.com', 'https://accounts.google.com/gsi/']),
|
||||
styleSrc: new Set(['https://accounts.google.com/gsi/style']),
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies four lists of at most 64 sources of 512 characters each. Invalid or
|
||||
* oversized metadata is rejected as a whole; refresh retains the last valid inputs.
|
||||
* Only origins and the backend's fixed wildcard/path constants are admitted.
|
||||
* @param {unknown} value - The backend's optional source object.
|
||||
* @returns {Record<string, string[]> | undefined} Validated source lists.
|
||||
*/
|
||||
function validatedDashboardSources(value) {
|
||||
if (value === undefined) return undefined
|
||||
const invalid = () => { throw new Error('Invalid dashboard CSP source metadata') }
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) return invalid()
|
||||
const fields = Object.keys(value)
|
||||
if (fields.length !== 4 || fields.some((field) => !Object.hasOwn(SOURCE_FIELDS, field))) return invalid()
|
||||
const copied = {}
|
||||
for (const field of Object.keys(SOURCE_FIELDS)) {
|
||||
const sources = value[field]
|
||||
if (!Array.isArray(sources) || sources.length > 64) return invalid()
|
||||
copied[field] = sources.map((source) => {
|
||||
if (typeof source !== 'string' || source.length > 512 || /[\s;,'"?#@\\]/.test(source)) return invalid()
|
||||
if (SPECIAL_SOURCES[field].has(source)) return source
|
||||
if (source.includes('*')) return invalid()
|
||||
try {
|
||||
const url = new URL(source)
|
||||
const origin = /^(https?):\/\/(\[[0-9a-f:.]+\]|[a-z0-9.-]+)(?::([0-9]{1,5}))?$/.exec(source)
|
||||
if (!origin || !['http:', 'https:'].includes(url.protocol)) return invalid()
|
||||
if (!origin[2].startsWith('[') && url.hostname !== origin[2]) return invalid()
|
||||
if (origin[3] && Number(origin[3]) > 65535) return invalid()
|
||||
} catch { return invalid() }
|
||||
return source
|
||||
})
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a document's Content-Security-Policy: the defaults, the identity provider origins
|
||||
* (connect-src), the deployment's additions, then the document's inline script hashes.
|
||||
@@ -155,12 +197,17 @@ export function inlineScriptHashes(html) {
|
||||
* @param {object} options - The policy inputs.
|
||||
* @param {string[]} [options.connectOrigins] - Identity provider origins.
|
||||
* @param {[string, string[]][]} [options.additions] - The deployment's additions.
|
||||
* @param {unknown} [options.dashboardCspSources] - Bounded source additions from public metadata.
|
||||
* @param {string[]} [options.scriptHashes] - The document's inline script hashes.
|
||||
* @returns {string} The header value.
|
||||
*/
|
||||
export function buildContentSecurityPolicy({ connectOrigins = [], additions = [], scriptHashes = [] } = {}) {
|
||||
export function buildContentSecurityPolicy({ connectOrigins = [], additions = [], scriptHashes = [], dashboardCspSources } = {}) {
|
||||
const directives = new Map(DEFAULT_DIRECTIVES.map(([name, sources]) => [name, new Set(sources)]))
|
||||
for (const origin of connectOrigins) directives.get('connect-src').add(origin)
|
||||
const metadataSources = validatedDashboardSources(dashboardCspSources)
|
||||
for (const [field, sources] of Object.entries(metadataSources ?? {})) {
|
||||
for (const source of sources) directives.get(SOURCE_FIELDS[field]).add(source)
|
||||
}
|
||||
for (const [name, sources] of additions) {
|
||||
if (!directives.has(name)) directives.set(name, new Set())
|
||||
for (const source of sources) directives.get(name).add(source)
|
||||
@@ -170,28 +217,31 @@ export function buildContentSecurityPolicy({ connectOrigins = [], additions = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the identity provider origins from the authentication metadata, refreshed in the
|
||||
* Keeps validated policy inputs from authentication metadata, refreshed in the
|
||||
* background once they are older than `ttlMs`; a failed refresh keeps the last value.
|
||||
*
|
||||
* @param {() => Promise<unknown>} loadAuthentication - Fetches the authentication metadata.
|
||||
* @param {number} [ttlMs] - How long a value is fresh.
|
||||
* @returns {() => Promise<string[]>} Resolves the current origins (waits only for the first load).
|
||||
* @returns {() => Promise<{connectOrigins: string[], dashboardCspSources?: Record<string, string[]>}>} Current inputs; waits only for first load.
|
||||
*/
|
||||
export function createIdentityProviderOrigins(loadAuthentication, ttlMs = 60_000) {
|
||||
let origins = null
|
||||
export function createDashboardPolicyInputs(loadAuthentication, ttlMs = 60_000) {
|
||||
let inputs = null
|
||||
let loadedAt = 0
|
||||
let loading = null
|
||||
const load = () => {
|
||||
loading ??= loadAuthentication()
|
||||
.then((authentication) => { origins = identityProviderOrigins(authentication) })
|
||||
.catch((error) => { console.warn('[qqq] Could not read the authentication metadata for the security policy:', error instanceof Error ? error.message : error) })
|
||||
.then((authentication) => {
|
||||
const dashboardCspSources = validatedDashboardSources(authentication?.dashboardCspSources)
|
||||
inputs = { connectOrigins: identityProviderOrigins(authentication), dashboardCspSources }
|
||||
})
|
||||
.catch((error) => { console.warn('[qqq] Could not read the authentication metadata for the security policy:', error instanceof Error ? error.name : 'unknown') })
|
||||
.finally(() => { loadedAt = Date.now(); loading = null })
|
||||
return loading
|
||||
}
|
||||
return async () => {
|
||||
if (origins === null) await load()
|
||||
if (inputs === null) await load()
|
||||
else if (Date.now() - loadedAt > ttlMs) void load()
|
||||
return origins ?? []
|
||||
return inputs ?? { connectOrigins: [] }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,14 @@ QQQ_ACCEPTANCE_MODE=standalone pnpm test:acceptance # container-image build
|
||||
- **javalin mode (default).** The static export (`pnpm build:export`, `out/`) goes on the
|
||||
classpath as `next-dashboard/`, ahead of the sample jar. `QApplicationJavalinServer` then
|
||||
serves it at `/`, which is exactly what a fresh application gets.
|
||||
- **standalone mode.** Tests the Node standalone build that the container image uses.
|
||||
- **standalone mode.** Tests the Node standalone build through the container's
|
||||
`qqq-server.mjs` entrypoint. Builds twice from clean `.next` directories: a staged
|
||||
authentication-variant build baked to the security backend, then the main build
|
||||
baked to the shared backend. Next rewrites are build-time values; changing only a
|
||||
server environment variable cannot retarget them. The variant validates its actual
|
||||
route manifest and marker before startup, disables Java dashboard serving, and
|
||||
restarts its owned Node server when authentication metadata changes. Javalin mode
|
||||
still uses its export and does not start this second Node server.
|
||||
- **API-versioned report setup.** The acceptance server loads the matching
|
||||
`qqq-middleware-api` JAR from `~/.m2/repository/com/kingsrook/qqq/qqq-middleware-api/<version>/`.
|
||||
Set `QQQ_MIDDLEWARE_API_JAR` to another path when Maven uses a different local repository.
|
||||
@@ -44,12 +51,60 @@ QQQ_ACCEPTANCE_MODE=standalone pnpm test:acceptance # container-image build
|
||||
HTTP and broker ports for concurrent runs. The security variant uses
|
||||
`QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT` (default backend HTTP port + 10),
|
||||
`QQQ_ACCEPTANCE_SECURITY_ESB_PORT` (default main ESB port + 1, normally 61617),
|
||||
and `QQQ_ACCEPTANCE_SECURITY_IDP_PORT` (default frontend HTTP port + 10).
|
||||
`QQQ_ACCEPTANCE_SECURITY_IDP_PORT` (default frontend HTTP port + 10), and
|
||||
`QQQ_ACCEPTANCE_SECURITY_FRONTEND_PORT` (standalone browser origin, default frontend
|
||||
HTTP port + 20). Browser redirects use the mode-specific UI origin; direct backend
|
||||
control, SQL and permission/replay requests retain the security backend origin.
|
||||
Its broker setting is passed as `qqq.sample.esb.port`; explicit variant
|
||||
`options.properties` retain precedence over these defaults.
|
||||
- **Results.** Output lands in `test-results/acceptance/`: `report.json`, `gate.json` (with
|
||||
per-project counts in `byProject`), the HTML report, and a trace, video and screenshot for
|
||||
each failure.
|
||||
- **Transport evidence.** CI enables `QQQ_ACCEPTANCE_TRANSPORT_TRACE=1`; set it locally
|
||||
to retain `QQQ_TRANSPORT` events in the acceptance server log for documents, static
|
||||
chunks and versioned API requests. Events contain request/connection IDs, paths without
|
||||
queries, status, write byte counts and failure classes; they omit headers, cookies and
|
||||
bodies. A completed server write does not prove browser receipt or rendering. A write
|
||||
can fail even when final completion reports HTTP 200, so inspect `firstWriteFailure`
|
||||
separately. This observer is acceptance-only and adds synchronous logging overhead;
|
||||
leave the variable unset for a control run with the original handler tree. Native and
|
||||
ordinary server logs can still contain session data; keep raw evidence private.
|
||||
- **Document/XHR evidence (opt-in).** Set `QQQ_ACCEPTANCE_BROWSER_TRACE=1` locally to
|
||||
attach `browser-boundary.json` through the diagnostics fixture. CI uses `navigation`
|
||||
mode only for the exact NAV023 unique-key and NAV056 application-theme-override cases
|
||||
on WebKit/tablet; other cases, including SEC043, do not opt in through that mode.
|
||||
By default, only `/app/person[/]`, `/app/person/key[/]`, `/app/person/2[/]`, and
|
||||
`/app/person.bulkEdit[/]` documents on configured UI/API origins are observed. To also
|
||||
capture dashboard documents at exactly `/app` and `/app/`, set
|
||||
`QQQ_ACCEPTANCE_BROWSER_TRACE_SESSION=1` alongside `QQQ_ACCEPTANCE_BROWSER_TRACE=1`
|
||||
locally. The session flag alone installs nothing and does not select additional tests;
|
||||
other dashboard paths are not included. Requests are exact method/path matches:
|
||||
GET person metadata, person record 2 and person.bulkEdit process metadata;
|
||||
POST `/qqq/v1/manageSession`, querySavedView init, person query/count and person.bulkEdit init.
|
||||
GET `/qqq/v1/manageSession` is excluded. The helper emits
|
||||
fixed aliases, document UUID/sequence/clocks, native XHR send return/throw, status and
|
||||
terminal events. It never emits URLs, query/hash values, headers, credentials, payloads,
|
||||
error messages or stacks, and adds no request headers or interception. Disabled mode
|
||||
installs no binding or init script. One listener set follows each reused XHR generation.
|
||||
Native OPENED reentry or reuse before the recorder observes the prior `loadend`
|
||||
emits one `xhr-incomplete` marker and suppresses attribution for that instance for
|
||||
its remaining lifetime. This includes overlapping requests and earlier application
|
||||
event listeners; no endpoint, generation or attempt is guessed. Excluded requests
|
||||
retain only completion state so their old events cannot impersonate later selected
|
||||
reuse; no excluded target data is emitted. Native calls and outcomes are preserved.
|
||||
Each document is limited to 512 events plus an overflow marker and 256 selected XHRs;
|
||||
Node retains at most 4096 validated events with invalid/drop counters. Missing delivery,
|
||||
overflow or missing `ready` means incomplete evidence; zero counters do not prove
|
||||
complete delivery. Lifecycle phase is the last
|
||||
observed pagehide/pageshow state, not proof of browser-internal teardown; no unload or
|
||||
beforeunload hooks are added. Snapshotting uses Node memory even after a page fails.
|
||||
Existing diagnostics, assertions and timeouts are unchanged. RSC fetches and document
|
||||
navigation are not XHRs: correlate existing Playwright/server/native traces instead.
|
||||
Send-return disproves a synchronous throw for that invocation; a pageError alone does
|
||||
not prove one. XHR load observes completion in JS, not UI rendering; successful server
|
||||
writes still do not prove receipt. Binding delivery has nonzero overhead and can be
|
||||
lost during destruction. Retain source/export/runner provenance and raw traces privately;
|
||||
passing instrumented cells do not attribute historical failures or establish a fix.
|
||||
- **Browsers.** `QQQ_ACCEPTANCE_BROWSERS` picks the Playwright projects (default `chromium`).
|
||||
The documented matrix is `chromium,firefox,webkit,mobile`; results and the exact commands
|
||||
are in `docs/acceptance/browser-matrix.md`.
|
||||
@@ -162,3 +217,24 @@ Successful broker-level purge/delete/move/pause and a numeric dead-letter count
|
||||
a broker management endpoint; this fixture does not claim to prove those operations.
|
||||
The ESB table-probe blanket 404 diagnostic exemption is removed: unexpected failed
|
||||
requests fail acceptance, and negative cases allow only their specific denial.
|
||||
|
||||
## Standalone security qualification (#734)
|
||||
|
||||
Preserve the original SEC-034..036 table-authentication requirements alongside the
|
||||
header/enforcement rows SEC-038/040 and both SEC-039 provider-metadata cases:
|
||||
|
||||
```bash
|
||||
QQQ_ACCEPTANCE_MODE=standalone \
|
||||
QQQ_ACCEPTANCE_BROWSERS=chromium,firefox,webkit,mobile,tablet \
|
||||
node scripts/acceptance.mjs --grep '\[SEC-(034|035|036|038|039|040)\]'
|
||||
```
|
||||
|
||||
This bounded selection currently collects 43 cases (nine per desktop, eight per
|
||||
phone/tablet; the Auth0 SEC-039 case is desktop-only). It uses the existing partial
|
||||
matrix gate, zero retries and strict diagnostics. A passing Javalin run cannot
|
||||
qualify this mode. Retain the actual Node response origins, both build identities,
|
||||
frontend/backend source and artifact hashes, runtime versions, original failures
|
||||
and raw reports. Never use a stale static export to make standalone variants pass.
|
||||
The original loaded-branding assertion also remains required; missing standalone
|
||||
assets must be investigated, not skipped. Local discovery or unit success is not
|
||||
native qualification or issue closeout.
|
||||
@@ -32,10 +32,8 @@ import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QAuthenticationException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaDataInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
@@ -43,7 +41,6 @@ import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModu
|
||||
import com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.MaterialDashboardThemeMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.routeproviders.NextDashboardRouteProvider;
|
||||
import com.kingsrook.qqq.middleware.javalin.routeproviders.NextDashboardSecurityHeaders;
|
||||
import com.kingsrook.sampleapp.SampleJavalinServer;
|
||||
import com.kingsrook.sampleapp.metadata.SampleMetaDataProvider;
|
||||
@@ -128,6 +125,7 @@ public class AcceptanceSampleServer
|
||||
server.withJavalinConfigCustomizer(config ->
|
||||
{
|
||||
config.jetty.host = "127.0.0.1";
|
||||
AcceptanceTransportTrace.configure(config);
|
||||
config.routes.get("/acceptance/ready", context -> context.status(ready ? 200 : 503).result(ready ? "ready" : "starting"));
|
||||
config.routes.post("/acceptance/esb/failure", context ->
|
||||
{
|
||||
@@ -365,29 +363,13 @@ public class AcceptanceSampleServer
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The origins the QQQ server adds to the dashboard's Content-Security-Policy
|
||||
** for this application (a QuickSight embed and custom component bundles from
|
||||
** metadata, then the override hook's), written as QQQ_DASHBOARD_CSP_SOURCES:
|
||||
** in standalone mode the container image's Node server serves the dashboard
|
||||
** and takes them from that variable (QRun-IO/qqq#734).
|
||||
** Only the explicit owned-service overrides. Application metadata sources
|
||||
** must come from the production authentication metadata contract.
|
||||
*******************************************************************************/
|
||||
private static String dashboardCspSources()
|
||||
{
|
||||
NextDashboardSecurityHeaders defaults = new NextDashboardSecurityHeaders();
|
||||
NextDashboardSecurityHeaders headers = new NextDashboardSecurityHeaders();
|
||||
for(QWidgetMetaDataInterface widget : instance.getWidgets().values())
|
||||
{
|
||||
if(WidgetType.QUICK_SIGHT_CHART.getType().equals(widget.getType()))
|
||||
{
|
||||
headers.withSources("frame-src", NextDashboardRouteProvider.QUICKSIGHT_FRAME_SOURCE);
|
||||
}
|
||||
Object sourceUrl = widget.getDefaultValues() == null ? null : widget.getDefaultValues().get("componentSourceUrl");
|
||||
String origin = sourceUrl == null ? null : NextDashboardSecurityHeaders.originOf(String.valueOf(sourceUrl));
|
||||
if(WidgetType.CUSTOM_COMPONENT.getType().equals(widget.getType()) && origin != null)
|
||||
{
|
||||
headers.withSources(NextDashboardSecurityHeaders.SCRIPT_SRC, origin);
|
||||
}
|
||||
}
|
||||
WidgetsFixtures.allowFakeService(headers);
|
||||
|
||||
List<String> parts = new ArrayList<>();
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import java.nio.ByteBuffer;
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import org.eclipse.jetty.http.HttpFields;
|
||||
import org.eclipse.jetty.io.Content;
|
||||
import org.eclipse.jetty.server.Request;
|
||||
import org.eclipse.jetty.server.handler.EventsHandler;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Opt-in, metadata-only transport evidence for browser availability. This fixture
|
||||
** observes Jetty write callbacks and final completion, after Javalin's request
|
||||
** logger (which runs before output/compression closure). It never consumes a
|
||||
** request/response buffer, changes headers, or logs cookie/body/query values.
|
||||
*******************************************************************************/
|
||||
public final class AcceptanceTransportTrace extends EventsHandler
|
||||
{
|
||||
private final AtomicLong sequence = new AtomicLong();
|
||||
private final Map<Request, Observation> requests = new ConcurrentHashMap<>();
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Javalin 7.2.3 applies this customizer before attaching its servlet context
|
||||
** beneath the wrapper. Disabled runs retain the original handler tree.
|
||||
***************************************************************************/
|
||||
public static void configure(JavalinConfig config)
|
||||
{
|
||||
if("1".equals(System.getenv("QQQ_ACCEPTANCE_TRANSPORT_TRACE")))
|
||||
{
|
||||
config.jetty.modifyServer(server ->
|
||||
{
|
||||
AcceptanceTransportTrace observer = new AcceptanceTransportTrace();
|
||||
observer.setHandler(server.getHandler());
|
||||
server.setHandler(observer);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onBeforeHandling(Request request)
|
||||
{
|
||||
String path = request.getHttpURI().getPath();
|
||||
if(path.equals("/") || path.startsWith("/app/") || path.startsWith("/_next/static/") || path.startsWith("/qqq/v1/"))
|
||||
{
|
||||
requests.put(request, new Observation(sequence.incrementAndGet()));
|
||||
emit(request, "start", 0, false, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onResponseBegin(Request request, int status, HttpFields headers)
|
||||
{
|
||||
Observation observation = requests.get(request);
|
||||
if(observation != null) observation.status = status;
|
||||
emit(request, "response-begin", 0, false, null);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onResponseWriteComplete(Request request, boolean last, ByteBuffer content, Throwable failure)
|
||||
{
|
||||
// Jetty supplies a read-only duplicate retaining the original write bounds.
|
||||
// A failed write may have sent only part; its bytes are attempted, not delivered.
|
||||
int bytes = content == null ? 0 : content.remaining();
|
||||
Observation observation = requests.get(request);
|
||||
if(observation != null && failure == null) observation.successfulWriteBytes.addAndGet(bytes);
|
||||
if(observation != null && failure != null) observation.firstWriteFailure.compareAndSet(null, failure.getClass().getName());
|
||||
emit(request, "write-complete", bytes, last, failure);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onAfterHandling(Request request, boolean handled, Throwable failure)
|
||||
{
|
||||
emit(request, "handler-return", 0, false, failure);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onComplete(Request request, int status, HttpFields headers, Throwable failure)
|
||||
{
|
||||
Observation observation = requests.get(request);
|
||||
if(observation != null) observation.status = status;
|
||||
emit(request, "complete", 0, true, failure);
|
||||
requests.remove(request);
|
||||
}
|
||||
|
||||
|
||||
// Override payload-bearing default debug callbacks: even DEBUG must not dump
|
||||
// request content, response buffers, headers, or trailers from this observer.
|
||||
@Override
|
||||
protected void onRequestRead(Request request, Content.Chunk chunk) { }
|
||||
|
||||
@Override
|
||||
protected void onResponseWrite(Request request, boolean last, ByteBuffer content) { }
|
||||
|
||||
@Override
|
||||
protected void onResponseTrailersComplete(Request request, HttpFields trailers) { }
|
||||
|
||||
|
||||
private void emit(Request request, String event, int writeBytes, boolean last, Throwable failure)
|
||||
{
|
||||
Observation observation = requests.get(request);
|
||||
if(observation == null) return;
|
||||
System.out.println("QQQ_TRANSPORT " + new JSONObject()
|
||||
.put("time", Instant.now().toString())
|
||||
.put("nanoTime", System.nanoTime())
|
||||
.put("pid", ProcessHandle.current().pid())
|
||||
.put("requestId", observation.id)
|
||||
.put("connectionId", request.getConnectionMetaData().getId())
|
||||
.put("method", request.getMethod())
|
||||
.put("path", request.getHttpURI().getPath())
|
||||
.put("event", event)
|
||||
.put("status", observation.status == 0 ? JSONObject.NULL : observation.status)
|
||||
.put("writeBytes", writeBytes)
|
||||
// Completed server writes do not prove client receipt, decode or paint.
|
||||
.put("successfulWriteBytes", observation.successfulWriteBytes.get())
|
||||
.put("last", last)
|
||||
// Javalin can handle a failed write, leaving final completion's failure null.
|
||||
.put("firstWriteFailure", observation.firstWriteFailure.get() == null ? JSONObject.NULL : observation.firstWriteFailure.get())
|
||||
.put("failure", failure == null ? JSONObject.NULL : failure.getClass().getName()));
|
||||
}
|
||||
|
||||
|
||||
private static final class Observation
|
||||
{
|
||||
private final long id;
|
||||
private final AtomicLong successfulWriteBytes = new AtomicLong();
|
||||
private volatile int status;
|
||||
private final AtomicReference<String> firstWriteFailure = new AtomicReference<>();
|
||||
|
||||
private Observation(long id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,21 @@ public class SecurityAcceptanceServer
|
||||
}
|
||||
default -> throw new QException("Unknown qqq.security.auth: " + AUTH);
|
||||
}
|
||||
String analyticsOrigin = System.getProperty("qqq.security.analytics.origin");
|
||||
if(analyticsOrigin != null)
|
||||
{
|
||||
if(!analyticsOrigin.matches("http://127\\.0\\.0\\.1:[0-9]{1,5}"))
|
||||
{
|
||||
throw new QException("The owned analytics fixture requires a loopback origin.");
|
||||
}
|
||||
Map<String, String> environment = new java.util.LinkedHashMap<>(defined.getEnvironmentValues());
|
||||
environment.put("ANALYTICS_PROVIDERS", "posthog");
|
||||
environment.put("ANALYTICS_PLUGIN_SCRIPTS", analyticsOrigin + "/owned-plugin.js");
|
||||
environment.put("POSTHOG_ENABLED", "true");
|
||||
environment.put("POSTHOG_PROJECT_API_KEY", "owned-test-key");
|
||||
environment.put("POSTHOG_HOST", analyticsOrigin);
|
||||
defined.setEnvironmentValues(environment);
|
||||
}
|
||||
instance = defined;
|
||||
return defined;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"id": "ESB-001",
|
||||
"feature": "Admin overview and real table-to-process delivery",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -21,7 +21,8 @@
|
||||
"Fresh test starts with zero consumed and no receipts"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
@@ -29,7 +30,7 @@
|
||||
"id": "ESB-002",
|
||||
"feature": "Developer publications, subscribers and safe process links",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -41,7 +42,8 @@
|
||||
"No process init request or receipt from navigating metadata"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
@@ -49,7 +51,7 @@
|
||||
"id": "ESB-003",
|
||||
"feature": "Person READ permission",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -60,7 +62,8 @@
|
||||
"Table section is absent; its browser request and direct backend request return 403"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
@@ -68,7 +71,7 @@
|
||||
"id": "ESB-004",
|
||||
"feature": "ESB app permission",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -79,7 +82,8 @@
|
||||
"No ESB navigation link and direct overview is 403 without esbView"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
@@ -87,7 +91,7 @@
|
||||
"id": "ESB-005",
|
||||
"feature": "Subscriber process permission",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -98,7 +102,8 @@
|
||||
"Subscriber is omitted; process and dead-letter endpoints return 403"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
@@ -106,7 +111,7 @@
|
||||
"id": "ESB-006",
|
||||
"feature": "Trigger lifecycle and topic subscription",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -118,7 +123,8 @@
|
||||
"No receipt while paused; each action reports its final completion"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
@@ -126,7 +132,7 @@
|
||||
"id": "ESB-007",
|
||||
"feature": "Operate permission enforcement",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -137,7 +143,8 @@
|
||||
"Pause resume restart replay queue pause queue resume and move all return 403; controls absent"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
@@ -145,7 +152,7 @@
|
||||
"id": "ESB-008",
|
||||
"feature": "Delete permission enforcement",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -156,7 +163,8 @@
|
||||
"Purge and delete requests return 403; destructive controls absent"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
@@ -164,7 +172,7 @@
|
||||
"id": "ESB-009",
|
||||
"feature": "Broker capability gating",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -175,7 +183,8 @@
|
||||
"Queue pause resume purge delete and move controls are absent when capabilities are false"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
},
|
||||
@@ -183,7 +192,7 @@
|
||||
"id": "ESB-010",
|
||||
"feature": "Dead-letter replay confirmation and delivery",
|
||||
"source": [
|
||||
"QRun-IO/qqq-frontend-next#10 (task 18 and addendum)",
|
||||
"QRun-IO/qqq#986 (transferred from frontend#10; task 18 and addendum)",
|
||||
"qqq/docs/specs/2026-09-25-qqq-esb-design.md sections 7-9,12"
|
||||
],
|
||||
"fixture": "SampleJavalinServer embedded Artemis + EsbFixtures receipt/failure/reset support",
|
||||
@@ -196,7 +205,8 @@
|
||||
"Opening and cancelling confirmation issues no replay and preserves the message"
|
||||
],
|
||||
"issues": [
|
||||
"QRun-IO/qqq-frontend-next#10"
|
||||
"QRun-IO/qqq#986",
|
||||
"QRun-IO/qqq#990"
|
||||
],
|
||||
"required": true
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ async function receipts(backend: Backend) {
|
||||
return backend.sql('SELECT subject, event_type, first_name FROM acceptance_esb_receipt ORDER BY id')
|
||||
}
|
||||
|
||||
test('[ESB-001] admin sees the real topic and trigger; inserting a person runs its subscriber', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-001] admin sees the real topic and trigger; inserting a person runs its subscriber @mobile', async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
await open(page, '/app/esb')
|
||||
await expect(page.locator('[data-qqq-id="esb-destination-personEvents"]')).toContainText('Topic')
|
||||
@@ -63,7 +63,7 @@ test('[ESB-001] admin sees the real topic and trigger; inserting a person runs i
|
||||
await expect(row.locator('div').filter({ has: page.locator('dt').filter({ hasText: /^Consumed$/ }) }).locator('dd')).toHaveText('1')
|
||||
})
|
||||
|
||||
test('[ESB-002] table and process Developer views expose publications and subscribers without running a process', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-002] table and process Developer views expose publications and subscribers without running a process @mobile', async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
const processInits: string[] = []
|
||||
page.on('request', (request) => {
|
||||
@@ -88,7 +88,7 @@ test('[ESB-002] table and process Developer views expose publications and subscr
|
||||
|
||||
test.describe('table READ enforcement', () => {
|
||||
test.use({ persona: 'noPersonRead' })
|
||||
test('[ESB-003] no person READ hides the section and the server refuses its data', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-003] no person READ hides the section and the server refuses its data @mobile', async ({ page, backend, diagnostics }) => {
|
||||
diagnostics.allow(/^request: GET \/qqq\/v1\/esb\/table\/person 403$/)
|
||||
diagnostics.allow(/^console: Failed to load resource: the server responded with a status of 403 \(Forbidden\)$/)
|
||||
const denied = page.waitForResponse((response) => new URL(response.url()).pathname === '/qqq/v1/esb/table/person')
|
||||
@@ -104,7 +104,7 @@ test.describe('table READ enforcement', () => {
|
||||
|
||||
test.describe('app enforcement', () => {
|
||||
test.use({ persona: 'noEsbView' })
|
||||
test('[ESB-004] no ESB app access hides navigation and denies overview while table READ still works', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-004] no ESB app access hides navigation and denies overview while table READ still works @mobile', async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
await open(page, '/app/person/dev')
|
||||
await expect(page.locator('[data-qqq-id="esb-section-person"]')).toBeVisible()
|
||||
@@ -116,7 +116,7 @@ test.describe('app enforcement', () => {
|
||||
|
||||
test.describe('subscriber enforcement', () => {
|
||||
test.use({ persona: 'noSyncPerson' })
|
||||
test('[ESB-005] a denied process is omitted from subscribers and its endpoints refuse access', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-005] a denied process is omitted from subscribers and its endpoints refuse access @mobile', async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
await open(page, '/app/person/dev')
|
||||
const section = page.locator('[data-qqq-id="esb-section-person"]')
|
||||
@@ -129,7 +129,7 @@ test.describe('subscriber enforcement', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('[ESB-006] pause holds a real message in the topic subscription; resume and restart complete', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-006] pause holds a real message in the topic subscription; resume and restart complete @mobile', async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
await open(page, '/app/esb')
|
||||
const row = page.locator(triggerRow)
|
||||
@@ -162,7 +162,7 @@ test('[ESB-006] pause holds a real message in the topic subscription; resume and
|
||||
|
||||
test.describe('operate permission', () => {
|
||||
test.use({ persona: 'noEsbOperate' })
|
||||
test('[ESB-007] operate controls are absent and every operate process denies direct invocation', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-007] operate controls are absent and every operate process denies direct invocation @mobile', async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
await open(page, '/app/esb')
|
||||
const row = page.locator(triggerRow)
|
||||
@@ -180,7 +180,7 @@ test.describe('operate permission', () => {
|
||||
|
||||
test.describe('delete permission', () => {
|
||||
test.use({ persona: 'noEsbDelete' })
|
||||
test('[ESB-008] delete permission is independent of operate and enforced by both destructive processes', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-008] delete permission is independent of operate and enforced by both destructive processes @mobile', async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
await open(page, '/app/esb')
|
||||
const row = page.locator(triggerRow)
|
||||
@@ -197,7 +197,7 @@ test.describe('delete permission', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('[ESB-009] unavailable broker capabilities hide queue mutations, while JMS browsing remains usable', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-009] unavailable broker capabilities hide queue mutations, while JMS browsing remains usable @mobile', async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
await open(page, '/app/esb')
|
||||
const status = await overview(backend)
|
||||
@@ -208,7 +208,7 @@ test('[ESB-009] unavailable broker capabilities hide queue mutations, while JMS
|
||||
await expect(page.getByRole('button', { name: /^(Pause queue|Resume queue|Purge|Delete older|Delete selected|Move selected)/ })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('[ESB-010] real dead letters require replay-all confirmation; cancel preserves them and confirm runs the subscriber', async ({ page, backend, diagnostics }) => {
|
||||
test('[ESB-010] real dead letters require replay-all confirmation; cancel preserves them and confirm runs the subscriber @mobile', async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
await backend.failEsbSync(true)
|
||||
await insertPerson(backend, 'Replay')
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { expect, type Page } from '@playwright/test'
|
||||
|
||||
/** Wait for the upstream report setup renderer before clicking a pivot control it can move. */
|
||||
export async function openReadyPivotEditor(page: Page) {
|
||||
const setup = page.locator('[data-qqq-id="form-widget-reportSetupWidget"]')
|
||||
const edit = setup.getByRole('button', { name: 'Edit Filters and Columns', exact: true })
|
||||
await expect(edit).toBeVisible()
|
||||
await expect(edit).toBeEnabled()
|
||||
await expect(setup.getByRole('status', { name: 'Loading widget' })).toHaveCount(0)
|
||||
await page.getByRole('button', { name: 'Edit Pivot Table', exact: true }).click()
|
||||
}
|
||||
@@ -6,6 +6,8 @@
|
||||
*/
|
||||
|
||||
import type { Page } from '@playwright/test'
|
||||
import { armGreetRouteResponses } from '../../support/greet-route-readiness'
|
||||
import { ACCEPTANCE_UI_URL } from '../../support/ports'
|
||||
import { expect, open, test, type Backend } from '../../support/fixtures'
|
||||
import { expectNoHorizontalScroll, expectTouchReady, expectTouchTargets } from '../../support/touch'
|
||||
import { allowQuickSight, appNavigation, expectBreadcrumbs, expectRecords, findNode, recordCollection, recordItems, v1MetaData, waitForShell } from './nav-helpers'
|
||||
@@ -120,12 +122,26 @@ test.describe('routing', () => {
|
||||
{ path: `/app/person/savedView/${view.id}`, crumbs: ['People App', 'Greetings App', 'Person', 'Saved View'], active: 'Person', content: (p) => expect(p.locator('#main-content')).toBeVisible() },
|
||||
]
|
||||
for (const { path, crumbs, content, active } of cases) {
|
||||
await open(page, path)
|
||||
await content(page)
|
||||
await expectBreadcrumbs(page, crumbs)
|
||||
const nav = await appNavigation(page)
|
||||
await expect(nav.getByRole('link', { name: active, exact: true }), path).toBeVisible()
|
||||
await expect(nav.getByRole('link', { name: active, exact: true }), path).toHaveAttribute('aria-current', 'page')
|
||||
const processResponses = path === '/app/greetInteractive'
|
||||
? armGreetRouteResponses(page, new URL(ACCEPTANCE_UI_URL).origin)
|
||||
: undefined
|
||||
try {
|
||||
await open(page, path)
|
||||
await content(page)
|
||||
await expectBreadcrumbs(page, crumbs)
|
||||
const nav = await appNavigation(page)
|
||||
await expect(nav.getByRole('link', { name: active, exact: true }), path).toBeVisible()
|
||||
await expect(nav.getByRole('link', { name: active, exact: true }), path).toHaveAttribute('aria-current', 'page')
|
||||
if (processResponses) {
|
||||
// The bare process URL completes with the same validation error asserted by PRC-004.
|
||||
await processResponses.wait
|
||||
await expect(page.locator('[data-qqq-id="process-error-message"]')).toBeVisible()
|
||||
await expect(page.locator('[data-qqq-id="process-error-message"]')).toHaveText('Missing input records.')
|
||||
await expect(page.locator('[data-qqq-id="process-run-greetInteractive"]')).toHaveAttribute('data-process-phase', 'error')
|
||||
}
|
||||
} finally {
|
||||
processResponses?.dispose()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import AxeBuilder from '@axe-core/playwright'
|
||||
import type { Page } from '@playwright/test'
|
||||
import { appNavigation } from './nav-helpers'
|
||||
import { openReadyPivotEditor } from './pivot-readiness'
|
||||
import { expect, open, test } from '../../support/fixtures'
|
||||
|
||||
// Theme metadata is instance-wide; every scenario needs the backend reset before navigation.
|
||||
@@ -44,7 +45,7 @@ for (const mode of ['light', 'dark', 'application'] as const) {
|
||||
if (mode === 'application') {
|
||||
await expect.poll(() => page.locator('html').evaluate(root => getComputedStyle(root).getPropertyValue('--color-destructive').trim())).toBe('#f97316')
|
||||
}
|
||||
await page.getByRole('button', { name: 'Edit Pivot Table', exact: true }).click()
|
||||
await openReadyPivotEditor(page)
|
||||
await page.locator('[data-qqq-id="pivot-editor-add-row"]').click()
|
||||
await page.locator('[data-qqq-id="pivot-editor-ok"]').click()
|
||||
await expect(page.locator('[data-qqq-id="pivot-editor-error"]')).toContainText('Missing value in 1 field.')
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { expect, open, test } from '../../support/fixtures'
|
||||
import { expectColumn, sqlColumn } from './query-helpers'
|
||||
|
||||
for (const action of ['selection', 'refresh']) {
|
||||
test(`[QRY-030] body checkbox retains keyboard focus after ${action}`, async ({ page, backend, diagnostics }, testInfo) => {
|
||||
void diagnostics
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await open(page, '/app/qryItem')
|
||||
const ids = await sqlColumn(backend, 'select id from qry_item order by id desc')
|
||||
await expectColumn(page, 'id', ids)
|
||||
const checkbox = page.locator('[data-qqq-id="grid-select-row-0"]')
|
||||
await expect(checkbox).not.toBeChecked()
|
||||
await checkbox.focus()
|
||||
await expect(checkbox).toBeFocused()
|
||||
const original = await checkbox.elementHandle()
|
||||
expect(original).not.toBeNull()
|
||||
const navigation = await page.evaluate(() => ({ url: location.href, length: history.length }))
|
||||
if (action === 'selection') {
|
||||
await page.keyboard.press('Space')
|
||||
await expect(checkbox).toBeChecked()
|
||||
await expect(page.locator('[data-qqq-id="bulk-selection-text"]')).toHaveText('1 record is selected.')
|
||||
} else {
|
||||
const query = page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === '/qqq/v1/table/qryItem/query')
|
||||
await page.keyboard.press('r')
|
||||
const response = await query
|
||||
expect(response.status()).toBe(200)
|
||||
expect(await response.finished()).toBeNull()
|
||||
await expectColumn(page, 'id', ids)
|
||||
await expect(checkbox).not.toBeChecked()
|
||||
}
|
||||
await testInfo.attach('cell-focus-boundary', { body: JSON.stringify({
|
||||
action,
|
||||
original: await original!.evaluate(element => ({ connected: element.isConnected, focused: document.activeElement === element })),
|
||||
current: await checkbox.evaluate(element => ({ focused: document.activeElement === element, checked: (element as HTMLInputElement).checked })),
|
||||
navigationUnchanged: JSON.stringify(await page.evaluate(() => ({ url: location.href, length: history.length }))) === JSON.stringify(navigation),
|
||||
}), contentType: 'application/json' })
|
||||
expect(await page.evaluate(() => ({ url: location.href, length: history.length }))).toEqual(navigation)
|
||||
await expect(checkbox).toBeFocused()
|
||||
expect(await original!.evaluate(element => element.isConnected)).toBe(true)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { expect, open, test as acceptanceTest } from '../../support/fixtures'
|
||||
import { allowBlockedByPolicy, parsePolicy } from './support/csp'
|
||||
import { listCell } from './support/ui'
|
||||
import { resetVariant, SECURITY_UI_URL, startVariant, stopVariant } from './support/variant'
|
||||
|
||||
interface OwnedAnalytics {
|
||||
origin: string
|
||||
deniedOrigin: string
|
||||
counts: { scripts: number; initialize: number; record: number; denied: number }
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
async function listen(server: Server): Promise<string> {
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
return `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
||||
}
|
||||
|
||||
async function ownedAnalytics(): Promise<OwnedAnalytics> {
|
||||
const counts = { scripts: 0, initialize: 0, record: 0, denied: 0 }
|
||||
const cors = { 'Access-Control-Allow-Origin': SECURITY_UI_URL }
|
||||
const denied = createServer((request, response) => {
|
||||
counts.denied++
|
||||
request.resume()
|
||||
response.writeHead(200, cors)
|
||||
response.end('owned endpoint')
|
||||
})
|
||||
const deniedOrigin = await listen(denied)
|
||||
let origin = ''
|
||||
const allowed = createServer((request, response) => {
|
||||
request.resume()
|
||||
if (request.url === '/owned-plugin.js') {
|
||||
counts.scripts++
|
||||
response.writeHead(200, { ...cors, 'Content-Type': 'text/javascript' })
|
||||
// The normal application hook loads this plugin, then creates this registered provider.
|
||||
// Fixed aliases only: no user, record, URL, event or session payload is transmitted.
|
||||
response.end(`window.QQQAnalytics.register('posthog', () => ({
|
||||
initialize() {
|
||||
fetch(${JSON.stringify(origin)} + '/collect/initialize', { method: 'POST', body: 'initialize' });
|
||||
fetch(${JSON.stringify(deniedOrigin)} + '/collect', { method: 'POST', body: 'blocked-control' }).catch(() => {});
|
||||
},
|
||||
record() { fetch(${JSON.stringify(origin)} + '/collect/record', { method: 'POST', body: 'record' }); },
|
||||
reset() {}
|
||||
}));`)
|
||||
} else if (request.method === 'POST' && request.url === '/collect/initialize') {
|
||||
counts.initialize++
|
||||
response.writeHead(200, cors)
|
||||
response.end('ok')
|
||||
} else if (request.method === 'POST' && request.url === '/collect/record') {
|
||||
counts.record++
|
||||
response.writeHead(200, cors)
|
||||
response.end('ok')
|
||||
} else {
|
||||
response.writeHead(404, cors)
|
||||
response.end()
|
||||
}
|
||||
})
|
||||
origin = await listen(allowed)
|
||||
return {
|
||||
origin, deniedOrigin, counts,
|
||||
close: async () => {
|
||||
await Promise.all([allowed, denied].map((server) => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve())
|
||||
// The browser can still own preconnected or incomplete HTTP sockets during teardown.
|
||||
server.closeAllConnections()
|
||||
})))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const test = acceptanceTest.extend<{ analytics: OwnedAnalytics }>({
|
||||
baseURL: async ({}, provide) => { await provide(SECURITY_UI_URL) },
|
||||
analytics: async ({}, provide) => {
|
||||
const service = await ownedAnalytics()
|
||||
try {
|
||||
await startVariant('FULLY_ANONYMOUS', { properties: { 'qqq.security.analytics.origin': service.origin } })
|
||||
await resetVariant()
|
||||
await provide(service)
|
||||
} finally {
|
||||
try { await stopVariant() } finally { await service.close() }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
test('[SEC-040] metadata-configured analytics loads through the application hook and cannot contact an unconfigured origin @mobile', async ({ page, analytics, diagnostics }, testInfo) => {
|
||||
let policy: Record<string, string[]> = {}
|
||||
try {
|
||||
policy = parsePolicy((await page.request.get('/login')).headers()['content-security-policy'])
|
||||
expect(policy['connect-src']).toEqual(["'self'", analytics.origin])
|
||||
expect(policy['script-src'].filter((source) => !source.startsWith("'sha256-"))).toEqual(["'self'", analytics.origin])
|
||||
allowBlockedByPolicy(diagnostics, analytics.deniedOrigin)
|
||||
|
||||
await open(page, '/app/person')
|
||||
await expect(listCell(page, 'Person', 'Avery')).toBeVisible()
|
||||
await expect.poll(() => analytics.counts.initialize).toBe(1)
|
||||
await expect.poll(() => analytics.counts.record).toBeGreaterThan(0)
|
||||
expect(analytics.counts.scripts).toBe(1)
|
||||
await expect.poll(() => diagnostics.cspViolations.some((entry) => entry === `connect-src ${analytics.deniedOrigin}` || entry.startsWith(`connect-src ${analytics.deniedOrigin}/`))).toBe(true)
|
||||
expect(analytics.counts.denied).toBe(0)
|
||||
expect(diagnostics.cspViolations.every((entry) => entry === `connect-src ${analytics.deniedOrigin}` || entry.startsWith(`connect-src ${analytics.deniedOrigin}/`))).toBe(true)
|
||||
} finally {
|
||||
await testInfo.attach('owned-analytics-csp', {
|
||||
body: JSON.stringify({
|
||||
counts: { ...analytics.counts },
|
||||
configuredOrigin: analytics.origin,
|
||||
deniedOrigin: analytics.deniedOrigin,
|
||||
policy,
|
||||
}),
|
||||
contentType: 'application/json',
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect, open, test as acceptanceTest } from '../../support/fixtures'
|
||||
import { startFakeOidc, type FakeOidcProvider } from '../../support/fake-oidc'
|
||||
import { IDP_PORT, resetVariant, SECURITY_URL, startVariant, stopVariant, variantSql } from './support/variant'
|
||||
import { IDP_PORT, resetVariant, SECURITY_URL, SECURITY_UI_URL, startVariant, stopVariant, variantSql } from './support/variant'
|
||||
import { listCell, navigation, openUserMenu, recordRequests } from './support/ui'
|
||||
import { parsePolicy } from './support/csp'
|
||||
|
||||
@@ -29,18 +29,18 @@ const AUTH0_VARIANT = {
|
||||
}
|
||||
|
||||
const auth0Test = acceptanceTest.extend<{ auth0: FakeOidcProvider }, { auth0Provider: FakeOidcProvider }>({
|
||||
auth0Provider: [async ({}, use) => {
|
||||
const provider = await startFakeOidc({ port: IDP_PORT, clientId: AUTH0_CLIENT, clientSecret: 'unused-by-spa-flow', audience: AUDIENCE, allowedRedirectPrefix: `${SECURITY_URL}/` })
|
||||
await use(provider)
|
||||
auth0Provider: [async ({}, provide) => {
|
||||
const provider = await startFakeOidc({ port: IDP_PORT, clientId: AUTH0_CLIENT, clientSecret: 'unused-by-spa-flow', audience: AUDIENCE, auth0Issuer: true, allowedRedirectPrefix: `${SECURITY_UI_URL}/` })
|
||||
await provide(provider)
|
||||
await stopVariant()
|
||||
await provider.close()
|
||||
}, { scope: 'worker' }],
|
||||
baseURL: async ({}, use) => { await use(SECURITY_URL) },
|
||||
auth0: async ({ auth0Provider }, use) => {
|
||||
baseURL: async ({}, provide) => { await provide(SECURITY_UI_URL) },
|
||||
auth0: async ({ auth0Provider }, provide) => {
|
||||
await startVariant('AUTH_0', AUTH0_VARIANT)
|
||||
await resetVariant()
|
||||
auth0Provider.reset()
|
||||
await use(auth0Provider)
|
||||
await provide(auth0Provider)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -50,14 +50,14 @@ auth0Test.describe('AUTH_0 (owned Auth0-compatible provider)', () => {
|
||||
await open(page, '/app/person')
|
||||
await expect(page.getByRole('heading', { name: 'QRun Test Identity Provider' })).toBeVisible()
|
||||
const authorize = auth0.requests.find((request) => request.path === '/authorize')
|
||||
expect(authorize?.query).toMatchObject({ client_id: AUTH0_CLIENT, audience: AUDIENCE, code_challenge_method: 'S256', redirect_uri: `${SECURITY_URL}/` })
|
||||
expect(authorize?.query).toMatchObject({ client_id: AUTH0_CLIENT, audience: AUDIENCE, code_challenge_method: 'S256', redirect_uri: `${SECURITY_UI_URL}/` })
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
await expect(listCell(page, 'Person', 'Avery')).toBeVisible()
|
||||
|
||||
// a public SPA client: the browser redeems the code with its PKCE verifier, no secret
|
||||
const [token] = auth0.requests.filter((request) => request.path === '/oauth/token')
|
||||
expect(token.authorization).toBeUndefined()
|
||||
expect(token.form).toMatchObject({ grant_type: 'authorization_code', client_id: AUTH0_CLIENT, redirect_uri: `${SECURITY_URL}/` })
|
||||
expect(token.form).toMatchObject({ grant_type: 'authorization_code', client_id: AUTH0_CLIENT, redirect_uri: `${SECURITY_UI_URL}/` })
|
||||
expect(token.form.code_verifier).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
// the backend fetched the provider keys to verify the access token
|
||||
expect(auth0.requests.some((request) => request.path === '/.well-known/jwks.json')).toBe(true)
|
||||
@@ -72,7 +72,7 @@ auth0Test.describe('AUTH_0 (owned Auth0-compatible provider)', () => {
|
||||
const menu = await openUserMenu(page)
|
||||
await menu.getByRole('menuitem', { name: 'Log Out' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'You have signed out' })).toBeVisible()
|
||||
await expect.poll(() => auth0.requests.some((request) => request.path === '/v2/logout' && request.query.client_id === AUTH0_CLIENT && request.query.returnTo === `${SECURITY_URL}/login`)).toBe(true)
|
||||
await expect.poll(() => auth0.requests.some((request) => request.path === '/v2/logout' && request.query.client_id === AUTH0_CLIENT && request.query.returnTo === `${SECURITY_UI_URL}/login`)).toBe(true)
|
||||
await expect.poll(() => auth0.activeSessions()).toBe(0)
|
||||
expect((await context.cookies()).map((cookie) => cookie.name)).not.toContain('sessionUUID')
|
||||
await open(page, '/app/person')
|
||||
@@ -107,7 +107,7 @@ auth0Test.describe('AUTH_0 (owned Auth0-compatible provider)', () => {
|
||||
|
||||
auth0Test('[SEC-039] the policy allows the browser token exchange with the configured Auth0 domain only', async ({ page, auth0, diagnostics }) => {
|
||||
const policy = parsePolicy((await page.request.get('/login')).headers()['content-security-policy'])
|
||||
expect(policy['connect-src']).toEqual(["'self'", auth0.issuer])
|
||||
expect(policy['connect-src']).toEqual(["'self'", auth0.origin])
|
||||
await open(page, '/app/person')
|
||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||
await expect(listCell(page, 'Person', 'Avery')).toBeVisible()
|
||||
@@ -118,11 +118,11 @@ auth0Test.describe('AUTH_0 (owned Auth0-compatible provider)', () => {
|
||||
})
|
||||
|
||||
const anonymousTest = acceptanceTest.extend<{ anonymous: void }>({
|
||||
baseURL: async ({}, use) => { await use(SECURITY_URL) },
|
||||
anonymous: async ({}, use) => {
|
||||
baseURL: async ({}, provide) => { await provide(SECURITY_UI_URL) },
|
||||
anonymous: async ({}, provide) => {
|
||||
await startVariant('FULLY_ANONYMOUS')
|
||||
await resetVariant()
|
||||
await use()
|
||||
await provide()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -215,9 +215,20 @@ test.describe('security review fixes (QRun-IO/qqq#696)', () => {
|
||||
await open(page, '/app')
|
||||
await expect(recentPet).toBeVisible()
|
||||
await expect(recentPet).toHaveAttribute('href', /^\/app\/pet\/1\/?$/)
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('qqq.recordQueryView.person', JSON.stringify({
|
||||
queryFilter: { criteria: [{ fieldName: 'firstName', operator: 'EQUALS', values: ['Alice private filter'] }] },
|
||||
viewIdentity: 'empty',
|
||||
}))
|
||||
localStorage.setItem('qqq.currentSavedViewId.person', '7')
|
||||
})
|
||||
// the same identity keeps its list across a reload
|
||||
await page.reload()
|
||||
await expect(recentPet).toBeVisible()
|
||||
expect(await page.evaluate(() => JSON.parse(localStorage.getItem('qqq.recordQueryView.person') ?? 'null'))).toMatchObject({
|
||||
queryFilter: { criteria: [{ fieldName: 'firstName', operator: 'EQUALS', values: ['Alice private filter'] }] },
|
||||
})
|
||||
expect(await page.evaluate(() => localStorage.getItem('qqq.currentSavedViewId.person'))).toBe('7')
|
||||
|
||||
// no logout: the next session in this browser belongs to someone else
|
||||
await backend.setPersona('admin', 'bob')
|
||||
@@ -226,6 +237,8 @@ test.describe('security review fixes (QRun-IO/qqq#696)', () => {
|
||||
await expect((await navigation(page)).locator('[data-qqq-id="sidebar-user-name"]')).toContainText('Bob')
|
||||
await expect(page.locator('[data-qqq-id^="dashboard-recent-"]')).toHaveCount(0)
|
||||
expect(await page.evaluate(() => localStorage.getItem('qqq-recent-records') ?? '')).not.toContain('/app/pet/1')
|
||||
expect(await page.evaluate(() => localStorage.getItem('qqq.recordQueryView.person'))).toBeNull()
|
||||
expect(await page.evaluate(() => localStorage.getItem('qqq.currentSavedViewId.person'))).toBeNull()
|
||||
await open(page, '/app/person')
|
||||
await expect(listCell(page, 'Person', 'Avery')).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { BrowserContext, Page } from '@playwright/test'
|
||||
import { expect, open, test } from '../../support/fixtures'
|
||||
import { startFakeOidc, type FakeOidcProvider } from '../../support/fake-oidc'
|
||||
import { ACCEPTANCE_UI_URL } from '../../support/ports'
|
||||
import { expireTableSessions, IDP_PORT, resetVariant, SECURITY_URL, startVariant, stopVariant, variantSql } from './support/variant'
|
||||
import { expireTableSessions, IDP_PORT, resetVariant, SECURITY_URL, SECURITY_UI_URL, startVariant, stopVariant, variantSql } from './support/variant'
|
||||
import { listCell, navigation, openUserMenu } from './support/ui'
|
||||
|
||||
const SESSION_COOKIES = ['sessionId', 'sessionUUID']
|
||||
@@ -102,11 +102,11 @@ test('[SEC-048] script in the page can neither read the session cookies nor obta
|
||||
})
|
||||
|
||||
const tableTest = test.extend<{ tableBased: void }>({
|
||||
baseURL: async ({}, use) => { await use(SECURITY_URL) },
|
||||
tableBased: async ({}, use) => {
|
||||
baseURL: async ({}, provide) => { await provide(SECURITY_UI_URL) },
|
||||
tableBased: async ({}, provide) => {
|
||||
await startVariant('TABLE_BASED')
|
||||
await resetVariant()
|
||||
await use()
|
||||
await provide()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -270,18 +270,18 @@ const OAUTH2_VARIANT = {
|
||||
}
|
||||
|
||||
const oauthTest = test.extend<{ idp: FakeOidcProvider }, { httpOnlyOauthProvider: FakeOidcProvider }>({
|
||||
httpOnlyOauthProvider: [async ({}, use) => {
|
||||
const provider = await startFakeOidc({ port: IDP_PORT, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, allowedRedirectPrefix: `${SECURITY_URL}/` })
|
||||
await use(provider)
|
||||
httpOnlyOauthProvider: [async ({}, provide) => {
|
||||
const provider = await startFakeOidc({ port: IDP_PORT, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, allowedRedirectPrefix: `${SECURITY_UI_URL}/` })
|
||||
await provide(provider)
|
||||
await stopVariant()
|
||||
await provider.close()
|
||||
}, { scope: 'worker' }],
|
||||
baseURL: async ({}, use) => { await use(SECURITY_URL) },
|
||||
idp: async ({ httpOnlyOauthProvider }, use) => {
|
||||
baseURL: async ({}, provide) => { await provide(SECURITY_UI_URL) },
|
||||
idp: async ({ httpOnlyOauthProvider }, provide) => {
|
||||
await startVariant('OAUTH2', OAUTH2_VARIANT)
|
||||
await resetVariant()
|
||||
httpOnlyOauthProvider.reset()
|
||||
await use(httpOnlyOauthProvider)
|
||||
await provide(httpOnlyOauthProvider)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect, open, test as acceptanceTest } from '../../support/fixtures'
|
||||
import { startFakeOidc, type FakeOidcProvider } from '../../support/fake-oidc'
|
||||
import { IDP_PORT, resetVariant, SECURITY_URL, startVariant, stopVariant, variantSql } from './support/variant'
|
||||
import { IDP_PORT, resetVariant, SECURITY_URL, SECURITY_UI_URL, startVariant, stopVariant, variantSql } from './support/variant'
|
||||
import { listCell, navigation, openUserMenu } from './support/ui'
|
||||
import { allowBlockedByPolicy, parsePolicy } from './support/csp'
|
||||
|
||||
@@ -21,18 +21,18 @@ const VARIANT = {
|
||||
}
|
||||
|
||||
const test = acceptanceTest.extend<{ idp: FakeOidcProvider }, { oauthProvider: FakeOidcProvider }>({
|
||||
oauthProvider: [async ({}, use) => {
|
||||
const provider = await startFakeOidc({ port: IDP_PORT, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, allowedRedirectPrefix: `${SECURITY_URL}/` })
|
||||
await use(provider)
|
||||
oauthProvider: [async ({}, provide) => {
|
||||
const provider = await startFakeOidc({ port: IDP_PORT, clientId: CLIENT_ID, clientSecret: CLIENT_SECRET, allowedRedirectPrefix: `${SECURITY_UI_URL}/` })
|
||||
await provide(provider)
|
||||
await stopVariant()
|
||||
await provider.close()
|
||||
}, { scope: 'worker' }],
|
||||
baseURL: async ({}, use) => { await use(SECURITY_URL) },
|
||||
idp: async ({ oauthProvider }, use) => {
|
||||
baseURL: async ({}, provide) => { await provide(SECURITY_UI_URL) },
|
||||
idp: async ({ oauthProvider }, provide) => {
|
||||
await startVariant('OAUTH2', VARIANT)
|
||||
await resetVariant()
|
||||
oauthProvider.reset()
|
||||
await use(oauthProvider)
|
||||
await provide(oauthProvider)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -53,7 +53,7 @@ test.describe('OAUTH2 with PKCE', () => {
|
||||
await expect(page).toHaveURL(new RegExp(`^${idp.issuer}/authorize\\?`))
|
||||
const [authorize] = authorizeRequests(idp)
|
||||
expect(authorize.query).toMatchObject({
|
||||
response_type: 'code', client_id: CLIENT_ID, redirect_uri: `${SECURITY_URL}/token`,
|
||||
response_type: 'code', client_id: CLIENT_ID, redirect_uri: `${SECURITY_UI_URL}/token`,
|
||||
code_challenge_method: 'S256', scope: 'openid profile email',
|
||||
})
|
||||
expect(authorize.query.code_challenge).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
@@ -68,7 +68,7 @@ test.describe('OAUTH2 with PKCE', () => {
|
||||
const token = idp.requests.filter((request) => request.path === '/oauth/token')
|
||||
expect(token).toHaveLength(1)
|
||||
expect(token[0].authorization).toBe(`Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`)
|
||||
expect(token[0].form).toMatchObject({ grant_type: 'authorization_code', redirect_uri: `${SECURITY_URL}/token` })
|
||||
expect(token[0].form).toMatchObject({ grant_type: 'authorization_code', redirect_uri: `${SECURITY_UI_URL}/token` })
|
||||
expect(token[0].form.code_verifier).toMatch(/^[A-Za-z0-9_-]{43}$/)
|
||||
expect(await variantSql('select user_id from user_session')).toEqual([{ user_id: 'oidc|dana' }])
|
||||
|
||||
@@ -142,7 +142,7 @@ test.describe('OAUTH2 with PKCE', () => {
|
||||
expect((await backendLogout).status()).toBe(200)
|
||||
await expect(page).toHaveURL(/\/login\/?$/)
|
||||
await expect(page.getByRole('heading', { name: 'You have signed out' })).toBeVisible()
|
||||
await expect.poll(() => idp.requests.some((request) => request.path === '/logout' && request.query.post_logout_redirect_uri === `${SECURITY_URL}/login`)).toBe(true)
|
||||
await expect.poll(() => idp.requests.some((request) => request.path === '/logout' && request.query.post_logout_redirect_uri === `${SECURITY_UI_URL}/login`)).toBe(true)
|
||||
expect(await sessions()).toBe(0)
|
||||
await expect.poll(() => idp.activeSessions()).toBe(0)
|
||||
|
||||
|
||||
@@ -66,9 +66,22 @@ test.describe('MOCK sessions', () => {
|
||||
await expect(page.getByRole('heading', { name: /Charlie/ }).first()).toBeVisible()
|
||||
await open(page, '/app/person/1')
|
||||
await expect(page.getByRole('heading', { name: /Avery/ }).first()).toBeVisible()
|
||||
await page.evaluate(() => {
|
||||
for (const table of ['person', 'pet']) {
|
||||
localStorage.setItem(`qqq.recordQueryView.${table}`, JSON.stringify({
|
||||
queryFilter: { criteria: [{ fieldName: 'name', operator: 'EQUALS', values: ['Alice private filter'] }] },
|
||||
viewIdentity: 'empty',
|
||||
}))
|
||||
localStorage.setItem(`qqq.currentSavedViewId.${table}`, '7')
|
||||
}
|
||||
})
|
||||
const menu = await openUserMenu(page)
|
||||
await menu.getByRole('menuitem', { name: 'Log Out' }).click()
|
||||
await expect(page.getByRole('heading', { name: 'You have signed out' })).toBeVisible()
|
||||
expect(await page.evaluate(() => ['person', 'pet'].flatMap((table) => [
|
||||
localStorage.getItem(`qqq.recordQueryView.${table}`),
|
||||
localStorage.getItem(`qqq.currentSavedViewId.${table}`),
|
||||
]))).toEqual([null, null, null, null])
|
||||
|
||||
// a different, less privileged identity signs in on the same browser
|
||||
// (mock sessions are keyed by the sessionId cookie the harness assigns)
|
||||
|
||||
@@ -8,23 +8,26 @@
|
||||
// Security-area backend variants (tests/acceptance/fixture/SecurityAcceptanceServer.java).
|
||||
// One variant runs at a time on the security port; specs that need a different
|
||||
// authentication module stop the running one first. The variant serves the same
|
||||
// static Next export as the shared acceptance backend (javalin mode).
|
||||
// static Next export in Javalin mode, or its own immutable Node build in standalone mode.
|
||||
import { spawn, spawnSync, type ChildProcess } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { test as base, type APIRequestContext } from '@playwright/test'
|
||||
import { ACCEPTANCE_BACKEND_PORT, ACCEPTANCE_FRONTEND_PORT } from '../../../support/ports'
|
||||
import type { Diagnostics, Persona, SampleUser } from '../../../support/fixtures'
|
||||
import { test as acceptanceTest } from '../../../support/fixtures'
|
||||
import { resolveFixtureClasspath } from '../../../../../scripts/acceptance-paths.mjs'
|
||||
import { ACCEPTANCE_MODE, SECURITY_BACKEND_PORT, SECURITY_BACKEND_URL, SECURITY_FRONTEND_PORT, SECURITY_IDP_PORT, SECURITY_STANDALONE, SECURITY_UI_URL, resolveFixtureClasspath } from '../../../../../scripts/acceptance-paths.mjs'
|
||||
|
||||
import { assertStandaloneTarget } from '../../../../../scripts/acceptance-standalone.mjs'
|
||||
|
||||
export { SECURITY_UI_URL }
|
||||
|
||||
export type AuthMode = 'MOCK' | 'OAUTH2' | 'AUTH_0' | 'FULLY_ANONYMOUS' | 'TABLE_BASED' | 'UNSUPPORTED'
|
||||
|
||||
export const SECURITY_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_BACKEND_PORT ?? ACCEPTANCE_BACKEND_PORT + 10)
|
||||
export const SECURITY_PORT = SECURITY_BACKEND_PORT
|
||||
export const SECURITY_ESB_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_ESB_PORT ?? Number(process.env.QQQ_ACCEPTANCE_ESB_PORT ?? 61616) + 1)
|
||||
export const IDP_PORT = Number(process.env.QQQ_ACCEPTANCE_SECURITY_IDP_PORT ?? ACCEPTANCE_FRONTEND_PORT + 10)
|
||||
export const SECURITY_URL = `http://127.0.0.1:${SECURITY_PORT}`
|
||||
export const IDP_PORT = SECURITY_IDP_PORT
|
||||
export const SECURITY_URL = SECURITY_BACKEND_URL
|
||||
export const IDP_URL = `http://127.0.0.1:${IDP_PORT}`
|
||||
|
||||
const EXPORT_CLASSPATH = path.resolve('test-results/acceptance/export-classpath')
|
||||
@@ -43,21 +46,24 @@ function compileFixtures(classpath: string) {
|
||||
mkdirSync(CLASSES, { recursive: true })
|
||||
const directory = path.resolve('tests/acceptance/fixture')
|
||||
const sources = readdirSync(directory).filter((name) => name.endsWith('.java')).map((name) => path.join(directory, name))
|
||||
const result = spawnSync('javac', ['-proc:none', '-encoding', 'UTF-8', '-cp', classpath, '-d', CLASSES, ...sources], { encoding: 'utf8' })
|
||||
const result = spawnSync('javac', ['-proc:none', '-sourcepath', '', '-encoding', 'UTF-8', '-cp', classpath, '-d', CLASSES, ...sources], { encoding: 'utf8' })
|
||||
if (result.status !== 0) throw new Error(`Security fixture compilation failed:\n${result.stderr}`)
|
||||
compiled = true
|
||||
}
|
||||
|
||||
interface Running { mode: AuthMode; key: string; process: ChildProcess; output: string[] }
|
||||
interface Running { mode: AuthMode; key: string; process: ChildProcess; frontend?: ChildProcess; output: string[] }
|
||||
let running: Running | null = null
|
||||
process.on('exit', () => { if (running?.process.exitCode === null) running.process.kill('SIGKILL') })
|
||||
process.on('exit', () => {
|
||||
if (running?.frontend?.exitCode === null) running.frontend.kill('SIGKILL')
|
||||
if (running?.process.exitCode === null) running.process.kill('SIGKILL')
|
||||
})
|
||||
|
||||
async function waitForReady(child: Running, timeoutMs = 120_000) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
while (Date.now() < deadline) {
|
||||
if (child.process.exitCode !== null) throw new Error(`Security variant exited (${child.process.exitCode}):\n${child.output.slice(-40).join('')}`)
|
||||
try {
|
||||
const response = await fetch(`${SECURITY_URL}/acceptance/ready`)
|
||||
const response = await fetch(`${SECURITY_URL}/acceptance/ready`, { signal: AbortSignal.timeout(5_000) })
|
||||
if (response.ok) return
|
||||
} catch {
|
||||
// not listening yet
|
||||
@@ -71,12 +77,66 @@ export async function stopVariant() {
|
||||
if (!running) return
|
||||
const current = running
|
||||
running = null
|
||||
if (current.process.exitCode === null) {
|
||||
const exited = new Promise((resolve) => current.process.once('exit', resolve))
|
||||
current.process.kill('SIGTERM')
|
||||
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 15_000))])
|
||||
if (current.process.exitCode === null) current.process.kill('SIGKILL')
|
||||
// Stop the browser-facing server first; its metadata cache belongs to this backend mode.
|
||||
let stopped = true
|
||||
for (const child of [current.frontend, current.process]) {
|
||||
if (!child || child.exitCode !== null || child.signalCode) continue
|
||||
let onExit: () => void
|
||||
const exited = new Promise<boolean>((resolve) => { onExit = () => resolve(true); child.once('exit', onExit) })
|
||||
const wait = async (milliseconds: number) => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const result = await Promise.race([exited, new Promise<boolean>((resolve) => { timer = setTimeout(() => resolve(false), milliseconds) })])
|
||||
clearTimeout(timer)
|
||||
return result
|
||||
}
|
||||
child.kill('SIGTERM')
|
||||
if (!await wait(15_000)) {
|
||||
child.kill('SIGKILL')
|
||||
if (!await wait(5_000)) stopped = false
|
||||
}
|
||||
child.removeListener('exit', onExit!)
|
||||
}
|
||||
if (!stopped) {
|
||||
current.key = '' // failed teardown must never be eligible for same-mode reuse
|
||||
running = current // retain ownership for final cleanup; no new variant can bypass this child
|
||||
throw new Error('An owned security server did not exit; refusing to start another variant on its port.')
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts the actual container entrypoint, with a separately baked variant rewrite target. */
|
||||
async function startStandalone(current: Running) {
|
||||
assertStandaloneTarget(SECURITY_STANDALONE, SECURITY_URL)
|
||||
// A retained export must not turn this into a Javalin qualification.
|
||||
const backendDocument = await fetch(`${SECURITY_URL}/login`, { signal: AbortSignal.timeout(5_000) })
|
||||
if (backendDocument.ok && /text\/html/i.test(backendDocument.headers.get('content-type') ?? '')) {
|
||||
throw new Error('Standalone security backend unexpectedly serves a dashboard document.')
|
||||
}
|
||||
const authentication = await fetch(`${SECURITY_URL}/qqq/v1/metaData/authentication`, { signal: AbortSignal.timeout(5_000) })
|
||||
if (!authentication.ok) throw new Error('Security authentication metadata request failed.')
|
||||
const metadata = await authentication.json()
|
||||
if (metadata.type !== (current.mode === 'UNSUPPORTED' ? 'ACCEPTANCE_UNKNOWN' : current.mode)) {
|
||||
throw new Error('Security backend authentication mode does not match the selected variant.')
|
||||
}
|
||||
current.frontend = spawn(process.execPath, ['qqq-server.mjs'], {
|
||||
cwd: SECURITY_STANDALONE,
|
||||
env: { ...process.env, NODE_ENV: 'production', HOSTNAME: '127.0.0.1', PORT: String(SECURITY_FRONTEND_PORT), QQQ_DASHBOARD_CSP_SOURCES: '' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
const keep = (chunk: Buffer) => { current.output.push(chunk.toString()); if (current.output.length > 400) current.output.shift() }
|
||||
current.frontend.stdout?.on('data', keep)
|
||||
current.frontend.stderr?.on('data', keep)
|
||||
const deadline = Date.now() + 120_000
|
||||
while (Date.now() < deadline) {
|
||||
if (current.frontend.exitCode !== null || current.frontend.signalCode) throw new Error(`Standalone security server exited (${current.frontend.exitCode})`)
|
||||
try {
|
||||
const response = await fetch(`${SECURITY_UI_URL}/login`, { signal: AbortSignal.timeout(Math.min(5_000, Math.max(1, deadline - Date.now()))) })
|
||||
if (response.ok && new URL(response.url).origin === SECURITY_UI_URL && /text\/html/i.test(response.headers.get('content-type') ?? '')) return
|
||||
} catch {
|
||||
// the Node server is still starting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||
}
|
||||
throw new Error('Standalone security server did not become ready on its own browser origin.')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,9 +145,10 @@ export async function stopVariant() {
|
||||
*/
|
||||
export async function startVariant(mode: AuthMode, options: { env?: Record<string, string>; properties?: Record<string, string> } = {}) {
|
||||
const key = JSON.stringify({ mode, ...options })
|
||||
if (running?.key === key && running.process.exitCode === null) return
|
||||
if (running?.key === key && running.process.exitCode === null && !running.process.signalCode
|
||||
&& (ACCEPTANCE_MODE === 'javalin' || (running.frontend?.exitCode === null && !running.frontend.signalCode))) return
|
||||
await stopVariant()
|
||||
if (!existsSync(path.join(EXPORT_CLASSPATH, 'next-dashboard', 'index.html'))) {
|
||||
if (ACCEPTANCE_MODE === 'javalin' && !existsSync(path.join(EXPORT_CLASSPATH, 'next-dashboard', 'index.html'))) {
|
||||
throw new Error('No Next export on the acceptance classpath; run node scripts/acceptance.mjs (javalin mode) first.')
|
||||
}
|
||||
const classpath = resolveFixtureClasspath(sampleJar())
|
||||
@@ -101,9 +162,9 @@ export async function startVariant(mode: AuthMode, options: { env?: Record<strin
|
||||
`-Dqqq.sample.esb.port=${SECURITY_ESB_PORT}`,
|
||||
`-Dqqq.security.auth=${mode}`,
|
||||
'-Duser.timezone=UTC',
|
||||
'-Dqqq.javalin.frontend=next',
|
||||
`-Dqqq.javalin.frontend=${ACCEPTANCE_MODE === 'standalone' ? 'none' : 'next'}`,
|
||||
...Object.entries(options.properties ?? {}).map(([name, value]) => `-D${name}=${value}`),
|
||||
'-cp', [EXPORT_CLASSPATH, CLASSES, classpath].join(path.delimiter),
|
||||
'-cp', [...(ACCEPTANCE_MODE === 'javalin' ? [EXPORT_CLASSPATH] : []), CLASSES, classpath].join(path.delimiter),
|
||||
'SecurityAcceptanceServer',
|
||||
]
|
||||
const child = spawn('java', args, { env: { ...process.env, ...options.env }, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
@@ -112,7 +173,13 @@ export async function startVariant(mode: AuthMode, options: { env?: Record<strin
|
||||
child.stdout?.on('data', keep)
|
||||
child.stderr?.on('data', keep)
|
||||
running = { mode, key, process: child, output }
|
||||
await waitForReady(running)
|
||||
try {
|
||||
await waitForReady(running)
|
||||
if (ACCEPTANCE_MODE === 'standalone') await startStandalone(running)
|
||||
} catch (error) {
|
||||
await stopVariant()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function control(pathName: string, body: unknown) {
|
||||
@@ -146,7 +213,7 @@ export const test = acceptanceTest.extend<{ security: SecurityBackend }, { secur
|
||||
await provide()
|
||||
await stopVariant()
|
||||
}, { scope: 'worker' }],
|
||||
baseURL: async ({ securityVariant }, provide) => { void securityVariant; await provide(SECURITY_URL) },
|
||||
baseURL: async ({ securityVariant }, provide) => { void securityVariant; await provide(SECURITY_UI_URL) },
|
||||
security: async ({ context, persona, user, playwright, securityVariant }, provide) => {
|
||||
void securityVariant
|
||||
await startVariant('MOCK')
|
||||
|
||||
@@ -11,18 +11,18 @@
|
||||
// a session row (SecurityFixtures.primeTableBased seeds the users).
|
||||
import type { Page } from '@playwright/test'
|
||||
import { expect, open, test as acceptanceTest } from '../../support/fixtures'
|
||||
import { expireTableSessions, resetVariant, SECURITY_URL, startVariant, variantSql } from './support/variant'
|
||||
import { expireTableSessions, resetVariant, SECURITY_URL, SECURITY_UI_URL, startVariant, variantSql } from './support/variant'
|
||||
import { listCell, navigation, openUserMenu, recordRequests } from './support/ui'
|
||||
|
||||
const TESS = { username: 'tess.table', password: 'table:pass-2026', name: 'Tess Table (table-based)' }
|
||||
const RAVI = { username: 'ravi.rows', password: 'rows-pass-2026', name: 'Ravi Rows (table-based)' }
|
||||
|
||||
const test = acceptanceTest.extend<{ tableBased: void }>({
|
||||
baseURL: async ({}, use) => { await use(SECURITY_URL) },
|
||||
tableBased: async ({}, use) => {
|
||||
baseURL: async ({}, provide) => { await provide(SECURITY_UI_URL) },
|
||||
tableBased: async ({}, provide) => {
|
||||
await startVariant('TABLE_BASED')
|
||||
await resetVariant()
|
||||
await use()
|
||||
await provide()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -202,10 +202,10 @@ test('[SEC-037] logout deletes the stored session, keeps protected pages on the
|
||||
})
|
||||
|
||||
const unsupportedTest = acceptanceTest.extend<{ unsupported: void }>({
|
||||
baseURL: async ({}, use) => { await use(SECURITY_URL) },
|
||||
unsupported: async ({}, use) => {
|
||||
baseURL: async ({}, provide) => { await provide(SECURITY_UI_URL) },
|
||||
unsupported: async ({}, provide) => {
|
||||
await startVariant('UNSUPPORTED')
|
||||
await use()
|
||||
await provide()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -48,3 +48,66 @@ for (const height of [320, 720]) {
|
||||
await expect(cells).toHaveText(sorted.map(row => row.first_name!))
|
||||
})
|
||||
}
|
||||
|
||||
// Focus can remain on a column header after the user scrolls its container away.
|
||||
// Opening with the keyboard must bring the reference back before positioning the menu.
|
||||
test('[WID-073] a focused report column scrolled out of view still opens a reachable menu', async ({ page, backend, diagnostics }, testInfo) => {
|
||||
void diagnostics
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
expect((await backend.api.put('/data/savedReport/1', { multipart: {
|
||||
tableName: 'person', queryFilterJson: JSON.stringify({ orderBys: [{ fieldName: 'id', isAscending: true }] }),
|
||||
columnsJson: JSON.stringify({ columns: ['id', 'firstName'].map(name => ({ name, isVisible: true })) }),
|
||||
} })).status()).toBe(200)
|
||||
const people = await backend.sql('select first_name from person order by id')
|
||||
await open(page, '/app/savedReport/1')
|
||||
const preview = byId(page, 'filter-preview-reportSetupWidget')
|
||||
await expect(preview.getByRole('gridcell').and(preview.locator('[data-qqq-id="grid-cell-firstName"]'))).toHaveText(people.map(row => row.first_name!))
|
||||
await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {
|
||||
writeText: async (text: string) => { document.documentElement.dataset.copiedReportValues = text },
|
||||
} }))
|
||||
const trigger = preview.getByRole('button', { name: 'First Name column menu', exact: true })
|
||||
const main = byId(page, 'main-content')
|
||||
const menu = page.getByRole('menu', { name: 'First Name column menu', exact: true })
|
||||
const navigation = await page.evaluate(() => ({ url: location.href, length: history.length }))
|
||||
await trigger.scrollIntoViewIfNeeded()
|
||||
await trigger.focus()
|
||||
await expect(trigger).toBeInViewport()
|
||||
const visibleScroll = await main.evaluate(element => element.scrollTop)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(menu).toBeVisible()
|
||||
expect(await main.evaluate(element => element.scrollTop)).toBe(visibleScroll)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
|
||||
// A wheel gesture changes scroll without refocusing the offscreen trigger.
|
||||
const mainBounds = await main.boundingBox()
|
||||
expect(mainBounds).not.toBeNull()
|
||||
await page.mouse.move(mainBounds!.x + 20, mainBounds!.y + 20)
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(() => main.evaluate(element => element.scrollTop)).toBe(0)
|
||||
await expect(trigger).toBeFocused()
|
||||
expect((await trigger.boundingBox())!.y).toBeGreaterThan(720)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(menu).toBeVisible()
|
||||
try {
|
||||
await expect.poll(async () => {
|
||||
const bounds = await menu.boundingBox()
|
||||
return Boolean(bounds && bounds.y >= 8 && bounds.y + bounds.height <= 712)
|
||||
}).toBe(true)
|
||||
} finally {
|
||||
await testInfo.attach('scrolled-column-menu-bounds', { body: JSON.stringify({
|
||||
trigger: await trigger.boundingBox(), menu: await menu.boundingBox(),
|
||||
scrollTop: await main.evaluate(element => element.scrollTop),
|
||||
}), contentType: 'application/json' })
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(menu).toBeVisible()
|
||||
await menu.getByRole('menuitem', { name: 'Copy full query values', exact: true }).click()
|
||||
await expect(page.locator('html')).toHaveAttribute('data-copied-report-values', people.map(row => row.first_name!).join('\n') + '\n')
|
||||
await expect(menu).toBeHidden()
|
||||
expect(await page.evaluate(() => ({ url: location.href, length: history.length }))).toEqual(navigation)
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { expect, open, test } from '../../support/fixtures'
|
||||
import { byId } from './widget-support'
|
||||
|
||||
for (const { copy, offscreen } of [{ copy: 'page', offscreen: false }, { copy: 'full query', offscreen: false }, { copy: 'full query', offscreen: true }]) {
|
||||
test(`[WID-073] report column ${copy} copy${offscreen ? ' from an offscreen trigger' : ''} preserves keyboard focus after its notice`, async ({ page, backend, diagnostics }) => {
|
||||
void diagnostics
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
expect((await backend.api.put('/data/savedReport/1', { multipart: {
|
||||
tableName: 'person', queryFilterJson: JSON.stringify({ orderBys: [{ fieldName: 'id', isAscending: true }] }),
|
||||
columnsJson: JSON.stringify({ columns: ['id', 'firstName'].map(name => ({ name, isVisible: true })) }),
|
||||
} })).status()).toBe(200)
|
||||
const people = await backend.sql('select first_name from person order by id')
|
||||
await open(page, '/app/savedReport/1')
|
||||
const preview = byId(page, 'filter-preview-reportSetupWidget')
|
||||
const cells = preview.getByRole('gridcell').and(preview.locator('[data-qqq-id="grid-cell-firstName"]'))
|
||||
await expect(cells).toHaveText(people.map(row => row.first_name!))
|
||||
await page.evaluate(() => Object.defineProperty(navigator, 'clipboard', { configurable: true, value: {
|
||||
writeText: async (text: string) => { document.documentElement.dataset.copiedReportValues = text },
|
||||
} }))
|
||||
const navigation = await page.evaluate(() => ({ url: location.href, length: history.length }))
|
||||
const trigger = preview.getByRole('button', { name: 'First Name column menu', exact: true })
|
||||
await trigger.scrollIntoViewIfNeeded()
|
||||
await trigger.focus()
|
||||
const triggerId = await trigger.getAttribute('id')
|
||||
if (offscreen) {
|
||||
const main = page.getByRole('main')
|
||||
const bounds = await main.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
await page.mouse.move(bounds!.x + 20, bounds!.y + 20)
|
||||
await page.mouse.wheel(0, -2000)
|
||||
await expect.poll(() => main.evaluate(element => element.scrollTop)).toBe(0)
|
||||
await expect(trigger).toBeFocused()
|
||||
expect((await trigger.boundingBox())!.y).toBeGreaterThan(720)
|
||||
}
|
||||
await page.keyboard.press('Enter')
|
||||
const menu = page.getByRole('menu', { name: 'First Name column menu', exact: true })
|
||||
await expect(menu).toBeVisible()
|
||||
await page.keyboard.press('End')
|
||||
await expect(menu.getByRole('menuitem', { name: 'Copy full query values', exact: true })).toBeFocused()
|
||||
if (copy === 'page') await page.keyboard.press('ArrowUp')
|
||||
await expect(menu.getByRole('menuitem', { name: `Copy ${copy} values`, exact: true })).toBeFocused()
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(page.locator('html')).toHaveAttribute('data-copied-report-values', people.map(row => row.first_name!).join('\n') + '\n')
|
||||
await expect(preview.getByText(`Copied ${people.length} First Name values.`, { exact: true })).toBeVisible()
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await expect(trigger).toHaveAttribute('id', triggerId!)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(menu).toBeVisible()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await expect(cells).toHaveText(people.map(row => row.first_name!))
|
||||
expect(await page.evaluate(() => ({ url: location.href, length: history.length }))).toEqual(navigation)
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
// The stock sample's widget dashboard and greetings app: every sample widget renders its real payload.
|
||||
import { readChartLabelBoxes, type ChartLabelBox } from '../../support/chart-label-geometry'
|
||||
import { expect, open, test } from '../../support/fixtures'
|
||||
import { expectNoHorizontalScroll, expectTouchReady } from '../../support/touch'
|
||||
import { chartTable, expectLoaded, isLargeLayout, widget, widgetPayload } from './widget-support'
|
||||
@@ -217,7 +218,12 @@ test.describe('sample widgets dashboard', () => {
|
||||
const labels = card.locator('.recharts-xAxis .recharts-cartesian-axis-tick-value')
|
||||
await expect(labels).toHaveText(payload.chartData.labels)
|
||||
for (const label of await labels.all()) await expect(label).not.toHaveAttribute('transform', /rotate/)
|
||||
const boxes = await labels.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().toJSON()))
|
||||
let boxes: ChartLabelBox[] = []
|
||||
await expect.poll(async () => {
|
||||
const sample = await labels.evaluateAll(readChartLabelBoxes, payload.chartData.labels)
|
||||
boxes = sample ?? []
|
||||
return sample !== null
|
||||
}).toBe(true)
|
||||
for (let i = 1; i < boxes.length; i++) expect(boxes[i].left).toBeGreaterThan(boxes[i - 1].right)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { once } from 'node:events'
|
||||
import { connect, type Socket } from 'node:net'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
interface Service {
|
||||
origin: string
|
||||
deniedOrigin: string
|
||||
close: () => Promise<void>
|
||||
}
|
||||
type Fixture = (args: Record<string, never>, provide: (service: Service) => Promise<void>) => Promise<void>
|
||||
const state = vi.hoisted(() => ({ fixture: undefined as Fixture | undefined, stopError: undefined as Error | undefined }))
|
||||
|
||||
// Run the actual fixture; replace only test registration and the Java process boundary.
|
||||
vi.mock('@playwright/test', () => ({ expect }))
|
||||
vi.mock('./fixtures', () => ({
|
||||
expect,
|
||||
open: () => {},
|
||||
test: { extend: (fixtures: { analytics: Fixture }) => {
|
||||
state.fixture = fixtures.analytics
|
||||
return () => {}
|
||||
} },
|
||||
}))
|
||||
vi.mock('../specs/security/support/variant', () => ({
|
||||
SECURITY_UI_URL: 'http://127.0.0.1:18775',
|
||||
startVariant: async () => {},
|
||||
resetVariant: async () => {},
|
||||
stopVariant: async () => { if (state.stopError) throw state.stopError },
|
||||
}))
|
||||
|
||||
const sockets: Socket[] = []
|
||||
const services: Service[] = []
|
||||
const running: Promise<void>[] = []
|
||||
|
||||
async function socketAt(origin: string) {
|
||||
const socket = connect({ host: '127.0.0.1', port: Number(new URL(origin).port) })
|
||||
sockets.push(socket)
|
||||
await once(socket, 'connect')
|
||||
return socket
|
||||
}
|
||||
|
||||
async function refused(origin: string) {
|
||||
const socket = connect({ host: '127.0.0.1', port: Number(new URL(origin).port) })
|
||||
sockets.push(socket)
|
||||
return new Promise<string>((resolve) => {
|
||||
socket.once('connect', () => { socket.destroy(); resolve('connected') })
|
||||
socket.once('error', (error: NodeJS.ErrnoException) => resolve(error.code ?? 'unknown'))
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const socket of sockets) socket.destroy()
|
||||
await Promise.allSettled(running)
|
||||
await Promise.allSettled(services.map((service) => service.close()))
|
||||
sockets.length = 0; services.length = 0; running.length = 0
|
||||
state.stopError = undefined
|
||||
})
|
||||
|
||||
it.each(['preconnected', 'incomplete request'])('closes both owned listeners and %s clients during fixture teardown', async (kind) => {
|
||||
await import('../specs/security/analytics-csp.spec')
|
||||
let service!: Service
|
||||
const clients: Socket[] = []
|
||||
const closed: Promise<unknown>[] = []
|
||||
const fixture = state.fixture!({}, async (value) => {
|
||||
service = value; services.push(value)
|
||||
for (const origin of [value.origin, value.deniedOrigin]) {
|
||||
const socket = await socketAt(origin)
|
||||
clients.push(socket)
|
||||
// A partial header keeps the HTTP parser active without invoking a fixture route.
|
||||
if (kind === 'incomplete request') socket.write('POST /collect HTTP/1.1\r\nHost: 127.0.0.1\r\n')
|
||||
socket.on('error', () => {})
|
||||
closed.push(new Promise<void>((resolve) => socket.once('close', () => resolve())))
|
||||
}
|
||||
})
|
||||
running.push(fixture)
|
||||
await fixture
|
||||
await Promise.all(closed)
|
||||
expect(clients.every((socket) => socket.destroyed)).toBe(true)
|
||||
expect(await refused(service.origin)).toBe('ECONNREFUSED')
|
||||
expect(await refused(service.deniedOrigin)).toBe('ECONNREFUSED')
|
||||
})
|
||||
|
||||
it('still closes both owned listeners when stopping the backend rejects, preserving that error', async () => {
|
||||
await import('../specs/security/analytics-csp.spec')
|
||||
const failure = new Error('owned backend stop failed')
|
||||
state.stopError = failure
|
||||
let service!: Service
|
||||
const fixture = state.fixture!({}, async (value) => { service = value; services.push(value) })
|
||||
running.push(fixture)
|
||||
await expect(fixture).rejects.toBe(failure)
|
||||
expect(await refused(service.origin)).toBe('ECONNREFUSED')
|
||||
expect(await refused(service.deniedOrigin)).toBe('ECONNREFUSED')
|
||||
})
|
||||
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { browserBoundaryInit, createBoundaryBuffer, installBrowserBoundaryCapture, shouldCaptureBrowserBoundary } from './browser-boundary-capture'
|
||||
|
||||
type EventRecord = Record<string, unknown>
|
||||
|
||||
/** Native delegate substitute: exposes argument/identity contracts without HTTP or jsdom XHR behavior. */
|
||||
class NativeXHR extends EventTarget {
|
||||
readyState = 0
|
||||
status = 0
|
||||
calls: { operation: string; receiver: unknown; args: unknown[] }[] = []
|
||||
openResult = { native: 'open' }
|
||||
sendResult = { native: 'send' }
|
||||
openFailure: unknown
|
||||
sendFailure: unknown
|
||||
open(...args: unknown[]) {
|
||||
this.calls.push({ operation: 'open', receiver: this, args })
|
||||
if (this.openFailure) throw this.openFailure
|
||||
// Reopening aborts the previous request before the new OPENED state.
|
||||
if (this.readyState === 2) this.dispatchEvent(new Event('abort'))
|
||||
const wasOpened = this.readyState === 1
|
||||
this.readyState = 1
|
||||
if (!wasOpened) this.dispatchEvent(new Event('readystatechange'))
|
||||
return this.openResult
|
||||
}
|
||||
send(...args: unknown[]) {
|
||||
this.calls.push({ operation: 'send', receiver: this, args })
|
||||
if (this.sendFailure) throw this.sendFailure
|
||||
return this.sendResult
|
||||
}
|
||||
state(readyState: number, status: number) {
|
||||
this.readyState = readyState
|
||||
this.status = status
|
||||
this.dispatchEvent(new Event('readystatechange'))
|
||||
}
|
||||
}
|
||||
|
||||
const originalOpen = Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'open')!
|
||||
const originalSend = Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'send')!
|
||||
const config = { origins: ['http://localhost:3000'], maxEvents: 256 }
|
||||
let events: EventRecord[]
|
||||
let cleanup: (() => void) | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
history.replaceState({}, '', '/app/person')
|
||||
events = []
|
||||
vi.stubGlobal('XMLHttpRequest', NativeXHR)
|
||||
vi.stubGlobal('__qqqBrowserBoundary', (event: EventRecord) => { events.push(event); return Promise.resolve() })
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup?.()
|
||||
cleanup = undefined
|
||||
Object.defineProperty(NativeXHR.prototype, 'open', originalOpen)
|
||||
Object.defineProperty(NativeXHR.prototype, 'send', originalSend)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
function install() { cleanup = browserBoundaryInit(config) }
|
||||
function kinds() { return events.map(event => event.event) }
|
||||
function event(overrides: EventRecord = {}) {
|
||||
return { version: 1, documentId: '12345678-1234-4234-8234-123456789abc', seq: 1,
|
||||
timeOrigin: 1000, now: 2, page: 'person', event: 'ready', phase: 'active', ...overrides }
|
||||
}
|
||||
|
||||
describe('document-local boundary capture', () => {
|
||||
it('captures the production session POST and excludes GET and other session paths', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
const body = { accessToken: 'BODY_SECRET' }
|
||||
expect(xhr.open('POST', '/qqq/v1/manageSession?token=QUERY_SECRET#HASH_SECRET')).toBe(xhr.openResult)
|
||||
expect(xhr.send(body)).toBe(xhr.sendResult)
|
||||
xhr.state(2, 200)
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('load'))
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
expect(kinds()).toEqual(['ready', 'open-return', 'send-enter', 'send-return', 'headers', 'done', 'load', 'loadend'])
|
||||
expect(events.slice(1).every(row => row.endpoint === 'session' && row.method === 'POST')).toBe(true)
|
||||
expect(xhr.calls[1]).toEqual({ operation: 'send', receiver: xhr, args: [body] })
|
||||
const before = events.length
|
||||
for (const [method, url] of [
|
||||
['GET', '/qqq/v1/manageSession'], ['POST', '/qqq/v1/manageSession/'],
|
||||
['POST', '/qqq/v1/manageSessionExtra'], ['POST', 'https://untrusted.invalid/qqq/v1/manageSession'],
|
||||
]) {
|
||||
const excluded = new NativeXHR()
|
||||
excluded.open(method, url)
|
||||
excluded.send(body)
|
||||
excluded.state(4, 200)
|
||||
excluded.dispatchEvent(new Event('loadend'))
|
||||
expect(excluded.calls).toHaveLength(2)
|
||||
}
|
||||
expect(events).toHaveLength(before)
|
||||
expect(JSON.stringify(events)).not.toMatch(/SECRET|accessToken|manageSession|untrusted/)
|
||||
})
|
||||
|
||||
it('observes native arguments/results without reading credentials or payloads', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
const body = { password: 'BODY_SECRET' }
|
||||
expect(xhr.open('POST', '/qqq/v1/table/person/query?token=QUERY_SECRET#HASH_SECRET', true, 'USER_SECRET', 'PASSWORD_SECRET')).toBe(xhr.openResult)
|
||||
expect(xhr.send(body)).toBe(xhr.sendResult)
|
||||
expect(xhr.calls).toEqual([
|
||||
{ operation: 'open', receiver: xhr, args: ['POST', '/qqq/v1/table/person/query?token=QUERY_SECRET#HASH_SECRET', true, 'USER_SECRET', 'PASSWORD_SECRET'] },
|
||||
{ operation: 'send', receiver: xhr, args: [body] },
|
||||
])
|
||||
expect(kinds()).toEqual(['ready', 'open-return', 'send-enter', 'send-return'])
|
||||
expect(events.at(-1)).toMatchObject({ endpoint: 'person-query', method: 'POST', xhr: 1, generation: 1, attempt: 1 })
|
||||
expect(JSON.stringify(events)).not.toMatch(/SECRET|password|token=|\/qqq|\/app/)
|
||||
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'open')).toMatchObject({ configurable: originalOpen.configurable, writable: originalOpen.writable, enumerable: originalOpen.enumerable })
|
||||
})
|
||||
|
||||
it('rethrows the identical native exception and never reads its message or name', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
const failure = new Error('MESSAGE_SECRET')
|
||||
Object.defineProperty(failure, 'name', { get() { throw new Error('must not read name') } })
|
||||
xhr.sendFailure = failure
|
||||
let caught: unknown
|
||||
try { xhr.send() } catch (error) { caught = error }
|
||||
expect(caught).toBe(failure)
|
||||
expect(kinds().slice(-2)).toEqual(['send-enter', 'send-throw'])
|
||||
expect(events.at(-1)?.errorClass).toBe('Error')
|
||||
expect(JSON.stringify(events)).not.toContain('SECRET')
|
||||
expect(xhr.calls.filter(call => call.operation === 'send')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('preserves failed open and unknown URL objects without coercion or stale request identity', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
const url = { toString: vi.fn(() => 'OBJECT_SECRET') }
|
||||
xhr.open('GET', url)
|
||||
xhr.send('BODY_SECRET')
|
||||
expect(url.toString).not.toHaveBeenCalled()
|
||||
expect(kinds().filter(kind => kind === 'send-enter')).toEqual([])
|
||||
const failure = new DOMException('PRIVATE', 'SyntaxError')
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
xhr.openFailure = failure
|
||||
expect(() => xhr.open('POST', '/qqq/v1/manageSession')).toThrow(failure)
|
||||
expect(events.at(-1)).toMatchObject({ event: 'open-throw', errorClass: 'DOMException' })
|
||||
expect(JSON.stringify(events)).not.toMatch(/SECRET|PRIVATE/)
|
||||
})
|
||||
|
||||
it('uses one listener set and distinguishes reuse after the prior loadend', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
xhr.send()
|
||||
xhr.state(2, 200)
|
||||
xhr.state(2, 200)
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
xhr.open('POST', '/qqq/v1/table/person/count')
|
||||
xhr.send()
|
||||
xhr.state(2, 500)
|
||||
xhr.state(4, 500)
|
||||
xhr.state(4, 500)
|
||||
xhr.dispatchEvent(new Event('load'))
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
expect(events.filter(row => row.event === 'headers').map(row => [row.generation, row.status])).toEqual([[1, 200], [2, 500]])
|
||||
expect(events.filter(row => row.event === 'done').map(row => row.generation)).toEqual([1, 2])
|
||||
expect(events.filter(row => row.event === 'loadend').map(row => [row.xhr, row.generation])).toEqual([[1, 1], [1, 2]])
|
||||
})
|
||||
|
||||
it.each(['first-send', 'nested-open', 'reopen-send'])('marks %s during native OPENED as incomplete instead of inventing attribution', scenario => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
if (scenario === 'reopen-send') {
|
||||
xhr.open('POST', '/qqq/v1/table/person/query')
|
||||
xhr.send()
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
}
|
||||
let entered = false
|
||||
let sendResult: unknown
|
||||
const body = { secret: 'BODY_SECRET' }
|
||||
xhr.addEventListener('readystatechange', () => {
|
||||
if (xhr.readyState !== 1 || entered) return
|
||||
entered = true
|
||||
if (scenario === 'nested-open') xhr.open('POST', '/qqq/v1/table/person/count')
|
||||
sendResult = xhr.send(body)
|
||||
})
|
||||
const start = events.length
|
||||
expect(xhr.open('POST', '/qqq/v1/manageSession')).toBe(xhr.openResult)
|
||||
expect(sendResult).toBe(xhr.sendResult)
|
||||
expect(xhr.calls.at(-1)).toEqual({ operation: 'send', receiver: xhr, args: [body] })
|
||||
const sendCount = scenario === 'reopen-send' ? 2 : 1
|
||||
expect(xhr.calls.filter(call => call.operation === 'send')).toHaveLength(sendCount)
|
||||
xhr.state(2, 200)
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('load'))
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
const after = events.slice(start)
|
||||
expect(after).toHaveLength(1)
|
||||
expect(after[0]).toMatchObject({ event: 'xhr-incomplete', xhr: 1,
|
||||
reason: scenario === 'nested-open' ? 'reentrant-open' : 'send-during-open' })
|
||||
expect(after[0]).not.toHaveProperty('endpoint')
|
||||
expect(after[0]).not.toHaveProperty('generation')
|
||||
expect(after[0]).not.toHaveProperty('attempt')
|
||||
// Once ambiguous, reusing that instance cannot silently regain trustworthy attribution.
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
xhr.send()
|
||||
expect(events.slice(start)).toEqual(after)
|
||||
const next = new NativeXHR()
|
||||
next.open('POST', '/qqq/v1/table/person/count')
|
||||
next.send()
|
||||
expect(events.at(-1)).toMatchObject({ event: 'send-return', xhr: 2, endpoint: 'person-count', attempt: 1 })
|
||||
})
|
||||
|
||||
it.each(['/qqq/v1/manageSession', '/excluded'])('marks reuse from an earlier %s load listener incomplete before old events are attributed', initialURL => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
let reused = false
|
||||
xhr.addEventListener('load', () => {
|
||||
if (reused) return
|
||||
reused = true
|
||||
xhr.open('POST', '/qqq/v1/table/person/count')
|
||||
xhr.send()
|
||||
})
|
||||
xhr.open('POST', initialURL)
|
||||
xhr.send()
|
||||
xhr.state(4, 200)
|
||||
const start = events.length
|
||||
xhr.dispatchEvent(new Event('load'))
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('load'))
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
expect(xhr.calls.filter(call => call.operation === 'send')).toHaveLength(2)
|
||||
expect(events.slice(start)).toEqual([expect.objectContaining({ event: 'xhr-incomplete', reason: 'reuse-before-loadend', xhr: 1 })])
|
||||
expect(events.at(-1)).not.toHaveProperty('endpoint')
|
||||
})
|
||||
|
||||
it('keeps terminal events on the active send when a repeated send throws', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
xhr.send()
|
||||
xhr.sendFailure = new DOMException('PRIVATE', 'InvalidStateError')
|
||||
expect(() => xhr.send()).toThrow(DOMException)
|
||||
xhr.state(4, 0)
|
||||
xhr.dispatchEvent(new Event('abort'))
|
||||
expect(events.find(row => row.event === 'send-throw')).toMatchObject({ attempt: 2 })
|
||||
expect(events.find(row => row.event === 'abort')).toMatchObject({ attempt: 1 })
|
||||
})
|
||||
|
||||
it.each(['error', 'abort', 'timeout'])('retains %s separately from loadend and status zero', terminal => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('GET', '/qqq/v1/metaData/table/person')
|
||||
xhr.send()
|
||||
xhr.state(4, 0)
|
||||
xhr.dispatchEvent(new Event(terminal))
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
expect(events.slice(-2).map(row => [row.event, row.status])).toEqual([[terminal, 0], ['loadend', 0]])
|
||||
})
|
||||
|
||||
it('keeps observer failures out of native delegation and absorbs binding rejection', async () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
vi.stubGlobal('__qqqBrowserBoundary', () => { throw new Error('OBSERVER_SECRET') })
|
||||
expect(xhr.open('POST', '/qqq/v1/manageSession')).toBe(xhr.openResult)
|
||||
vi.stubGlobal('__qqqBrowserBoundary', () => Promise.reject(new Error('OBSERVER_SECRET')))
|
||||
expect(xhr.send()).toBe(xhr.sendResult)
|
||||
await Promise.resolve()
|
||||
expect(xhr.calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('does not install outside the exact owned page and origin allowlist', () => {
|
||||
history.replaceState({}, '', '/app/person/private-token')
|
||||
install()
|
||||
expect(events).toEqual([])
|
||||
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'send')).toEqual(originalSend)
|
||||
})
|
||||
|
||||
it('excludes unselected endpoints and cross-origin requests', () => {
|
||||
install()
|
||||
for (const url of ['/qqq/v1/table/person/PRIVATE', 'https://unrelated.invalid/qqq/v1/manageSession']) {
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('POST', url)
|
||||
xhr.send('SECRET')
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
}
|
||||
expect(kinds()).toEqual(['ready'])
|
||||
})
|
||||
|
||||
it('records document identity/lifecycle without installing unload listeners or changing same-document identity', () => {
|
||||
const listen = vi.spyOn(window, 'addEventListener')
|
||||
install()
|
||||
window.dispatchEvent(new PageTransitionEvent('pagehide', { persisted: true }))
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: true }))
|
||||
expect(kinds()).toEqual(['ready', 'pagehide', 'pageshow'])
|
||||
expect(new Set(events.map(row => row.documentId)).size).toBe(1)
|
||||
expect(events.map(row => row.seq)).toEqual([1, 2, 3])
|
||||
expect(events.slice(1).map(row => row.persisted)).toEqual([true, true])
|
||||
expect(listen.mock.calls.map(call => call[0])).not.toContain('beforeunload')
|
||||
expect(listen.mock.calls.map(call => call[0])).not.toContain('unload')
|
||||
listen.mockRestore()
|
||||
})
|
||||
|
||||
it('captures the exact process aliases only when explicitly selected', () => {
|
||||
history.replaceState({}, '', '/app/person/2')
|
||||
install()
|
||||
expect(events).toEqual([])
|
||||
cleanup = browserBoundaryInit({ ...config, includeProcessPaths: true })
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('GET', '/qqq/v1/table/person/2')
|
||||
xhr.send()
|
||||
expect(events.at(-1)).toMatchObject({ page: 'person-record', endpoint: 'person-record', method: 'GET' })
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
xhr.open('GET', '/qqq/v1/metaData/process/person.bulkEdit')
|
||||
xhr.send()
|
||||
expect(events.at(-1)).toMatchObject({ endpoint: 'process-metadata' })
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
xhr.open('POST', '/qqq/v1/processes/person.bulkEdit/init')
|
||||
xhr.send()
|
||||
expect(events.at(-1)).toMatchObject({ endpoint: 'process-init' })
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
const before = events.length
|
||||
xhr.open('GET', '/qqq/v1/processes/person.bulkEdit/init')
|
||||
xhr.send()
|
||||
expect(events).toHaveLength(before)
|
||||
})
|
||||
|
||||
it.each(['/application', '/app/other', '/app/person/1', '/app//', '/app/%2f'])('does not widen dashboard capture to %s', path => {
|
||||
history.replaceState({}, '', path)
|
||||
cleanup = browserBoundaryInit({ ...config, includeDashboardPaths: true })
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
xhr.send()
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow'))
|
||||
expect(events).toEqual([])
|
||||
expect(cleanup).toBeUndefined()
|
||||
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'open')).toEqual(originalOpen)
|
||||
expect(xhr.calls).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('preserves an invalid native receiver and restores native descriptors on cleanup', () => {
|
||||
install()
|
||||
expect(() => NativeXHR.prototype.send.call(null as unknown as NativeXHR)).toThrow(TypeError)
|
||||
cleanup?.()
|
||||
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'open')).toEqual(originalOpen)
|
||||
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'send')).toEqual(originalSend)
|
||||
})
|
||||
|
||||
it('bounds document output with an explicit overflow event', () => {
|
||||
cleanup = browserBoundaryInit({ ...config, maxEvents: 3 })
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
xhr.send()
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
expect(events).toHaveLength(4)
|
||||
expect(events.at(-1)).toMatchObject({ event: 'overflow' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node capture boundary', () => {
|
||||
it.each(['/app', '/app/'])('installs dashboard session capture only when opted in at %s', async path => {
|
||||
history.replaceState({}, '', path)
|
||||
install()
|
||||
expect(events).toEqual([])
|
||||
expect(Object.getOwnPropertyDescriptor(NativeXHR.prototype, 'open')).toEqual(originalOpen)
|
||||
const context = { exposeBinding: vi.fn(), addInitScript: vi.fn() }
|
||||
const capture = await installBrowserBoundaryCapture(context, {
|
||||
enabled: true, origins: config.origins, includeDashboardPaths: true,
|
||||
})
|
||||
const binding = context.exposeBinding.mock.calls[0][1]
|
||||
const source = { page: {}, frame: {} }
|
||||
vi.stubGlobal('__qqqBrowserBoundary', (value: EventRecord) => binding(source, value))
|
||||
const [init, options] = context.addInitScript.mock.calls[0]
|
||||
cleanup = init(options)
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('POST', '/qqq/v1/manageSession?private=QUERY_SECRET')
|
||||
xhr.send({ accessToken: 'BODY_SECRET' })
|
||||
xhr.state(2, 200)
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('load'))
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
window.dispatchEvent(new PageTransitionEvent('pageshow', { persisted: false }))
|
||||
const snapshot = capture!.snapshot()
|
||||
expect(snapshot).toMatchObject({ invalid: 0, dropped: 0 })
|
||||
expect(snapshot.events.map(row => row.event)).toEqual([
|
||||
'ready', 'open-return', 'send-enter', 'send-return', 'headers', 'done', 'load', 'loadend', 'pageshow',
|
||||
])
|
||||
expect(snapshot.events.every(row => row.page === 'dashboard' && row.pageId === 'page-1' && row.frameId === 'frame-1')).toBe(true)
|
||||
expect(snapshot.events[2]).toMatchObject({ endpoint: 'session', method: 'POST' })
|
||||
snapshot.events[0].page = 'CHANGED'
|
||||
expect(capture!.snapshot().events[0].page).toBe('dashboard')
|
||||
expect(JSON.stringify(capture!.snapshot())).not.toMatch(/SECRET|accessToken|private=|manageSession/)
|
||||
})
|
||||
|
||||
it('rejects malformed/private payloads and bounds records with explicit counters', () => {
|
||||
const buffer = createBoundaryBuffer(2)
|
||||
buffer.add(event(), 'page-1', 'frame-1')
|
||||
buffer.add(event({ seq: 2, message: 'SECRET' }), 'page-1', 'frame-1')
|
||||
buffer.add(event({ seq: 2, endpoint: '/PRIVATE' }), 'page-1', 'frame-1')
|
||||
buffer.add(event({ seq: 2, now: Infinity }), 'page-1', 'frame-1')
|
||||
buffer.add(event({ seq: 2 }), 'page-1', 'frame-1')
|
||||
buffer.add(event({ seq: 3 }), 'page-1', 'frame-1')
|
||||
expect(buffer.snapshot()).toMatchObject({ invalid: 3, dropped: 1, events: [
|
||||
{ pageId: 'page-1', frameId: 'frame-1', seq: 1 }, { seq: 2 },
|
||||
] })
|
||||
expect(JSON.stringify(buffer.snapshot())).not.toContain('SECRET')
|
||||
})
|
||||
|
||||
it('retains copied bounded dashboard metadata and rejects raw paths and private fields', () => {
|
||||
const buffer = createBoundaryBuffer(1)
|
||||
const row = event({ page: 'dashboard', endpoint: 'session', method: 'POST' })
|
||||
buffer.add(row, 'page-1', 'frame-1')
|
||||
row.page = 'MUTATED_SECRET'
|
||||
buffer.add(event({ page: '/app?token=PRIVATE' }), 'page-1', 'frame-1')
|
||||
buffer.add(event({ page: 'dashboard', cookies: 'PRIVATE' }), 'page-1', 'frame-1')
|
||||
buffer.add(event({ page: 'dashboard', seq: 2 }), 'page-1', 'frame-1')
|
||||
expect(buffer.snapshot()).toMatchObject({ invalid: 2, dropped: 1, events: [{ page: 'dashboard', endpoint: 'session', method: 'POST' }] })
|
||||
expect(JSON.stringify(buffer.snapshot())).not.toMatch(/SECRET|PRIVATE/)
|
||||
})
|
||||
|
||||
it('disabled installer creates no bindings, init scripts or capture handle', async () => {
|
||||
const context = { exposeBinding: vi.fn(), addInitScript: vi.fn() }
|
||||
expect(await installBrowserBoundaryCapture(context, { enabled: false, origins: config.origins, includeDashboardPaths: true })).toBeUndefined()
|
||||
expect(context.exposeBinding).not.toHaveBeenCalled()
|
||||
expect(context.addInitScript).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('selective hosted navigation capture', () => {
|
||||
const lookup = '[NAV-023] a unique-key value opens the matching record in place of the key URL @mobile'
|
||||
const theme = '[NAV-056] appearance preferences explain an application theme override @mobile'
|
||||
|
||||
it.each(['webkit', 'tablet'])('captures only the two retained failure titles on %s', project => {
|
||||
expect(shouldCaptureBrowserBoundary('navigation', project, lookup)).toBe(true)
|
||||
expect(shouldCaptureBrowserBoundary('navigation', project, theme)).toBe(true)
|
||||
expect(shouldCaptureBrowserBoundary('navigation', project, '[NAV-056] appearance preferences apply immediately and survive a reload @mobile')).toBe(false)
|
||||
expect(shouldCaptureBrowserBoundary('navigation', project, `${lookup} extra`)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['chromium', 'firefox', 'mobile'])('excludes %s while preserving explicit full capture', project => {
|
||||
expect(shouldCaptureBrowserBoundary('navigation', project, lookup)).toBe(false)
|
||||
expect(shouldCaptureBrowserBoundary('navigation', project, theme)).toBe(false)
|
||||
expect(shouldCaptureBrowserBoundary('1', project, 'unrelated case')).toBe(true)
|
||||
})
|
||||
|
||||
it.each([undefined, '', '0', 'true', 'navigation-extra'])('leaves capture disabled for mode %s', mode => {
|
||||
expect(shouldCaptureBrowserBoundary(mode, 'webkit', lookup)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['/app/person/key', '/app/person/key/'])('observes %s with a fixed alias and excludes key values', pathname => {
|
||||
history.replaceState({}, '', `${pathname}?email=QUERY_SECRET#HASH_SECRET`)
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
expect(xhr.open('GET', '/qqq/v1/metaData/table/person?token=API_SECRET')).toBe(xhr.openResult)
|
||||
expect(xhr.send()).toBe(xhr.sendResult)
|
||||
expect(kinds()).toEqual(['ready', 'open-return', 'send-enter', 'send-return'])
|
||||
expect(events.every(row => row.page === 'person-key')).toBe(true)
|
||||
expect(events.at(-1)).toMatchObject({ endpoint: 'person-metadata', method: 'GET' })
|
||||
const buffer = createBoundaryBuffer()
|
||||
for (const row of events) buffer.add(row, 'page-1', 'frame-1')
|
||||
expect(buffer.snapshot()).toMatchObject({ invalid: 0, dropped: 0 })
|
||||
expect(buffer.snapshot().events).toHaveLength(4)
|
||||
expect(JSON.stringify(buffer.snapshot())).not.toMatch(/SECRET|email|token|\/app|\/qqq/)
|
||||
})
|
||||
|
||||
it('does not expand the page allow-list to other key lookups', () => {
|
||||
history.replaceState({}, '', '/app/navDeepItem/key?code=SECRET')
|
||||
install()
|
||||
expect(cleanup).toBeUndefined()
|
||||
expect(events).toEqual([])
|
||||
expect(NativeXHR.prototype.open).toBe(originalOpen.value)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,297 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import type { BrowserContext, Frame, Page } from '@playwright/test'
|
||||
|
||||
interface BrowserCaptureConfig {
|
||||
origins: string[]
|
||||
maxEvents: number
|
||||
includeProcessPaths?: boolean
|
||||
includeDashboardPaths?: boolean
|
||||
}
|
||||
|
||||
/** Selects capture without changing the default-off or explicit full-capture switch. */
|
||||
export function shouldCaptureBrowserBoundary(mode: string | undefined, project: string, title: string): boolean {
|
||||
if (mode === '1') return true
|
||||
if (mode !== 'navigation' || (project !== 'webkit' && project !== 'tablet')) return false
|
||||
return title === '[NAV-023] a unique-key value opens the matching record in place of the key URL @mobile'
|
||||
|| title === '[NAV-056] appearance preferences explain an application theme override @mobile'
|
||||
}
|
||||
|
||||
type BoundaryEvent = Record<string, string | number | boolean>
|
||||
|
||||
/** Runs before application scripts. Keep all runtime dependencies inside this serializable function. */
|
||||
export function browserBoundaryInit(config: BrowserCaptureConfig): (() => void) | undefined {
|
||||
const pages: Record<string, string> = {
|
||||
'/app/person': 'person', '/app/person/': 'person',
|
||||
'/app/person/key': 'person-key', '/app/person/key/': 'person-key',
|
||||
}
|
||||
if (config.includeDashboardPaths) Object.assign(pages, { '/app': 'dashboard', '/app/': 'dashboard' })
|
||||
const endpoints: Record<string, string> = {
|
||||
'GET /qqq/v1/metaData/table/person': 'person-metadata',
|
||||
'POST /qqq/v1/processes/querySavedView/init': 'saved-view-init',
|
||||
'POST /qqq/v1/table/person/query': 'person-query',
|
||||
'POST /qqq/v1/table/person/count': 'person-count',
|
||||
'POST /qqq/v1/manageSession': 'session',
|
||||
}
|
||||
if (config.includeProcessPaths) {
|
||||
Object.assign(pages, { '/app/person/2': 'person-record', '/app/person/2/': 'person-record',
|
||||
'/app/person.bulkEdit': 'person-bulk-edit', '/app/person.bulkEdit/': 'person-bulk-edit' })
|
||||
Object.assign(endpoints, { 'GET /qqq/v1/table/person/2': 'person-record',
|
||||
'GET /qqq/v1/metaData/process/person.bulkEdit': 'process-metadata',
|
||||
'POST /qqq/v1/processes/person.bulkEdit/init': 'process-init' })
|
||||
}
|
||||
const page = pages[location.pathname]
|
||||
if (!config.origins.includes(location.origin) || !page) return
|
||||
const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
|
||||
const documentId = crypto.randomUUID()
|
||||
const timeOrigin = performance.timeOrigin
|
||||
let sequence = 0
|
||||
let phase = 'active'
|
||||
let active = true
|
||||
let overflow = false
|
||||
let nextXHR = 0
|
||||
const limit = Math.max(1, Math.min(config.maxEvents, 4096))
|
||||
const errorClass = (error: unknown) => error instanceof DOMException ? 'DOMException' : error instanceof Error ? 'Error' : 'other'
|
||||
const emit = (event: string, details: BoundaryEvent = {}) => {
|
||||
if (!active || overflow) return
|
||||
try {
|
||||
if (sequence >= limit) { overflow = true; event = 'overflow'; details = {} }
|
||||
const binding = (globalThis as unknown as { __qqqBrowserBoundary?: (value: BoundaryEvent) => Promise<unknown> }).__qqqBrowserBoundary
|
||||
const result = binding?.({ version: 1, documentId, seq: ++sequence, timeOrigin, now: performance.now(), page, event, phase, ...details })
|
||||
// A dying document may lose binding delivery. Never turn that gap into an application rejection.
|
||||
void result?.catch(() => undefined)
|
||||
} catch { /* Observation must never replace the native call's outcome. */ }
|
||||
}
|
||||
const selected = (args: unknown[]) => {
|
||||
try {
|
||||
if (typeof args[0] !== 'string' || typeof args[1] !== 'string') return
|
||||
const method = args[0].toUpperCase()
|
||||
const url = new URL(args[1], location.href)
|
||||
const endpoint = endpoints[`${method} ${url.pathname}`]
|
||||
if (endpoint && methods.includes(method) && config.origins.includes(url.origin)) return { method, endpoint }
|
||||
} catch { /* Unknown URL forms are delegated without extra coercion. */ }
|
||||
}
|
||||
interface Observation {
|
||||
xhr: number
|
||||
generation: number
|
||||
attempt: number
|
||||
activeAttempt: number
|
||||
awaitingEnd: boolean
|
||||
target?: { method: string; endpoint: string }
|
||||
headers: boolean
|
||||
done: boolean
|
||||
}
|
||||
const observations = new WeakMap<XMLHttpRequest, Observation>()
|
||||
const identities = new WeakMap<XMLHttpRequest, number>()
|
||||
const opening = new WeakMap<XMLHttpRequest, ReturnType<typeof selected>>()
|
||||
const incomplete = new WeakMap<XMLHttpRequest, 'reentrant-open' | 'send-during-open' | 'reuse-before-loadend'>()
|
||||
const reportedIncomplete = new WeakSet<XMLHttpRequest>()
|
||||
const identity = (xhr: XMLHttpRequest) => {
|
||||
let id = identities.get(xhr)
|
||||
if (id === undefined && nextXHR < 256) { id = ++nextXHR; identities.set(xhr, id) }
|
||||
return id
|
||||
}
|
||||
const markIncomplete = (xhr: XMLHttpRequest, reason: 'reentrant-open' | 'send-during-open' | 'reuse-before-loadend', relevant = false) => {
|
||||
try {
|
||||
const observation = observations.get(xhr)
|
||||
relevant ||= Boolean(opening.get(xhr) || observation?.target)
|
||||
if (!incomplete.has(xhr)) incomplete.set(xhr, reason)
|
||||
if (observation) observation.target = undefined
|
||||
if (relevant && !reportedIncomplete.has(xhr)) {
|
||||
reportedIncomplete.add(xhr)
|
||||
const id = identity(xhr)
|
||||
emit('xhr-incomplete', { reason: incomplete.get(xhr)!, ...(id === undefined ? {} : { xhr: id }) })
|
||||
}
|
||||
} catch { /* Reentrant instrumentation must not change native receiver validation. */ }
|
||||
}
|
||||
const prototype = XMLHttpRequest.prototype
|
||||
const openDescriptor = Object.getOwnPropertyDescriptor(prototype, 'open')!
|
||||
const sendDescriptor = Object.getOwnPropertyDescriptor(prototype, 'send')!
|
||||
const nativeOpen = prototype.open
|
||||
const nativeSend = prototype.send
|
||||
const details = (observation: Observation): BoundaryEvent => ({
|
||||
xhr: observation.xhr, generation: observation.generation, attempt: observation.activeAttempt, ...observation.target,
|
||||
})
|
||||
const observe = (xhr: XMLHttpRequest) => {
|
||||
const listener = (event: Event) => {
|
||||
try {
|
||||
const observation = observations.get(xhr)
|
||||
if (!observation || incomplete.has(xhr)) return
|
||||
if (event.type === 'loadend') observation.awaitingEnd = false
|
||||
if (!observation.target) return
|
||||
const status = xhr.status
|
||||
const readyState = xhr.readyState
|
||||
let kind = event.type
|
||||
if (kind === 'readystatechange') {
|
||||
if (readyState === 2 && !observation.headers) { observation.headers = true; kind = 'headers' }
|
||||
else if (readyState === 4 && !observation.done) { observation.done = true; kind = 'done' }
|
||||
else return
|
||||
}
|
||||
emit(kind, { ...details(observation), status, readyState })
|
||||
} catch { /* Status getters or observer delivery cannot affect application event listeners. */ }
|
||||
}
|
||||
for (const name of ['readystatechange', 'load', 'error', 'abort', 'timeout', 'loadend']) xhr.addEventListener(name, listener)
|
||||
}
|
||||
const wrappedOpen = function (this: XMLHttpRequest, ...args: unknown[]) {
|
||||
const target = selected(args)
|
||||
let outer = false
|
||||
try {
|
||||
if (observations.get(this)?.awaitingEnd) markIncomplete(this, 'reuse-before-loadend', Boolean(target))
|
||||
if (opening.has(this)) markIncomplete(this, 'reentrant-open', Boolean(target))
|
||||
else { opening.set(this, target); outer = true }
|
||||
if (incomplete.has(this)) markIncomplete(this, incomplete.get(this)!, Boolean(target))
|
||||
} catch { /* Invalid receivers still reach the original native validation. */ }
|
||||
let result: unknown
|
||||
try { result = Reflect.apply(nativeOpen, this, args) }
|
||||
catch (error) {
|
||||
if (target && !incomplete.has(this)) emit('open-throw', { ...target, errorClass: errorClass(error) })
|
||||
throw error
|
||||
} finally {
|
||||
if (outer) opening.delete(this)
|
||||
}
|
||||
if (incomplete.has(this)) return result
|
||||
// Native open can synchronously abort a previous generation; update only after delegation.
|
||||
try {
|
||||
let observation = observations.get(this)
|
||||
if (!observation) {
|
||||
// Excluded requests retain only lifecycle state so old events cannot impersonate later selected reuse.
|
||||
observation = { xhr: 0, generation: 0, attempt: 0, activeAttempt: 0, awaitingEnd: false, headers: false, done: false }
|
||||
observations.set(this, observation)
|
||||
observe(this)
|
||||
}
|
||||
if (target && observation.xhr === 0) {
|
||||
const id = identity(this)
|
||||
if (id === undefined) { emit('xhr-limit'); return result }
|
||||
observation.xhr = id
|
||||
}
|
||||
if (observation) {
|
||||
observation.generation += 1
|
||||
observation.attempt = 0
|
||||
observation.activeAttempt = 0
|
||||
observation.awaitingEnd = false
|
||||
observation.target = target
|
||||
observation.headers = false
|
||||
observation.done = false
|
||||
if (target) emit('open-return', details(observation))
|
||||
}
|
||||
} catch { /* Do not replace a successful native return with an observer failure. */ }
|
||||
return result
|
||||
}
|
||||
const wrappedSend = function (this: XMLHttpRequest, ...args: unknown[]) {
|
||||
// OPENED callbacks may send/reopen before the outer open returns. Their identity is ambiguous.
|
||||
if (opening.has(this)) markIncomplete(this, 'send-during-open')
|
||||
if (incomplete.has(this)) return Reflect.apply(nativeSend, this, args)
|
||||
const observation = observations.get(this)
|
||||
const sendGeneration = observation?.generation
|
||||
const previouslyAwaitingEnd = observation?.awaitingEnd
|
||||
if (observation) observation.awaitingEnd = true
|
||||
const previousAttempt = observation?.activeAttempt
|
||||
let call: BoundaryEvent | undefined
|
||||
if (observation?.target) {
|
||||
observation.activeAttempt = ++observation.attempt
|
||||
// Freeze this call's identity: synchronous native events can reopen the same XHR.
|
||||
call = details(observation)
|
||||
emit('send-enter', call)
|
||||
}
|
||||
let result: unknown
|
||||
try { result = Reflect.apply(nativeSend, this, args) }
|
||||
catch (error) {
|
||||
if (call && !incomplete.has(this)) emit('send-throw', { ...call, errorClass: errorClass(error) })
|
||||
if (observation && observation.generation === sendGeneration) {
|
||||
observation.activeAttempt = previousAttempt ?? 0
|
||||
observation.awaitingEnd = previouslyAwaitingEnd ?? false
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (call && !incomplete.has(this)) emit('send-return', call)
|
||||
return result
|
||||
}
|
||||
Object.defineProperty(prototype, 'open', { ...openDescriptor, value: wrappedOpen })
|
||||
Object.defineProperty(prototype, 'send', { ...sendDescriptor, value: wrappedSend })
|
||||
const lifecycle = (event: Event) => {
|
||||
if (event.type === 'pagehide') phase = 'pagehide'
|
||||
else if (event.type === 'pageshow') phase = 'active'
|
||||
const details: BoundaryEvent = {}
|
||||
if (event.type === 'pageshow' || event.type === 'pagehide') details.persisted = (event as PageTransitionEvent).persisted
|
||||
emit(event.type, details)
|
||||
}
|
||||
const visibility = () => emit('visibilitychange', { visibility: document.visibilityState === 'visible' ? 'visible' : 'hidden' })
|
||||
for (const name of ['pagehide', 'pageshow', 'DOMContentLoaded', 'load']) window.addEventListener(name, lifecycle)
|
||||
document.addEventListener('visibilitychange', visibility)
|
||||
emit('ready')
|
||||
return () => {
|
||||
active = false
|
||||
for (const name of ['pagehide', 'pageshow', 'DOMContentLoaded', 'load']) window.removeEventListener(name, lifecycle)
|
||||
document.removeEventListener('visibilitychange', visibility)
|
||||
if (prototype.open === wrappedOpen) Object.defineProperty(prototype, 'open', openDescriptor)
|
||||
if (prototype.send === wrappedSend) Object.defineProperty(prototype, 'send', sendDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
/** Validates the untrusted binding payload before retaining only bounded metadata. */
|
||||
export function createBoundaryBuffer(maxEvents = 4096) {
|
||||
const events: (BoundaryEvent & { pageId: string; frameId: string; receivedAt: number })[] = []
|
||||
let invalid = 0
|
||||
let dropped = 0
|
||||
const limit = Math.max(1, Math.min(maxEvents, 4096))
|
||||
const numeric = ['seq', 'timeOrigin', 'now', 'xhr', 'generation', 'attempt', 'status', 'readyState']
|
||||
const enums: Record<string, string[]> = {
|
||||
page: ['person', 'person-key', 'person-record', 'person-bulk-edit', 'dashboard'], phase: ['active', 'pagehide'],
|
||||
event: ['ready', 'pagehide', 'pageshow', 'DOMContentLoaded', 'load', 'visibilitychange', 'open-return', 'open-throw', 'send-enter', 'send-return', 'send-throw', 'headers', 'done', 'error', 'abort', 'timeout', 'loadend', 'overflow', 'xhr-limit', 'xhr-incomplete'],
|
||||
reason: ['reentrant-open', 'send-during-open', 'reuse-before-loadend'],
|
||||
endpoint: ['person-metadata', 'saved-view-init', 'person-query', 'person-count', 'session', 'person-record', 'process-metadata', 'process-init'],
|
||||
method: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'],
|
||||
errorClass: ['DOMException', 'Error', 'other'], visibility: ['visible', 'hidden'],
|
||||
}
|
||||
const validate = (value: unknown): value is BoundaryEvent => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return false
|
||||
const row = value as Record<string, unknown>
|
||||
if (row.version !== 1 || typeof row.documentId !== 'string' || !/^[a-f\d]{8}-(?:[a-f\d]{4}-){3}[a-f\d]{12}$/i.test(row.documentId)) return false
|
||||
for (const key of ['seq', 'timeOrigin', 'now', 'page', 'event', 'phase']) if (!(key in row)) return false
|
||||
for (const [key, field] of Object.entries(row)) {
|
||||
if (key === 'version' || key === 'documentId') continue
|
||||
if (numeric.includes(key)) {
|
||||
if (typeof field !== 'number' || !Number.isFinite(field) || field < 0 || field > Number.MAX_SAFE_INTEGER) return false
|
||||
if (['seq', 'xhr', 'generation', 'attempt', 'status', 'readyState'].includes(key) && !Number.isInteger(field)) return false
|
||||
if (key === 'status' && field > 599 || key === 'readyState' && field > 4) return false
|
||||
} else if (key === 'persisted') { if (typeof field !== 'boolean') return false }
|
||||
else if (!enums[key]?.includes(field as string)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
return {
|
||||
add(value: unknown, pageId: string, frameId: string) {
|
||||
try {
|
||||
if (!validate(value)) { invalid += 1; return }
|
||||
if (events.length >= limit) { dropped += 1; return }
|
||||
events.push({ ...value, pageId, frameId, receivedAt: Date.now() })
|
||||
} catch { invalid += 1 }
|
||||
},
|
||||
snapshot: () => ({ version: 1, limit, invalid, dropped, events: events.map(event => ({ ...event })) }),
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs nothing when disabled; snapshotting reads Node memory only, even after page destruction. */
|
||||
export async function installBrowserBoundaryCapture(
|
||||
context: Pick<BrowserContext, 'exposeBinding' | 'addInitScript'>,
|
||||
config: { enabled: boolean; origins: string[]; includeProcessPaths?: boolean; includeDashboardPaths?: boolean },
|
||||
) {
|
||||
if (!config.enabled) return undefined
|
||||
const buffer = createBoundaryBuffer()
|
||||
const pages = new WeakMap<Page, string>()
|
||||
const frames = new WeakMap<Frame, string>()
|
||||
let pageSequence = 0
|
||||
let frameSequence = 0
|
||||
await context.exposeBinding('__qqqBrowserBoundary', ({ page, frame }, value: unknown) => {
|
||||
if (!pages.has(page)) pages.set(page, `page-${++pageSequence}`)
|
||||
if (!frames.has(frame)) frames.set(frame, `frame-${++frameSequence}`)
|
||||
buffer.add(value, pages.get(page)!, frames.get(frame)!)
|
||||
})
|
||||
await context.addInitScript(browserBoundaryInit, { origins: config.origins, maxEvents: 512, includeProcessPaths: config.includeProcessPaths, includeDashboardPaths: config.includeDashboardPaths })
|
||||
return { snapshot: buffer.snapshot }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { readChartLabelBoxes, type ChartLabelBox } from './chart-label-geometry'
|
||||
|
||||
const labels = ['January', 'February', 'March', 'April', 'May']
|
||||
const complete = labels.map((_, index) => ({ left: 80 + index * 60, right: 122 + index * 60, width: 42, height: 13 }))
|
||||
const zero = { left: 0, right: 0, width: 0, height: 0 }
|
||||
|
||||
function ticks(boxes: ChartLabelBox[], texts = labels) {
|
||||
return boxes.map((box, index) => {
|
||||
const node = document.createElementNS('http://www.w3.org/2000/svg', 'text')
|
||||
node.textContent = texts[index]
|
||||
// Explicit fields also represent invalid measurements without relying on DOMRect normalization.
|
||||
vi.spyOn(node, 'getBoundingClientRect').mockReturnValue({ ...box, x: box.left, y: 0, top: 0, bottom: box.height, toJSON: () => box })
|
||||
return node
|
||||
})
|
||||
}
|
||||
|
||||
it('keeps all expected text with zero geometry unready', () => {
|
||||
const nodes = ticks(labels.map(() => zero))
|
||||
expect(nodes.map(node => node.textContent)).toEqual(labels)
|
||||
expect(readChartLabelBoxes(nodes, labels)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the retained one-real/four-zero geometry pattern unready', () => {
|
||||
const nodes = ticks([{ left: 80.140625, right: 121.859375, width: 41.71875, height: 12.515625 }, zero, zero, zero, zero])
|
||||
expect(nodes.map(node => node.textContent)).toEqual(labels)
|
||||
expect(readChartLabelBoxes(nodes, labels)).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat empty expected labels and no nodes as ready', () => {
|
||||
expect(readChartLabelBoxes([], [])).toBeNull()
|
||||
})
|
||||
|
||||
it('requires the complete expected label count', () => {
|
||||
expect(readChartLabelBoxes(ticks(complete.slice(0, -1)), labels)).toBeNull()
|
||||
})
|
||||
|
||||
it('requires expected text in the original order in the same geometry sample', () => {
|
||||
expect(readChartLabelBoxes(ticks(complete, [...labels].reverse()), labels)).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['left', 'right', 'width', 'height'] as const)('rejects a non-finite %s', (field) => {
|
||||
expect(readChartLabelBoxes(ticks([{ ...complete[0], [field]: Number.NaN }, ...complete.slice(1)]), labels)).toBeNull()
|
||||
})
|
||||
|
||||
it.each(['width', 'height'] as const)('rejects zero %s even when the other dimensions are positive', (field) => {
|
||||
expect(readChartLabelBoxes(ticks([{ ...complete[0], [field]: 0 }, ...complete.slice(1)]), labels)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the complete measured sample for the original strict overlap check', () => {
|
||||
const nodes = ticks(complete)
|
||||
const boxes = readChartLabelBoxes(nodes, labels)!
|
||||
expect(boxes).toEqual(complete)
|
||||
for (let i = 1; i < boxes.length; i++) expect(boxes[i].left).toBeGreaterThan(boxes[i - 1].right)
|
||||
for (const node of nodes) expect(node.getBoundingClientRect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns complete overlapping geometry immediately so the original assertion fails', () => {
|
||||
const overlap = [{ ...complete[0], right: complete[1].left + 1, width: 61 }, ...complete.slice(1)]
|
||||
const nodes = ticks(overlap)
|
||||
const boxes = readChartLabelBoxes(nodes, labels)!
|
||||
expect(boxes).toEqual(overlap)
|
||||
expect(() => {
|
||||
for (let i = 1; i < boxes.length; i++) expect(boxes[i].left).toBeGreaterThan(boxes[i - 1].right)
|
||||
}).toThrow()
|
||||
for (const node of nodes) expect(node.getBoundingClientRect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
export interface ChartLabelBox {
|
||||
left: number
|
||||
right: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** Returns one complete rendered sample; overlap remains the caller's strict assertion. */
|
||||
export function readChartLabelBoxes(nodes: Element[], expectedLabels: string[]): ChartLabelBox[] | null {
|
||||
if (expectedLabels.length === 0 || nodes.length !== expectedLabels.length) return null
|
||||
const boxes: ChartLabelBox[] = []
|
||||
for (let index = 0; index < nodes.length; index++) {
|
||||
const node = nodes[index]
|
||||
if (node.textContent?.replace(/\s+/g, ' ').trim() !== expectedLabels[index].replace(/\s+/g, ' ').trim()) return null
|
||||
const { left, right, width, height } = node.getBoundingClientRect()
|
||||
if (![left, right, width, height].every(Number.isFinite) || width <= 0 || height <= 0) return null
|
||||
boxes.push({ left, right, width, height })
|
||||
}
|
||||
return boxes
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/** @file Actual owned-provider issuer, discovery and signed-token contracts. */
|
||||
import { createHash, createPublicKey, verify } from 'node:crypto'
|
||||
import { createServer } from 'node:net'
|
||||
import { bypass } from 'msw'
|
||||
import { expect, it } from 'vitest'
|
||||
import { startFakeOidc } from './fake-oidc'
|
||||
|
||||
/** Reserve an available loopback port without using any shared acceptance server. */
|
||||
async function unusedPort(): Promise<number> {
|
||||
const server = createServer()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', resolve)
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') throw new Error('Missing test port')
|
||||
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
||||
return address.port
|
||||
}
|
||||
|
||||
it.each([false, true])('keeps endpoints on the origin and signs consistent issuer claims (Auth0=%s)', async (auth0Issuer) => {
|
||||
const port = await unusedPort()
|
||||
const origin = `http://127.0.0.1:${port}`
|
||||
const expectedIssuer = origin + (auth0Issuer ? '/' : '')
|
||||
const clientId = 'owned-client'
|
||||
const redirectUri = 'http://127.0.0.1:13999/callback'
|
||||
const provider = await startFakeOidc({ port, clientId, clientSecret: 'owned-secret', allowedRedirectPrefix: 'http://127.0.0.1:13999/', auth0Issuer })
|
||||
try {
|
||||
const discoveryResponse = await fetch(bypass(`${origin}/.well-known/openid-configuration`))
|
||||
expect(discoveryResponse.status).toBe(200)
|
||||
const discovery = await discoveryResponse.json()
|
||||
const verifier = 'owned-pkce-verifier-with-enough-characters-for-this-test'
|
||||
const form = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', scope: 'openid profile', state: 'owned-state', code_challenge_method: 'S256', code_challenge: createHash('sha256').update(verifier).digest('base64url'), decision: 'allow' })
|
||||
const authorization = await fetch(bypass(`${origin}/authorize/decision`, { method: 'POST', body: form, redirect: 'manual' }))
|
||||
expect(authorization.status).toBe(302)
|
||||
const code = new URL(authorization.headers.get('location')!).searchParams.get('code')!
|
||||
const tokenResponse = await fetch(bypass(discovery.token_endpoint, { method: 'POST', body: new URLSearchParams({ client_id: clientId, grant_type: 'authorization_code', redirect_uri: redirectUri, code, code_verifier: verifier }) }))
|
||||
expect(tokenResponse.status).toBe(200)
|
||||
const tokens = await tokenResponse.json()
|
||||
const jwksResponse = await fetch(bypass(discovery.jwks_uri))
|
||||
expect(jwksResponse.status).toBe(200)
|
||||
const jwks = await jwksResponse.json()
|
||||
const key = createPublicKey({ key: jwks.keys[0], format: 'jwk' })
|
||||
for (const token of [tokens.access_token, tokens.id_token] as string[]) {
|
||||
const [header, claims, signature] = token.split('.')
|
||||
expect(verify('RSA-SHA256', Buffer.from(`${header}.${claims}`), key, Buffer.from(signature, 'base64url'))).toBe(true)
|
||||
expect(JSON.parse(Buffer.from(claims, 'base64url').toString()).iss).toBe(expectedIssuer)
|
||||
}
|
||||
expect(discovery).toMatchObject({ issuer: expectedIssuer, authorization_endpoint: `${origin}/authorize`, token_endpoint: `${origin}/oauth/token`, jwks_uri: `${origin}/.well-known/jwks.json`, userinfo_endpoint: `${origin}/userinfo`, end_session_endpoint: `${origin}/logout` })
|
||||
expect(provider.issuer).toBe(expectedIssuer)
|
||||
if (auth0Issuer) expect(provider.origin).toBe(origin)
|
||||
expect(provider.requests.every(request => !request.path.startsWith('//'))).toBe(true)
|
||||
} finally {
|
||||
await provider.close()
|
||||
}
|
||||
})
|
||||
@@ -23,6 +23,8 @@ export interface FakeOidcOptions {
|
||||
allowedRedirectPrefix: string
|
||||
/** Extra access-token claims (e.g. an Auth0 audience). */
|
||||
audience?: string
|
||||
/** Auth0 requires a trailing slash in its trusted issuer, independently of endpoint origins. */
|
||||
auth0Issuer?: boolean
|
||||
users?: FakeUser[]
|
||||
}
|
||||
|
||||
@@ -45,6 +47,9 @@ interface PendingCode {
|
||||
}
|
||||
|
||||
export interface FakeOidcProvider {
|
||||
/** Network origin for endpoints and browser CSP; never has a trailing slash. */
|
||||
origin: string
|
||||
/** Exact protocol issuer advertised in discovery and signed token claims. */
|
||||
issuer: string
|
||||
requests: RecordedRequest[]
|
||||
/** Makes the next token exchange fail with invalid_grant. */
|
||||
@@ -91,7 +96,8 @@ function escapeHtml(value: string): string {
|
||||
* @returns The running provider.
|
||||
*/
|
||||
export async function startFakeOidc(options: FakeOidcOptions): Promise<FakeOidcProvider> {
|
||||
const issuer = `http://127.0.0.1:${options.port}`
|
||||
const origin = `http://127.0.0.1:${options.port}`
|
||||
const issuer = origin + (options.auth0Issuer ? '/' : '')
|
||||
const users = options.users ?? DEFAULT_USERS
|
||||
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
||||
const kid = base64Url(randomBytes(8))
|
||||
@@ -103,11 +109,11 @@ export async function startFakeOidc(options: FakeOidcOptions): Promise<FakeOidcP
|
||||
|
||||
const discovery = {
|
||||
issuer,
|
||||
authorization_endpoint: `${issuer}/authorize`,
|
||||
token_endpoint: `${issuer}/oauth/token`,
|
||||
jwks_uri: `${issuer}/.well-known/jwks.json`,
|
||||
userinfo_endpoint: `${issuer}/userinfo`,
|
||||
end_session_endpoint: `${issuer}/logout`,
|
||||
authorization_endpoint: `${origin}/authorize`,
|
||||
token_endpoint: `${origin}/oauth/token`,
|
||||
jwks_uri: `${origin}/.well-known/jwks.json`,
|
||||
userinfo_endpoint: `${origin}/userinfo`,
|
||||
end_session_endpoint: `${origin}/logout`,
|
||||
response_types_supported: ['code'],
|
||||
subject_types_supported: ['public'],
|
||||
id_token_signing_alg_values_supported: ['RS256'],
|
||||
@@ -161,7 +167,7 @@ export async function startFakeOidc(options: FakeOidcOptions): Promise<FakeOidcP
|
||||
}
|
||||
|
||||
const handler = async (request: IncomingMessage, response: ServerResponse) => {
|
||||
const url = new URL(request.url ?? '/', issuer)
|
||||
const url = new URL(request.url ?? '/', origin)
|
||||
const body = request.method === 'POST' ? await readBody(request) : ''
|
||||
const form = Object.fromEntries(new URLSearchParams(body))
|
||||
requests.push({ method: request.method ?? 'GET', path: url.pathname, query: Object.fromEntries(url.searchParams), form, authorization: request.headers.authorization })
|
||||
@@ -272,6 +278,7 @@ export async function startFakeOidc(options: FakeOidcOptions): Promise<FakeOidcP
|
||||
server.listen(options.port, '127.0.0.1', () => resolve())
|
||||
})
|
||||
return {
|
||||
origin,
|
||||
issuer,
|
||||
requests,
|
||||
failNextTokenExchange: () => { failNext = true },
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { test as base, expect, type APIRequestContext, type Page, type Request, type Response } from '@playwright/test'
|
||||
import { installBrowserBoundaryCapture, shouldCaptureBrowserBoundary } from './browser-boundary-capture'
|
||||
import { interruptedLegacyFont, type FontRequestOutcome } from './font-diagnostics'
|
||||
import { ACCEPTANCE_BACKEND_PORT, ACCEPTANCE_BACKEND_URL, ACCEPTANCE_UI_URL } from './ports'
|
||||
|
||||
@@ -63,7 +64,7 @@ async function control(path: string, body: unknown) {
|
||||
return text ? JSON.parse(text) : null
|
||||
}
|
||||
|
||||
export const test = base.extend<{ persona: Persona; user: SampleUser; backend: Backend; diagnostics: Diagnostics }>({
|
||||
export const test = base.extend<{ persona: Persona; user: SampleUser; backend: Backend; diagnostics: Diagnostics; _browserBoundaryCapture: void }>({
|
||||
persona: ['admin', { option: true }],
|
||||
user: ['alice', { option: true }],
|
||||
|
||||
@@ -94,7 +95,25 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
|
||||
}
|
||||
},
|
||||
|
||||
diagnostics: async ({ page }, provide, testInfo) => {
|
||||
_browserBoundaryCapture: async ({ context }, provide, testInfo) => {
|
||||
const capture = await installBrowserBoundaryCapture(context, {
|
||||
enabled: shouldCaptureBrowserBoundary(process.env.QQQ_ACCEPTANCE_BROWSER_TRACE, testInfo.project.name, testInfo.title),
|
||||
origins: [new URL(ACCEPTANCE_UI_URL).origin, new URL(ACCEPTANCE_BACKEND_URL).origin],
|
||||
includeProcessPaths: true,
|
||||
includeDashboardPaths: process.env.QQQ_ACCEPTANCE_BROWSER_TRACE_SESSION === '1',
|
||||
})
|
||||
try {
|
||||
await provide()
|
||||
} finally {
|
||||
// Node memory only: failure teardown must not wait for a stalled document or response.
|
||||
if (capture) await testInfo.attach('browser-boundary.json', {
|
||||
body: JSON.stringify(capture.snapshot()), contentType: 'application/json',
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
diagnostics: async ({ page, _browserBoundaryCapture }, provide, testInfo) => {
|
||||
void _browserBoundaryCapture
|
||||
const allowed: (string | RegExp)[] = []
|
||||
const matches = (text: string) => allowed.some((pattern) => typeof pattern === 'string' ? text.includes(pattern) : pattern.test(text))
|
||||
const diagnostics: Diagnostics = { pageErrors: [], consoleErrors: [], failedRequests: [], cspViolations: [], interruptedFetches: [], legacyRequests: [], allow: (pattern) => { allowed.push(pattern) } }
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
import { EventEmitter } from 'node:events'
|
||||
import type { Page, Response } from '@playwright/test'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { armGreetRouteResponses } from './greet-route-readiness'
|
||||
|
||||
const origin = 'http://127.0.0.1:18765'
|
||||
function fixture() {
|
||||
const events = new EventEmitter()
|
||||
const frame = { url: () => `${origin}/app/greetInteractive` }
|
||||
const page = Object.assign(events, { mainFrame: () => frame }) as unknown as Page
|
||||
function response(role: 'metadata' | 'init', options: { referer?: string; origin?: string; method?: string; status?: number; frame?: object; finished?: Promise<Error | null> } = {}) {
|
||||
const request = {
|
||||
method: () => options.method ?? (role === 'metadata' ? 'GET' : 'POST'),
|
||||
frame: () => options.frame ?? frame,
|
||||
headers: () => ({ referer: options.referer ?? `${origin}/app/greetInteractive` }),
|
||||
}
|
||||
return {
|
||||
url: () => `${options.origin ?? origin}/qqq/v1/${role === 'metadata' ? 'metaData/table/person' : 'processes/greetInteractive/init'}`,
|
||||
request: () => request,
|
||||
status: () => options.status ?? 200,
|
||||
finished: () => options.finished ?? Promise.resolve(null),
|
||||
} as unknown as Response
|
||||
}
|
||||
return { events, frame, page, response }
|
||||
}
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it.each(['metadata', 'init'] as const)('stays pending until the actual %s response body finishes', async (role) => {
|
||||
const f = fixture()
|
||||
const barrier = armGreetRouteResponses(f.page, origin)
|
||||
f.events.emit('framenavigated', f.frame)
|
||||
let finish!: (error: Error | null) => void
|
||||
let settled = false
|
||||
void barrier.wait.then(() => { settled = true })
|
||||
f.events.emit('response', f.response(role, { finished: new Promise(resolve => { finish = resolve }) }))
|
||||
f.events.emit('response', f.response(role === 'metadata' ? 'init' : 'metadata'))
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
finish(null)
|
||||
await barrier.wait
|
||||
expect(f.events.listenerCount('response')).toBe(0)
|
||||
barrier.dispose()
|
||||
})
|
||||
|
||||
it('ignores previous-document, other-origin, wrong-method, subframe and precommit responses', async () => {
|
||||
const f = fixture(); const barrier = armGreetRouteResponses(f.page, origin)
|
||||
let settled = false; void barrier.wait.then(() => { settled = true })
|
||||
f.events.emit('response', f.response('metadata'))
|
||||
f.events.emit('framenavigated', f.frame)
|
||||
for (const options of [{ referer: `${origin}/app/person` }, { origin: 'http://other.test' }, { method: 'POST' }, { frame: {} }]) {
|
||||
f.events.emit('response', f.response('metadata', options))
|
||||
}
|
||||
f.events.emit('response', f.response('init'))
|
||||
await Promise.resolve(); await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
f.events.emit('response', f.response('metadata'))
|
||||
await barrier.wait; barrier.dispose()
|
||||
})
|
||||
|
||||
it.each([{ status: 500 }, { finished: Promise.resolve(new Error('transport failed')) }])('rejects an unsuccessful owned response', async (options) => {
|
||||
const f = fixture(); const barrier = armGreetRouteResponses(f.page, origin)
|
||||
f.events.emit('framenavigated', f.frame)
|
||||
f.events.emit('response', f.response('metadata', options))
|
||||
await expect(barrier.wait).rejects.toThrow('NAV-018')
|
||||
expect(f.events.listenerCount('response')).toBe(0)
|
||||
barrier.dispose()
|
||||
})
|
||||
|
||||
it('bounds a response whose headers arrive but body never completes to 15 seconds', async () => {
|
||||
vi.useFakeTimers()
|
||||
const f = fixture(); const barrier = armGreetRouteResponses(f.page, origin)
|
||||
const rejected = expect(barrier.wait).rejects.toThrow('15 seconds')
|
||||
f.events.emit('framenavigated', f.frame)
|
||||
f.events.emit('response', f.response('metadata', { finished: new Promise(() => {}) }))
|
||||
f.events.emit('response', f.response('init'))
|
||||
await vi.advanceTimersByTimeAsync(15_000)
|
||||
await rejected
|
||||
expect(f.events.listenerCount('response')).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('disposes armed listeners/timer immediately if navigation throws, without replacing its error', async () => {
|
||||
vi.useFakeTimers()
|
||||
const f = fixture(); const failure = new Error('original navigation failure')
|
||||
const run = async () => {
|
||||
const barrier = armGreetRouteResponses(f.page, origin)
|
||||
try { throw failure } finally { barrier.dispose() }
|
||||
}
|
||||
await expect(run()).rejects.toBe(failure)
|
||||
await Promise.resolve()
|
||||
expect(f.events.listenerCount('response')).toBe(0)
|
||||
expect(f.events.listenerCount('framenavigated')).toBe(0)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('retains the actual response.finished rejection and removes observation listeners', async () => {
|
||||
const f = fixture(); const barrier = armGreetRouteResponses(f.page, origin)
|
||||
const failure = new Error('original body completion rejection')
|
||||
f.events.emit('framenavigated', f.frame)
|
||||
f.events.emit('response', f.response('metadata', { finished: Promise.reject(failure) }))
|
||||
await expect(barrier.wait).rejects.toBe(failure)
|
||||
expect(f.events.listenerCount('response')).toBe(0)
|
||||
expect(f.events.listenerCount('framenavigated')).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/** @file Owned response completion before NAV-018 leaves its bare process route. */
|
||||
import type { Frame, Page, Response } from '@playwright/test'
|
||||
|
||||
/** Arm before opening this unique process visit; dispose in finally if navigation/assertions fail. */
|
||||
export function armGreetRouteResponses(page: Page, origin: string) {
|
||||
const processURLs = [origin + '/app/greetInteractive', origin + '/app/greetInteractive/']
|
||||
const pending = new Set(['GET /qqq/v1/metaData/table/person', 'POST /qqq/v1/processes/greetInteractive/init'])
|
||||
let committed = false
|
||||
let settled = false
|
||||
let resolve!: () => void
|
||||
let reject!: (error: Error) => void
|
||||
const wait = new Promise<void>((yes, no) => { resolve = yes; reject = no })
|
||||
// Opening/asserting can fail before the caller awaits this promise.
|
||||
void wait.catch(() => undefined)
|
||||
const navigated = (frame: Frame) => {
|
||||
if (frame === page.mainFrame() && processURLs.includes(frame.url())) committed = true
|
||||
}
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer)
|
||||
page.off('framenavigated', navigated)
|
||||
page.off('response', received)
|
||||
}
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
cleanup()
|
||||
if (error) reject(error)
|
||||
else resolve()
|
||||
}
|
||||
const received = (response: Response) => {
|
||||
const request = response.request()
|
||||
const url = new URL(response.url())
|
||||
const key = `${request.method()} ${url.pathname}`
|
||||
if (!committed || url.origin !== origin || url.search || url.hash || !pending.has(key)
|
||||
|| request.frame() !== page.mainFrame() || !processURLs.includes(request.headers().referer)) return
|
||||
// Keep the deadline active through the body, not just response headers.
|
||||
void (async () => {
|
||||
if (response.status() !== 200) throw new Error('NAV-018 process response must return 200')
|
||||
if (await response.finished()) throw new Error('NAV-018 process response did not finish successfully')
|
||||
if (settled) return
|
||||
pending.delete(key)
|
||||
if (pending.size === 0) finish()
|
||||
})().catch((error: unknown) => finish(error instanceof Error ? error : new Error('NAV-018 response completion failed')))
|
||||
}
|
||||
const timer = setTimeout(() => finish(new Error('NAV-018 process responses did not finish within 15 seconds')), 15_000)
|
||||
page.on('framenavigated', navigated)
|
||||
page.on('response', received)
|
||||
return { wait, dispose: () => finish(new Error('NAV-018 response observation disposed')) }
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/** @file Exercises the real variant lifecycle with only process and filesystem boundaries replaced. */
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
launched: [] as { command: string; args: string[]; options: unknown }[],
|
||||
mode: 'TABLE_BASED', failMetadata: false, deferNodeExit: false, ignoreAllKill: false, signals: [] as AbortSignal[], backendServesHtml: false, wrongTarget: false, hasExport: true, killed: [] as string[],
|
||||
}))
|
||||
vi.mock('node:child_process', () => {
|
||||
const processes = {
|
||||
spawnSync: () => ({ status: 0 }),
|
||||
spawn: (command: string, args: string[], options: unknown) => {
|
||||
state.launched.push({ command, args, options })
|
||||
const events = new EventEmitter()
|
||||
const child = Object.assign(events, {
|
||||
stdout: new EventEmitter(), stderr: new EventEmitter(), exitCode: null as number | null,
|
||||
kill(signal: string) {
|
||||
state.killed.push(`${command}:${signal}`)
|
||||
if (state.ignoreAllKill) return true
|
||||
if (state.deferNodeExit && command !== 'java') {
|
||||
if (signal === 'SIGKILL') setTimeout(() => { this.exitCode = 0; events.emit('exit', 0) }, 50)
|
||||
return true
|
||||
}
|
||||
this.exitCode = 0; events.emit('exit', 0); return true
|
||||
},
|
||||
})
|
||||
return child
|
||||
},
|
||||
}
|
||||
return { ...processes, default: processes }
|
||||
})
|
||||
// Existing stale export and build files cannot determine the browser's selected server.
|
||||
vi.mock('node:fs', async (original) => {
|
||||
const fs = {
|
||||
...await original<typeof import('node:fs')>(), existsSync: (file: string) => file.endsWith('next-dashboard/index.html') ? state.hasExport : true, mkdirSync: () => {}, rmSync: () => {},
|
||||
readdirSync: () => ['Fixture.java'],
|
||||
readFileSync: (file: string) => file.endsWith('routes-manifest.json')
|
||||
? JSON.stringify({ rewrites: { beforeFiles: [], afterFiles: ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession', 'apis.json', 'api'].map((prefix) => ({ source: `/${prefix}/:path*`, destination: `http://127.0.0.1:${state.wrongTarget ? '18765' : '18775'}/${prefix}/:path*` })), fallback: [] } })
|
||||
: 'http://127.0.0.1:18775\n',
|
||||
}
|
||||
return { ...fs, default: fs }
|
||||
})
|
||||
// Test registration is outside this lifecycle contract; do not initialize a second runner in Vitest.
|
||||
vi.mock('@playwright/test', () => ({ test: { extend: () => ({}) } }))
|
||||
vi.mock('./fixtures', () => ({ test: { extend: () => ({}) }, expect }))
|
||||
|
||||
async function variant(mode = 'standalone') {
|
||||
vi.stubEnv('QQQ_ACCEPTANCE_MODE', mode)
|
||||
vi.stubEnv('QQQ_SAMPLE_JAR', '/owned/qqq-sample-project-4.1.0-RC.1-jar-with-dependencies.jar')
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, options?: RequestInit) => {
|
||||
if (options?.signal) state.signals.push(options.signal)
|
||||
if (state.failMetadata && url.endsWith('/metaData/authentication')) throw new Error('metadata request aborted')
|
||||
return {
|
||||
ok: !url.endsWith(':18775/login') || state.backendServesHtml,
|
||||
url, status: url.endsWith(':18775/login') && !state.backendServesHtml ? 404 : 200,
|
||||
headers: new Headers({ 'content-type': 'text/html' }),
|
||||
json: async () => ({ type: state.mode }),
|
||||
} }))
|
||||
return import('../specs/security/support/variant')
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs(); vi.unstubAllGlobals(); vi.resetModules()
|
||||
state.launched.length = 0; state.killed.length = 0; state.signals.length = 0
|
||||
state.failMetadata = false; state.deferNodeExit = false; state.ignoreAllKill = false; vi.useRealTimers()
|
||||
state.mode = 'TABLE_BASED'; state.backendServesHtml = false; state.wrongTarget = false; state.hasExport = true
|
||||
})
|
||||
|
||||
it.each([true, false])('standalone disables the Javalin UI regardless of a retained export (%s)', async (hasExport) => {
|
||||
state.hasExport = hasExport
|
||||
const server = await variant()
|
||||
try {
|
||||
await server.startVariant('TABLE_BASED')
|
||||
const java = state.launched.find(({ command }) => command === 'java')!
|
||||
expect(java.args).toContain('-Dqqq.javalin.frontend=none')
|
||||
expect(java.args[java.args.indexOf('-cp') + 1]).not.toContain('export-classpath')
|
||||
expect(server.SECURITY_UI_URL).toBe('http://127.0.0.1:13785')
|
||||
expect(server.SECURITY_URL).toBe('http://127.0.0.1:18775')
|
||||
const node = state.launched.find(({ command }) => command !== 'java')!
|
||||
expect(node.args).toEqual(['qqq-server.mjs'])
|
||||
expect(node.options).toMatchObject({ env: { PORT: '13785', QQQ_DASHBOARD_CSP_SOURCES: '' } })
|
||||
} finally { await server.stopVariant() }
|
||||
})
|
||||
|
||||
it('rejects a valid build marker whose actual rewrite still targets the shared backend', async () => {
|
||||
state.wrongTarget = true
|
||||
const server = await variant()
|
||||
await expect(server.startVariant('TABLE_BASED')).rejects.toThrow('wrong backend rewrite')
|
||||
expect(state.launched.map(({ command }) => command)).toEqual(['java'])
|
||||
expect(state.killed).toEqual(['java:SIGTERM'])
|
||||
})
|
||||
|
||||
it('rejects a backend that still serves a dashboard despite standalone mode', async () => {
|
||||
state.backendServesHtml = true
|
||||
const server = await variant()
|
||||
await expect(server.startVariant('TABLE_BASED')).rejects.toThrow('unexpectedly serves a dashboard')
|
||||
expect(state.launched.map(({ command }) => command)).toEqual(['java'])
|
||||
expect(state.killed).toEqual(['java:SIGTERM'])
|
||||
})
|
||||
|
||||
it('rejects metadata from the wrong backend mode before starting Node', async () => {
|
||||
state.mode = 'MOCK'
|
||||
const server = await variant()
|
||||
await expect(server.startVariant('TABLE_BASED')).rejects.toThrow('mode does not match')
|
||||
expect(state.launched.map(({ command }) => command)).toEqual(['java'])
|
||||
})
|
||||
|
||||
it('preserves Javalin export serving and its browser origin', async () => {
|
||||
const server = await variant('javalin')
|
||||
try {
|
||||
await server.startVariant('TABLE_BASED')
|
||||
expect(state.launched).toHaveLength(1)
|
||||
expect(state.launched[0].args).toContain('-Dqqq.javalin.frontend=next')
|
||||
expect(state.launched[0].args.join(' ')).toContain('export-classpath')
|
||||
expect(server.SECURITY_UI_URL).toBe('http://127.0.0.1:18775')
|
||||
} finally { await server.stopVariant() }
|
||||
})
|
||||
|
||||
it('reuses one mode but restarts Node before changing identity-provider metadata', async () => {
|
||||
const server = await variant()
|
||||
try {
|
||||
await server.startVariant('TABLE_BASED')
|
||||
await server.startVariant('TABLE_BASED')
|
||||
expect(state.launched).toHaveLength(2)
|
||||
state.mode = 'OAUTH2'
|
||||
await server.startVariant('OAUTH2')
|
||||
expect(state.launched).toHaveLength(4)
|
||||
expect(state.killed).toEqual([`${process.execPath}:SIGTERM`, 'java:SIGTERM'])
|
||||
} finally { await server.stopVariant() }
|
||||
expect(state.killed).toEqual([`${process.execPath}:SIGTERM`, 'java:SIGTERM', `${process.execPath}:SIGTERM`, 'java:SIGTERM'])
|
||||
})
|
||||
|
||||
it('Javalin still requires an export rather than silently switching server modes', async () => {
|
||||
state.hasExport = false
|
||||
const server = await variant('javalin')
|
||||
await expect(server.startVariant('TABLE_BASED')).rejects.toThrow('No Next export')
|
||||
expect(state.launched).toEqual([])
|
||||
})
|
||||
|
||||
it('bounds startup requests and cleans up Java when metadata loading aborts', async () => {
|
||||
state.failMetadata = true
|
||||
const server = await variant()
|
||||
await expect(server.startVariant('TABLE_BASED')).rejects.toThrow('metadata request aborted')
|
||||
expect(state.signals).toHaveLength(3)
|
||||
expect(state.signals.every((signal) => signal instanceof AbortSignal)).toBe(true)
|
||||
expect(state.killed).toEqual(['java:SIGTERM'])
|
||||
})
|
||||
|
||||
it('waits for forced Node termination before replacing a variant backend', async () => {
|
||||
vi.useFakeTimers()
|
||||
const server = await variant()
|
||||
try {
|
||||
await server.startVariant('TABLE_BASED')
|
||||
state.deferNodeExit = true
|
||||
state.mode = 'OAUTH2'
|
||||
const replacing = server.startVariant('OAUTH2')
|
||||
expect(state.launched).toHaveLength(2)
|
||||
await vi.advanceTimersByTimeAsync(15_001)
|
||||
expect(state.launched).toHaveLength(2)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
await replacing
|
||||
expect(state.killed).toEqual([`${process.execPath}:SIGTERM`, `${process.execPath}:SIGKILL`, 'java:SIGTERM'])
|
||||
expect(state.launched).toHaveLength(4)
|
||||
} finally { state.deferNodeExit = false; await server.stopVariant() }
|
||||
})
|
||||
|
||||
it('never reuses a same-mode server after its bounded teardown failed', async () => {
|
||||
vi.useFakeTimers()
|
||||
const server = await variant()
|
||||
try {
|
||||
await server.startVariant('TABLE_BASED')
|
||||
state.ignoreAllKill = true
|
||||
const stopping = server.stopVariant().catch((error: Error) => error)
|
||||
await vi.advanceTimersByTimeAsync(40_001)
|
||||
expect(await stopping).toBeInstanceOf(Error)
|
||||
const restarting = server.startVariant('TABLE_BASED').catch((error: Error) => error)
|
||||
await vi.advanceTimersByTimeAsync(40_001)
|
||||
expect(await restarting).toBeInstanceOf(Error)
|
||||
expect(state.launched).toHaveLength(2)
|
||||
} finally { state.ignoreAllKill = false; await server.stopVariant() }
|
||||
})
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
// Record URL state must be durable before the user reloads or leaves the page.
|
||||
import { test, expect } from '@playwright/test'
|
||||
import { setupApiMocks, METADATA } from './api-mocks'
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
const table = structuredClone(METADATA.tables.person)
|
||||
table.sections.push(
|
||||
{ ...table.sections[0], name: 'contact', label: 'Contact', tier: 'T2', fieldNames: ['email'] },
|
||||
{ ...table.sections[0], name: 'status', label: 'Status', tier: 'T3', fieldNames: ['status'] },
|
||||
)
|
||||
await page.route('**/qqq/v1/metaData/table/person**', (route) => route.fulfill({ json: table }))
|
||||
await page.goto('/app/person/1?from=%2Fapp%2Fperson&fromLabel=People&keep=yes')
|
||||
await expect(page.getByRole('tab', { name: 'Contact', exact: true })).toBeVisible()
|
||||
await page.waitForLoadState('networkidle')
|
||||
})
|
||||
|
||||
test('tab selection survives an immediate reload', async ({ page }, testInfo) => {
|
||||
const requests: string[] = []
|
||||
page.on('request', (request) => {
|
||||
if (request.isNavigationRequest() || request.url().includes('_rsc=')) requests.push(request.url())
|
||||
})
|
||||
const before = await page.evaluate(() => history.length)
|
||||
const reloaded = page.waitForEvent('domcontentloaded')
|
||||
const urlAtClick = await page.getByRole('tab', { name: 'Contact', exact: true }).evaluate((tab) => {
|
||||
(tab as HTMLElement).click()
|
||||
const url = location.href
|
||||
location.reload()
|
||||
return url
|
||||
})
|
||||
await reloaded
|
||||
await testInfo.attach('navigation', { body: JSON.stringify({ urlAtClick, requests }), contentType: 'application/json' })
|
||||
await expect(page.getByRole('tab', { name: 'Contact', exact: true })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(new URL(urlAtClick).searchParams.get('tab')).toBe('section-contact')
|
||||
expect(await page.evaluate(() => history.length)).toBe(before)
|
||||
})
|
||||
|
||||
test('view selection preserves URL context and survives immediate departure and Back', async ({ page }, testInfo) => {
|
||||
await page.evaluate(() => history.replaceState(history.state, '', location.href + '#identity'))
|
||||
const requests: string[] = []
|
||||
page.on('request', (request) => {
|
||||
if (request.isNavigationRequest() || request.url().includes('_rsc=')) requests.push(request.url())
|
||||
})
|
||||
const snapshot = await page.getByRole('radio', { name: 'List view' }).evaluate((radio) => {
|
||||
(radio as HTMLElement).focus()
|
||||
const scroll = window.scrollY
|
||||
;(radio as HTMLElement).click()
|
||||
const result = { url: location.href, focused: document.activeElement === radio, scrollBefore: scroll, scrollAfter: window.scrollY }
|
||||
location.assign('/app/person')
|
||||
return result
|
||||
})
|
||||
await expect(page).toHaveURL(/\/app\/person\/?$/)
|
||||
await expect(page.locator('[data-qqq-id="main-content"]')).toBeVisible()
|
||||
await page.goBack()
|
||||
await expect(page.locator('[data-qqq-id="record-view-list-mode"]')).toBeVisible()
|
||||
await testInfo.attach('navigation', { body: JSON.stringify({ snapshot, requests, returnUrl: page.url() }), contentType: 'application/json' })
|
||||
const url = new URL(page.url())
|
||||
expect(url.searchParams.get('view')).toBe('list')
|
||||
expect(url.searchParams.get('from')).toBe('/app/person')
|
||||
expect(url.searchParams.get('fromLabel')).toBe('People')
|
||||
expect(url.searchParams.get('keep')).toBe('yes')
|
||||
expect(url.hash).toBe('#identity')
|
||||
expect(snapshot.focused).toBe(true)
|
||||
expect(snapshot.scrollAfter).toBe(snapshot.scrollBefore)
|
||||
await expect(page.locator('[data-qqq-id="link-back-to-table"]')).toHaveAttribute('href', /\/app\/person\/?$/)
|
||||
})
|
||||
|
||||
test('keyboard tabs retain focus and view changes do not add history entries', async ({ page }, testInfo) => {
|
||||
const requests: string[] = []
|
||||
page.on('request', (request) => {
|
||||
if (request.isNavigationRequest() || request.url().includes('_rsc=')) requests.push(request.url())
|
||||
})
|
||||
await page.setViewportSize({ width: 1280, height: 480 })
|
||||
const contact = page.getByRole('tab', { name: 'Contact', exact: true })
|
||||
await contact.focus()
|
||||
const before = await page.evaluate(() => {
|
||||
const main = document.querySelector<HTMLElement>('#main-content')!
|
||||
main.scrollTop = 30
|
||||
return { length: history.length, scroll: main.scrollTop }
|
||||
})
|
||||
expect(before.scroll).toBeGreaterThan(0)
|
||||
await page.keyboard.press('Enter')
|
||||
await expect(contact).toHaveAttribute('aria-selected', 'true')
|
||||
await expect(contact).toBeFocused()
|
||||
expect(await page.locator('#main-content').evaluate((main) => main.scrollTop)).toBe(before.scroll)
|
||||
await page.getByRole('radio', { name: 'List view' }).click()
|
||||
await expect(page.locator('[data-qqq-id="record-view-list-mode"]')).toBeVisible()
|
||||
await page.getByRole('radio', { name: 'Card view' }).click()
|
||||
await expect(contact).toHaveAttribute('aria-selected', 'true')
|
||||
await testInfo.attach('before-reload', { body: JSON.stringify({ before, url: page.url(), requests }), contentType: 'application/json' })
|
||||
expect(requests).toEqual([])
|
||||
await page.reload()
|
||||
await expect(contact).toHaveAttribute('aria-selected', 'true')
|
||||
expect(await page.evaluate(() => history.length)).toBe(before.length)
|
||||
await testInfo.attach('navigation', { body: JSON.stringify({ before, url: page.url(), requests }), contentType: 'application/json' })
|
||||
})
|
||||
|
||||
|
||||
test('successive tab and view changes preserve both values and the saved view preference', async ({ page }) => {
|
||||
await page.evaluate(() => localStorage.setItem('qqq-user-preferences', JSON.stringify({ recordDefaultViewMode: 'list' })))
|
||||
await page.reload()
|
||||
await expect(page.getByRole('radio', { name: 'List view' })).toHaveAttribute('aria-checked', 'true')
|
||||
await page.goto('/app/person/1?view=tabs&keep=yes#identity')
|
||||
const contact = page.getByRole('tab', { name: 'Contact', exact: true })
|
||||
await expect(contact).toBeVisible()
|
||||
const snapshot = await page.evaluate(() => {
|
||||
const tab = document.querySelector<HTMLElement>('[data-qqq-id="record-tab-section-contact"]')
|
||||
if (!tab) throw new Error('Contact tab is missing')
|
||||
tab.click()
|
||||
document.querySelector<HTMLElement>('[role="radio"][aria-label="List view"]')!.click()
|
||||
return { url: location.href, preference: localStorage.getItem('qqq-user-preferences') }
|
||||
})
|
||||
const url = new URL(snapshot.url)
|
||||
expect(url.searchParams.get('tab')).toBe('section-contact')
|
||||
expect(url.searchParams.get('view')).toBe('list')
|
||||
expect(url.searchParams.get('keep')).toBe('yes')
|
||||
expect(url.hash).toBe('#identity')
|
||||
expect(JSON.parse(snapshot.preference!).recordDefaultViewMode).toBe('list')
|
||||
await page.reload()
|
||||
await expect(page.locator('[data-qqq-id="record-view-list-mode"]')).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
/** @file Standalone staging must preserve the actual build and reject a stale proxy destination. */
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, symlinkSync, readlinkSync, statSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { assertStandaloneTarget, stageStandalone, assertExplicitCspSources } from '../../scripts/acceptance-standalone.mjs'
|
||||
|
||||
const directories: string[] = []
|
||||
const backend = 'http://127.0.0.1:18775'
|
||||
function fixture() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'qqq-standalone-stage-'))
|
||||
directories.push(root)
|
||||
const source = path.join(root, 'source')
|
||||
const staged = path.join(root, 'staged')
|
||||
const files: Record<string, string> = {
|
||||
'.next/standalone/server.js': 'immutable generated server',
|
||||
'.next/standalone/.next/routes-manifest.json': JSON.stringify({ rewrites: { beforeFiles: [], afterFiles: ['qqq', 'data', 'widget', 'metaData', 'download', 'processes', 'possibleValues', 'reports', 'manageSession', 'apis.json', 'api'].map((prefix) => ({ source: `/${prefix}/:path*`, destination: `${backend}/${prefix}/:path*` })), fallback: [] } }),
|
||||
'.next/static/chunk.js': 'browser chunk',
|
||||
'.next/standalone/node_modules/.pnpm/example/index.js': 'independent dependency',
|
||||
'public/icon.svg': '<svg/>',
|
||||
'standalone/qqq-server.mjs': 'container entrypoint',
|
||||
'standalone/security-headers.mjs': 'policy implementation',
|
||||
'standalone/branding-assets.mjs': 'declared branding aliases',
|
||||
'standalone/backend-prefixes.json': '[]',
|
||||
}
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
const file = path.join(source, name); mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 }); writeFileSync(file, content, { mode: 0o600 })
|
||||
}
|
||||
symlinkSync('.pnpm/example', path.join(source, '.next/standalone/node_modules/example'))
|
||||
return { source, staged }
|
||||
}
|
||||
|
||||
afterEach(() => { for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }) })
|
||||
|
||||
it('keeps the variant build, static assets and entrypoint after the main build directory is replaced', () => {
|
||||
const { source, staged } = fixture()
|
||||
stageStandalone(staged, backend, source)
|
||||
rmSync(path.join(source, '.next'), { recursive: true })
|
||||
expect(() => assertStandaloneTarget(staged, backend)).not.toThrow()
|
||||
expect(readFileSync(path.join(staged, 'server.js'), 'utf8')).toBe('immutable generated server')
|
||||
expect(readFileSync(path.join(staged, '.next/static/chunk.js'), 'utf8')).toBe('browser chunk')
|
||||
expect(readFileSync(path.join(staged, 'public/icon.svg'), 'utf8')).toBe('<svg/>')
|
||||
expect(readFileSync(path.join(staged, 'qqq-server.mjs'), 'utf8')).toBe('container entrypoint')
|
||||
expect(readFileSync(path.join(staged, 'branding-assets.mjs'), 'utf8')).toBe('declared branding aliases')
|
||||
expect(readlinkSync(path.join(staged, 'node_modules/example'))).toBe('.pnpm/example')
|
||||
expect(readFileSync(path.join(staged, 'node_modules/example/index.js'), 'utf8')).toBe('independent dependency')
|
||||
expect(statSync(path.join(staged, 'server.js')).mode & 0o777).toBe(0o600)
|
||||
expect(statSync(path.join(staged, '.next/static')).mode & 0o777).toBe(0o700)
|
||||
})
|
||||
|
||||
it('rejects an old shared-backend rewrite even with a new valid variant marker', () => {
|
||||
const { source, staged } = fixture()
|
||||
const file = path.join(source, '.next/standalone/.next/routes-manifest.json')
|
||||
writeFileSync(file, readFileSync(file, 'utf8').replace(/:18775\//g, ':18765/'))
|
||||
expect(() => stageStandalone(staged, backend, source)).toThrow('wrong backend rewrite')
|
||||
expect(readFileSync(path.join(staged, 'acceptance-backend.txt'), 'utf8')).toBe(`${backend}\n`)
|
||||
})
|
||||
|
||||
it('rejects a staged build bound to a different backend port', () => {
|
||||
const { source, staged } = fixture()
|
||||
stageStandalone(staged, backend, source)
|
||||
expect(() => assertStandaloneTarget(staged, 'http://127.0.0.1:18875')).toThrow('No standalone security build')
|
||||
})
|
||||
|
||||
it('allows only explicit owned service overrides, never metadata-derived admissions', () => {
|
||||
expect(() => assertExplicitCspSources('frame-src http://127.0.0.1:9000; img-src http://127.0.0.1:9000; media-src http://127.0.0.1:9000')).not.toThrow()
|
||||
for (const sources of ['script-src https://cdn.example', 'connect-src https://analytics.example', 'frame-src https://*.quicksight.aws.amazon.com',
|
||||
'frame-src https://user:secret@owned.example', 'frame-src https://owned.example/path', 'img-src data:', 'default-src *']) {
|
||||
expect(() => assertExplicitCspSources(sources)).toThrow('Only explicit owned-service CSP overrides are allowed')
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Path authority, public-file precedence and bounded branding cache contracts. */
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { brandingImagePath, createBrandingAssetAlias } from '../../standalone/branding-assets.mjs'
|
||||
|
||||
const invalid = ['logo.png', 'https://images.example/a.png', '//images.example/a.png', 'data:image/png;base64,a', '/', '/brand/../a.png', '/brand/%2e%2e/a.png', '/brand/%252e%252e/a.png', '/brand/a%2fb.png', '/brand/a%5cb.png', '/brand//a.png', '/brand/./a.png', '/brand/a%00.png', '/brand/a%ZZ.png', '/brand/a%FF.png', '/brand/index.html', '/brand/private.js', '/app/a.png', '/login/a.png', '/callback/a.png', '/_next/a.png', '/qqq/a.png', '/data/a.png', '/metaData/a.png']
|
||||
const roots: string[] = []
|
||||
function directory() { const root = mkdtempSync(path.join(tmpdir(), 'branding-assets-')); roots.push(root); return root }
|
||||
afterEach(() => { vi.restoreAllMocks(); for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) })
|
||||
|
||||
it.each(invalid)('rejects unsafe or unrelated declaration %s before routing', (value) => expect(brandingImagePath(value)).toBeNull())
|
||||
it('canonicalizes only once and excludes query/fragment from lookup', () => {
|
||||
expect(brandingImagePath('/brand/logo.png?v=1#icon')).toBe('/brand/logo.png')
|
||||
expect(brandingImagePath('/brand/a%20b.png')).toBe('/brand/a b.png')
|
||||
expect(brandingImagePath('/brand/%C3%A9.png')).toBe('/brand/é.png')
|
||||
expect(brandingImagePath('/brand/a+b.PNG')).toBe('/brand/a+b.PNG')
|
||||
expect(brandingImagePath(null)).toBeNull()
|
||||
})
|
||||
it('uses at most two declared roles with GET/HEAD and leaves other methods/routes alone', async () => {
|
||||
const load = vi.fn(async () => ({ branding: { logo: '/brand/logo.png?v=1', icon: '/brand/icon.svg' } }))
|
||||
const alias = createBrandingAssetAlias(load, directory())
|
||||
expect(await alias('POST', '/brand/logo.png')).toBeNull()
|
||||
for (const value of invalid) expect(await alias('GET', value)).toBeNull()
|
||||
expect(load).not.toHaveBeenCalled()
|
||||
expect(await alias('GET', '/brand/logo.png?cache=2')).toBe('/qqq/branding/logo')
|
||||
expect(await alias('HEAD', '/brand/icon.svg')).toBe('/qqq/branding/icon')
|
||||
expect(await alias('GET', '/brand/unlisted.png')).toBeNull()
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
it('keeps a real public file with Next, while outside-root symlinks do not grant local-file authority', async () => {
|
||||
const root = directory(); const outside = directory()
|
||||
mkdirSync(path.join(root, 'brand'))
|
||||
writeFileSync(path.join(root, 'brand/logo.png'), 'local public file')
|
||||
writeFileSync(path.join(outside, 'icon.svg'), 'outside bytes')
|
||||
symlinkSync(path.join(outside, 'icon.svg'), path.join(root, 'brand/icon.svg'))
|
||||
const load = vi.fn(async () => ({ branding: { logo: '/brand/logo.png', icon: '/brand/icon.svg' } }))
|
||||
const alias = createBrandingAssetAlias(load, root)
|
||||
expect(await alias('GET', '/brand/logo.png')).toBeNull()
|
||||
expect(load).not.toHaveBeenCalled()
|
||||
expect(await alias('GET', '/brand/icon.svg')).toBe('/qqq/branding/icon')
|
||||
})
|
||||
it('refreshes expired declarations before use and removes old aliases on failed refresh', async () => {
|
||||
let now = 100; vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
const load = vi.fn().mockResolvedValueOnce({ branding: { logo: '/first.png' } }).mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce({ branding: { logo: '/second.png' } })
|
||||
const alias = createBrandingAssetAlias(load, directory())
|
||||
expect(await alias('GET', '/first.png')).toBe('/qqq/branding/logo')
|
||||
now += 60_000
|
||||
expect(await alias('GET', '/first.png')).toBeNull()
|
||||
expect(await alias('GET', '/second.png')).toBeNull()
|
||||
expect(load).toHaveBeenCalledTimes(2)
|
||||
now += 5_000
|
||||
expect(await alias('GET', '/second.png')).toBe('/qqq/branding/logo')
|
||||
expect(await alias('GET', '/first.png')).toBeNull()
|
||||
})
|
||||
it('coalesces concurrent initial loads and consistently prefers logo for identical paths', async () => {
|
||||
let finish!: (value: unknown) => void
|
||||
const load = vi.fn(() => new Promise((resolve) => { finish = resolve }))
|
||||
const alias = createBrandingAssetAlias(load, directory())
|
||||
const first = alias('GET', '/same.png'); const second = alias('HEAD', '/same.png')
|
||||
await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(1))
|
||||
finish({ branding: { logo: '/same.png', icon: '/same.png' } })
|
||||
expect(await first).toBe('/qqq/branding/logo'); expect(await second).toBe('/qqq/branding/logo')
|
||||
})
|
||||
it('missing and external-only branding adds no aliases', async () => {
|
||||
for (const value of [undefined, {}, { branding: null }, { branding: { logo: 'https://images.example/logo.png', icon: 'data:image/png;base64,a' } }]) {
|
||||
const alias = createBrandingAssetAlias(async () => value, directory())
|
||||
expect(await alias('GET', '/logo.png')).toBeNull()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/** @file Real standalone wrapper dispatch for declared branding assets. */
|
||||
import { spawn, type ChildProcess } from 'node:child_process'
|
||||
import { createServer, type Server } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { mkdtempSync, copyFileSync, writeFileSync, readdirSync, rmSync, mkdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
|
||||
let child: ChildProcess | undefined
|
||||
let backend: Server | undefined
|
||||
let stage: string | undefined
|
||||
afterEach(async () => {
|
||||
if (child && child.exitCode === null) {
|
||||
const exited = new Promise<void>((resolve) => child!.once('exit', () => resolve()))
|
||||
child.kill('SIGKILL')
|
||||
await exited
|
||||
}
|
||||
if (backend) await new Promise<void>((resolve, reject) => backend!.close((error) => error ? reject(error) : resolve()))
|
||||
if (stage) rmSync(stage, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function startWrapper(metadataFails = false) {
|
||||
const requests: string[] = []
|
||||
backend = createServer((req, res) => {
|
||||
requests.push(req.url ?? '')
|
||||
if (req.url === '/qqq/v1/metaData/authentication') {
|
||||
if (metadataFails) return
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.end(JSON.stringify({ type: 'FULLY_ANONYMOUS', dashboardCspSources: { connectSrc: ['https://analytics.example'], scriptSrc: ['https://cdn.example'], frameSrc: ['https://*.quicksight.aws.amazon.com'], styleSrc: [] }, branding: { logo: '/brand/logo.png?version=1', icon: '/local-icon.svg' } }))
|
||||
} else if (req.url === '/qqq/branding/logo') {
|
||||
res.setHeader('Content-Type', 'image/png')
|
||||
res.end('owned image bytes')
|
||||
} else { res.statusCode = 404; res.end() }
|
||||
})
|
||||
await new Promise<void>((resolve) => backend!.listen(0, '127.0.0.1', resolve))
|
||||
const target = `http://127.0.0.1:${(backend.address() as AddressInfo).port}`
|
||||
const lease = createServer()
|
||||
await new Promise<void>((resolve) => lease.listen(0, '127.0.0.1', resolve))
|
||||
const port = (lease.address() as AddressInfo).port
|
||||
await new Promise<void>((resolve) => lease.close(() => resolve()))
|
||||
stage = mkdtempSync(path.join(tmpdir(), 'branding-wrapper-'))
|
||||
for (const name of readdirSync('standalone').filter((name) => /\.(mjs|json)$/.test(name))) copyFileSync(path.join('standalone', name), path.join(stage, name))
|
||||
mkdirSync(path.join(stage, 'public'))
|
||||
writeFileSync(path.join(stage, 'public/local-icon.svg'), '<svg>local public bytes</svg>')
|
||||
// The controlled Next listener forwards only the existing baked /qqq prefix.
|
||||
writeFileSync(path.join(stage, 'server.js'), `const http = require('node:http');
|
||||
http.createServer(async (req, res) => {
|
||||
if (req.url.startsWith('/qqq/')) {
|
||||
const response = await fetch(${JSON.stringify(target)} + req.url, { method: req.method });
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers));
|
||||
res.end(Buffer.from(await response.arrayBuffer()));
|
||||
} else if (req.url === '/login') { res.setHeader('Content-Type', 'text/html'); res.end('<html><script>self.boot = true</script></html>'); }
|
||||
else if (req.url === '/local-icon.svg') { res.end(require('node:fs').readFileSync('public/local-icon.svg')); }
|
||||
else { res.statusCode = 404; res.end('Next route not found'); }
|
||||
}).listen(process.env.PORT, '127.0.0.1', () => process.send('ready'));`)
|
||||
child = spawn(process.execPath, ['qqq-server.mjs'], { cwd: stage, env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port), QQQ_DASHBOARD_CSP_SOURCES: '' }, stdio: ['ignore', 'ignore', 'ignore', 'ipc'] })
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('wrapper startup timeout')), 5000)
|
||||
child!.once('message', () => { clearTimeout(timer); resolve() })
|
||||
child!.once('exit', (code) => { clearTimeout(timer); reject(new Error(`wrapper exited ${code}`)) })
|
||||
})
|
||||
return { origin: `http://127.0.0.1:${port}`, requests }
|
||||
}
|
||||
|
||||
it('aliases only declared images through the baked backend route and keeps local files with Next', async () => {
|
||||
const { origin, requests } = await startWrapper()
|
||||
const response = await fetch(`${origin}/brand/logo.png?cache=2`)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe('owned image bytes')
|
||||
expect(requests).toContain('/qqq/branding/logo')
|
||||
const before = requests.length
|
||||
expect((await fetch(`${origin}/unlisted.png`)).status).toBe(404)
|
||||
expect(requests.slice(before)).not.toContain('/qqq/branding/logo')
|
||||
expect(await (await fetch(`${origin}/local-icon.svg`)).text()).toBe('<svg>local public bytes</svg>')
|
||||
expect(requests).not.toContain('/qqq/branding/icon')
|
||||
expect((await fetch(`${origin}/brand/logo.png`, { method: 'HEAD' })).status).toBe(200)
|
||||
}, 15000)
|
||||
|
||||
it('bounds unavailable metadata at startup and preserves the real not-found response', async () => {
|
||||
const { origin, requests } = await startWrapper(true)
|
||||
const started = Date.now()
|
||||
// Alias and existing CSP first-load requests each retain their independent 5s bound.
|
||||
const response = await fetch(`${origin}/brand/logo.png`, { signal: AbortSignal.timeout(13000) })
|
||||
expect(response.status).toBe(404)
|
||||
expect(Date.now() - started).toBeLessThan(12500)
|
||||
expect(requests.filter((url) => url === '/qqq/v1/metaData/authentication')).toHaveLength(2)
|
||||
expect(requests).not.toContain('/qqq/branding/logo')
|
||||
}, 15000)
|
||||
|
||||
it('applies public backend policy inputs to an actual HTML response with no operator additions', async () => {
|
||||
const { origin, requests } = await startWrapper()
|
||||
const response = await fetch(`${origin}/login`)
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.text()).toBe('<html><script>self.boot = true</script></html>')
|
||||
const policy = response.headers.get('content-security-policy') ?? ''
|
||||
expect(policy).toContain("script-src 'self' https://cdn.example 'sha256-")
|
||||
expect(policy).toContain("connect-src 'self' https://analytics.example")
|
||||
expect(policy).toContain("frame-src 'self' https://*.quicksight.aws.amazon.com")
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
expect(response.headers.get('x-frame-options')).toBe('DENY')
|
||||
expect(requests.every((url) => url === '/qqq/v1/metaData/authentication')).toBe(true)
|
||||
}, 15000)
|
||||
@@ -25,7 +25,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
applyDashboardSecurityHeaders,
|
||||
buildContentSecurityPolicy,
|
||||
createIdentityProviderOrigins,
|
||||
createDashboardPolicyInputs,
|
||||
identityProviderOrigins,
|
||||
inlineScriptHashes,
|
||||
isBackendPath,
|
||||
@@ -110,6 +110,63 @@ describe('dashboard policy (QRun-IO/qqq#734)', () => {
|
||||
expect(policy.split('; ').map((part) => part.split(' ')[0]).slice(-2)).toEqual(['frame-ancestors', 'report-uri'])
|
||||
})
|
||||
|
||||
it('adds backend metadata sources without operator duplication or relaxed defaults', () => {
|
||||
const policy = buildContentSecurityPolicy({
|
||||
dashboardCspSources: {
|
||||
connectSrc: ['https://*.google-analytics.com'],
|
||||
scriptSrc: ['https://cdn.example.test'],
|
||||
frameSrc: ['https://*.quicksight.aws.amazon.com'],
|
||||
styleSrc: ['https://accounts.google.com/gsi/style'],
|
||||
},
|
||||
scriptHashes: ["'sha256-owned='"],
|
||||
})
|
||||
expect(policy).toContain("script-src 'self' https://cdn.example.test 'sha256-owned='")
|
||||
expect(policy).toContain("connect-src 'self' https://*.google-analytics.com")
|
||||
expect(policy).toContain("frame-src 'self' https://*.quicksight.aws.amazon.com")
|
||||
expect(policy).toContain("style-src 'self' 'unsafe-inline' https://accounts.google.com/gsi/style")
|
||||
expect(policy).toContain("object-src 'none'")
|
||||
expect(policy).not.toContain('unsafe-eval')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'*', 'https://*.example.test', "'unsafe-inline'", "'nonce-owned'", 'data:',
|
||||
'https://cdn.example.test;script-src *', 'https://user:secret@cdn.example.test',
|
||||
'https://cdn.example.test?token=secret', 'https://cdn.example.test#secret',
|
||||
'https://cdn.example.test/path', 'https://cdn.example.test\n', 'https://cdn.example.test,https://other.test',
|
||||
])('rejects unsafe metadata source %s without quoting it in the error', (source) => {
|
||||
const make = () => buildContentSecurityPolicy({ dashboardCspSources: { connectSrc: [], scriptSrc: [source], frameSrc: [], styleSrc: [] } })
|
||||
expect(make).toThrow(/^Invalid dashboard CSP source metadata$/)
|
||||
})
|
||||
|
||||
it.each(['https://cdn.example.test:443', 'http://cdn.example.test:80', 'http://[0:0:0:0:0:0:0:1]:80', 'https://[2001:db8:0:0::1]:443'])('accepts the Java origin-only spelling %s without changing its scope', (source) => {
|
||||
const policy = buildContentSecurityPolicy({ dashboardCspSources: { connectSrc: [], scriptSrc: [source], frameSrc: [], styleSrc: [] } })
|
||||
expect(policy).toContain(`script-src 'self' ${source};`)
|
||||
})
|
||||
|
||||
it('rejects unknown fields, missing fields, oversized collections and wrong-directive special sources', () => {
|
||||
const empty = { connectSrc: [], scriptSrc: [], frameSrc: [], styleSrc: [] }
|
||||
for (const value of [null, [], {}, { ...empty, policy: "script-src *" },
|
||||
{ ...empty, scriptSrc: Array(65).fill('https://cdn.example.test') },
|
||||
{ ...empty, scriptSrc: ['https://*.quicksight.aws.amazon.com'] },
|
||||
{ ...empty, scriptSrc: ['https://' + 'a'.repeat(513)] },
|
||||
{ ...empty, scriptSrc: [42] }]) {
|
||||
expect(() => buildContentSecurityPolicy({ dashboardCspSources: value })).toThrow(/^Invalid dashboard CSP source metadata$/)
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts only the backend fixed wildcard/path forms in their original directives', () => {
|
||||
const policy = buildContentSecurityPolicy({ dashboardCspSources: {
|
||||
connectSrc: ['https://*.google-analytics.com', 'https://*.analytics.google.com', 'https://accounts.google.com/gsi/', 'http://127.0.0.1:9000'],
|
||||
scriptSrc: ['https://accounts.google.com/gsi/client', 'https://apis.google.com'],
|
||||
frameSrc: ['https://*.quicksight.aws.amazon.com', 'https://accounts.google.com/gsi/', 'https://docs.google.com'],
|
||||
styleSrc: ['https://accounts.google.com/gsi/style'],
|
||||
} })
|
||||
expect(policy).toContain('https://accounts.google.com/gsi/client')
|
||||
expect(policy).toContain('https://*.analytics.google.com')
|
||||
expect(policy).toContain('http://127.0.0.1:9000')
|
||||
expect(policy).not.toContain('https://accounts.google.com;')
|
||||
})
|
||||
|
||||
it('refuses malformed additions', () => {
|
||||
expect(parseSourceAdditions(undefined)).toEqual([])
|
||||
expect(parseSourceAdditions(' ; ')).toEqual([])
|
||||
@@ -154,24 +211,43 @@ describe('identity provider origins from the authentication metadata', () => {
|
||||
.mockResolvedValueOnce({ type: 'OAUTH2', values: { baseUrl: 'https://one.example' } })
|
||||
.mockRejectedValueOnce(new Error('backend down'))
|
||||
.mockResolvedValueOnce({ type: 'OAUTH2', values: { baseUrl: 'https://two.example' } })
|
||||
const origins = createIdentityProviderOrigins(load, 100)
|
||||
expect(await origins()).toEqual(['https://one.example'])
|
||||
expect(await origins()).toEqual(['https://one.example'])
|
||||
const origins = createDashboardPolicyInputs(load, 100)
|
||||
expect((await origins()).connectOrigins).toEqual(['https://one.example'])
|
||||
expect((await origins()).connectOrigins).toEqual(['https://one.example'])
|
||||
expect(load).toHaveBeenCalledTimes(1)
|
||||
now += 200
|
||||
expect(await origins()).toEqual(['https://one.example'])
|
||||
expect((await origins()).connectOrigins).toEqual(['https://one.example'])
|
||||
await vi.waitFor(() => expect(warn).toHaveBeenCalled())
|
||||
now += 200
|
||||
await origins()
|
||||
await vi.waitFor(async () => expect(await origins()).toEqual(['https://two.example']))
|
||||
await vi.waitFor(async () => expect((await origins()).connectOrigins).toEqual(['https://two.example']))
|
||||
expect(load).toHaveBeenCalledTimes(3)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('copies metadata inputs and retains the last valid policy when a refresh is malformed', async () => {
|
||||
let now = 1_000
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const first = { type: 'OAUTH2', values: { baseUrl: 'https://idp.example' }, dashboardCspSources: { connectSrc: [], scriptSrc: ['https://cdn.example'], frameSrc: [], styleSrc: [] } }
|
||||
const load = vi.fn().mockResolvedValueOnce(first).mockResolvedValueOnce({ dashboardCspSources: { policy: '*' } })
|
||||
const inputs = createDashboardPolicyInputs(load, 100)
|
||||
const captured = await inputs()
|
||||
first.dashboardCspSources.scriptSrc[0] = 'https://changed.example'
|
||||
expect(buildContentSecurityPolicy(captured)).toContain("script-src 'self' https://cdn.example")
|
||||
expect(buildContentSecurityPolicy(captured)).toContain("connect-src 'self' https://idp.example")
|
||||
now += 200
|
||||
await inputs()
|
||||
await vi.waitFor(() => expect(console.warn).toHaveBeenCalled())
|
||||
expect(buildContentSecurityPolicy(await inputs())).toBe(buildContentSecurityPolicy(captured))
|
||||
expect(vi.mocked(console.warn).mock.calls[0]?.[1]).toBe('Error')
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('without any metadata allows no identity provider', async () => {
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined)
|
||||
const origins = createIdentityProviderOrigins(() => Promise.reject(new Error('backend down')))
|
||||
expect(await origins()).toEqual([])
|
||||
const origins = createDashboardPolicyInputs(() => Promise.reject(new Error('backend down')))
|
||||
expect((await origins()).connectOrigins).toEqual([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user