Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68c2567f02
|
||
|
|
26ab79f752
|
||
|
|
18d1fd6e37 | ||
|
|
eee644a280 | ||
|
|
7682aa1ff0 | ||
|
|
8e334cd944 | ||
|
|
255554a184 | ||
|
|
e1d60b9634 | ||
|
|
d81b7ed5ae | ||
|
|
b8313b6819 | ||
|
|
22c9e02f20 | ||
|
|
138c3c6db5 | ||
|
|
70f26e9c9a | ||
|
|
2bc75d779e | ||
|
|
17e22edaee | ||
|
|
66d02c7b6a | ||
|
|
20fe7b7b9b | ||
|
|
4190ee01ba | ||
|
|
62d1e77847 | ||
|
|
a23fd997db | ||
|
|
f367756b8c | ||
|
|
4a055463bd | ||
|
|
c1a4efd7d7 | ||
|
|
e0fb2598b0 | ||
|
|
61286717e5 |
No files matched your search
@@ -73,6 +73,8 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -225,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
|
||||
|
||||
|
||||
@@ -459,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
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
# Next UI — unreleased
|
||||
|
||||
[RC11 preparation](1.0.0-RC.11.md) adds the searchable-selector and script-completion input-race corrections from [PR24](https://github.com/QRun-IO/qqq-frontend-next/pull/24). [RC10 is published and immutable](https://github.com/QRun-IO/qqq-frontend-next/releases/tag/v1.0.0-RC.10), including the earlier browser-stability, ESB and record-view URL corrections. RC11 is not yet published. 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).
|
||||
|
||||
## New features
|
||||
|
||||
- SQL Workbench: render the `SQL_WORKBENCH` widget of the [qbit-sql-workbench](https://github.com/QRun-IO/qbit-sql-workbench) QBit as a super-user SQL notebook.
|
||||
- Personal and shared notebooks with SQL and markdown cells, `:parameters`, versions and pinned results.
|
||||
- A CodeMirror 6 editor with dialect highlighting, schema and QQQ-name autocomplete, and formatting.
|
||||
- A typed, sortable, paged result grid with CSV/XLSX/JSON export, quick charts, and EXPLAIN plan trees.
|
||||
- Read-only runs by default, and a two-step write confirmation for users with the write permission.
|
||||
- CodeMirror and the formatter load only on that page (new `lazyChunks` budgets).
|
||||
|
||||
## 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)
|
||||
@@ -22,8 +32,31 @@
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
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
|
||||
|
||||
@@ -28,7 +28,14 @@
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "6.18.6",
|
||||
"@codemirror/commands": "6.8.1",
|
||||
"@codemirror/lang-sql": "6.9.0",
|
||||
"@codemirror/language": "6.11.2",
|
||||
"@codemirror/state": "6.5.2",
|
||||
"@codemirror/view": "6.38.1",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@lezer/highlight": "1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
@@ -54,6 +61,7 @@
|
||||
"react-hook-form": "^7.71.2",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.7",
|
||||
"sql-formatter": "15.6.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
|
||||
+13
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. Raise a cap only with a measured reason in the commit.",
|
||||
"$comment": "Bundle budget for the static export, checked by scripts/perf-budget.mjs (QRun-IO/qqq#710). Sizes are KB gzip. '_' is the exported placeholder of a dynamic segment. The complete 1.0 UI includes wave-2 shell, record, form, process and widget parity. After lazy screen dispatch, the 2026-09-26 export measures 245.1 KB on login, 340.8 KB on the largest developer route, 371.7 KB on copy, and 1020.9 KB across all 97 emitted chunks (excluding separately budgeted RapiDoc). These caps leave roughly 3-5% headroom; route caps measure HTML first load and total measures all on-demand features. CI's default-builder export measured 19.2 KB gzip of shared CSS on 2026-09-26; the 21 KB cap leaves about 9% headroom. The 100 KB largest-chunk cap remains unchanged. Raise a cap only with a measured reason in the commit. SQL Workbench (2026-10-11): the qbit-sql-workbench SQL_WORKBENCH widget loads CodeMirror 6 and the workbench UI only through import() from WidgetRenderer, and sql-formatter only when Format is clicked; both are budgeted under lazyChunks. Adding the lazy import measured /app/_/_/dev at 353.1 KB (main 350.0 KB, already at its cap; the same tree without the WidgetRenderer hook-in measures 350.1 KB), so that route's cap is 355 KB.",
|
||||
"firstLoadJsGzipKB": 350,
|
||||
"routes": {
|
||||
"/": 255,
|
||||
@@ -14,7 +14,8 @@
|
||||
"/app/_/_/copy": 390,
|
||||
"/app/_/_/edit": 350,
|
||||
"/app/_/create": 350,
|
||||
"/app/_/savedView/_": 350
|
||||
"/app/_/savedView/_": 350,
|
||||
"/app/_/_/dev": 355
|
||||
},
|
||||
"cssGzipKB": 21,
|
||||
"totalJsGzipKB": 1050,
|
||||
@@ -24,6 +25,16 @@
|
||||
"$comment": "RapiDoc (the table developer view's API docs, QRun-IO/qqq#714) loads only when that view opens: import('rapidoc') in TableApiDocs. Measured 204.5 KB gzip at 1.0; budget about 5% above.",
|
||||
"marker": "customElements.define(\"rapi-doc\"",
|
||||
"gzipKB": 215
|
||||
},
|
||||
"sqlWorkbench": {
|
||||
"$comment": "CodeMirror 6 (state, view, language, autocomplete, lang-sql) plus the SQL Workbench widget, loaded when the SQL_WORKBENCH widget renders: import() in WidgetRenderer. Measured 127.2 KB gzip at introduction; budget about 6% above.",
|
||||
"marker": "qqq-sql-workbench-editor",
|
||||
"gzipKB": 135
|
||||
},
|
||||
"sqlFormatter": {
|
||||
"$comment": "sql-formatter with the postgresql, mysql, sqlite and sql dialects only (formatDialect), loaded when a workbench cell's Format button is first used: import() in SqlCell. Measured 60.2 KB gzip at introduction; budget about 8% above.",
|
||||
"marker": "logicalOperatorNewline",
|
||||
"gzipKB": 65
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+175
-2
@@ -13,9 +13,30 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete':
|
||||
specifier: 6.18.6
|
||||
version: 6.18.6
|
||||
'@codemirror/commands':
|
||||
specifier: 6.8.1
|
||||
version: 6.8.1
|
||||
'@codemirror/lang-sql':
|
||||
specifier: 6.9.0
|
||||
version: 6.9.0
|
||||
'@codemirror/language':
|
||||
specifier: 6.11.2
|
||||
version: 6.11.2
|
||||
'@codemirror/state':
|
||||
specifier: 6.5.2
|
||||
version: 6.5.2
|
||||
'@codemirror/view':
|
||||
specifier: 6.38.1
|
||||
version: 6.38.1
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.71.2(react@19.2.4))
|
||||
'@lezer/highlight':
|
||||
specifier: 1.2.1
|
||||
version: 1.2.1
|
||||
'@radix-ui/react-alert-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -91,6 +112,9 @@ importers:
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
sql-formatter:
|
||||
specifier: 15.6.6
|
||||
version: 15.6.6
|
||||
tailwind-merge:
|
||||
specifier: ^3.5.0
|
||||
version: 3.5.0
|
||||
@@ -335,6 +359,24 @@ packages:
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@codemirror/autocomplete@6.18.6':
|
||||
resolution: {integrity: sha512-PHHBXFomUs5DF+9tCOM/UoW6XQ4R44lLNNhRaW9PKPTU0D7lIjRg3ElxaJnTwsl/oHiR93WSXDBrekhoUGCPtg==}
|
||||
|
||||
'@codemirror/commands@6.8.1':
|
||||
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
||||
|
||||
'@codemirror/lang-sql@6.9.0':
|
||||
resolution: {integrity: sha512-xmtpWqKSgum1B1J3Ro6rf7nuPqf2+kJQg5SjrofCAcyCThOe0ihSktSoXfXuhQBnwx1QbmreBbLJM5Jru6zitg==}
|
||||
|
||||
'@codemirror/language@6.11.2':
|
||||
resolution: {integrity: sha512-p44TsNArL4IVXDTbapUmEkAlvWs2CFQbcfc0ymDsis1kH2wh0gcY96AS29c/vp2d0y2Tquk1EDSaawpzilUiAw==}
|
||||
|
||||
'@codemirror/state@6.5.2':
|
||||
resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==}
|
||||
|
||||
'@codemirror/view@6.38.1':
|
||||
resolution: {integrity: sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==}
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -875,12 +917,24 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@lezer/common@1.5.3':
|
||||
resolution: {integrity: sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==}
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
|
||||
|
||||
'@lezer/lr@1.4.11':
|
||||
resolution: {integrity: sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==}
|
||||
|
||||
'@lit-labs/ssr-dom-shim@1.6.0':
|
||||
resolution: {integrity: sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==}
|
||||
|
||||
'@lit/reactive-element@2.1.2':
|
||||
resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==}
|
||||
|
||||
'@marijn/find-cluster-break@1.0.4':
|
||||
resolution: {integrity: sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==}
|
||||
|
||||
'@mdx-js/react@3.1.1':
|
||||
resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==}
|
||||
peerDependencies:
|
||||
@@ -2748,6 +2802,9 @@ packages:
|
||||
core-js-pure@3.50.0:
|
||||
resolution: {integrity: sha512-6GP3Pxz4IKyWjAfa747vIu/jilB5z29JWROLqH/b+pXVcpgh6tM06ZIBwSuglgVqzDYURhOK6oEzTrG0bCHitA==}
|
||||
|
||||
crelt@1.0.7:
|
||||
resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -2909,6 +2966,9 @@ packages:
|
||||
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
|
||||
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
|
||||
|
||||
discontinuous-range@1.0.0:
|
||||
resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==}
|
||||
|
||||
doctrine@2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -3881,6 +3941,9 @@ packages:
|
||||
module-alias@2.3.4:
|
||||
resolution: {integrity: sha512-bOclZt8hkpuGgSSoG07PKmvzTizROilUTvLNyrMqvlC9snhs7y7GzjNWAVbISIOlhCP1T14rH1PDAV9iNyBq/w==}
|
||||
|
||||
moo@0.5.3:
|
||||
resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==}
|
||||
|
||||
mrmime@2.0.1:
|
||||
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3918,6 +3981,10 @@ packages:
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
nearley@2.20.1:
|
||||
resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==}
|
||||
hasBin: true
|
||||
|
||||
needle@2.9.1:
|
||||
resolution: {integrity: sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==}
|
||||
engines: {node: '>= 4.4.x'}
|
||||
@@ -4163,6 +4230,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
railroad-diagrams@1.0.0:
|
||||
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
|
||||
|
||||
ramda-adjunct@5.1.0:
|
||||
resolution: {integrity: sha512-8qCpl2vZBXEJyNbi4zqcgdfHtcdsWjOGbiNSEnEBrM6Y0OKOT8UxJbIVGm1TIcjaSu2MxaWcgtsNlKlCk7o7qg==}
|
||||
engines: {node: '>=0.10.3'}
|
||||
@@ -4172,6 +4242,10 @@ packages:
|
||||
ramda@0.30.1:
|
||||
resolution: {integrity: sha512-tEF5I22zJnuclswcZMc8bDIrwRHRzf+NqVEmqg50ShAZMP7MWeR/RGDthfM/p+BlqvF2fXAzpn8i+SJcYD3alw==}
|
||||
|
||||
randexp@0.4.6:
|
||||
resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
randexp@0.5.3:
|
||||
resolution: {integrity: sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4314,6 +4388,10 @@ packages:
|
||||
engines: {node: '>= 0.4'}
|
||||
hasBin: true
|
||||
|
||||
ret@0.1.15:
|
||||
resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
ret@0.2.2:
|
||||
resolution: {integrity: sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -4470,6 +4548,10 @@ packages:
|
||||
spdx-license-ids@3.0.23:
|
||||
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
|
||||
|
||||
sql-formatter@15.6.6:
|
||||
resolution: {integrity: sha512-bZydXEXhaNDQBr8xYHC3a8thwcaMuTBp0CkKGjwGYDsIB26tnlWeWPwJtSQ0TEwiJcz9iJJON5mFPkx7XroHcg==}
|
||||
hasBin: true
|
||||
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
@@ -4559,6 +4641,9 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
style-mod@4.1.4:
|
||||
resolution: {integrity: sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -4913,6 +4998,9 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5207,6 +5295,49 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@codemirror/autocomplete@6.18.6':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.2
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.38.1
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@codemirror/commands@6.8.1':
|
||||
dependencies:
|
||||
'@codemirror/language': 6.11.2
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.38.1
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@codemirror/lang-sql@6.9.0':
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.18.6
|
||||
'@codemirror/language': 6.11.2
|
||||
'@codemirror/state': 6.5.2
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.11
|
||||
|
||||
'@codemirror/language@6.11.2':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
'@codemirror/view': 6.38.1
|
||||
'@lezer/common': 1.5.3
|
||||
'@lezer/highlight': 1.2.1
|
||||
'@lezer/lr': 1.4.11
|
||||
style-mod: 4.1.4
|
||||
|
||||
'@codemirror/state@6.5.2':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.4
|
||||
|
||||
'@codemirror/view@6.38.1':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.2
|
||||
crelt: 1.0.7
|
||||
style-mod: 4.1.4
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
@@ -5651,12 +5782,24 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
'@lezer/common@1.5.3': {}
|
||||
|
||||
'@lezer/highlight@1.2.1':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@lezer/lr@1.4.11':
|
||||
dependencies:
|
||||
'@lezer/common': 1.5.3
|
||||
|
||||
'@lit-labs/ssr-dom-shim@1.6.0': {}
|
||||
|
||||
'@lit/reactive-element@2.1.2':
|
||||
dependencies:
|
||||
'@lit-labs/ssr-dom-shim': 1.6.0
|
||||
|
||||
'@marijn/find-cluster-break@1.0.4': {}
|
||||
|
||||
'@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)':
|
||||
dependencies:
|
||||
'@types/mdx': 2.0.14
|
||||
@@ -7702,8 +7845,7 @@ snapshots:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
|
||||
commander@2.20.3:
|
||||
optional: true
|
||||
commander@2.20.3: {}
|
||||
|
||||
comment-parser@1.4.5: {}
|
||||
|
||||
@@ -7715,6 +7857,8 @@ snapshots:
|
||||
|
||||
core-js-pure@3.50.0: {}
|
||||
|
||||
crelt@1.0.7: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -7848,6 +7992,8 @@ snapshots:
|
||||
|
||||
diff-sequences@29.6.3: {}
|
||||
|
||||
discontinuous-range@1.0.0: {}
|
||||
|
||||
doctrine@2.1.0:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
@@ -8996,6 +9142,8 @@ snapshots:
|
||||
|
||||
module-alias@2.3.4: {}
|
||||
|
||||
moo@0.5.3: {}
|
||||
|
||||
mrmime@2.0.1: {}
|
||||
|
||||
ms@2.0.0: {}
|
||||
@@ -9035,6 +9183,13 @@ snapshots:
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
nearley@2.20.1:
|
||||
dependencies:
|
||||
commander: 2.20.3
|
||||
moo: 0.5.3
|
||||
railroad-diagrams: 1.0.0
|
||||
randexp: 0.4.6
|
||||
|
||||
needle@2.9.1:
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
@@ -9327,12 +9482,19 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
railroad-diagrams@1.0.0: {}
|
||||
|
||||
ramda-adjunct@5.1.0(ramda@0.30.1):
|
||||
dependencies:
|
||||
ramda: 0.30.1
|
||||
|
||||
ramda@0.30.1: {}
|
||||
|
||||
randexp@0.4.6:
|
||||
dependencies:
|
||||
discontinuous-range: 1.0.0
|
||||
ret: 0.1.15
|
||||
|
||||
randexp@0.5.3:
|
||||
dependencies:
|
||||
drange: 1.1.1
|
||||
@@ -9510,6 +9672,8 @@ snapshots:
|
||||
path-parse: 1.0.7
|
||||
supports-preserve-symlinks-flag: 1.0.0
|
||||
|
||||
ret@0.1.15: {}
|
||||
|
||||
ret@0.2.2: {}
|
||||
|
||||
rettime@0.10.1: {}
|
||||
@@ -9719,6 +9883,11 @@ snapshots:
|
||||
|
||||
spdx-license-ids@3.0.23: {}
|
||||
|
||||
sql-formatter@15.6.6:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
nearley: 2.20.1
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stack-utils@2.0.6:
|
||||
@@ -9842,6 +10011,8 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
style-mod@4.1.4: {}
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.4):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -10245,6 +10416,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useRouteParams } from '@/lib/hooks/use-route-params'
|
||||
import { addRecentRecord, removeRecentRecord } from '@/lib/utils/recent-records'
|
||||
import { recordAnalytics } from '@/lib/analytics'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import { getProcessesForTable } from '@/lib/utils/process-utils'
|
||||
import { getProcessesForRecordView } from '@/lib/utils/process-utils'
|
||||
import { useQContext } from '@/lib/context/q-context'
|
||||
import { loadMetaData, loadTableMetaData } from '@/lib/api/metadata'
|
||||
import { auditSource } from '@/lib/api/audits'
|
||||
@@ -191,7 +191,7 @@ export default function RecordViewPage() {
|
||||
)
|
||||
}
|
||||
|
||||
const tableProcesses = metaData ? getProcessesForTable(metaData, slug) : []
|
||||
const tableProcesses = metaData ? getProcessesForRecordView(metaData, slug) : []
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -78,7 +78,7 @@ describe('BulkLoadMapping', () => {
|
||||
mapping.requiredFields[1].defaultValue = 'all@example.invalid'
|
||||
mapping.requiredFields[0].columnIndex = 0
|
||||
expect(mapping.toProfile().profile.fieldList).toEqual([
|
||||
{ fieldName: 'firstName', columnIndex: 0, headerName: null, doValueMapping: false, clearIfEmpty: false },
|
||||
{ fieldName: 'firstName', columnIndex: 0, doValueMapping: false, clearIfEmpty: false },
|
||||
{ fieldName: 'email', defaultValue: 'all@example.invalid' },
|
||||
])
|
||||
})
|
||||
@@ -133,6 +133,21 @@ describe('BulkLoadMapping header row, key fields and repeats (#726)', () => {
|
||||
expect(mapping.requiredFields[0]).toMatchObject({ columnIndex: 0, headerName: null })
|
||||
})
|
||||
|
||||
it('omits the header name from a header-off profile and keeps the column index (QRun-IO/qqq#660)', () => {
|
||||
const mapping = new BulkLoadMapping(person)
|
||||
mapping.changeHasHeaderRow(false, file)
|
||||
mapping.requiredFields[0].columnIndex = 0
|
||||
mapping.requiredFields[1].columnIndex = 1
|
||||
const { haveErrors, profile } = mapping.toProfile()
|
||||
expect(haveErrors).toBe(false)
|
||||
for (const profileField of profile.fieldList) expect(profileField).not.toHaveProperty('headerName')
|
||||
const sent: unknown = JSON.parse(String(profileSubmitValues(mapping, profile).fieldListJSON))
|
||||
expect(sent).toStrictEqual([
|
||||
{ fieldName: 'firstName', columnIndex: 0, doValueMapping: false, clearIfEmpty: false },
|
||||
{ fieldName: 'email', columnIndex: 1, doValueMapping: false, clearIfEmpty: false },
|
||||
])
|
||||
})
|
||||
|
||||
it('maps a saved profile by header name in a file whose columns moved', () => {
|
||||
const moved = new FileDescription(['Email', 'First Name'], ['A', 'B'], [[], []])
|
||||
const mapping = BulkLoadMapping.fromProfile(person, {
|
||||
|
||||
@@ -366,8 +366,9 @@ export class BulkLoadMapping {
|
||||
haveErrors = true
|
||||
field.error = 'You must select a column.'
|
||||
} else {
|
||||
// Header-off files have no header name; published backends throw on `headerName: null`.
|
||||
const profileField: BulkLoadProfileField = {
|
||||
fieldName, columnIndex: field.columnIndex, headerName: field.headerName, doValueMapping: field.doValueMapping, clearIfEmpty: field.clearIfEmpty,
|
||||
fieldName, columnIndex: field.columnIndex, ...(field.headerName === null ? {} : { headerName: field.headerName }), doValueMapping: field.doValueMapping, clearIfEmpty: field.clearIfEmpty,
|
||||
}
|
||||
if (this.valueMappings[fieldName]) profileField.valueMappings = this.valueMappings[fieldName]
|
||||
profile.fieldList.push(profileField)
|
||||
|
||||
@@ -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
|
||||
*
|
||||
* 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 MarkdownView — renders a markdown cell's text as React elements (never as HTML):
|
||||
* headings, paragraphs, bullet and numbered lists, fenced code, and inline bold, italic, code
|
||||
* and http(s) links. Anything else is shown as text, so a notebook can never inject markup.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* Renders inline markdown (bold, italic, code, links) as React nodes.
|
||||
*
|
||||
* @param text - One line or paragraph.
|
||||
* @param keyPrefix - Key prefix for the produced nodes.
|
||||
* @returns The nodes.
|
||||
*/
|
||||
export function renderInline(text: string, keyPrefix: string): React.ReactNode[] {
|
||||
const nodes: React.ReactNode[] = []
|
||||
const pattern = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\((https?:\/\/[^\s)]+)\))/g
|
||||
let last = 0
|
||||
let match: RegExpExecArray | null
|
||||
let index = 0
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
if (match.index > last) nodes.push(text.slice(last, match.index))
|
||||
const token = match[0]
|
||||
const key = `${keyPrefix}-${index++}`
|
||||
if (token.startsWith('`')) {
|
||||
nodes.push(
|
||||
<code key={key} className="rounded bg-muted px-1 font-mono text-[0.9em]">
|
||||
{token.slice(1, -1)}
|
||||
</code>
|
||||
)
|
||||
} else if (token.startsWith('**')) {
|
||||
nodes.push(<strong key={key}>{token.slice(2, -2)}</strong>)
|
||||
} else if (token.startsWith('*')) {
|
||||
nodes.push(<em key={key}>{token.slice(1, -1)}</em>)
|
||||
} else {
|
||||
const label = token.slice(1, token.indexOf(']'))
|
||||
nodes.push(
|
||||
<a
|
||||
key={key}
|
||||
href={match[2]}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
{label}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
last = match.index + token.length
|
||||
}
|
||||
if (last < text.length) nodes.push(text.slice(last))
|
||||
return nodes
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a markdown document.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.source - The markdown text.
|
||||
* @returns The rendered blocks.
|
||||
*/
|
||||
export function MarkdownView({ source }: { source: string }) {
|
||||
const blocks: React.ReactNode[] = []
|
||||
const lines = source.split('\n')
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]
|
||||
const key = `b${i}`
|
||||
if (line.trim().startsWith('```')) {
|
||||
const code: string[] = []
|
||||
i++
|
||||
while (i < lines.length && !lines[i].trim().startsWith('```')) code.push(lines[i++])
|
||||
i++
|
||||
blocks.push(
|
||||
<pre key={key} className="overflow-x-auto rounded bg-muted p-2 font-mono text-xs">
|
||||
{code.join('\n')}
|
||||
</pre>
|
||||
)
|
||||
continue
|
||||
}
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line)
|
||||
if (heading) {
|
||||
const level = heading[1].length
|
||||
const sizes = ['text-xl', 'text-lg', 'text-base', 'text-sm', 'text-sm', 'text-sm']
|
||||
const Tag = `h${Math.min(level + 1, 6)}` as 'h2'
|
||||
blocks.push(
|
||||
<Tag key={key} className={`${sizes[level - 1]} font-semibold`}>
|
||||
{renderInline(heading[2], key)}
|
||||
</Tag>
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
|
||||
const ordered = /^\s*\d+\./.test(line)
|
||||
const items: React.ReactNode[] = []
|
||||
while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) {
|
||||
items.push(
|
||||
<li key={`${key}-${items.length}`}>
|
||||
{renderInline(lines[i].replace(/^\s*([-*]|\d+\.)\s+/, ''), `${key}-${items.length}`)}
|
||||
</li>
|
||||
)
|
||||
i++
|
||||
}
|
||||
blocks.push(
|
||||
ordered ? (
|
||||
<ol key={key} className="list-decimal pl-5">
|
||||
{items}
|
||||
</ol>
|
||||
) : (
|
||||
<ul key={key} className="list-disc pl-5">
|
||||
{items}
|
||||
</ul>
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (line.trim() === '') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const paragraph: string[] = []
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== '' &&
|
||||
!/^(#{1,6}\s|```|\s*([-*]|\d+\.)\s+)/.test(lines[i])
|
||||
)
|
||||
paragraph.push(lines[i++])
|
||||
blocks.push(<p key={key}>{renderInline(paragraph.join(' '), key)}</p>)
|
||||
}
|
||||
return <div className="space-y-2 text-sm">{blocks}</div>
|
||||
}
|
||||
@@ -0,0 +1,598 @@
|
||||
/*
|
||||
* 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 NotebookEditor — one notebook: title, default backend, :parameters, ordered SQL and
|
||||
* markdown cells, save (each save is a version), publish to the shared library, duplicate,
|
||||
* delete, and the version history with restore. With no notebook id it edits an unsaved
|
||||
* scratch notebook that Save creates.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
SqlNotebook,
|
||||
SqlNotebookCell,
|
||||
SqlNotebookInput,
|
||||
SqlNotebookParameter,
|
||||
SqlWorkbenchCapabilities,
|
||||
} from '@/types'
|
||||
import {
|
||||
createSqlNotebook,
|
||||
deleteSqlNotebook,
|
||||
duplicateSqlNotebook,
|
||||
getSqlNotebook,
|
||||
listSqlNotebookVersions,
|
||||
listSqlSnapshots,
|
||||
publishSqlNotebook,
|
||||
restoreSqlNotebookVersion,
|
||||
saveSqlNotebook,
|
||||
} from '@/lib/api/sql-workbench'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { coerceParameterValue, extractSqlParameters, newCellKey } from '@/lib/utils/sql-workbench'
|
||||
import { MarkdownView } from './MarkdownView'
|
||||
import { SqlCell } from './SqlCell'
|
||||
|
||||
/** A request to add a cell (e.g. from history). */
|
||||
export interface InsertCellRequest {
|
||||
sql: string
|
||||
backendName: string
|
||||
nonce: number
|
||||
}
|
||||
|
||||
/** Props for {@link NotebookEditor}. */
|
||||
export interface NotebookEditorProps {
|
||||
notebookId: number | null
|
||||
capabilities: SqlWorkbenchCapabilities
|
||||
insertRequest: InsertCellRequest | null
|
||||
/** Called once the pending insert request has been applied, so the owner can clear it. */
|
||||
onInsertConsumed: () => void
|
||||
onSelect: (id: number | null) => void
|
||||
}
|
||||
|
||||
const PARAMETER_TYPES: NonNullable<SqlNotebookParameter['type']>[] = [
|
||||
'STRING',
|
||||
'INTEGER',
|
||||
'DECIMAL',
|
||||
'BOOLEAN',
|
||||
'DATE',
|
||||
'DATE_TIME',
|
||||
]
|
||||
|
||||
/**
|
||||
* Loads the notebook (when saved) and renders its editor.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The editor, or a loading / error state.
|
||||
*/
|
||||
export function NotebookEditor(props: NotebookEditorProps) {
|
||||
const { notebookId, capabilities } = props
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.sqlNotebook(notebookId ?? -1),
|
||||
queryFn: () => getSqlNotebook(notebookId!),
|
||||
enabled: notebookId !== null,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
if (notebookId !== null && isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
Loading notebook…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (notebookId !== null && (error || !data)) {
|
||||
return (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
Could not open the notebook: {getErrorMessage(error, 'not found')}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const notebook: SqlNotebook = data ?? {
|
||||
id: -1,
|
||||
title: 'Untitled notebook',
|
||||
description: null,
|
||||
visibility: 'PERSONAL',
|
||||
ownerUserId: '',
|
||||
ownerName: null,
|
||||
defaultBackendName: capabilities.backends[0]?.name ?? null,
|
||||
currentVersion: 0,
|
||||
canEdit: true,
|
||||
createDate: null,
|
||||
modifyDate: null,
|
||||
parameters: [],
|
||||
cells: [
|
||||
{ cellKey: newCellKey(), cellType: 'SQL', content: '', title: null, backendName: null },
|
||||
],
|
||||
}
|
||||
return (
|
||||
<NotebookDraft
|
||||
key={`${notebookId ?? 'new'}-${notebook.currentVersion}`}
|
||||
{...props}
|
||||
notebook={notebook}
|
||||
saved={notebookId !== null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The editable draft of a notebook.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.notebook - The loaded (or blank) notebook.
|
||||
* @param props.saved - Whether it exists on the server.
|
||||
* @returns The editor.
|
||||
*/
|
||||
function NotebookDraft(props: NotebookEditorProps & { notebook: SqlNotebook; saved: boolean }) {
|
||||
const { notebook, saved, capabilities, insertRequest, onInsertConsumed, onSelect } = props
|
||||
const queryClient = useQueryClient()
|
||||
const [title, setTitle] = useState(notebook.title)
|
||||
const [defaultBackendName, setDefaultBackendName] = useState(
|
||||
notebook.defaultBackendName ?? capabilities.backends[0]?.name ?? ''
|
||||
)
|
||||
const [cells, setCells] = useState<SqlNotebookCell[]>(notebook.cells)
|
||||
const [parameters, setParameters] = useState<SqlNotebookParameter[]>(notebook.parameters ?? [])
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries((notebook.parameters ?? []).map((p) => [p.name, p.defaultValue ?? '']))
|
||||
)
|
||||
const [dirty, setDirty] = useState(!saved)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [showVersions, setShowVersions] = useState(false)
|
||||
const [previewing, setPreviewing] = useState<Record<string, boolean>>({})
|
||||
const canEdit = notebook.canEdit
|
||||
|
||||
// The request is parent state that outlives this draft (it remounts on save, restore,
|
||||
// duplicate and notebook switch), so apply each nonce once and hand it back to be cleared.
|
||||
const handledInsertNonce = useRef<number | null>(null)
|
||||
useEffect(() => {
|
||||
if (!insertRequest || handledInsertNonce.current === insertRequest.nonce) return
|
||||
handledInsertNonce.current = insertRequest.nonce
|
||||
setCells((current) => [
|
||||
...current,
|
||||
{
|
||||
cellKey: newCellKey(),
|
||||
cellType: 'SQL',
|
||||
content: insertRequest.sql,
|
||||
backendName: insertRequest.backendName,
|
||||
title: null,
|
||||
},
|
||||
])
|
||||
setDirty(true)
|
||||
onInsertConsumed()
|
||||
}, [insertRequest, onInsertConsumed])
|
||||
|
||||
const { data: snapshots = [] } = useQuery({
|
||||
queryKey: queryKeys.sqlNotebookSnapshots(notebook.id),
|
||||
queryFn: () => listSqlSnapshots(notebook.id),
|
||||
enabled: saved,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
const { data: versions = [] } = useQuery({
|
||||
queryKey: queryKeys.sqlNotebookVersions(notebook.id),
|
||||
queryFn: () => listSqlNotebookVersions(notebook.id),
|
||||
enabled: saved && showVersions,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
const parameterNames = useMemo(() => {
|
||||
const names = parameters.map((p) => p.name)
|
||||
for (const cell of cells) {
|
||||
if (cell.cellType === 'SQL')
|
||||
for (const name of extractSqlParameters(cell.content))
|
||||
if (!names.includes(name)) names.push(name)
|
||||
}
|
||||
return names
|
||||
}, [cells, parameters])
|
||||
|
||||
const paramsFor = (sql: string) =>
|
||||
Object.fromEntries(
|
||||
extractSqlParameters(sql).map((name) => [
|
||||
name,
|
||||
coerceParameterValue(values[name], parameters.find((p) => p.name === name)?.type),
|
||||
])
|
||||
)
|
||||
|
||||
const updateCells = (next: SqlNotebookCell[]) => {
|
||||
setCells(next)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const setParameterType = (name: string, type: SqlNotebookParameter['type']) => {
|
||||
const existing = parameters.find((p) => p.name === name)
|
||||
setParameters(
|
||||
existing
|
||||
? parameters.map((p) => (p.name === name ? { ...p, type } : p))
|
||||
: [...parameters, { name, type, defaultValue: values[name] ?? null }]
|
||||
)
|
||||
setDirty(true)
|
||||
}
|
||||
|
||||
const input = (): SqlNotebookInput => ({
|
||||
title: title.trim() || 'Untitled notebook',
|
||||
defaultBackendName,
|
||||
parameters: parameterNames.map((name) => ({
|
||||
name,
|
||||
type: parameters.find((p) => p.name === name)?.type ?? 'STRING',
|
||||
defaultValue: values[name] ?? null,
|
||||
})),
|
||||
cells: cells.map((c, i) => ({ ...c, sortOrder: i })),
|
||||
})
|
||||
|
||||
const refreshLists = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: [...queryKeys.sqlWorkbench(), 'notebooks'] })
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
const result = saved
|
||||
? await saveSqlNotebook(notebook.id, input())
|
||||
: await createSqlNotebook(input())
|
||||
queryClient.setQueryData(queryKeys.sqlNotebook(result.id), result)
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.sqlNotebookVersions(result.id) })
|
||||
refreshLists()
|
||||
setDirty(false)
|
||||
toast.success(`Saved version ${result.currentVersion}`)
|
||||
if (!saved) onSelect(result.id)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const act = async (
|
||||
action: () => Promise<SqlNotebook | void>,
|
||||
message: string,
|
||||
select?: (n: SqlNotebook | void) => number | null
|
||||
) => {
|
||||
try {
|
||||
const result = await action()
|
||||
if (result) queryClient.setQueryData(queryKeys.sqlNotebook(result.id), result)
|
||||
refreshLists()
|
||||
toast.success(message)
|
||||
if (select) onSelect(select(result))
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
const move = (index: number, delta: number) => {
|
||||
const next = [...cells]
|
||||
const [cell] = next.splice(index, 1)
|
||||
next.splice(index + delta, 0, cell)
|
||||
updateCells(next)
|
||||
}
|
||||
|
||||
const button = 'rounded border px-2 py-1 text-xs disabled:opacity-50'
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded border px-2 py-1 text-base font-semibold"
|
||||
value={title}
|
||||
aria-label="Notebook title"
|
||||
onChange={(e) => {
|
||||
setTitle(e.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
readOnly={!canEdit}
|
||||
data-qqq-id="input-sql-notebook-title"
|
||||
/>
|
||||
<label className="inline-flex items-center gap-1 text-xs">
|
||||
Default backend
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={defaultBackendName}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => {
|
||||
setDefaultBackendName(e.target.value)
|
||||
setDirty(true)
|
||||
}}
|
||||
data-qqq-id="select-sql-notebook-backend"
|
||||
>
|
||||
{capabilities.backends.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} bg-primary text-primary-foreground`}
|
||||
onClick={save}
|
||||
disabled={saving || !dirty}
|
||||
data-qqq-id="button-sql-notebook-save"
|
||||
>
|
||||
{saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
|
||||
</button>
|
||||
)}
|
||||
{saved && canEdit && capabilities.canShare && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => publishSqlNotebook(notebook.id, notebook.visibility !== 'SHARED'),
|
||||
notebook.visibility === 'SHARED'
|
||||
? 'Removed from the library'
|
||||
: 'Published to the library'
|
||||
)
|
||||
}
|
||||
data-qqq-id="button-sql-notebook-publish"
|
||||
>
|
||||
{notebook.visibility === 'SHARED' ? 'Unpublish' : 'Publish'}
|
||||
</button>
|
||||
)}
|
||||
{saved && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => duplicateSqlNotebook(notebook.id),
|
||||
'Copied to your notebooks',
|
||||
(n) => (n ? n.id : null)
|
||||
)
|
||||
}
|
||||
data-qqq-id="button-sql-notebook-duplicate"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
)}
|
||||
{saved && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => setShowVersions(!showVersions)}
|
||||
aria-expanded={showVersions}
|
||||
data-qqq-id="button-sql-notebook-versions"
|
||||
>
|
||||
Versions
|
||||
</button>
|
||||
)}
|
||||
{saved && canEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} text-destructive`}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete “${notebook.title}” and its versions?`))
|
||||
void act(
|
||||
() => deleteSqlNotebook(notebook.id),
|
||||
'Notebook deleted',
|
||||
() => null
|
||||
)
|
||||
}}
|
||||
data-qqq-id="button-sql-notebook-delete"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!canEdit && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shared by {notebook.ownerName ?? notebook.ownerUserId}. You can run it; duplicate it to
|
||||
keep changes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showVersions && (
|
||||
<ul className="space-y-1 rounded-md border p-2 text-xs" aria-label="Versions">
|
||||
{versions.map((v) => (
|
||||
<li key={v.versionNumber} className="flex items-center gap-2">
|
||||
<span className="font-mono">v{v.versionNumber}</span>
|
||||
<span>{v.savedByName}</span>
|
||||
<span className="text-muted-foreground">{v.createDate}</span>
|
||||
{v.comment && <span className="truncate">{v.comment}</span>}
|
||||
{canEdit && v.versionNumber !== notebook.currentVersion && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${button} ml-auto`}
|
||||
onClick={() =>
|
||||
act(
|
||||
() => restoreSqlNotebookVersion(notebook.id, v.versionNumber),
|
||||
`Restored version ${v.versionNumber}`
|
||||
)
|
||||
}
|
||||
data-qqq-id={`button-sql-restore-${v.versionNumber}`}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{parameterNames.length > 0 && (
|
||||
<fieldset className="flex flex-wrap gap-3 rounded-md border p-2 text-xs">
|
||||
<legend className="px-1 font-medium">Parameters</legend>
|
||||
{parameterNames.map((name) => {
|
||||
const type = parameters.find((p) => p.name === name)?.type ?? 'STRING'
|
||||
return (
|
||||
<span key={name} className="inline-flex items-center gap-1">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
<span className="font-mono">:{name}</span>
|
||||
<input
|
||||
className="w-36 rounded border px-1 py-0.5"
|
||||
type={
|
||||
type === 'DATE'
|
||||
? 'date'
|
||||
: type === 'DATE_TIME'
|
||||
? 'datetime-local'
|
||||
: type === 'INTEGER' || type === 'DECIMAL'
|
||||
? 'number'
|
||||
: 'text'
|
||||
}
|
||||
value={values[name] ?? ''}
|
||||
onChange={(e) => setValues({ ...values, [name]: e.target.value })}
|
||||
data-qqq-id={`input-sql-param-${name}`}
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={type}
|
||||
aria-label={`Type of :${name}`}
|
||||
onChange={(e) =>
|
||||
setParameterType(name, e.target.value as SqlNotebookParameter['type'])
|
||||
}
|
||||
data-qqq-id={`select-sql-param-type-${name}`}
|
||||
>
|
||||
{PARAMETER_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{cells.map((cell, index) => (
|
||||
<div key={cell.cellKey} className="group relative">
|
||||
{canEdit && (
|
||||
<div className="absolute -right-1 -top-2 z-10 hidden gap-1 group-focus-within:flex group-hover:flex">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border bg-background p-0.5"
|
||||
onClick={() => move(index, -1)}
|
||||
disabled={index === 0}
|
||||
aria-label={`Move cell ${index + 1} up`}
|
||||
data-qqq-id={`button-sql-cell-up-${index}`}
|
||||
>
|
||||
<span aria-hidden>▲</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border bg-background p-0.5"
|
||||
onClick={() => move(index, 1)}
|
||||
disabled={index === cells.length - 1}
|
||||
aria-label={`Move cell ${index + 1} down`}
|
||||
data-qqq-id={`button-sql-cell-down-${index}`}
|
||||
>
|
||||
<span aria-hidden>▼</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border bg-background p-0.5 text-destructive"
|
||||
onClick={() => updateCells(cells.filter((_, i) => i !== index))}
|
||||
aria-label={`Delete cell ${index + 1}`}
|
||||
data-qqq-id={`button-sql-cell-delete-${index}`}
|
||||
>
|
||||
<span aria-hidden>✕</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{cell.cellType === 'MARKDOWN' ? (
|
||||
<section className="rounded-md border p-3" aria-label={`Markdown cell ${index + 1}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="mb-1 text-xs text-muted-foreground underline"
|
||||
onClick={() =>
|
||||
setPreviewing({ ...previewing, [cell.cellKey]: !previewing[cell.cellKey] })
|
||||
}
|
||||
data-qqq-id={`button-sql-markdown-toggle-${index}`}
|
||||
>
|
||||
{previewing[cell.cellKey] === false ? 'Preview' : 'Edit'}
|
||||
</button>
|
||||
{previewing[cell.cellKey] === false ? (
|
||||
<textarea
|
||||
className="block h-28 w-full rounded border p-2 font-mono text-sm"
|
||||
value={cell.content}
|
||||
aria-label={`Markdown for cell ${index + 1}`}
|
||||
onChange={(e) =>
|
||||
updateCells(
|
||||
cells.map((c, i) => (i === index ? { ...c, content: e.target.value } : c))
|
||||
)
|
||||
}
|
||||
data-qqq-id={`textarea-sql-markdown-${index}`}
|
||||
/>
|
||||
) : (
|
||||
<MarkdownView source={cell.content || '*Empty markdown cell*'} />
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<SqlCell
|
||||
cell={cell}
|
||||
index={index}
|
||||
capabilities={capabilities}
|
||||
defaultBackendName={defaultBackendName}
|
||||
paramsFor={paramsFor}
|
||||
notebookId={saved ? notebook.id : null}
|
||||
canEdit={canEdit}
|
||||
snapshots={snapshots.filter((s) => s.cellKey === cell.cellKey)}
|
||||
onChange={(next) => updateCells(cells.map((c, i) => (i === index ? next : c)))}
|
||||
onSnapshotsChanged={() =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.sqlNotebookSnapshots(notebook.id),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() =>
|
||||
updateCells([
|
||||
...cells,
|
||||
{
|
||||
cellKey: newCellKey(),
|
||||
cellType: 'SQL',
|
||||
content: '',
|
||||
title: null,
|
||||
backendName: null,
|
||||
},
|
||||
])
|
||||
}
|
||||
data-qqq-id="button-sql-add-sql-cell"
|
||||
>
|
||||
+ SQL cell
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => {
|
||||
const cellKey = newCellKey()
|
||||
setPreviewing({ ...previewing, [cellKey]: false })
|
||||
updateCells([
|
||||
...cells,
|
||||
{ cellKey, cellType: 'MARKDOWN', content: '', title: null, backendName: null },
|
||||
])
|
||||
}}
|
||||
data-qqq-id="button-sql-add-markdown-cell"
|
||||
>
|
||||
+ Markdown cell
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* 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 PlanTree — a query plan as a collapsible tree: each node's operation, target, cost range,
|
||||
* estimated rows and (with ANALYZE) actual time, rows and loops, with a bar scaled to the most
|
||||
* expensive node. A toggle shows the raw plan text.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import type { SqlExplainResponse, SqlPlanNode } from '@/types'
|
||||
import { planMax } from '@/lib/utils/sql-workbench'
|
||||
|
||||
/**
|
||||
* Formats a plan number.
|
||||
*
|
||||
* @param value - The number.
|
||||
* @returns Up to two decimals, or an empty string.
|
||||
*/
|
||||
function n(value: number | null): string {
|
||||
return value === null || value === undefined ? '' : Number(value.toFixed(2)).toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one node and its children.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.node - The node.
|
||||
* @param props.max - The plan's largest cost or time.
|
||||
* @param props.useActual - Scale bars by actual time.
|
||||
* @returns The node as a list item.
|
||||
*/
|
||||
function PlanNodeItem({
|
||||
node,
|
||||
max,
|
||||
useActual,
|
||||
}: {
|
||||
node: SqlPlanNode
|
||||
max: number
|
||||
useActual: boolean
|
||||
}) {
|
||||
const value = (useActual ? node.actualTimeMs : node.totalCost) ?? 0
|
||||
const width = max > 0 ? Math.max(2, Math.round((value / max) * 100)) : 0
|
||||
const extras = Object.entries(node.extra ?? {})
|
||||
const summary = (
|
||||
<span className="inline-flex flex-wrap items-center gap-x-3 gap-y-0.5">
|
||||
<span className="font-medium">{node.label}</span>
|
||||
{node.detail && <span className="text-muted-foreground">{node.detail}</span>}
|
||||
{node.totalCost !== null && (
|
||||
<span className="font-mono text-xs">
|
||||
cost {n(node.startupCost)}..{n(node.totalCost)}
|
||||
</span>
|
||||
)}
|
||||
{node.planRows !== null && <span className="font-mono text-xs">rows {n(node.planRows)}</span>}
|
||||
{node.actualTimeMs !== null && (
|
||||
<span className="font-mono text-xs">
|
||||
actual {n(node.actualTimeMs)} ms · {n(node.actualRows)} rows × {n(node.loops)}
|
||||
</span>
|
||||
)}
|
||||
{width > 0 && (
|
||||
<span className="inline-block h-1.5 w-24 rounded bg-muted" aria-hidden>
|
||||
<span className="block h-1.5 rounded bg-primary" style={{ width: `${width}%` }} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
<li className="my-1">
|
||||
<details open>
|
||||
<summary className="cursor-pointer">{summary}</summary>
|
||||
{extras.length > 0 && (
|
||||
<dl className="ml-4 grid grid-cols-[auto_1fr] gap-x-2 text-xs text-muted-foreground">
|
||||
{extras.map(([k, v]) => (
|
||||
<React.Fragment key={k}>
|
||||
<dt>{k}</dt>
|
||||
<dd className="truncate font-mono">
|
||||
{typeof v === 'string' ? v : JSON.stringify(v)}
|
||||
</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
{node.children.length > 0 && (
|
||||
<ul className="ml-4 border-l pl-3">
|
||||
{node.children.map((child, i) => (
|
||||
<PlanNodeItem key={i} node={child} max={max} useActual={useActual} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</details>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an EXPLAIN response.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.explain - The response.
|
||||
* @returns The tree, the raw text, or the error.
|
||||
*/
|
||||
export function PlanTree({ explain }: { explain: SqlExplainResponse }) {
|
||||
const [raw, setRaw] = useState(false)
|
||||
if (explain.status !== 'SUCCEEDED' || !explain.plan) {
|
||||
return (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{explain.error ?? 'The plan could not be produced.'}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
const useActual = explain.analyze === true
|
||||
const max = planMax(explain.plan, useActual)
|
||||
return (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{explain.analyze ? 'EXPLAIN ANALYZE (executed, rolled back)' : 'EXPLAIN (estimated)'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-auto rounded border px-2 py-0.5"
|
||||
onClick={() => setRaw(!raw)}
|
||||
data-qqq-id="button-sql-plan-raw"
|
||||
>
|
||||
{raw ? 'Tree' : 'Raw'}
|
||||
</button>
|
||||
</div>
|
||||
{raw ? (
|
||||
<pre className="max-h-96 overflow-auto rounded bg-muted p-2 font-mono text-xs">
|
||||
{explain.raw}
|
||||
</pre>
|
||||
) : (
|
||||
<ul aria-label="Query plan">
|
||||
<PlanNodeItem node={explain.plan} max={max} useActual={useActual} />
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
*
|
||||
* 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 QuickChart — a bar, line or pie chart of a result's current rows: pick the category
|
||||
* column and the value columns. Uses Recharts, as the dashboard chart widgets do.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
|
||||
import type { SqlColumn, SqlValue } from '@/types'
|
||||
import { numericColumnIndexes, toChartRows } from '@/lib/utils/sql-workbench'
|
||||
|
||||
const COLORS = [
|
||||
'#6366f1',
|
||||
'#10b981',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#06b6d4',
|
||||
'#8b5cf6',
|
||||
'#84cc16',
|
||||
'#ec4899',
|
||||
]
|
||||
|
||||
/**
|
||||
* Renders the chart builder.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.columns - Result columns.
|
||||
* @param props.rows - Rows to plot (the current page).
|
||||
* @returns The controls and the chart, or a hint when nothing is plottable.
|
||||
*/
|
||||
export function QuickChart({ columns, rows }: { columns: SqlColumn[]; rows: SqlValue[][] }) {
|
||||
const numeric = useMemo(() => numericColumnIndexes(columns, rows), [columns, rows])
|
||||
const firstCategory = columns.findIndex((_, i) => !numeric.includes(i))
|
||||
const [type, setType] = useState<'bar' | 'line' | 'pie'>('bar')
|
||||
const [xIndex, setXIndex] = useState(firstCategory >= 0 ? firstCategory : 0)
|
||||
const [yIndexes, setYIndexes] = useState<number[]>(
|
||||
numeric.filter((i) => i !== xIndex).slice(0, 1)
|
||||
)
|
||||
|
||||
if (numeric.length === 0 || rows.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Charts need at least one numeric column and one row.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const data = toChartRows(columns, rows, xIndex, yIndexes)
|
||||
const series = yIndexes.map((i) => columns[i].label)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||
<label className="inline-flex items-center gap-1">
|
||||
Chart
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'bar' | 'line' | 'pie')}
|
||||
data-qqq-id="select-sql-chart-type"
|
||||
>
|
||||
<option value="bar">Bar</option>
|
||||
<option value="line">Line</option>
|
||||
<option value="pie">Pie</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
Category
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={xIndex}
|
||||
onChange={(e) => setXIndex(Number(e.target.value))}
|
||||
data-qqq-id="select-sql-chart-x"
|
||||
>
|
||||
{columns.map((c, i) => (
|
||||
<option key={i} value={i}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<fieldset className="inline-flex flex-wrap items-center gap-2">
|
||||
<legend className="sr-only">Values</legend>
|
||||
<span>Values</span>
|
||||
{numeric.map((i) => (
|
||||
<label key={i} className="inline-flex items-center gap-1">
|
||||
<input
|
||||
type={type === 'pie' ? 'radio' : 'checkbox'}
|
||||
name="sql-chart-y"
|
||||
checked={yIndexes.includes(i)}
|
||||
onChange={(e) =>
|
||||
setYIndexes(
|
||||
type === 'pie'
|
||||
? [i]
|
||||
: e.target.checked
|
||||
? [...yIndexes, i]
|
||||
: yIndexes.filter((y) => y !== i)
|
||||
)
|
||||
}
|
||||
data-qqq-id={`checkbox-sql-chart-y-${i}`}
|
||||
/>
|
||||
{columns[i].label}
|
||||
</label>
|
||||
))}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div
|
||||
className="h-72 w-full"
|
||||
role="img"
|
||||
aria-label={`${type} chart of ${series.join(', ') || 'no values'} by ${columns[xIndex]?.label}`}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{type === 'pie' ? (
|
||||
<PieChart>
|
||||
<Pie data={data} dataKey={series[0] ?? ''} nameKey="x" outerRadius="80%" label>
|
||||
{data.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
) : type === 'line' ? (
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="x" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s}
|
||||
type="monotone"
|
||||
dataKey={s}
|
||||
stroke={COLORS[i % COLORS.length]}
|
||||
dot={false}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
) : (
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="x" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{series.map((s, i) => (
|
||||
<Bar key={s} dataKey={s} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</BarChart>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* 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 ResultGrid — typed, sortable, paginated view of a workbench result. The first page comes
|
||||
* with the run; later pages and sorts are read from the server's cached copy of the result
|
||||
* (`GET /executions/{uuid}/rows`), so SQL never re-runs. Copies the page as TSV and links the
|
||||
* CSV / XLSX / JSON exports of the whole (capped) result.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type { SqlColumn, SqlValue } from '@/types'
|
||||
import { getSqlRows, sqlExportUrl } from '@/lib/api/sql-workbench'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { formatSqlValue, isBinaryValue, prettyJson, rowsToTsv } from '@/lib/utils/sql-workbench'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
|
||||
/** Props for {@link ResultGrid}. */
|
||||
export interface ResultGridProps {
|
||||
executionUuid: string
|
||||
columns: SqlColumn[]
|
||||
firstPage: SqlValue[][]
|
||||
rowCount: number
|
||||
pageSize: number
|
||||
truncated?: boolean
|
||||
truncatedReason?: 'ROWS' | 'BYTES' | null
|
||||
/** Hide paging/sort/export (pinned snapshots, which have no cached result). */
|
||||
staticRows?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one cell value by its column kind.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.value - The value.
|
||||
* @param props.column - Its column.
|
||||
* @returns The cell content.
|
||||
*/
|
||||
export function SqlValueCell({ value, column }: { value: SqlValue; column: SqlColumn }) {
|
||||
if (value === null) return <span className="italic text-muted-foreground">NULL</span>
|
||||
if (isBinaryValue(value)) {
|
||||
return (
|
||||
<span
|
||||
className="text-muted-foreground"
|
||||
title={`0x${value.hexPreview}${value.length > value.hexPreview.length / 2 ? '…' : ''}`}
|
||||
>
|
||||
{formatSqlValue(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (column.kind === 'json' && typeof value === 'string') {
|
||||
return (
|
||||
<details>
|
||||
<summary className="max-w-xs cursor-pointer truncate font-mono text-xs">{value}</summary>
|
||||
<pre className="mt-1 max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
|
||||
{prettyJson(value)}
|
||||
</pre>
|
||||
</details>
|
||||
)
|
||||
}
|
||||
if (column.kind === 'boolean') return <span className="font-mono">{String(value)}</span>
|
||||
return (
|
||||
<span className={cn(column.kind === 'number' && 'font-mono tabular-nums')}>
|
||||
{formatSqlValue(value)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the grid.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The table with paging and export controls.
|
||||
*/
|
||||
export function ResultGrid(props: ResultGridProps) {
|
||||
const {
|
||||
executionUuid,
|
||||
columns,
|
||||
firstPage,
|
||||
rowCount,
|
||||
pageSize,
|
||||
truncated,
|
||||
truncatedReason,
|
||||
staticRows = false,
|
||||
} = props
|
||||
const [page, setPage] = useState(0)
|
||||
const [sort, setSort] = useState<{ column: number; dir: 'asc' | 'desc' } | null>(null)
|
||||
const pages = Math.max(1, Math.ceil(rowCount / pageSize))
|
||||
const needsFetch = !staticRows && (page > 0 || sort !== null)
|
||||
|
||||
const { data, isFetching, error } = useQuery({
|
||||
queryKey: [
|
||||
...queryKeys.sqlWorkbench(),
|
||||
'rows',
|
||||
executionUuid,
|
||||
page,
|
||||
pageSize,
|
||||
sort?.column ?? null,
|
||||
sort?.dir ?? null,
|
||||
],
|
||||
queryFn: () => getSqlRows(executionUuid, page * pageSize, pageSize, sort?.column, sort?.dir),
|
||||
enabled: needsFetch,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const rows = needsFetch ? (data?.rows ?? []) : firstPage
|
||||
|
||||
const toggleSort = (index: number) => {
|
||||
setPage(0)
|
||||
setSort((current) =>
|
||||
current?.column !== index
|
||||
? { column: index, dir: 'asc' }
|
||||
: current.dir === 'asc'
|
||||
? { column: index, dir: 'desc' }
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
const copy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(rowsToTsv(columns, rows))
|
||||
toast.success(`Copied ${rows.length} rows`)
|
||||
} catch {
|
||||
toast.error('Could not copy to the clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
if (columns.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground">The statement returned no rows.</p>
|
||||
}
|
||||
|
||||
const from = rowCount === 0 ? 0 : page * pageSize + 1
|
||||
const to = Math.min(rowCount, (page + 1) * pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="max-h-[28rem] overflow-auto rounded-md border">
|
||||
<table
|
||||
className="w-full border-collapse text-sm"
|
||||
aria-label="Query result"
|
||||
aria-busy={isFetching}
|
||||
>
|
||||
<thead className="sticky top-0 bg-muted">
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={index}
|
||||
scope="col"
|
||||
className="whitespace-nowrap border-b px-2 py-1 text-left font-medium"
|
||||
aria-sort={
|
||||
sort?.column === index
|
||||
? sort.dir === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: 'none'
|
||||
}
|
||||
>
|
||||
{staticRows ? (
|
||||
<span title={column.typeName}>{column.label}</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 hover:underline"
|
||||
onClick={() => toggleSort(index)}
|
||||
title={`${column.typeName} — sort`}
|
||||
data-qqq-id={`button-sql-sort-${index}`}
|
||||
>
|
||||
{column.label}
|
||||
{sort?.column === index &&
|
||||
(sort.dir === 'asc' ? (
|
||||
<span aria-hidden>▲</span>
|
||||
) : (
|
||||
<span aria-hidden>▼</span>
|
||||
))}
|
||||
</button>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, r) => (
|
||||
<tr key={r} className="odd:bg-background even:bg-muted/30">
|
||||
{columns.map((column, c) => (
|
||||
<td
|
||||
key={c}
|
||||
className={cn(
|
||||
'border-b px-2 py-1 align-top',
|
||||
column.kind === 'number' && 'text-right'
|
||||
)}
|
||||
>
|
||||
<SqlValueCell value={row[c] ?? null} column={column} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
Could not load rows: {error instanceof Error ? error.message : 'error'}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
Rows {from}–{to} of {rowCount}
|
||||
{truncated && (
|
||||
<span className="ml-1 text-amber-600">
|
||||
(capped by the {truncatedReason === 'BYTES' ? 'size' : 'row'} limit)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{!staticRows && pages > 1 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-0.5 disabled:opacity-50"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage(page - 1)}
|
||||
data-qqq-id="button-sql-page-prev"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span>
|
||||
Page {page + 1} of {pages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-2 py-0.5 disabled:opacity-50"
|
||||
disabled={page + 1 >= pages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
data-qqq-id="button-sql-page-next"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto inline-flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-0.5"
|
||||
onClick={copy}
|
||||
data-qqq-id="button-sql-copy"
|
||||
>
|
||||
Copy page
|
||||
</button>
|
||||
{!staticRows &&
|
||||
(['csv', 'xlsx', 'json'] as const).map((format) => (
|
||||
<a
|
||||
key={format}
|
||||
href={sqlExportUrl(executionUuid, format)}
|
||||
download
|
||||
className="inline-flex items-center gap-1 rounded border px-2 py-0.5"
|
||||
data-qqq-id={`link-sql-export-${format}`}
|
||||
>
|
||||
{format.toUpperCase()}
|
||||
</a>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,488 @@
|
||||
/*
|
||||
* 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 SqlCell — one SQL cell of a notebook: backend and mode, the editor, Run / Cancel /
|
||||
* Explain / Format, and the result as a grid, a quick chart or a plan. Write mode runs the
|
||||
* two-step protocol: a preview (rolled-back dry run) opens {@link WriteConfirmDialog}, and only
|
||||
* its confirmation commits.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import type {
|
||||
SqlExecuteResponse,
|
||||
SqlExplainResponse,
|
||||
SqlNotebookCell,
|
||||
SqlResultSnapshot,
|
||||
SqlWorkbenchCapabilities,
|
||||
} from '@/types'
|
||||
import {
|
||||
cancelSqlExecution,
|
||||
createSqlSnapshot,
|
||||
executeSql,
|
||||
explainSql,
|
||||
getSqlSchema,
|
||||
} from '@/lib/api/sql-workbench'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { formatDuration, newCellKey } from '@/lib/utils/sql-workbench'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { PlanTree } from './PlanTree'
|
||||
import { QuickChart } from './QuickChart'
|
||||
import { ResultGrid } from './ResultGrid'
|
||||
import { SqlEditor } from './SqlEditor'
|
||||
import { WriteConfirmDialog } from './WriteConfirmDialog'
|
||||
|
||||
/** Props for {@link SqlCell}. */
|
||||
export interface SqlCellProps {
|
||||
cell: SqlNotebookCell
|
||||
index: number
|
||||
capabilities: SqlWorkbenchCapabilities
|
||||
defaultBackendName: string
|
||||
/** Parameter values for this run, by name (already typed). */
|
||||
paramsFor: (sql: string) => Record<string, string | number | boolean | null>
|
||||
notebookId: number | null
|
||||
canEdit: boolean
|
||||
snapshots: SqlResultSnapshot[]
|
||||
onChange: (cell: SqlNotebookCell) => void
|
||||
onSnapshotsChanged: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a SQL cell.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The cell.
|
||||
*/
|
||||
export function SqlCell(props: SqlCellProps) {
|
||||
const {
|
||||
cell,
|
||||
index,
|
||||
capabilities,
|
||||
defaultBackendName,
|
||||
paramsFor,
|
||||
notebookId,
|
||||
canEdit,
|
||||
snapshots,
|
||||
onChange,
|
||||
onSnapshotsChanged,
|
||||
} = props
|
||||
const queryClient = useQueryClient()
|
||||
const backendName = cell.backendName || defaultBackendName
|
||||
const backend = capabilities.backends.find((b) => b.name === backendName)
|
||||
const [writeMode, setWriteMode] = useState(false)
|
||||
const [runningUuid, setRunningUuid] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<SqlExecuteResponse | null>(null)
|
||||
const [explain, setExplain] = useState<SqlExplainResponse | null>(null)
|
||||
const [preview, setPreview] = useState<SqlExecuteResponse | null>(null)
|
||||
const [committing, setCommitting] = useState(false)
|
||||
const [requestError, setRequestError] = useState<string | null>(null)
|
||||
const [tab, setTab] = useState<'grid' | 'chart' | 'plan'>('grid')
|
||||
|
||||
const { data: schema } = useQuery({
|
||||
queryKey: queryKeys.sqlWorkbenchSchema(backendName),
|
||||
queryFn: () => getSqlSchema(backendName),
|
||||
enabled: !!backend,
|
||||
staleTime: 5 * 60_000,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
const afterRun = () => queryClient.invalidateQueries({ queryKey: queryKeys.sqlHistory() })
|
||||
|
||||
const run = async () => {
|
||||
if (!backend || runningUuid || !cell.content.trim()) return
|
||||
const executionUuid = newCellKey()
|
||||
setRunningUuid(executionUuid)
|
||||
setRequestError(null)
|
||||
try {
|
||||
const response = await executeSql({
|
||||
backendName,
|
||||
sql: cell.content,
|
||||
params: paramsFor(cell.content),
|
||||
mode: writeMode ? 'WRITE' : 'READ_ONLY',
|
||||
notebookId: notebookId ?? undefined,
|
||||
cellKey: cell.cellKey,
|
||||
executionUuid,
|
||||
})
|
||||
if (response.status === 'PREVIEW') {
|
||||
setPreview(response)
|
||||
} else {
|
||||
setResult(response)
|
||||
setTab('grid')
|
||||
}
|
||||
} catch (error) {
|
||||
setRequestError(getErrorMessage(error))
|
||||
} finally {
|
||||
setRunningUuid(null)
|
||||
void afterRun()
|
||||
}
|
||||
}
|
||||
|
||||
const commit = async (acknowledgeRowGuard: boolean) => {
|
||||
if (!preview?.confirmToken) return
|
||||
setCommitting(true)
|
||||
try {
|
||||
const response = await executeSql({
|
||||
backendName,
|
||||
sql: cell.content,
|
||||
params: paramsFor(cell.content),
|
||||
mode: 'WRITE',
|
||||
notebookId: notebookId ?? undefined,
|
||||
cellKey: cell.cellKey,
|
||||
confirmToken: preview.confirmToken,
|
||||
acknowledgeRowGuard,
|
||||
})
|
||||
setResult(response)
|
||||
setTab('grid')
|
||||
if (response.status === 'SUCCEEDED')
|
||||
toast.success(`Committed: ${response.rowsAffected ?? 0} rows affected`)
|
||||
} catch (error) {
|
||||
setRequestError(getErrorMessage(error))
|
||||
} finally {
|
||||
setCommitting(false)
|
||||
setPreview(null)
|
||||
void afterRun()
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = async () => {
|
||||
if (!runningUuid) return
|
||||
try {
|
||||
await cancelSqlExecution(runningUuid)
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
const runExplain = async (analyze: boolean) => {
|
||||
if (!backend || !cell.content.trim()) return
|
||||
setRequestError(null)
|
||||
try {
|
||||
setExplain(
|
||||
await explainSql({
|
||||
backendName,
|
||||
sql: cell.content,
|
||||
params: paramsFor(cell.content),
|
||||
analyze,
|
||||
})
|
||||
)
|
||||
setTab('plan')
|
||||
} catch (error) {
|
||||
setRequestError(getErrorMessage(error))
|
||||
} finally {
|
||||
void afterRun()
|
||||
}
|
||||
}
|
||||
|
||||
const format = async () => {
|
||||
try {
|
||||
const { formatWorkbenchSql } = await import('@/lib/utils/sql-format')
|
||||
onChange({ ...cell, content: formatWorkbenchSql(cell.content, backend?.dialect) })
|
||||
} catch (error) {
|
||||
toast.error(`Could not format: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const pin = async () => {
|
||||
if (!notebookId || !result) return
|
||||
try {
|
||||
await createSqlSnapshot(notebookId, {
|
||||
executionUuid: result.executionUuid,
|
||||
cellKey: cell.cellKey,
|
||||
})
|
||||
toast.success('Result pinned')
|
||||
onSnapshotsChanged()
|
||||
} catch (error) {
|
||||
toast.error(getErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
const button = 'rounded border px-2 py-1 text-xs disabled:opacity-50'
|
||||
|
||||
return (
|
||||
<section
|
||||
className="space-y-2 rounded-md border p-3"
|
||||
aria-label={`SQL cell ${index + 1}`}
|
||||
data-qqq-id={`section-sql-cell-${index}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<input
|
||||
className="min-w-0 flex-1 rounded border-transparent bg-transparent px-1 py-0.5 text-sm font-medium hover:border-border focus:border-border"
|
||||
value={cell.title ?? ''}
|
||||
placeholder={`SQL cell ${index + 1}`}
|
||||
aria-label={`Title of SQL cell ${index + 1}`}
|
||||
onChange={(e) => onChange({ ...cell, title: e.target.value })}
|
||||
data-qqq-id={`input-sql-cell-title-${index}`}
|
||||
/>
|
||||
<label className="inline-flex items-center gap-1">
|
||||
Backend
|
||||
<select
|
||||
className="rounded border px-1 py-0.5"
|
||||
value={backendName}
|
||||
onChange={(e) => onChange({ ...cell, backendName: e.target.value })}
|
||||
data-qqq-id={`select-sql-backend-${index}`}
|
||||
>
|
||||
{capabilities.backends.map((b) => (
|
||||
<option key={b.name} value={b.name}>
|
||||
{b.label} ({b.dialect})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{capabilities.canWrite ? (
|
||||
<label
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded px-1.5 py-0.5',
|
||||
writeMode ? 'bg-destructive/15 text-destructive' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={writeMode}
|
||||
onChange={(e) => setWriteMode(e.target.checked)}
|
||||
data-qqq-id={`checkbox-sql-write-mode-${index}`}
|
||||
/>
|
||||
Write mode
|
||||
</label>
|
||||
) : (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5">Read-only</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SqlEditor
|
||||
value={cell.content}
|
||||
onChange={(content) => onChange({ ...cell, content })}
|
||||
dialect={backend?.dialect}
|
||||
schema={schema}
|
||||
onRun={run}
|
||||
label={`SQL for cell ${index + 1}`}
|
||||
qqqId={`editor-sql-cell-${index}`}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{runningUuid ? (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={cancel}
|
||||
data-qqq-id={`button-sql-cancel-${index}`}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(button, 'bg-primary text-primary-foreground')}
|
||||
onClick={run}
|
||||
disabled={!backend}
|
||||
data-qqq-id={`button-sql-run-${index}`}
|
||||
>
|
||||
{writeMode ? 'Preview write' : 'Run'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => runExplain(false)}
|
||||
disabled={!backend || !!runningUuid}
|
||||
data-qqq-id={`button-sql-explain-${index}`}
|
||||
>
|
||||
Explain
|
||||
</button>
|
||||
{capabilities.canWrite && (
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={() => runExplain(true)}
|
||||
disabled={!backend || !!runningUuid}
|
||||
title="Executes the statement in a transaction that is rolled back"
|
||||
data-qqq-id={`button-sql-explain-analyze-${index}`}
|
||||
>
|
||||
Explain analyze
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={button}
|
||||
onClick={format}
|
||||
data-qqq-id={`button-sql-format-${index}`}
|
||||
>
|
||||
Format
|
||||
</button>
|
||||
{runningUuid && (
|
||||
<span className="text-xs text-muted-foreground" role="status">
|
||||
Running…
|
||||
</span>
|
||||
)}
|
||||
{backend && !backend.readOnlyEnforced && !writeMode && (
|
||||
<span className="text-xs text-amber-600">
|
||||
This backend does not enforce read-only sessions; only the statement check applies.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{requestError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{requestError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preview && (
|
||||
<WriteConfirmDialog
|
||||
preview={preview}
|
||||
backendName={backendName}
|
||||
pending={committing}
|
||||
onConfirm={commit}
|
||||
onCancel={() => setPreview(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(result || explain) && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-1 text-xs" role="tablist" aria-label="Result views">
|
||||
{(['grid', 'chart', 'plan'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5',
|
||||
tab === t ? 'bg-muted font-medium' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => setTab(t)}
|
||||
data-qqq-id={`tab-sql-${t}-${index}`}
|
||||
>
|
||||
{t === 'grid' ? 'Grid' : t === 'chart' ? 'Chart' : 'Plan'}
|
||||
</button>
|
||||
))}
|
||||
{result && (
|
||||
<span className="ml-2 text-muted-foreground">
|
||||
{result.status}
|
||||
{result.durationMs != null && ` · ${formatDuration(result.durationMs)}`}
|
||||
{result.rowsAffected != null && ` · ${result.rowsAffected} rows affected`}
|
||||
</span>
|
||||
)}
|
||||
{result?.status === 'SUCCEEDED' &&
|
||||
notebookId &&
|
||||
canEdit &&
|
||||
(result.columns?.length ?? 0) > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(button, 'ml-auto')}
|
||||
onClick={pin}
|
||||
data-qqq-id={`button-sql-pin-${index}`}
|
||||
>
|
||||
Pin result
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{tab === 'grid' && result && (
|
||||
<ResultView
|
||||
key={result.executionUuid}
|
||||
result={result}
|
||||
pageSize={capabilities.limits.pageSize}
|
||||
/>
|
||||
)}
|
||||
{tab === 'chart' && result?.status === 'SUCCEEDED' && (
|
||||
<QuickChart
|
||||
key={result.executionUuid}
|
||||
columns={result.columns ?? []}
|
||||
rows={result.rows ?? []}
|
||||
/>
|
||||
)}
|
||||
{tab === 'plan' &&
|
||||
(explain ? (
|
||||
<PlanTree explain={explain} />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Use Explain to see the plan.</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snapshots.length > 0 && (
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">
|
||||
Pinned results ({snapshots.length})
|
||||
</summary>
|
||||
{snapshots.map((s) => (
|
||||
<div key={s.id} className="mt-2 space-y-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.label ?? 'Snapshot'} · {s.createDate ?? ''} · {s.rowCount} rows
|
||||
{s.truncated ? ' (truncated)' : ''}
|
||||
</p>
|
||||
<ResultGrid
|
||||
executionUuid={s.executionUuid ?? String(s.id)}
|
||||
columns={s.result.columns ?? []}
|
||||
firstPage={s.result.rows ?? []}
|
||||
rowCount={s.rowCount}
|
||||
pageSize={s.rowCount || 1}
|
||||
staticRows
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a run's outcome: the grid, or the failure, drift or rejection message.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.result - The run.
|
||||
* @param props.pageSize - Rows per page.
|
||||
* @returns The outcome.
|
||||
*/
|
||||
function ResultView({ result, pageSize }: { result: SqlExecuteResponse; pageSize: number }) {
|
||||
if (result.status === 'DRIFTED') {
|
||||
return (
|
||||
<p className="text-sm text-amber-700" role="alert">
|
||||
Nothing was committed: the affected rows changed since the preview (
|
||||
{result.previewAffectedRows} → {result.actualAffectedRows}). Preview again.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (result.status !== 'SUCCEEDED') {
|
||||
return (
|
||||
<p className="whitespace-pre-wrap text-sm text-destructive" role="alert">
|
||||
{result.status === 'TIMED_OUT'
|
||||
? 'Timed out: '
|
||||
: result.status === 'CANCELLED'
|
||||
? 'Cancelled: '
|
||||
: ''}
|
||||
{result.error ?? result.status}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ResultGrid
|
||||
executionUuid={result.executionUuid}
|
||||
columns={result.columns ?? []}
|
||||
firstPage={result.rows ?? []}
|
||||
rowCount={result.rowCount ?? 0}
|
||||
pageSize={pageSize}
|
||||
truncated={result.truncated}
|
||||
truncatedReason={result.truncatedReason}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* 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 SqlEditor — CodeMirror 6 SQL editor for workbench cells: dialect-aware highlighting,
|
||||
* schema autocomplete (tables, columns, keywords) plus QQQ table/field names that insert their
|
||||
* SQL names, and Ctrl/Cmd+Enter to run.
|
||||
*
|
||||
* Keyboard (WCAG 2.1.2 No Keyboard Trap): Tab is not bound, so Tab and Shift+Tab always move
|
||||
* focus out of the editor; indentation uses the editor's indent commands (Ctrl+] / Ctrl+[).
|
||||
* Loaded only inside the lazily imported workbench widget, so CodeMirror stays out of the
|
||||
* initial bundle.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import {
|
||||
autocompletion,
|
||||
closeBrackets,
|
||||
closeBracketsKeymap,
|
||||
completionKeymap,
|
||||
type CompletionSource,
|
||||
} from '@codemirror/autocomplete'
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
|
||||
import { MySQL, PostgreSQL, SQLite, StandardSQL, sql, type SQLDialect } from '@codemirror/lang-sql'
|
||||
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { Compartment, EditorState } from '@codemirror/state'
|
||||
import { EditorView, highlightActiveLine, keymap, lineNumbers, placeholder } from '@codemirror/view'
|
||||
|
||||
import type { SqlSchema, SqlWorkbenchDialect } from '@/types'
|
||||
import { editorDialectName, editorSchema, qqqNameCompletions } from '@/lib/utils/sql-workbench'
|
||||
|
||||
const DIALECTS: Record<ReturnType<typeof editorDialectName>, SQLDialect> = {
|
||||
PostgreSQL,
|
||||
MySQL,
|
||||
SQLite,
|
||||
StandardSQL,
|
||||
}
|
||||
|
||||
/** Props for {@link SqlEditor}. */
|
||||
export interface SqlEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
dialect?: SqlWorkbenchDialect
|
||||
schema?: SqlSchema
|
||||
readOnly?: boolean
|
||||
/** Called on Ctrl/Cmd+Enter. */
|
||||
onRun?: () => void
|
||||
/** Accessible name of the editor. */
|
||||
label: string
|
||||
/** `data-qqq-id` of the editable content. */
|
||||
qqqId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A completion source offering QQQ table and field names that insert SQL names.
|
||||
*
|
||||
* @param schema - The backend schema, with its QQQ tables.
|
||||
* @returns The completion source.
|
||||
*/
|
||||
function qqqCompletionSource(schema: SqlSchema | undefined): CompletionSource {
|
||||
const options = qqqNameCompletions(schema)
|
||||
return (context) => {
|
||||
const word = context.matchBefore(/[\w.]+/)
|
||||
if (!word || (word.from === word.to && !context.explicit)) return null
|
||||
return { from: word.from, options, validFor: /^[\w.]*$/ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the language extension for a dialect and schema.
|
||||
*
|
||||
* @param dialect - Backend dialect.
|
||||
* @param schema - Backend schema.
|
||||
* @returns The extensions for the language compartment.
|
||||
*/
|
||||
function languageExtensions(
|
||||
dialect: SqlWorkbenchDialect | undefined,
|
||||
schema: SqlSchema | undefined
|
||||
) {
|
||||
const sqlDialect = DIALECTS[editorDialectName(dialect)]
|
||||
return [
|
||||
sql({ dialect: sqlDialect, schema: editorSchema(schema), upperCaseKeywords: true }),
|
||||
sqlDialect.language.data.of({ autocomplete: qqqCompletionSource(schema) }),
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the editor.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The editor container.
|
||||
*/
|
||||
export function SqlEditor(props: SqlEditorProps) {
|
||||
const { value, onChange, dialect, schema, readOnly = false, onRun, label, qqqId } = props
|
||||
const hostRef = useRef<HTMLDivElement>(null)
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const languageRef = useRef(new Compartment())
|
||||
const readOnlyRef = useRef(new Compartment())
|
||||
const onChangeRef = useRef(onChange)
|
||||
const onRunRef = useRef(onRun)
|
||||
onChangeRef.current = onChange
|
||||
onRunRef.current = onRun
|
||||
|
||||
useEffect(() => {
|
||||
if (!hostRef.current) return
|
||||
const view = new EditorView({
|
||||
parent: hostRef.current,
|
||||
state: EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion({ activateOnTyping: true }),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
placeholder('SELECT … (Ctrl/Cmd+Enter to run)'),
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
run: () => {
|
||||
onRunRef.current?.()
|
||||
return true
|
||||
},
|
||||
},
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...historyKeymap,
|
||||
...completionKeymap,
|
||||
]),
|
||||
languageRef.current.of(languageExtensions(dialect, schema)),
|
||||
readOnlyRef.current.of(EditorState.readOnly.of(readOnly)),
|
||||
EditorView.contentAttributes.of({ 'aria-label': label, 'data-qqq-id': qqqId }),
|
||||
EditorView.lineWrapping,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) onChangeRef.current(update.state.doc.toString())
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '13px',
|
||||
border: '1px solid var(--border, #d4d4d8)',
|
||||
borderRadius: '6px',
|
||||
},
|
||||
'&.cm-focused': { outline: '2px solid var(--ring, #6366f1)', outlineOffset: '1px' },
|
||||
'.cm-content': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
|
||||
minHeight: '5rem',
|
||||
},
|
||||
'.cm-gutters': { backgroundColor: 'transparent', border: 'none' },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
})
|
||||
viewRef.current = view
|
||||
return () => {
|
||||
view.destroy()
|
||||
viewRef.current = null
|
||||
}
|
||||
// The view is created once; later prop changes are applied by the effects below.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: languageRef.current.reconfigure(languageExtensions(dialect, schema)),
|
||||
})
|
||||
}, [dialect, schema])
|
||||
|
||||
useEffect(() => {
|
||||
viewRef.current?.dispatch({
|
||||
effects: readOnlyRef.current.reconfigure(EditorState.readOnly.of(readOnly)),
|
||||
})
|
||||
}, [readOnly])
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
if (view && view.state.doc.toString() !== value) {
|
||||
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
|
||||
}
|
||||
}, [value])
|
||||
|
||||
return <div ref={hostRef} className="qqq-sql-workbench-editor min-w-0" />
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
/*
|
||||
* 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 the SQL Workbench building blocks: editor, markdown, plan tree, result grid, chart
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { axe } from 'jest-axe'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { sqlExplain, sqlSchema, sqlSelectResult } from '@/mocks/fixtures/sql-workbench'
|
||||
import { MarkdownView } from './MarkdownView'
|
||||
import { PlanTree } from './PlanTree'
|
||||
import { QuickChart } from './QuickChart'
|
||||
import { ResultGrid } from './ResultGrid'
|
||||
import { SqlEditor } from './SqlEditor'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
beforeAll(() => {
|
||||
// Recharts' ResponsiveContainer observes its size; jsdom has no ResizeObserver.
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* Renders an element inside a fresh query client.
|
||||
*
|
||||
* @param ui - The element.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderWithClient(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
describe('SqlEditor', () => {
|
||||
it('shows the value, reports edits, follows prop changes and runs on Mod-Enter', async () => {
|
||||
const onChange = vi.fn()
|
||||
const onRun = vi.fn()
|
||||
const { rerender } = render(
|
||||
<SqlEditor
|
||||
value="SELECT 1"
|
||||
onChange={onChange}
|
||||
onRun={onRun}
|
||||
dialect="postgresql"
|
||||
schema={sqlSchema}
|
||||
label="SQL"
|
||||
qqqId="editor-test"
|
||||
/>
|
||||
)
|
||||
const content = screen.getByLabelText('SQL')
|
||||
expect(content).toHaveAttribute('data-qqq-id', 'editor-test')
|
||||
expect(content.textContent).toContain('SELECT 1')
|
||||
|
||||
rerender(
|
||||
<SqlEditor
|
||||
value="SELECT 2"
|
||||
onChange={onChange}
|
||||
onRun={onRun}
|
||||
dialect="mysql"
|
||||
label="SQL"
|
||||
qqqId="editor-test"
|
||||
readOnly
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(screen.getByLabelText('SQL').textContent).toContain('SELECT 2'))
|
||||
expect(onChange).toHaveBeenCalledWith('SELECT 2')
|
||||
|
||||
const user = userEvent.setup()
|
||||
rerender(
|
||||
<SqlEditor
|
||||
value="SELECT 2"
|
||||
onChange={onChange}
|
||||
onRun={onRun}
|
||||
label="SQL"
|
||||
qqqId="editor-test"
|
||||
/>
|
||||
)
|
||||
screen.getByLabelText('SQL').focus()
|
||||
await user.keyboard('{Control>}{Enter}{/Control}')
|
||||
expect(onRun).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('MarkdownView', () => {
|
||||
it('renders blocks and inline markup as elements, never as HTML', () => {
|
||||
const { container } = render(
|
||||
<MarkdownView
|
||||
source={
|
||||
'# Title\n\nSome **bold**, *em*, `code` and [a link](https://example.com) <b>raw</b>\n\n- one\n- two\n\n1. first\n\n```\nSELECT 1\n```\n###### small'
|
||||
}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: 'Title' })).toBeInTheDocument()
|
||||
expect(screen.getByText('bold').tagName).toBe('STRONG')
|
||||
expect(screen.getByText('em').tagName).toBe('EM')
|
||||
expect(screen.getByText('code').tagName).toBe('CODE')
|
||||
expect(screen.getByRole('link', { name: 'a link' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://example.com'
|
||||
)
|
||||
expect(container.querySelector('b')).toBeNull()
|
||||
expect(screen.getByText(/<b>raw<\/b>/)).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('ul li')).toHaveLength(2)
|
||||
expect(container.querySelectorAll('ol li')).toHaveLength(1)
|
||||
expect(container.querySelector('pre')?.textContent).toBe('SELECT 1')
|
||||
expect(screen.getByRole('heading', { name: 'small' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not link javascript: URLs', () => {
|
||||
const { container } = render(<MarkdownView source="[x](javascript:alert(1))" />)
|
||||
expect(container.querySelector('a')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('PlanTree', () => {
|
||||
it('renders nodes with costs and toggles the raw plan', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(<PlanTree explain={sqlExplain} />)
|
||||
const tree = screen.getByRole('list', { name: 'Query plan' })
|
||||
expect(within(tree).getByText('Sort')).toBeInTheDocument()
|
||||
expect(within(tree).getByText('on person')).toBeInTheDocument()
|
||||
expect(within(tree).getByText('cost 10..12.5')).toBeInTheDocument()
|
||||
expect(within(tree).getByText('Sort Key')).toBeInTheDocument()
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
await user.click(screen.getByRole('button', { name: 'Raw' }))
|
||||
expect(screen.queryByRole('list', { name: 'Query plan' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows analyze times and errors', () => {
|
||||
const plan = { ...sqlExplain.plan!, actualTimeMs: 1.5, actualRows: 10, loops: 1 }
|
||||
const { rerender } = render(<PlanTree explain={{ ...sqlExplain, analyze: true, plan }} />)
|
||||
expect(screen.getByText(/actual 1.5 ms/)).toBeInTheDocument()
|
||||
rerender(<PlanTree explain={{ executionUuid: 'x', status: 'FAILED', error: 'syntax error' }} />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('syntax error')
|
||||
rerender(<PlanTree explain={{ executionUuid: 'x', status: 'FAILED' }} />)
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('could not be produced')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ResultGrid', () => {
|
||||
it('sorts through the server cache and copies the page', async () => {
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
const { container } = renderWithClient(
|
||||
<ResultGrid
|
||||
executionUuid="exec-1"
|
||||
columns={sqlSelectResult.columns!}
|
||||
firstPage={sqlSelectResult.rows!}
|
||||
rowCount={2}
|
||||
pageSize={10}
|
||||
truncated
|
||||
truncatedReason="BYTES"
|
||||
/>
|
||||
)
|
||||
expect(screen.getByText(/capped by the size limit/)).toBeInTheDocument()
|
||||
expect(screen.getByText('{"k":1}')).toBeInTheDocument()
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Copy page/ }))
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining('id\tfirst_name'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /^id/ }))
|
||||
expect(await screen.findByText('Tim')).toBeInTheDocument()
|
||||
expect(screen.getByRole('columnheader', { name: /^id/ })).toHaveAttribute(
|
||||
'aria-sort',
|
||||
'ascending'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: /^id/ }))
|
||||
expect(screen.getByRole('columnheader', { name: /^id/ })).toHaveAttribute(
|
||||
'aria-sort',
|
||||
'descending'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: /^id/ }))
|
||||
expect(screen.getByRole('columnheader', { name: /^id/ })).toHaveAttribute('aria-sort', 'none')
|
||||
})
|
||||
|
||||
it('handles statements without rows, static snapshots and copy failures', async () => {
|
||||
const user = userEvent.setup()
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockRejectedValue(new Error('no')) },
|
||||
configurable: true,
|
||||
})
|
||||
const { rerender } = renderWithClient(
|
||||
<ResultGrid executionUuid="x" columns={[]} firstPage={[]} rowCount={0} pageSize={10} />
|
||||
)
|
||||
expect(screen.getByText(/returned no rows/)).toBeInTheDocument()
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ResultGrid
|
||||
executionUuid="x"
|
||||
columns={sqlSelectResult.columns!}
|
||||
firstPage={sqlSelectResult.rows!}
|
||||
rowCount={2}
|
||||
pageSize={2}
|
||||
staticRows
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
expect(screen.queryByRole('link', { name: /CSV/ })).not.toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: /Copy page/ }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('QuickChart', () => {
|
||||
it('offers chart types and value columns, or explains why it cannot chart', async () => {
|
||||
const user = userEvent.setup()
|
||||
const columns = [
|
||||
{ name: 'm', label: 'month', typeName: 'text', jdbcType: 12, kind: 'string' as const },
|
||||
{ name: 'n', label: 'signups', typeName: 'int4', jdbcType: 4, kind: 'number' as const },
|
||||
]
|
||||
const { rerender } = render(
|
||||
<QuickChart
|
||||
columns={columns}
|
||||
rows={[
|
||||
['jan', 3],
|
||||
['feb', 5],
|
||||
]}
|
||||
/>
|
||||
)
|
||||
expect(screen.getByRole('img', { name: /bar chart of signups by month/ })).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('Chart'), 'line')
|
||||
expect(screen.getByRole('img', { name: /line chart/ })).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('Chart'), 'pie')
|
||||
await user.click(screen.getByLabelText('signups'))
|
||||
expect(screen.getByRole('img', { name: /pie chart of signups/ })).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('Category'), '1')
|
||||
rerender(<QuickChart columns={[columns[0]]} rows={[['jan']]} />)
|
||||
expect(screen.getByText(/need at least one numeric column/)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* 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 the SQL Workbench widget: notebooks, running, write confirmation, explain
|
||||
|
||||
import React from 'react'
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { axe } from 'jest-axe'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { QWidgetMetaData, SqlExecuteRequest } from '@/types'
|
||||
import { server } from '@/mocks/node'
|
||||
import { sqlReadOnlyCapabilities } from '@/mocks/fixtures/sql-workbench'
|
||||
import { WidgetRenderer } from '@/components/widgets/WidgetRenderer'
|
||||
import SqlWorkbenchWidget from './SqlWorkbenchWidget'
|
||||
|
||||
vi.mock('./SqlEditor', () => ({
|
||||
SqlEditor: ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
onRun,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
label: string
|
||||
onRun?: () => void
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label={label}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.ctrlKey) onRun?.()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
|
||||
beforeAll(() => {
|
||||
// Recharts' ResponsiveContainer observes its size; jsdom has no ResizeObserver.
|
||||
vi.stubGlobal(
|
||||
'ResizeObserver',
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
const BASE = '/qqq/v1/sql-workbench'
|
||||
const widgetMetaData = {
|
||||
name: 'sqlWorkbenchWidget',
|
||||
label: 'SQL Workbench',
|
||||
type: 'SQL_WORKBENCH',
|
||||
} as QWidgetMetaData
|
||||
|
||||
/**
|
||||
* Renders an element inside a fresh query client.
|
||||
*
|
||||
* @param ui - The element.
|
||||
* @returns The render result.
|
||||
*/
|
||||
function renderWithClient(ui: React.ReactElement) {
|
||||
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState(null, '', '/')
|
||||
})
|
||||
|
||||
describe('SqlWorkbenchWidget', () => {
|
||||
it('lists notebooks and opens one into the URL', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await user.click(await screen.findByRole('button', { name: /Monthly signups/ }))
|
||||
expect(await screen.findByDisplayValue('Monthly signups')).toBeInTheDocument()
|
||||
expect(window.location.search).toBe('?notebook=1')
|
||||
expect(screen.getByText('month')).toBeInTheDocument()
|
||||
expect(screen.getByText(':since')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'My notebooks' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Shared library' }))
|
||||
expect(screen.getByRole('button', { name: 'Shared library' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
expect(await screen.findByRole('button', { name: /Orphan media/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('adds a history entry as one cell, and not again on save or notebook switch', async () => {
|
||||
const user = userEvent.setup()
|
||||
window.history.replaceState(null, '', '/?notebook=1')
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await screen.findByDisplayValue('Monthly signups')
|
||||
const before = screen.getAllByLabelText(/^SQL for cell/).length
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /SELECT \* FROM person/ }))
|
||||
await waitFor(() => expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(before + 1))
|
||||
|
||||
// Save bumps the version, which remounts the draft: the request must not re-apply.
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
expect(await screen.findByRole('button', { name: 'Saved' })).toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(before + 1)
|
||||
|
||||
// Switching notebooks must not inject the old request into the other notebook.
|
||||
await user.click(screen.getByRole('button', { name: '+ New notebook' }))
|
||||
expect(await screen.findByDisplayValue('Untitled notebook')).toBeInTheDocument()
|
||||
expect(screen.getAllByLabelText(/^SQL for cell/)).toHaveLength(1)
|
||||
expect(screen.getByLabelText('SQL for cell 1')).toHaveValue('')
|
||||
})
|
||||
|
||||
it('shows an error when the notebook list fails to load', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/notebooks`, () =>
|
||||
HttpResponse.json({ error: 'Database unavailable.' }, { status: 500 })
|
||||
)
|
||||
)
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
expect(await screen.findByText(/Could not load notebooks/)).toBeInTheDocument()
|
||||
expect(screen.queryByText('No notebooks yet.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('locks the default backend and cell controls on a notebook the user cannot edit', async () => {
|
||||
const { sqlNotebook } = await import('@/mocks/fixtures/sql-workbench')
|
||||
server.use(
|
||||
http.get(`${BASE}/notebooks/:id`, () => HttpResponse.json({ ...sqlNotebook, canEdit: false }))
|
||||
)
|
||||
window.history.replaceState(null, '', '/?notebook=1')
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await screen.findByDisplayValue('Monthly signups')
|
||||
expect(screen.getByLabelText('Default backend')).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: /^Delete cell/ })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /^Move cell/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('runs read-only SQL and shows typed results', async () => {
|
||||
const user = userEvent.setup()
|
||||
let sent: SqlExecuteRequest | null = null
|
||||
server.use(
|
||||
http.post(`${BASE}/execute`, async ({ request }) => {
|
||||
sent = (await request.json()) as SqlExecuteRequest
|
||||
const { sqlSelectResult } = await import('@/mocks/fixtures/sql-workbench')
|
||||
return HttpResponse.json(sqlSelectResult)
|
||||
}),
|
||||
http.get(`${BASE}/capabilities`, async () => {
|
||||
const { sqlCapabilities } = await import('@/mocks/fixtures/sql-workbench')
|
||||
return HttpResponse.json({
|
||||
...sqlCapabilities,
|
||||
limits: { ...sqlCapabilities.limits, pageSize: 2 },
|
||||
})
|
||||
})
|
||||
)
|
||||
window.history.replaceState(null, '', '/?notebook=1')
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await user.click(await screen.findByRole('button', { name: 'Run' }))
|
||||
|
||||
const table = await screen.findByRole('table', { name: 'Query result' })
|
||||
expect(within(table).getByText('Darin')).toBeInTheDocument()
|
||||
expect(within(table).getAllByText('NULL').length).toBeGreaterThan(0)
|
||||
expect(within(table).getByText('<binary 4 bytes>')).toBeInTheDocument()
|
||||
expect(sent!.mode).toBe('READ_ONLY')
|
||||
expect(sent!.params).toEqual({ since: '2026-01-01' })
|
||||
expect(sent!.cellKey).toBe('c1')
|
||||
expect(sent!.executionUuid).toMatch(/[0-9a-f-]{36}/)
|
||||
expect(screen.getByRole('link', { name: /CSV/ })).toHaveAttribute(
|
||||
'href',
|
||||
'/qqq/v1/sql-workbench/executions/exec-1/export?format=csv'
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Next' }))
|
||||
expect(await within(table).findByText('Tim')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: 'Chart' }))
|
||||
expect(screen.getByLabelText('Chart')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows SQL errors as results', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
const editor = await screen.findByLabelText('SQL for cell 1')
|
||||
await user.type(editor, 'select broken')
|
||||
await user.click(screen.getByRole('button', { name: 'Run' }))
|
||||
expect(await screen.findByText(/column "broken" does not exist/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('previews a write and commits only after confirmation', async () => {
|
||||
const user = userEvent.setup()
|
||||
const bodies: SqlExecuteRequest[] = []
|
||||
server.use(
|
||||
http.post(`${BASE}/execute`, async ({ request }) => {
|
||||
const body = (await request.json()) as SqlExecuteRequest
|
||||
bodies.push(body)
|
||||
const { sqlWritePreview, sqlWriteCommit } = await import('@/mocks/fixtures/sql-workbench')
|
||||
return HttpResponse.json(body.confirmToken ? sqlWriteCommit : sqlWritePreview)
|
||||
})
|
||||
)
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await user.type(
|
||||
await screen.findByLabelText('SQL for cell 1'),
|
||||
'UPDATE person SET active = false'
|
||||
)
|
||||
await user.click(screen.getByLabelText('Write mode'))
|
||||
await user.click(screen.getByRole('button', { name: 'Preview write' }))
|
||||
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
expect(within(dialog).getByText(/1204 rows affected/)).toBeInTheDocument()
|
||||
const commit = within(dialog).getByRole('button', { name: 'Commit write' })
|
||||
expect(commit).toBeDisabled()
|
||||
await user.type(within(dialog).getByLabelText(/Type postgres to confirm/), 'postgres')
|
||||
expect(commit).toBeDisabled()
|
||||
await user.click(within(dialog).getByLabelText(/I understand/))
|
||||
await user.click(commit)
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
|
||||
expect(bodies).toHaveLength(2)
|
||||
expect(bodies[0].confirmToken).toBeUndefined()
|
||||
expect(bodies[1]).toMatchObject({
|
||||
mode: 'WRITE',
|
||||
confirmToken: 'token-1',
|
||||
acknowledgeRowGuard: true,
|
||||
})
|
||||
expect(await screen.findByText(/1204 rows affected/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains a statement as a plan tree', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
await user.type(await screen.findByLabelText('SQL for cell 1'), 'SELECT 1')
|
||||
await user.click(screen.getByRole('button', { name: 'Explain' }))
|
||||
const plan = await screen.findByRole('list', { name: 'Query plan' })
|
||||
expect(within(plan).getByText('Seq Scan')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Raw' }))
|
||||
expect(screen.getByText('[{"Plan": {}}]')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves a new notebook', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
const title = await screen.findByLabelText('Notebook title')
|
||||
await user.clear(title)
|
||||
await user.type(title, 'Fresh')
|
||||
await user.click(screen.getByRole('button', { name: '+ Markdown cell' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Save' }))
|
||||
await waitFor(() => expect(window.location.search).toBe('?notebook=9'))
|
||||
})
|
||||
|
||||
it('hides write controls for read-only users', async () => {
|
||||
server.use(http.get(`${BASE}/capabilities`, () => HttpResponse.json(sqlReadOnlyCapabilities)))
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
expect(await screen.findByText('Read-only')).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('Write mode')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Explain analyze' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('says when the workbench is unavailable', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/capabilities`, () =>
|
||||
HttpResponse.json({ error: 'Permission denied.' }, { status: 403 })
|
||||
)
|
||||
)
|
||||
renderWithClient(<SqlWorkbenchWidget widgetMetaData={widgetMetaData} />)
|
||||
expect(await screen.findByText(/not available to you/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('is rendered by WidgetRenderer for SQL_WORKBENCH and has no axe violations', async () => {
|
||||
const { container } = renderWithClient(
|
||||
<WidgetRenderer widgetMetaData={widgetMetaData} data={{}} />
|
||||
)
|
||||
await screen.findByRole('button', { name: '+ New notebook' })
|
||||
expect(await axe(container)).toHaveNoViolations()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* 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 SqlWorkbenchWidget — the qbit-sql-workbench app's `SQL_WORKBENCH` widget: notebook
|
||||
* library (mine / shared), execution history, and the notebook editor. Its data comes from the
|
||||
* permission-checked `/qqq/v1/sql-workbench` endpoints (as the ESB overview widget reads the ESB
|
||||
* endpoints); the open notebook is kept in the URL (`?notebook=<id>`). Loaded lazily by
|
||||
* WidgetRenderer, so CodeMirror and the formatter are only downloaded here.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import type { QWidgetMetaData, SqlHistoryEntry } from '@/types'
|
||||
import {
|
||||
getSqlHistory,
|
||||
getSqlWorkbenchCapabilities,
|
||||
listSqlNotebooks,
|
||||
} from '@/lib/api/sql-workbench'
|
||||
import { HANDLES_OWN_ERRORS, queryKeys } from '@/lib/query-client'
|
||||
import { getErrorMessage } from '@/lib/utils/error-utils'
|
||||
import { formatDuration } from '@/lib/utils/sql-workbench'
|
||||
import { cn } from '@/lib/utils/cn'
|
||||
import { NotebookEditor, type InsertCellRequest } from './NotebookEditor'
|
||||
|
||||
/**
|
||||
* The notebook id in the page URL.
|
||||
*
|
||||
* @returns The id, or null.
|
||||
*/
|
||||
function notebookFromUrl(): number | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const id = Number(new URLSearchParams(window.location.search).get('notebook'))
|
||||
return Number.isInteger(id) && id > 0 ? id : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the workbench.
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @param props.widgetMetaData - The widget's metadata, for `data-qqq-id` scoping.
|
||||
* @returns The workbench, or a loading / unavailable state.
|
||||
*/
|
||||
export default function SqlWorkbenchWidget({
|
||||
widgetMetaData,
|
||||
}: {
|
||||
widgetMetaData: QWidgetMetaData
|
||||
}) {
|
||||
const [notebookId, setNotebookId] = useState<number | null>(notebookFromUrl)
|
||||
const [scope, setScope] = useState<'mine' | 'shared'>('mine')
|
||||
const [insertRequest, setInsertRequest] = useState<InsertCellRequest | null>(null)
|
||||
const clearInsertRequest = useCallback(() => setInsertRequest(null), [])
|
||||
|
||||
const {
|
||||
data: capabilities,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.sqlWorkbenchCapabilities(),
|
||||
queryFn: getSqlWorkbenchCapabilities,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const {
|
||||
data: notebooks = [],
|
||||
isLoading: notebooksLoading,
|
||||
error: notebooksError,
|
||||
} = useQuery({
|
||||
queryKey: queryKeys.sqlNotebooks(scope),
|
||||
queryFn: () => listSqlNotebooks(scope),
|
||||
enabled: !!capabilities,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: queryKeys.sqlHistory(),
|
||||
queryFn: () => getSqlHistory(20),
|
||||
enabled: !!capabilities,
|
||||
meta: HANDLES_OWN_ERRORS,
|
||||
})
|
||||
|
||||
const select = (id: number | null) => {
|
||||
setNotebookId(id)
|
||||
const url = new URL(window.location.href)
|
||||
if (id === null) url.searchParams.delete('notebook')
|
||||
else url.searchParams.set('notebook', String(id))
|
||||
window.history.replaceState(window.history.state, '', url)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
Loading SQL Workbench…
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
Failed to load the SQL Workbench: {getErrorMessage(error)}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (!capabilities) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The SQL Workbench is not available to you on this server.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (capabilities.backends.length === 0) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No SQL backends are configured for the workbench.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const openFromHistory = (entry: SqlHistoryEntry) =>
|
||||
setInsertRequest({ sql: entry.sqlText, backendName: entry.backendName, nonce: Date.now() })
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid gap-4 lg:grid-cols-[16rem_1fr]"
|
||||
data-qqq-id={`widget-${widgetMetaData.name}`}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<nav aria-label="Notebooks" className="space-y-2">
|
||||
<div className="flex gap-1 text-xs" role="group" aria-label="Notebook library">
|
||||
{(['mine', 'shared'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
aria-pressed={scope === s}
|
||||
className={cn(
|
||||
'rounded px-2 py-1',
|
||||
scope === s ? 'bg-muted font-medium' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => setScope(s)}
|
||||
data-qqq-id={`tab-sql-notebooks-${s}`}
|
||||
>
|
||||
{s === 'mine' ? 'My notebooks' : 'Shared library'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded border px-2 py-1 text-left text-sm"
|
||||
onClick={() => select(null)}
|
||||
data-qqq-id="button-sql-new-notebook"
|
||||
>
|
||||
+ New notebook
|
||||
</button>
|
||||
{notebooksLoading ? (
|
||||
<p className="text-xs text-muted-foreground">Loading…</p>
|
||||
) : notebooksError ? (
|
||||
<p className="text-xs text-destructive" role="alert">
|
||||
Could not load notebooks: {getErrorMessage(notebooksError)}
|
||||
</p>
|
||||
) : notebooks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{scope === 'mine' ? 'No notebooks yet.' : 'Nothing has been shared yet.'}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{notebooks.map((n) => (
|
||||
<li key={n.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full truncate rounded px-2 py-1 text-left hover:bg-muted',
|
||||
n.id === notebookId && 'bg-muted font-medium'
|
||||
)}
|
||||
onClick={() => select(n.id)}
|
||||
aria-current={n.id === notebookId ? 'page' : undefined}
|
||||
title={n.description ?? n.title}
|
||||
data-qqq-id={`button-sql-notebook-${n.id}`}
|
||||
>
|
||||
{n.title}
|
||||
{n.visibility === 'SHARED' && scope === 'mine' && (
|
||||
<span className="ml-1 text-xs text-muted-foreground">(shared)</span>
|
||||
)}
|
||||
{scope === 'shared' && (
|
||||
<span className="block text-xs text-muted-foreground">{n.ownerName}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</nav>
|
||||
<section aria-label="History" className="space-y-1">
|
||||
<h3 className="text-xs font-medium text-muted-foreground">Recent runs</h3>
|
||||
<ul className="space-y-0.5 text-xs">
|
||||
{history.map((h) => (
|
||||
<li key={h.executionUuid}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full rounded px-1 py-0.5 text-left hover:bg-muted"
|
||||
onClick={() => openFromHistory(h)}
|
||||
title={`${h.mode} ${h.status} — add as a new cell`}
|
||||
data-qqq-id={`button-sql-history-${h.executionUuid}`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'mr-1',
|
||||
h.status === 'SUCCEEDED' ? 'text-emerald-600' : 'text-destructive'
|
||||
)}
|
||||
>
|
||||
{h.status === 'SUCCEEDED' ? '✓' : '✗'}
|
||||
</span>
|
||||
<span className="font-mono">{h.sqlText.slice(0, 60)}</span>
|
||||
<span className="ml-1 text-muted-foreground">{formatDuration(h.durationMs)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<NotebookEditor
|
||||
notebookId={notebookId}
|
||||
capabilities={capabilities}
|
||||
insertRequest={insertRequest}
|
||||
onInsertConsumed={clearInsertRequest}
|
||||
onSelect={select}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* 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 WriteConfirmDialog — the second step of write mode. Shows every statement with its kind
|
||||
* and the affected rows from the rolled-back dry run, requires acknowledging the row guard when
|
||||
* it is exceeded, and requires typing the backend name before the commit is sent.
|
||||
*/
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
|
||||
import type { SqlExecuteResponse } from '@/types'
|
||||
|
||||
/** Props for {@link WriteConfirmDialog}. */
|
||||
export interface WriteConfirmDialogProps {
|
||||
preview: SqlExecuteResponse
|
||||
backendName: string
|
||||
pending: boolean
|
||||
onConfirm: (acknowledgeRowGuard: boolean) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the confirmation dialog (open while mounted).
|
||||
*
|
||||
* @param props - Component props.
|
||||
* @returns The dialog.
|
||||
*/
|
||||
export function WriteConfirmDialog({
|
||||
preview,
|
||||
backendName,
|
||||
pending,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: WriteConfirmDialogProps) {
|
||||
const [typed, setTyped] = useState('')
|
||||
const [acknowledged, setAcknowledged] = useState(false)
|
||||
const guard = preview.rowGuardExceeded === true
|
||||
const canCommit = typed === backendName && (!guard || acknowledged) && !pending
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(open) => !open && onCancel()}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/40" />
|
||||
<Dialog.Content
|
||||
className="fixed left-1/2 top-1/2 z-50 w-[min(42rem,95vw)] -translate-x-1/2 -translate-y-1/2 space-y-3 rounded-lg border bg-background p-4 shadow-lg"
|
||||
data-qqq-id="dialog-sql-write-confirm"
|
||||
>
|
||||
<Dialog.Title className="text-base font-semibold">
|
||||
Confirm write on “{backendName}”
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="flex gap-2 rounded bg-amber-50 p-2 text-sm text-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
<span aria-hidden>⚠</span>
|
||||
Raw SQL bypasses QQQ record security, audits, automations and search indexing. It runs
|
||||
in one transaction and is audited.
|
||||
</Dialog.Description>
|
||||
<table className="w-full text-sm" aria-label="Statements to commit">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th scope="col">#</th>
|
||||
<th scope="col">Kind</th>
|
||||
<th scope="col">Statement</th>
|
||||
<th scope="col" className="text-right">
|
||||
Rows
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(preview.statements ?? []).map((s) => (
|
||||
<tr key={s.index} className="align-top">
|
||||
<td>{s.index + 1}</td>
|
||||
<td className="font-mono text-xs">{s.kind}</td>
|
||||
<td className="max-w-md truncate font-mono text-xs" title={s.text}>
|
||||
{s.text}
|
||||
</td>
|
||||
<td className="text-right tabular-nums">{s.affectedRows ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-sm">
|
||||
{preview.dryRunSupported
|
||||
? `Dry run (rolled back): ${preview.totalAffectedRows ?? 0} rows affected.`
|
||||
: (preview.dryRunNote ?? 'This write could not be dry-run.')}
|
||||
</p>
|
||||
{guard && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledged}
|
||||
onChange={(e) => setAcknowledged(e.target.checked)}
|
||||
data-qqq-id="checkbox-sql-row-guard"
|
||||
/>
|
||||
I understand this affects more than {preview.rowGuardLimit} rows.
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-sm">
|
||||
Type <span className="font-mono">{backendName}</span> to confirm
|
||||
<input
|
||||
className="mt-1 block w-full rounded border px-2 py-1 font-mono"
|
||||
value={typed}
|
||||
onChange={(e) => setTyped(e.target.value)}
|
||||
autoComplete="off"
|
||||
data-qqq-id="input-sql-confirm-backend"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Dialog.Close asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded border px-3 py-1 text-sm"
|
||||
data-qqq-id="button-sql-write-cancel"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded bg-destructive px-3 py-1 text-sm text-destructive-foreground disabled:opacity-50"
|
||||
disabled={!canCommit}
|
||||
onClick={() => onConfirm(acknowledged)}
|
||||
data-qqq-id="button-sql-write-commit"
|
||||
>
|
||||
{pending ? 'Committing…' : 'Commit write'}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
@@ -25,7 +25,7 @@
|
||||
* pieChart, quickSightChart, statistics, stackedBarChart, stepper, table, usaMap,
|
||||
* process, parentWidget, composite, childRecordList, customComponent, cronUI,
|
||||
* dynamicForm, dataBagViewer, pivotTableSetup, filterAndColumnsSetup, rowBuilder,
|
||||
* scriptViewer, and the ESB module's ESB_OVERVIEW — plus the demo shapes (recordGrid, quickLinks, processSummary,
|
||||
* scriptViewer, the ESB module's ESB_OVERVIEW and the SQL Workbench QBit's SQL_WORKBENCH — plus the demo shapes (recordGrid, quickLinks, processSummary,
|
||||
* block, parent) used by the mocked development API.
|
||||
*
|
||||
* Chart components are loaded lazily so Recharts is split out of the initial bundle.
|
||||
@@ -77,6 +77,7 @@ const LineChartWidget = lazy(() => import('./LineChartWidget').then((m) => ({ de
|
||||
const PieChartWidget = lazy(() => import('./PieChartWidget').then((m) => ({ default: m.PieChartWidget })))
|
||||
const FilterAndColumnsSetupWidget = lazy(() => import('./FilterAndColumnsSetupWidget').then((m) => ({ default: m.FilterAndColumnsSetupWidget })))
|
||||
const PivotTableSetupWidget = lazy(() => import('./PivotTableSetupWidget').then((m) => ({ default: m.PivotTableSetupWidget })))
|
||||
const SqlWorkbenchWidget = lazy(() => import('@/components/sql-workbench/SqlWorkbenchWidget'))
|
||||
const RowBuilderWidget = lazy(() => import('./RowBuilderWidget').then((m) => ({ default: m.RowBuilderWidget })))
|
||||
|
||||
/** Fallback while a lazy chart chunk loads; matches the default chart height. */
|
||||
@@ -215,6 +216,8 @@ export function WidgetRenderer({ widgetMetaData, data, recordContext, formContex
|
||||
return <ScriptViewerWidget {...common} data={data} />
|
||||
case 'ESB_OVERVIEW':
|
||||
return <EsbOverviewWidget widgetMetaData={widgetMetaData} />
|
||||
case 'SQL_WORKBENCH':
|
||||
return <Suspense fallback={SetupFallback}><SqlWorkbenchWidget widgetMetaData={widgetMetaData} /></Suspense>
|
||||
case 'recordGrid':
|
||||
return <RecordGridWidget data={data as unknown as RecordGridWidgetPayload} widgetName={name} />
|
||||
case 'quickLinks':
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* 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 the SQL Workbench API client
|
||||
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { server } from '@/mocks/node'
|
||||
import {
|
||||
sqlCapabilities,
|
||||
sqlExplain,
|
||||
sqlNotebook,
|
||||
sqlSchema,
|
||||
sqlSelectResult,
|
||||
sqlWritePreview,
|
||||
} from '@/mocks/fixtures/sql-workbench'
|
||||
import {
|
||||
cancelSqlExecution,
|
||||
createSqlNotebook,
|
||||
createSqlSnapshot,
|
||||
deleteSqlNotebook,
|
||||
deleteSqlSnapshot,
|
||||
duplicateSqlNotebook,
|
||||
executeSql,
|
||||
explainSql,
|
||||
getSqlHistory,
|
||||
getSqlNotebook,
|
||||
getSqlRows,
|
||||
getSqlSchema,
|
||||
getSqlWorkbenchCapabilities,
|
||||
listSqlNotebooks,
|
||||
listSqlNotebookVersions,
|
||||
listSqlSnapshots,
|
||||
publishSqlNotebook,
|
||||
restoreSqlNotebookVersion,
|
||||
saveSqlNotebook,
|
||||
sqlExportUrl,
|
||||
} from './sql-workbench'
|
||||
|
||||
const BASE = '/qqq/v1/sql-workbench'
|
||||
|
||||
describe('getSqlWorkbenchCapabilities', () => {
|
||||
it('returns the capabilities', async () => {
|
||||
expect(await getSqlWorkbenchCapabilities()).toEqual(sqlCapabilities)
|
||||
})
|
||||
|
||||
it.each([403, 404])('treats %i as unavailable', async (status) => {
|
||||
server.use(
|
||||
http.get(`${BASE}/capabilities`, () => HttpResponse.json({ error: 'x' }, { status }))
|
||||
)
|
||||
expect(await getSqlWorkbenchCapabilities()).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a non-workbench body (the SPA fallback) as unavailable', async () => {
|
||||
server.use(http.get(`${BASE}/capabilities`, () => HttpResponse.text('<html></html>')))
|
||||
expect(await getSqlWorkbenchCapabilities()).toBeNull()
|
||||
})
|
||||
|
||||
it('throws other errors', async () => {
|
||||
server.use(
|
||||
http.get(`${BASE}/capabilities`, () => HttpResponse.json({ error: 'boom' }, { status: 500 }))
|
||||
)
|
||||
await expect(getSqlWorkbenchCapabilities()).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('running SQL', () => {
|
||||
it('loads a schema, with and without refresh', async () => {
|
||||
let refresh: string | null = null
|
||||
server.use(
|
||||
http.get(`${BASE}/schema/:backend`, ({ request }) => {
|
||||
refresh = new URL(request.url).searchParams.get('refresh')
|
||||
return HttpResponse.json(sqlSchema)
|
||||
})
|
||||
)
|
||||
expect(await getSqlSchema('postgres')).toEqual(sqlSchema)
|
||||
expect(refresh).toBeNull()
|
||||
await getSqlSchema('postgres', true)
|
||||
expect(refresh).toBe('true')
|
||||
})
|
||||
|
||||
it('executes read-only and write requests', async () => {
|
||||
expect(await executeSql({ backendName: 'postgres', sql: 'SELECT 1' })).toEqual(sqlSelectResult)
|
||||
expect(await executeSql({ backendName: 'postgres', sql: 'UPDATE x', mode: 'WRITE' })).toEqual(
|
||||
sqlWritePreview
|
||||
)
|
||||
})
|
||||
|
||||
it('explains', async () => {
|
||||
expect(await explainSql({ backendName: 'postgres', sql: 'SELECT 1' })).toEqual(sqlExplain)
|
||||
})
|
||||
|
||||
it('pages with and without sorting', async () => {
|
||||
const seen: string[] = []
|
||||
server.use(
|
||||
http.get(`${BASE}/executions/:uuid/rows`, ({ request }) => {
|
||||
seen.push(new URL(request.url).search)
|
||||
return HttpResponse.json({ rows: [] })
|
||||
})
|
||||
)
|
||||
await getSqlRows('exec-1', 0, 10)
|
||||
await getSqlRows('exec-1', 10, 10, 2, 'desc')
|
||||
await getSqlRows('exec-1', 10, 10, 1)
|
||||
expect(seen).toEqual([
|
||||
'?offset=0&limit=10',
|
||||
'?offset=10&limit=10&sort=2&dir=desc',
|
||||
'?offset=10&limit=10&sort=1&dir=asc',
|
||||
])
|
||||
})
|
||||
|
||||
it('cancels, exports and lists history', async () => {
|
||||
expect(await cancelSqlExecution('exec-1')).toBe(true)
|
||||
expect(sqlExportUrl('exec 1', 'xlsx')).toBe(
|
||||
'/qqq/v1/sql-workbench/executions/exec%201/export?format=xlsx'
|
||||
)
|
||||
expect(await getSqlHistory()).toHaveLength(1)
|
||||
let params = ''
|
||||
server.use(
|
||||
http.get(`${BASE}/history`, ({ request }) => {
|
||||
params = new URL(request.url).search
|
||||
return HttpResponse.json({})
|
||||
})
|
||||
)
|
||||
expect(await getSqlHistory(5, 'postgres')).toEqual([])
|
||||
expect(params).toBe('?limit=5&backendName=postgres')
|
||||
})
|
||||
})
|
||||
|
||||
describe('notebooks', () => {
|
||||
it('lists, loads, creates, saves and deletes', async () => {
|
||||
expect((await listSqlNotebooks('mine'))[0].title).toBe('Monthly signups')
|
||||
expect((await listSqlNotebooks('shared'))[0].visibility).toBe('SHARED')
|
||||
expect(await getSqlNotebook(1)).toEqual(sqlNotebook)
|
||||
expect((await createSqlNotebook({ title: 'New' })).title).toBe('New')
|
||||
expect((await saveSqlNotebook(1, { title: 'Renamed' })).title).toBe('Renamed')
|
||||
await expect(deleteSqlNotebook(1)).resolves.toBeUndefined()
|
||||
server.use(http.get(`${BASE}/notebooks`, () => HttpResponse.json({})))
|
||||
expect(await listSqlNotebooks('mine')).toEqual([])
|
||||
})
|
||||
|
||||
it('publishes, duplicates, versions and snapshots', async () => {
|
||||
expect((await publishSqlNotebook(1, true)).visibility).toBe('SHARED')
|
||||
expect((await duplicateSqlNotebook(1)).id).toBe(10)
|
||||
expect(await listSqlNotebookVersions(1)).toHaveLength(2)
|
||||
expect((await restoreSqlNotebookVersion(1, 1)).currentVersion).toBe(3)
|
||||
expect(await listSqlSnapshots(1)).toEqual([])
|
||||
expect((await createSqlSnapshot(1, { executionUuid: 'exec-1', cellKey: 'c1' })).id).toBe(5)
|
||||
await expect(deleteSqlSnapshot(1, 5)).resolves.toBeUndefined()
|
||||
|
||||
server.use(
|
||||
http.get(`${BASE}/notebooks/:id/versions`, () => HttpResponse.json({})),
|
||||
http.get(`${BASE}/notebooks/:id/snapshots`, () => HttpResponse.json({}))
|
||||
)
|
||||
expect(await listSqlNotebookVersions(1)).toEqual([])
|
||||
expect(await listSqlSnapshots(1)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,340 @@
|
||||
/*
|
||||
* 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 SQL Workbench API — permission-checked `/qqq/v1/sql-workbench` endpoints served by the
|
||||
* qbit-sql-workbench QBit. SQL errors come back as results (status FAILED / TIMED_OUT /
|
||||
* CANCELLED), not as request errors; request errors are 400 / 401 / 403 / 404.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SqlExecuteRequest,
|
||||
SqlExecuteResponse,
|
||||
SqlExplainResponse,
|
||||
SqlExportFormat,
|
||||
SqlHistoryEntry,
|
||||
SqlNotebook,
|
||||
SqlNotebookInput,
|
||||
SqlNotebookSummary,
|
||||
SqlNotebookVersionSummary,
|
||||
SqlResultSnapshot,
|
||||
SqlRowsPage,
|
||||
SqlSchema,
|
||||
SqlWorkbenchCapabilities,
|
||||
} from '@/types'
|
||||
import { getErrorStatusCode } from '@/lib/utils/error-utils'
|
||||
import apiClient, { apiUrl } from './client'
|
||||
|
||||
/** Base path of the workbench routes under the API base URL. */
|
||||
export const SQL_WORKBENCH_PATH = '/sql-workbench'
|
||||
|
||||
/**
|
||||
* Loads what the caller may do, the limits and the target backends from `GET /capabilities`.
|
||||
*
|
||||
* 403 (no access), 404, and a 2xx body that is not a capabilities object (the SPA fallback when
|
||||
* the backend does not have the QBit) all mean the workbench is unavailable.
|
||||
*
|
||||
* @returns The capabilities, or `null` when the workbench is unavailable to this user.
|
||||
* @throws The request error for any other failure.
|
||||
*/
|
||||
export async function getSqlWorkbenchCapabilities(): Promise<SqlWorkbenchCapabilities | null> {
|
||||
try {
|
||||
const data = await apiClient.get<unknown>(`${SQL_WORKBENCH_PATH}/capabilities`)
|
||||
return isObject(data) && Array.isArray(data.backends) && isObject(data.limits)
|
||||
? (data as unknown as SqlWorkbenchCapabilities)
|
||||
: null
|
||||
} catch (error) {
|
||||
const status = getErrorStatusCode(error)
|
||||
if (status === 403 || status === 404) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a backend's schema for autocomplete from `GET /schema/{backend}`.
|
||||
*
|
||||
* @param backendName - Target backend.
|
||||
* @param refresh - Re-read instead of using the server's cache.
|
||||
* @returns The schema.
|
||||
*/
|
||||
export async function getSqlSchema(backendName: string, refresh = false): Promise<SqlSchema> {
|
||||
return apiClient.get<SqlSchema>(
|
||||
`${SQL_WORKBENCH_PATH}/schema/${encodeURIComponent(backendName)}`,
|
||||
{
|
||||
params: refresh ? { refresh: true } : undefined,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs SQL via `POST /execute`: read-only, a write preview (mode WRITE without a token), or a
|
||||
* write commit (mode WRITE with the preview's `confirmToken`).
|
||||
*
|
||||
* @param request - The run.
|
||||
* @returns The outcome.
|
||||
*/
|
||||
export async function executeSql(request: SqlExecuteRequest): Promise<SqlExecuteResponse> {
|
||||
return apiClient.post<SqlExecuteResponse>(`${SQL_WORKBENCH_PATH}/execute`, request, {
|
||||
timeout: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Explains one statement via `POST /explain`.
|
||||
*
|
||||
* @param request - Backend, SQL, parameters, and whether to ANALYZE (executes; rolled back).
|
||||
* @param request.backendName - Target backend.
|
||||
* @param request.sql - The statement.
|
||||
* @param request.params - Parameter values.
|
||||
* @param request.analyze - Run EXPLAIN ANALYZE.
|
||||
* @returns The plan.
|
||||
*/
|
||||
export async function explainSql(request: {
|
||||
backendName: string
|
||||
sql: string
|
||||
params?: Record<string, string | number | boolean | null>
|
||||
analyze?: boolean
|
||||
}): Promise<SqlExplainResponse> {
|
||||
return apiClient.post<SqlExplainResponse>(`${SQL_WORKBENCH_PATH}/explain`, request, {
|
||||
timeout: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Pages (and optionally sorts) a cached result via `GET /executions/{uuid}/rows`.
|
||||
*
|
||||
* @param executionUuid - The run.
|
||||
* @param offset - Rows to skip.
|
||||
* @param limit - Rows to return.
|
||||
* @param sort - Column index to sort by.
|
||||
* @param dir - Sort direction.
|
||||
* @returns The page.
|
||||
*/
|
||||
export async function getSqlRows(
|
||||
executionUuid: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
sort?: number,
|
||||
dir?: 'asc' | 'desc'
|
||||
): Promise<SqlRowsPage> {
|
||||
return apiClient.get<SqlRowsPage>(
|
||||
`${SQL_WORKBENCH_PATH}/executions/${encodeURIComponent(executionUuid)}/rows`,
|
||||
{
|
||||
params: { offset, limit, ...(sort == null ? {} : { sort, dir: dir ?? 'asc' }) },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a running execution via `POST /executions/{uuid}/cancel`.
|
||||
*
|
||||
* @param executionUuid - The run.
|
||||
* @returns Whether a running execution was found and cancelled.
|
||||
*/
|
||||
export async function cancelSqlExecution(executionUuid: string): Promise<boolean> {
|
||||
const data = await apiClient.post<{ cancelled: boolean }>(
|
||||
`${SQL_WORKBENCH_PATH}/executions/${encodeURIComponent(executionUuid)}/cancel`,
|
||||
{}
|
||||
)
|
||||
return data.cancelled === true
|
||||
}
|
||||
|
||||
/**
|
||||
* The download URL of a cached result export (the browser opens it, sending its session cookie).
|
||||
*
|
||||
* @param executionUuid - The run.
|
||||
* @param format - csv, xlsx or json.
|
||||
* @returns The URL.
|
||||
*/
|
||||
export function sqlExportUrl(executionUuid: string, format: SqlExportFormat): string {
|
||||
return apiUrl(
|
||||
`${SQL_WORKBENCH_PATH}/executions/${encodeURIComponent(executionUuid)}/export?format=${format}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The caller's recent executions from `GET /history`.
|
||||
*
|
||||
* @param limit - How many (server caps at 200).
|
||||
* @param backendName - Only this backend.
|
||||
* @returns Newest first.
|
||||
*/
|
||||
export async function getSqlHistory(limit = 50, backendName?: string): Promise<SqlHistoryEntry[]> {
|
||||
const data = await apiClient.get<{ executions: SqlHistoryEntry[] }>(
|
||||
`${SQL_WORKBENCH_PATH}/history`,
|
||||
{
|
||||
params: { limit, ...(backendName ? { backendName } : {}) },
|
||||
}
|
||||
)
|
||||
return data.executions ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists notebooks.
|
||||
*
|
||||
* @param scope - The caller's own, or the shared library.
|
||||
* @returns Summaries, most recently modified first.
|
||||
*/
|
||||
export async function listSqlNotebooks(scope: 'mine' | 'shared'): Promise<SqlNotebookSummary[]> {
|
||||
const data = await apiClient.get<{ notebooks: SqlNotebookSummary[] }>(
|
||||
`${SQL_WORKBENCH_PATH}/notebooks`,
|
||||
{
|
||||
params: { scope },
|
||||
}
|
||||
)
|
||||
return data.notebooks ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one notebook with its cells.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns The notebook.
|
||||
*/
|
||||
export async function getSqlNotebook(id: number): Promise<SqlNotebook> {
|
||||
return apiClient.get<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a personal notebook.
|
||||
*
|
||||
* @param input - Title, cells and parameters.
|
||||
* @returns The new notebook (version 1).
|
||||
*/
|
||||
export async function createSqlNotebook(input: SqlNotebookInput): Promise<SqlNotebook> {
|
||||
return apiClient.post<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks`, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a notebook (its cells are replaced; a new version is recorded).
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param input - The new content.
|
||||
* @returns The saved notebook.
|
||||
*/
|
||||
export async function saveSqlNotebook(id: number, input: SqlNotebookInput): Promise<SqlNotebook> {
|
||||
return apiClient.put<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}`, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a notebook (owner only).
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
*/
|
||||
export async function deleteSqlNotebook(id: number): Promise<void> {
|
||||
await apiClient.delete(`${SQL_WORKBENCH_PATH}/notebooks/${id}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a notebook to, or withdraws it from, the shared library.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param shared - Publish (true) or withdraw (false).
|
||||
* @returns The notebook.
|
||||
*/
|
||||
export async function publishSqlNotebook(id: number, shared: boolean): Promise<SqlNotebook> {
|
||||
return apiClient.post<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}/publish`, { shared })
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a personal copy of a readable notebook.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns The copy.
|
||||
*/
|
||||
export async function duplicateSqlNotebook(id: number): Promise<SqlNotebook> {
|
||||
return apiClient.post<SqlNotebook>(`${SQL_WORKBENCH_PATH}/notebooks/${id}/duplicate`, {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists a notebook's versions.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns Newest first.
|
||||
*/
|
||||
export async function listSqlNotebookVersions(id: number): Promise<SqlNotebookVersionSummary[]> {
|
||||
const data = await apiClient.get<{ versions: SqlNotebookVersionSummary[] }>(
|
||||
`${SQL_WORKBENCH_PATH}/notebooks/${id}/versions`
|
||||
)
|
||||
return data.versions ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a version (saved as a new version).
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param versionNumber - Version to restore.
|
||||
* @returns The notebook.
|
||||
*/
|
||||
export async function restoreSqlNotebookVersion(
|
||||
id: number,
|
||||
versionNumber: number
|
||||
): Promise<SqlNotebook> {
|
||||
return apiClient.post<SqlNotebook>(
|
||||
`${SQL_WORKBENCH_PATH}/notebooks/${id}/versions/${versionNumber}/restore`,
|
||||
{}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists a notebook's pinned results.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns Newest first.
|
||||
*/
|
||||
export async function listSqlSnapshots(id: number): Promise<SqlResultSnapshot[]> {
|
||||
const data = await apiClient.get<{ snapshots: SqlResultSnapshot[] }>(
|
||||
`${SQL_WORKBENCH_PATH}/notebooks/${id}/snapshots`
|
||||
)
|
||||
return data.snapshots ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins a cached result of the caller to a cell.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param input - The run, the cell, an optional label.
|
||||
* @param input.executionUuid - The run to pin.
|
||||
* @param input.cellKey - The cell it belongs to.
|
||||
* @param input.label - Optional label.
|
||||
* @returns The snapshot.
|
||||
*/
|
||||
export async function createSqlSnapshot(
|
||||
id: number,
|
||||
input: { executionUuid: string; cellKey: string; label?: string }
|
||||
): Promise<SqlResultSnapshot> {
|
||||
return apiClient.post<SqlResultSnapshot>(`${SQL_WORKBENCH_PATH}/notebooks/${id}/snapshots`, input)
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpins a snapshot.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @param snapshotId - Snapshot id.
|
||||
*/
|
||||
export async function deleteSqlSnapshot(id: number, snapshotId: number): Promise<void> {
|
||||
await apiClient.delete(`${SQL_WORKBENCH_PATH}/notebooks/${id}/snapshots/${snapshotId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value is a plain object.
|
||||
*
|
||||
* @param value - Anything.
|
||||
* @returns True for non-null, non-array objects.
|
||||
*/
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -463,6 +463,61 @@ export const queryKeys = {
|
||||
esbMessages: (kind: 'destination' | 'deadLetters', name: string, offset: number) =>
|
||||
[...queryKeys.esb(), 'messages', kind, name, offset] as const,
|
||||
|
||||
// SQL Workbench
|
||||
/**
|
||||
* Key for all SQL Workbench queries.
|
||||
*
|
||||
* @returns The SQL Workbench namespace query key tuple.
|
||||
*/
|
||||
sqlWorkbench: () => [...queryKeys.all(), 'sqlWorkbench'] as const,
|
||||
/**
|
||||
* Key for the caller's workbench capabilities (permissions, limits, backends).
|
||||
*
|
||||
* @returns The capabilities query key tuple.
|
||||
*/
|
||||
sqlWorkbenchCapabilities: () => [...queryKeys.sqlWorkbench(), 'capabilities'] as const,
|
||||
/**
|
||||
* Key for a backend's schema (autocomplete).
|
||||
*
|
||||
* @param backendName - Target backend.
|
||||
* @returns The schema query key tuple.
|
||||
*/
|
||||
sqlWorkbenchSchema: (backendName: string) => [...queryKeys.sqlWorkbench(), 'schema', backendName] as const,
|
||||
/**
|
||||
* Key for a notebook list.
|
||||
*
|
||||
* @param scope - The caller's own or the shared library.
|
||||
* @returns The notebook list query key tuple.
|
||||
*/
|
||||
sqlNotebooks: (scope: 'mine' | 'shared') => [...queryKeys.sqlWorkbench(), 'notebooks', scope] as const,
|
||||
/**
|
||||
* Key for one notebook.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns The notebook query key tuple.
|
||||
*/
|
||||
sqlNotebook: (id: number) => [...queryKeys.sqlWorkbench(), 'notebook', id] as const,
|
||||
/**
|
||||
* Key for a notebook's versions.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns The versions query key tuple.
|
||||
*/
|
||||
sqlNotebookVersions: (id: number) => [...queryKeys.sqlNotebook(id), 'versions'] as const,
|
||||
/**
|
||||
* Key for a notebook's pinned results.
|
||||
*
|
||||
* @param id - Notebook id.
|
||||
* @returns The snapshots query key tuple.
|
||||
*/
|
||||
sqlNotebookSnapshots: (id: number) => [...queryKeys.sqlNotebook(id), 'snapshots'] as const,
|
||||
/**
|
||||
* Key for the caller's execution history.
|
||||
*
|
||||
* @returns The history query key tuple.
|
||||
*/
|
||||
sqlHistory: () => [...queryKeys.sqlWorkbench(), 'history'] as const,
|
||||
|
||||
// Search
|
||||
/**
|
||||
* Key for all global search queries.
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
getProcessesForTable,
|
||||
getProcessesForRecordView,
|
||||
getSingleRecordProcesses,
|
||||
getBulkProcesses,
|
||||
getProcessesForAllScreens,
|
||||
@@ -100,6 +101,23 @@ describe('getProcessesForTable', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('getProcessesForRecordView', () => {
|
||||
it('sorts the table\'s own processes by label, then keeps all-screens processes in declared order', () => {
|
||||
const instance = {
|
||||
...makeInstance({
|
||||
zeta: makeProcess({ name: 'zeta', label: 'Zeta', tableName: 'person' }),
|
||||
alpha: makeProcess({ name: 'alpha', label: 'Alpha', tableName: 'person' }),
|
||||
mid: makeProcess({ name: 'mid', label: 'Mid', tableName: 'person' }),
|
||||
tag: makeProcess({ name: 'tag', label: 'Tag', tableName: '' }),
|
||||
audit: makeProcess({ name: 'audit', label: 'Audit', tableName: '' }),
|
||||
}),
|
||||
supplementalInstanceMetaData: { materialDashboard: { processNamesToAddToAllQueryAndViewScreens: ['tag', 'audit'] } },
|
||||
}
|
||||
expect(getProcessesForRecordView(instance, 'person').map((p) => p.name)).toEqual(['alpha', 'mid', 'zeta', 'tag', 'audit'])
|
||||
expect(getProcessesForTable(instance, 'person').map((p) => p.name)).toEqual(['zeta', 'alpha', 'mid', 'tag', 'audit'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('processes added to every query and record screen (Material)', () => {
|
||||
const withMaterial = (processes: Record<string, QProcessMetaData>, names: unknown) => ({
|
||||
...makeInstance(processes),
|
||||
|
||||
@@ -45,9 +45,45 @@ export function getProcessesForTable(
|
||||
metaData: QInstance,
|
||||
tableName: string
|
||||
): QProcessMetaData[] {
|
||||
const own = Object.values(metaData.processes ?? {}).filter(
|
||||
return withAllScreensProcesses(metaData, getOwnProcesses(metaData, tableName))
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes for the record view's action menu. Material sorts only the table's own
|
||||
* processes by label there (the query page keeps declared order); all-screens processes
|
||||
* still follow in their configured order.
|
||||
*
|
||||
* @param metaData - The full QQQ instance metadata.
|
||||
* @param tableName - The backend name of the table whose record is being viewed.
|
||||
* @returns The table's own visible, permitted processes sorted by label, then the
|
||||
* all-screens processes not already listed.
|
||||
*/
|
||||
export function getProcessesForRecordView(metaData: QInstance, tableName: string): QProcessMetaData[] {
|
||||
const own = getOwnProcesses(metaData, tableName).sort((a, b) => a.label.localeCompare(b.label))
|
||||
return withAllScreensProcesses(metaData, own)
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes declared for the table itself, before any all-screens additions.
|
||||
*
|
||||
* @param metaData - The full QQQ instance metadata.
|
||||
* @param tableName - The backend table name to match against `QProcessMetaData.tableName`.
|
||||
* @returns The table's own processes that are not hidden and that the user may run, in declared order.
|
||||
*/
|
||||
function getOwnProcesses(metaData: QInstance, tableName: string): QProcessMetaData[] {
|
||||
return Object.values(metaData.processes ?? {}).filter(
|
||||
(p) => p.tableName === tableName && !p.isHidden && p.hasPermission !== false
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the processes Material adds to every query and record screen.
|
||||
*
|
||||
* @param metaData - The full QQQ instance metadata.
|
||||
* @param own - The table's own processes, already filtered and ordered.
|
||||
* @returns `own` followed by the all-screens processes it does not already contain.
|
||||
*/
|
||||
function withAllScreensProcesses(metaData: QInstance, own: QProcessMetaData[]): QProcessMetaData[] {
|
||||
const added = getProcessesForAllScreens(metaData).filter((p) => !own.some((o) => o.name === p.name))
|
||||
return [...own, ...added]
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
*/
|
||||
|
||||
// Tests for the workbench SQL formatter
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatWorkbenchSql } from './sql-format'
|
||||
|
||||
describe('formatWorkbenchSql', () => {
|
||||
it('formats per dialect, upper-cases keywords and keeps :parameters', () => {
|
||||
expect(formatWorkbenchSql('select id from person where a = :since', 'postgresql')).toBe(
|
||||
'SELECT\n id\nFROM\n person\nWHERE\n a = :since'
|
||||
)
|
||||
expect(formatWorkbenchSql('select `id` from t', 'mysql')).toContain('SELECT')
|
||||
expect(formatWorkbenchSql('select 1', 'sqlite')).toBe('SELECT\n 1')
|
||||
expect(formatWorkbenchSql('select 1', undefined)).toBe('SELECT\n 1')
|
||||
})
|
||||
|
||||
it('throws on SQL it cannot parse', () => {
|
||||
expect(() => formatWorkbenchSql('select "unterminated', 'postgresql')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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 SQL formatting for the workbench's Format button. Imports only the four dialects the
|
||||
* workbench uses (sql-formatter's tree-shakable `formatDialect` API) and is loaded with
|
||||
* `import()` on first use, so the formatter is not part of any page load.
|
||||
*/
|
||||
|
||||
import { formatDialect, mysql, postgresql, sql, sqlite } from 'sql-formatter'
|
||||
|
||||
import type { SqlWorkbenchDialect } from '@/types'
|
||||
import { formatterLanguage } from './sql-workbench'
|
||||
|
||||
const DIALECTS = { postgresql, mysql, sqlite, sql }
|
||||
|
||||
/**
|
||||
* Formats SQL for a backend dialect, upper-casing keywords and keeping `:name` parameters.
|
||||
*
|
||||
* @param text - The SQL.
|
||||
* @param dialect - The backend's dialect.
|
||||
* @returns The formatted SQL.
|
||||
* @throws When the formatter cannot parse the SQL.
|
||||
*/
|
||||
export function formatWorkbenchSql(text: string, dialect: SqlWorkbenchDialect | undefined): string {
|
||||
return formatDialect(text, {
|
||||
dialect: DIALECTS[formatterLanguage(dialect)],
|
||||
keywordCase: 'upper',
|
||||
paramTypes: { named: [':'] },
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* 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 the SQL Workbench helpers
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { SqlColumn, SqlPlanNode, SqlSchema } from '@/types'
|
||||
import {
|
||||
coerceParameterValue,
|
||||
editorDialectName,
|
||||
editorSchema,
|
||||
extractSqlParameters,
|
||||
formatDuration,
|
||||
formatSqlValue,
|
||||
formatterLanguage,
|
||||
isBinaryValue,
|
||||
newCellKey,
|
||||
numericColumnIndexes,
|
||||
planMax,
|
||||
prettyJson,
|
||||
qqqNameCompletions,
|
||||
rowsToTsv,
|
||||
toChartRows,
|
||||
} from './sql-workbench'
|
||||
|
||||
const column = (label: string, kind: SqlColumn['kind']): SqlColumn => ({
|
||||
name: label,
|
||||
label,
|
||||
typeName: kind,
|
||||
jdbcType: 0,
|
||||
kind,
|
||||
})
|
||||
|
||||
describe('formatSqlValue', () => {
|
||||
it('renders nulls, binaries, arrays and scalars', () => {
|
||||
expect(formatSqlValue(null)).toBe('NULL')
|
||||
expect(formatSqlValue(undefined)).toBe('NULL')
|
||||
expect(formatSqlValue({ $binary: true, length: 4, hexPreview: 'cafebabe' })).toBe(
|
||||
'<binary 4 bytes>'
|
||||
)
|
||||
expect(formatSqlValue(['a', 1])).toBe('["a",1]')
|
||||
expect(formatSqlValue(true)).toBe('true')
|
||||
expect(formatSqlValue(1.5)).toBe('1.5')
|
||||
expect(isBinaryValue({ $binary: true })).toBe(true)
|
||||
expect(isBinaryValue([])).toBe(false)
|
||||
expect(isBinaryValue(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('pretty-prints JSON and leaves other text alone', () => {
|
||||
expect(prettyJson('{"a":1}')).toBe('{\n "a": 1\n}')
|
||||
expect(prettyJson('not json')).toBe('not json')
|
||||
})
|
||||
|
||||
it('builds tab-separated text', () => {
|
||||
expect(
|
||||
rowsToTsv(
|
||||
[column('a', 'string'), column('b', 'number')],
|
||||
[
|
||||
['x\ty', null],
|
||||
['z', 2],
|
||||
]
|
||||
)
|
||||
).toBe('a\tb\nx y\t\nz\t2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractSqlParameters', () => {
|
||||
it('finds parameters outside literals, comments and casts', () => {
|
||||
const sql = `SELECT id::text, ':skip', "x:y", \`a:b\` FROM t /* :c */ WHERE a = :a AND b = :b_2 OR a = :a -- :d
|
||||
AND e = $$ :e $$ AND f = $tag$ :f $tag$ AND g = :g`
|
||||
expect(extractSqlParameters(sql)).toEqual(['a', 'b_2', 'g'])
|
||||
})
|
||||
|
||||
it('handles escapes and unterminated text', () => {
|
||||
expect(extractSqlParameters("SELECT 'it''s :x', :y")).toEqual(['y'])
|
||||
expect(extractSqlParameters('SELECT 1 /* :open')).toEqual([])
|
||||
expect(extractSqlParameters('SELECT $$ :open')).toEqual([])
|
||||
expect(extractSqlParameters('SELECT :1, a:=:b')).toEqual(['b'])
|
||||
expect(extractSqlParameters('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('coerceParameterValue', () => {
|
||||
it('converts by declared type', () => {
|
||||
expect(coerceParameterValue('', 'STRING')).toBeNull()
|
||||
expect(coerceParameterValue(null)).toBeNull()
|
||||
expect(coerceParameterValue('12', 'INTEGER')).toBe(12)
|
||||
expect(coerceParameterValue('1.5', 'DECIMAL')).toBe(1.5)
|
||||
expect(coerceParameterValue('abc', 'INTEGER')).toBe('abc')
|
||||
expect(coerceParameterValue('yes', 'BOOLEAN')).toBe(true)
|
||||
expect(coerceParameterValue('no', 'BOOLEAN')).toBe(false)
|
||||
expect(coerceParameterValue('007')).toBe('007')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dialects', () => {
|
||||
it('maps backend dialects to editor and formatter dialects', () => {
|
||||
expect(editorDialectName('postgresql')).toBe('PostgreSQL')
|
||||
expect(editorDialectName('mysql')).toBe('MySQL')
|
||||
expect(editorDialectName('sqlite')).toBe('SQLite')
|
||||
expect(editorDialectName('h2')).toBe('StandardSQL')
|
||||
expect(editorDialectName(undefined)).toBe('StandardSQL')
|
||||
expect(formatterLanguage('postgresql')).toBe('postgresql')
|
||||
expect(formatterLanguage('mysql')).toBe('mysql')
|
||||
expect(formatterLanguage('sqlite')).toBe('sqlite')
|
||||
expect(formatterLanguage('h2')).toBe('sql')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autocomplete sources', () => {
|
||||
const schema: SqlSchema = {
|
||||
backendName: 'pg',
|
||||
dialect: 'postgresql',
|
||||
schemas: [
|
||||
{
|
||||
name: 'public',
|
||||
tables: [
|
||||
{
|
||||
name: 'person',
|
||||
type: 'TABLE',
|
||||
columns: [{ name: 'id', typeName: 'int4', nullable: false }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'audit',
|
||||
tables: [
|
||||
{
|
||||
name: 'person',
|
||||
type: 'TABLE',
|
||||
columns: [{ name: 'x', typeName: 'int4', nullable: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
keywords: [],
|
||||
functions: [],
|
||||
qqqTables: [
|
||||
{
|
||||
name: 'person',
|
||||
label: 'Person',
|
||||
sqlName: 'person',
|
||||
fields: [{ name: 'firstName', label: 'First Name', sqlName: 'first_name', type: 'STRING' }],
|
||||
},
|
||||
],
|
||||
cachedAt: '',
|
||||
}
|
||||
|
||||
it('maps tables to columns, bare and qualified', () => {
|
||||
expect(editorSchema(schema)).toEqual({
|
||||
'public.person': ['id'],
|
||||
person: ['id'],
|
||||
'audit.person': ['x'],
|
||||
})
|
||||
expect(editorSchema(undefined)).toEqual({})
|
||||
})
|
||||
|
||||
it('offers QQQ names that insert SQL names', () => {
|
||||
expect(qqqNameCompletions(schema)).toEqual([
|
||||
{ label: 'person', apply: 'person', detail: 'QQQ table → person', type: 'class' },
|
||||
{
|
||||
label: 'person.firstName',
|
||||
apply: 'person.first_name',
|
||||
detail: 'QQQ field → first_name',
|
||||
type: 'property',
|
||||
},
|
||||
])
|
||||
expect(qqqNameCompletions(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('charts', () => {
|
||||
const columns = [
|
||||
column('month', 'string'),
|
||||
column('n', 'number'),
|
||||
column('s', 'string'),
|
||||
column('t', 'string'),
|
||||
]
|
||||
const rows = [
|
||||
['jan', 1, '2.5', 'a'],
|
||||
['feb', null, '3', 'b'],
|
||||
]
|
||||
|
||||
it('finds numeric columns, including numeric text', () => {
|
||||
expect(numericColumnIndexes(columns, rows)).toEqual([1, 2])
|
||||
expect(numericColumnIndexes(columns, [])).toEqual([1])
|
||||
})
|
||||
|
||||
it('builds chart rows', () => {
|
||||
expect(toChartRows(columns, rows, 0, [1, 2])).toEqual([
|
||||
{ x: 'jan', n: 1, s: 2.5 },
|
||||
{ x: 'feb', n: 0, s: 3 },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('plans and misc', () => {
|
||||
const node = (
|
||||
cost: number | null,
|
||||
time: number | null,
|
||||
children: SqlPlanNode[] = []
|
||||
): SqlPlanNode => ({
|
||||
label: 'n',
|
||||
detail: null,
|
||||
startupCost: null,
|
||||
totalCost: cost,
|
||||
planRows: null,
|
||||
actualTimeMs: time,
|
||||
actualRows: null,
|
||||
loops: null,
|
||||
extra: {},
|
||||
children,
|
||||
})
|
||||
|
||||
it('finds the largest cost or time', () => {
|
||||
const plan = node(10, 1, [node(25, 0.5), node(null, 3)])
|
||||
expect(planMax(plan, false)).toBe(25)
|
||||
expect(planMax(plan, true)).toBe(3)
|
||||
})
|
||||
|
||||
it('makes cell keys and durations', () => {
|
||||
expect(newCellKey()).toMatch(/[0-9a-f-]{36}/)
|
||||
vi.stubGlobal('crypto', undefined)
|
||||
expect(newCellKey()).toMatch(/^cell-/)
|
||||
vi.unstubAllGlobals()
|
||||
expect(formatDuration(38)).toBe('38 ms')
|
||||
expect(formatDuration(1234)).toBe('1.2 s')
|
||||
expect(formatDuration(null)).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* 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 SQL Workbench helpers — value formatting, :parameter detection, dialect mapping,
|
||||
* autocomplete sources, chart data and plan costs. Pure functions; no React, no CodeMirror.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SqlBinaryValue,
|
||||
SqlColumn,
|
||||
SqlNotebookParameter,
|
||||
SqlPlanNode,
|
||||
SqlSchema,
|
||||
SqlValue,
|
||||
SqlWorkbenchDialect,
|
||||
} from '@/types'
|
||||
|
||||
/** CodeMirror `@codemirror/lang-sql` dialect export names. */
|
||||
export type EditorDialectName = 'PostgreSQL' | 'MySQL' | 'SQLite' | 'StandardSQL'
|
||||
|
||||
/** `sql-formatter` language names. */
|
||||
export type FormatterLanguage = 'postgresql' | 'mysql' | 'sqlite' | 'sql'
|
||||
|
||||
/** One completion entry for QQQ names that insert SQL names. */
|
||||
export interface QqqNameCompletion {
|
||||
label: string
|
||||
apply: string
|
||||
detail: string
|
||||
type: 'class' | 'property'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a value is a binary placeholder from the backend.
|
||||
*
|
||||
* @param value - A result value.
|
||||
* @returns True for `{ $binary: true, ... }`.
|
||||
*/
|
||||
export function isBinaryValue(value: unknown): value is SqlBinaryValue {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
(value as SqlBinaryValue).$binary === true
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Text for one result value in the grid, the clipboard and charts' labels.
|
||||
*
|
||||
* @param value - The value.
|
||||
* @returns `NULL` for null, `<binary N bytes>` for binary, JSON for arrays, else the value as text.
|
||||
*/
|
||||
export function formatSqlValue(value: SqlValue | undefined): string {
|
||||
if (value === null || value === undefined) return 'NULL'
|
||||
if (isBinaryValue(value)) return `<binary ${value.length} bytes>`
|
||||
if (Array.isArray(value)) return JSON.stringify(value)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-prints a JSON column value, or returns it unchanged when it is not valid JSON.
|
||||
*
|
||||
* @param value - The JSON text.
|
||||
* @returns Indented JSON.
|
||||
*/
|
||||
export function prettyJson(value: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(value), null, 2)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab-separated text of a result, for copying into a spreadsheet.
|
||||
*
|
||||
* @param columns - The columns (header row).
|
||||
* @param rows - The rows.
|
||||
* @returns Header and rows, tab-separated; NULL as empty.
|
||||
*/
|
||||
export function rowsToTsv(columns: SqlColumn[], rows: SqlValue[][]): string {
|
||||
const cell = (value: SqlValue) =>
|
||||
value === null ? '' : formatSqlValue(value).replace(/[\t\r\n]+/g, ' ')
|
||||
return [columns.map((c) => c.label).join('\t'), ...rows.map((r) => r.map(cell).join('\t'))].join(
|
||||
'\n'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Names of the `:name` parameters in SQL, in order of first appearance. Skips string literals,
|
||||
* quoted identifiers, comments, dollar quotes and Postgres `::` casts — the same rules as the
|
||||
* backend's scanner, so the UI asks for exactly the values the server will require.
|
||||
*
|
||||
* @param sql - The SQL text.
|
||||
* @returns Unique parameter names.
|
||||
*/
|
||||
export function extractSqlParameters(sql: string): string[] {
|
||||
const names: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let i = 0
|
||||
const n = sql.length
|
||||
const isStart = (c: string) => /[A-Za-z_]/.test(c)
|
||||
const isPart = (c: string) => /[A-Za-z0-9_]/.test(c)
|
||||
|
||||
while (i < n) {
|
||||
const c = sql[i]
|
||||
const next = sql[i + 1]
|
||||
if (c === '-' && next === '-') {
|
||||
while (i < n && sql[i] !== '\n') i++
|
||||
} else if (c === '/' && next === '*') {
|
||||
const end = sql.indexOf('*/', i + 2)
|
||||
i = end < 0 ? n : end + 2
|
||||
} else if (c === "'" || c === '"' || c === '`') {
|
||||
i++
|
||||
while (i < n) {
|
||||
if (sql[i] === c && sql[i + 1] === c) i += 2
|
||||
else if (sql[i] === c) break
|
||||
else i++
|
||||
}
|
||||
i++
|
||||
} else if (c === '$' && /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.test(sql.slice(i))) {
|
||||
const tag = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i))![0]
|
||||
const end = sql.indexOf(tag, i + tag.length)
|
||||
i = end < 0 ? n : end + tag.length
|
||||
} else if (c === ':' && next === ':') {
|
||||
i += 2
|
||||
} else if (c === ':' && next !== undefined && isStart(next) && sql[i - 1] !== ':') {
|
||||
let j = i + 1
|
||||
while (j < n && isPart(sql[j])) j++
|
||||
const name = sql.slice(i + 1, j)
|
||||
if (!seen.has(name)) {
|
||||
seen.add(name)
|
||||
names.push(name)
|
||||
}
|
||||
i = j
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a parameter's text input to the JSON value the backend binds.
|
||||
*
|
||||
* @param raw - What the user typed.
|
||||
* @param type - The notebook parameter's declared type, if any.
|
||||
* @returns `null` for empty input; a number for numeric types (or numeric-looking untyped input
|
||||
* is still sent as text, so `'007'` stays a string); a boolean for BOOLEAN; else the text.
|
||||
*/
|
||||
export function coerceParameterValue(
|
||||
raw: string | null | undefined,
|
||||
type?: SqlNotebookParameter['type']
|
||||
): string | number | boolean | null {
|
||||
if (raw === null || raw === undefined || raw === '') return null
|
||||
if (type === 'INTEGER' || type === 'DECIMAL') {
|
||||
const number = Number(raw)
|
||||
return Number.isFinite(number) ? number : raw
|
||||
}
|
||||
if (type === 'BOOLEAN') {
|
||||
return /^(true|1|yes|y)$/i.test(raw.trim())
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* The CodeMirror dialect for a backend dialect.
|
||||
*
|
||||
* @param dialect - The backend's dialect name.
|
||||
* @returns The `@codemirror/lang-sql` dialect export name.
|
||||
*/
|
||||
export function editorDialectName(dialect: SqlWorkbenchDialect | undefined): EditorDialectName {
|
||||
switch (dialect) {
|
||||
case 'postgresql':
|
||||
return 'PostgreSQL'
|
||||
case 'mysql':
|
||||
return 'MySQL'
|
||||
case 'sqlite':
|
||||
return 'SQLite'
|
||||
default:
|
||||
return 'StandardSQL'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `sql-formatter` language for a backend dialect.
|
||||
*
|
||||
* @param dialect - The backend's dialect name.
|
||||
* @returns The formatter language.
|
||||
*/
|
||||
export function formatterLanguage(dialect: SqlWorkbenchDialect | undefined): FormatterLanguage {
|
||||
switch (dialect) {
|
||||
case 'postgresql':
|
||||
return 'postgresql'
|
||||
case 'mysql':
|
||||
return 'mysql'
|
||||
case 'sqlite':
|
||||
return 'sqlite'
|
||||
default:
|
||||
return 'sql'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `schema` option for `@codemirror/lang-sql`: every table (bare and schema-qualified) mapped
|
||||
* to its column names.
|
||||
*
|
||||
* @param schema - The backend schema.
|
||||
* @returns Table name to column names.
|
||||
*/
|
||||
export function editorSchema(schema: SqlSchema | undefined): Record<string, string[]> {
|
||||
const result: Record<string, string[]> = {}
|
||||
for (const s of schema?.schemas ?? []) {
|
||||
for (const table of s.tables) {
|
||||
const columns = table.columns.map((c) => c.name)
|
||||
result[`${s.name}.${table.name}`] = columns
|
||||
if (!(table.name in result)) result[table.name] = columns
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Completions that let users type QQQ table and field names and insert the SQL names.
|
||||
*
|
||||
* @param schema - The backend schema (with its QQQ tables).
|
||||
* @returns One entry per QQQ table, and per field as `table.field`.
|
||||
*/
|
||||
export function qqqNameCompletions(schema: SqlSchema | undefined): QqqNameCompletion[] {
|
||||
const completions: QqqNameCompletion[] = []
|
||||
for (const table of schema?.qqqTables ?? []) {
|
||||
completions.push({
|
||||
label: table.name,
|
||||
apply: table.sqlName,
|
||||
detail: `QQQ table → ${table.sqlName}`,
|
||||
type: 'class',
|
||||
})
|
||||
for (const field of table.fields) {
|
||||
completions.push({
|
||||
label: `${table.name}.${field.name}`,
|
||||
apply: `${table.sqlName}.${field.sqlName}`,
|
||||
detail: `QQQ field → ${field.sqlName}`,
|
||||
type: 'property',
|
||||
})
|
||||
}
|
||||
}
|
||||
return completions
|
||||
}
|
||||
|
||||
/**
|
||||
* Indexes of the columns a chart can plot as values (numbers, or every row numeric text).
|
||||
*
|
||||
* @param columns - Result columns.
|
||||
* @param rows - Result rows.
|
||||
* @returns Column indexes.
|
||||
*/
|
||||
export function numericColumnIndexes(columns: SqlColumn[], rows: SqlValue[][]): number[] {
|
||||
return columns
|
||||
.map((column, index) => ({ column, index }))
|
||||
.filter(
|
||||
({ column, index }) =>
|
||||
column.kind === 'number' ||
|
||||
(rows.length > 0 &&
|
||||
rows.every(
|
||||
(r) =>
|
||||
r[index] === null ||
|
||||
(typeof r[index] === 'string' && r[index] !== '' && Number.isFinite(Number(r[index])))
|
||||
))
|
||||
)
|
||||
.map(({ index }) => index)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows for a Recharts chart: the x column's text as `x`, and each y column as a number keyed by its label.
|
||||
*
|
||||
* @param columns - Result columns.
|
||||
* @param rows - Result rows.
|
||||
* @param xIndex - Category column.
|
||||
* @param yIndexes - Value columns.
|
||||
* @returns Chart rows (nulls and non-numbers become 0).
|
||||
*/
|
||||
export function toChartRows(
|
||||
columns: SqlColumn[],
|
||||
rows: SqlValue[][],
|
||||
xIndex: number,
|
||||
yIndexes: number[]
|
||||
): Record<string, string | number>[] {
|
||||
return rows.map((row) => {
|
||||
const point: Record<string, string | number> = { x: formatSqlValue(row[xIndex]) }
|
||||
for (const y of yIndexes) {
|
||||
const number = Number(row[y])
|
||||
point[columns[y].label] = row[y] === null || !Number.isFinite(number) ? 0 : number
|
||||
}
|
||||
return point
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest total cost (or actual time) in a plan, to scale per-node cost bars.
|
||||
*
|
||||
* @param node - Plan root.
|
||||
* @param useActual - Use actual time instead of cost.
|
||||
* @returns The maximum, or 0.
|
||||
*/
|
||||
export function planMax(node: SqlPlanNode, useActual: boolean): number {
|
||||
const own = (useActual ? node.actualTimeMs : node.totalCost) ?? 0
|
||||
return Math.max(own, ...node.children.map((c) => planMax(c, useActual)))
|
||||
}
|
||||
|
||||
/**
|
||||
* A new, stable cell key.
|
||||
*
|
||||
* @returns A UUID.
|
||||
*/
|
||||
export function newCellKey(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||
return crypto.randomUUID()
|
||||
return `cell-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A human-readable duration.
|
||||
*
|
||||
* @param ms - Milliseconds.
|
||||
* @returns e.g. `38 ms`, `1.2 s`.
|
||||
*/
|
||||
export function formatDuration(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined) return ''
|
||||
return ms < 1000 ? `${ms} ms` : `${(ms / 1000).toFixed(1)} s`
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
/*
|
||||
* 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 Fixtures for the SQL Workbench endpoints, shaped as qbit-sql-workbench answers.
|
||||
*/
|
||||
|
||||
import type {
|
||||
SqlExecuteResponse,
|
||||
SqlExplainResponse,
|
||||
SqlHistoryEntry,
|
||||
SqlNotebook,
|
||||
SqlNotebookSummary,
|
||||
SqlRowsPage,
|
||||
SqlSchema,
|
||||
SqlWorkbenchCapabilities,
|
||||
} from '@/types'
|
||||
|
||||
export const sqlCapabilities: SqlWorkbenchCapabilities = {
|
||||
version: '1.0.0-RC.1',
|
||||
canWrite: true,
|
||||
canShare: true,
|
||||
writeModeEnabled: true,
|
||||
limits: {
|
||||
maxRows: 10000,
|
||||
pageSize: 500,
|
||||
maxPageSize: 2000,
|
||||
maxResultBytes: 20971520,
|
||||
statementTimeoutSeconds: 30,
|
||||
writeStatementTimeoutSeconds: 60,
|
||||
maxAffectedRowsWithoutAcknowledgement: 1000,
|
||||
maxAffectedRows: 100000,
|
||||
maxSqlLength: 100000,
|
||||
maxStatementsPerRun: 50,
|
||||
},
|
||||
backends: [
|
||||
{
|
||||
name: 'postgres',
|
||||
label: 'postgres',
|
||||
vendor: 'postgres',
|
||||
dialect: 'postgresql',
|
||||
readOnlyCredentials: true,
|
||||
readOnlyEnforced: true,
|
||||
transactionalDdl: true,
|
||||
writeAllowed: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export const sqlReadOnlyCapabilities: SqlWorkbenchCapabilities = {
|
||||
...sqlCapabilities,
|
||||
canWrite: false,
|
||||
canShare: false,
|
||||
backends: sqlCapabilities.backends.map((b) => ({ ...b, writeAllowed: false })),
|
||||
}
|
||||
|
||||
export const sqlSchema: SqlSchema = {
|
||||
backendName: 'postgres',
|
||||
dialect: 'postgresql',
|
||||
schemas: [
|
||||
{
|
||||
name: 'public',
|
||||
tables: [
|
||||
{
|
||||
name: 'person',
|
||||
type: 'TABLE',
|
||||
columns: [
|
||||
{ name: 'id', typeName: 'int4', nullable: false },
|
||||
{ name: 'first_name', typeName: 'text', nullable: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
truncated: false,
|
||||
keywords: ['SELECT', 'FROM'],
|
||||
functions: ['count'],
|
||||
qqqTables: [
|
||||
{
|
||||
name: 'person',
|
||||
label: 'Person',
|
||||
sqlName: 'person',
|
||||
fields: [{ name: 'firstName', label: 'First Name', sqlName: 'first_name', type: 'STRING' }],
|
||||
},
|
||||
],
|
||||
cachedAt: '2026-10-11T00:00:00Z',
|
||||
}
|
||||
|
||||
export const sqlSelectResult: SqlExecuteResponse = {
|
||||
executionUuid: 'exec-1',
|
||||
status: 'SUCCEEDED',
|
||||
mode: 'READ_ONLY',
|
||||
backendName: 'postgres',
|
||||
durationMs: 12,
|
||||
statements: [
|
||||
{ index: 0, kind: 'READ', leadingKeyword: 'SELECT', text: 'SELECT ...', affectedRows: null },
|
||||
],
|
||||
columns: [
|
||||
{ name: 'id', label: 'id', typeName: 'int4', jdbcType: 4, kind: 'number' },
|
||||
{ name: 'first_name', label: 'first_name', typeName: 'text', jdbcType: 12, kind: 'string' },
|
||||
{ name: 'doc', label: 'doc', typeName: 'jsonb', jdbcType: 1111, kind: 'json' },
|
||||
{ name: 'photo', label: 'photo', typeName: 'bytea', jdbcType: -2, kind: 'binary' },
|
||||
],
|
||||
rows: [
|
||||
[1, 'Darin', '{"k":1}', { $binary: true, length: 4, hexPreview: 'cafebabe' }],
|
||||
[2, null, null, null],
|
||||
],
|
||||
rowCount: 3,
|
||||
hasMore: true,
|
||||
truncated: false,
|
||||
truncatedReason: null,
|
||||
}
|
||||
|
||||
export const sqlRowsPage: SqlRowsPage = {
|
||||
executionUuid: 'exec-1',
|
||||
columns: sqlSelectResult.columns!,
|
||||
rows: [[3, 'Tim', null, null]],
|
||||
offset: 2,
|
||||
rowCount: 3,
|
||||
truncated: false,
|
||||
truncatedReason: null,
|
||||
}
|
||||
|
||||
export const sqlWritePreview: SqlExecuteResponse = {
|
||||
executionUuid: 'exec-2',
|
||||
status: 'PREVIEW',
|
||||
mode: 'WRITE_PREVIEW',
|
||||
backendName: 'postgres',
|
||||
durationMs: 41,
|
||||
statements: [
|
||||
{
|
||||
index: 0,
|
||||
kind: 'WRITE',
|
||||
leadingKeyword: 'UPDATE',
|
||||
text: 'UPDATE person SET active = false',
|
||||
affectedRows: 1204,
|
||||
},
|
||||
],
|
||||
dryRunSupported: true,
|
||||
dryRunNote: null,
|
||||
totalAffectedRows: 1204,
|
||||
rowGuardExceeded: true,
|
||||
rowGuardLimit: 1000,
|
||||
confirmToken: 'token-1',
|
||||
expiresAt: '2026-10-11T00:05:00Z',
|
||||
}
|
||||
|
||||
export const sqlWriteCommit: SqlExecuteResponse = {
|
||||
executionUuid: 'exec-3',
|
||||
status: 'SUCCEEDED',
|
||||
mode: 'WRITE_COMMIT',
|
||||
backendName: 'postgres',
|
||||
durationMs: 50,
|
||||
statements: [
|
||||
{
|
||||
index: 0,
|
||||
kind: 'WRITE',
|
||||
leadingKeyword: 'UPDATE',
|
||||
text: 'UPDATE person SET active = false',
|
||||
affectedRows: 1204,
|
||||
},
|
||||
],
|
||||
columns: [],
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
hasMore: false,
|
||||
truncated: false,
|
||||
rowsAffected: 1204,
|
||||
}
|
||||
|
||||
export const sqlExplain: SqlExplainResponse = {
|
||||
executionUuid: 'exec-4',
|
||||
status: 'SUCCEEDED',
|
||||
dialect: 'postgresql',
|
||||
analyze: false,
|
||||
raw: '[{"Plan": {}}]',
|
||||
plan: {
|
||||
label: 'Sort',
|
||||
detail: null,
|
||||
startupCost: 10,
|
||||
totalCost: 12.5,
|
||||
planRows: 100,
|
||||
actualTimeMs: null,
|
||||
actualRows: null,
|
||||
loops: null,
|
||||
extra: { 'Sort Key': '["id"]' },
|
||||
children: [
|
||||
{
|
||||
label: 'Seq Scan',
|
||||
detail: 'on person',
|
||||
startupCost: 0,
|
||||
totalCost: 7.1,
|
||||
planRows: 100,
|
||||
actualTimeMs: null,
|
||||
actualRows: null,
|
||||
loops: null,
|
||||
extra: {},
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const sqlHistory: SqlHistoryEntry[] = [
|
||||
{
|
||||
executionUuid: 'exec-1',
|
||||
backendName: 'postgres',
|
||||
mode: 'READ_ONLY',
|
||||
status: 'SUCCEEDED',
|
||||
sqlText: 'SELECT * FROM person',
|
||||
notebookId: 1,
|
||||
cellKey: 'c1',
|
||||
startTime: '2026-10-11T14:02:00Z',
|
||||
durationMs: 12,
|
||||
rowsReturned: 3,
|
||||
rowsAffected: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]
|
||||
|
||||
export const sqlNotebookSummary: SqlNotebookSummary = {
|
||||
id: 1,
|
||||
title: 'Monthly signups',
|
||||
description: null,
|
||||
visibility: 'PERSONAL',
|
||||
ownerUserId: 'user-1',
|
||||
ownerName: 'User One',
|
||||
defaultBackendName: 'postgres',
|
||||
currentVersion: 2,
|
||||
canEdit: true,
|
||||
createDate: '2026-10-01T00:00:00Z',
|
||||
modifyDate: '2026-10-11T00:00:00Z',
|
||||
}
|
||||
|
||||
export const sqlSharedNotebookSummary: SqlNotebookSummary = {
|
||||
...sqlNotebookSummary,
|
||||
id: 2,
|
||||
title: 'Orphan media',
|
||||
visibility: 'SHARED',
|
||||
ownerUserId: 'user-2',
|
||||
ownerName: 'User Two',
|
||||
canEdit: false,
|
||||
}
|
||||
|
||||
export const sqlNotebook: SqlNotebook = {
|
||||
...sqlNotebookSummary,
|
||||
parameters: [{ name: 'since', type: 'DATE', defaultValue: '2026-01-01' }],
|
||||
cells: [
|
||||
{
|
||||
cellKey: 'c0',
|
||||
cellType: 'MARKDOWN',
|
||||
title: null,
|
||||
content: '## Signups by **month**',
|
||||
backendName: null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
{
|
||||
cellKey: 'c1',
|
||||
cellType: 'SQL',
|
||||
title: 'Signups',
|
||||
content: 'SELECT * FROM person WHERE create_date >= :since',
|
||||
backendName: null,
|
||||
sortOrder: 1,
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import { possibleValuesHandlers } from './possible-values'
|
||||
import { processHandlers } from './processes'
|
||||
import { widgetHandlers } from './widgets'
|
||||
import { esbHandlers } from './esb'
|
||||
import { sqlWorkbenchHandlers } from './sql-workbench'
|
||||
|
||||
export const handlers = [
|
||||
...authHandlers,
|
||||
@@ -35,4 +36,5 @@ export const handlers = [
|
||||
...processHandlers,
|
||||
...widgetHandlers,
|
||||
...esbHandlers,
|
||||
...sqlWorkbenchHandlers,
|
||||
]
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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 MSW handlers for the SQL Workbench endpoints (`/qqq/v1/sql-workbench/*`).
|
||||
*/
|
||||
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import type { SqlExecuteRequest } from '@/types'
|
||||
import {
|
||||
sqlCapabilities,
|
||||
sqlExplain,
|
||||
sqlHistory,
|
||||
sqlNotebook,
|
||||
sqlNotebookSummary,
|
||||
sqlRowsPage,
|
||||
sqlSchema,
|
||||
sqlSelectResult,
|
||||
sqlSharedNotebookSummary,
|
||||
sqlWriteCommit,
|
||||
sqlWritePreview,
|
||||
} from '../fixtures/sql-workbench'
|
||||
|
||||
const BASE = '/qqq/v1/sql-workbench'
|
||||
|
||||
export const sqlWorkbenchHandlers = [
|
||||
http.get(`${BASE}/capabilities`, () => HttpResponse.json(sqlCapabilities)),
|
||||
http.get(`${BASE}/schema/:backend`, ({ params }) =>
|
||||
params.backend === 'postgres'
|
||||
? HttpResponse.json(sqlSchema)
|
||||
: HttpResponse.json(
|
||||
{ error: `Unknown SQL Workbench backend: ${String(params.backend)}` },
|
||||
{ status: 404 }
|
||||
)
|
||||
),
|
||||
http.post(`${BASE}/execute`, async ({ request }) => {
|
||||
const body = (await request.json()) as SqlExecuteRequest
|
||||
if (body.mode === 'WRITE') {
|
||||
return HttpResponse.json(body.confirmToken ? sqlWriteCommit : sqlWritePreview)
|
||||
}
|
||||
if (/^\s*select\s+broken/i.test(body.sql)) {
|
||||
return HttpResponse.json({
|
||||
...sqlSelectResult,
|
||||
status: 'FAILED',
|
||||
error: 'column "broken" does not exist',
|
||||
columns: undefined,
|
||||
rows: undefined,
|
||||
})
|
||||
}
|
||||
return HttpResponse.json(sqlSelectResult)
|
||||
}),
|
||||
http.post(`${BASE}/explain`, () => HttpResponse.json(sqlExplain)),
|
||||
http.get(`${BASE}/executions/:uuid/rows`, () => HttpResponse.json(sqlRowsPage)),
|
||||
http.post(`${BASE}/executions/:uuid/cancel`, () => HttpResponse.json({ cancelled: true })),
|
||||
http.get(`${BASE}/history`, () => HttpResponse.json({ executions: sqlHistory })),
|
||||
http.get(`${BASE}/notebooks`, ({ request }) => {
|
||||
const scope = new URL(request.url).searchParams.get('scope')
|
||||
return HttpResponse.json({
|
||||
notebooks: scope === 'shared' ? [sqlSharedNotebookSummary] : [sqlNotebookSummary],
|
||||
})
|
||||
}),
|
||||
http.post(`${BASE}/notebooks`, async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({
|
||||
...sqlNotebook,
|
||||
id: 9,
|
||||
title: body.title,
|
||||
cells: body.cells ?? [],
|
||||
currentVersion: 1,
|
||||
})
|
||||
}),
|
||||
http.get(`${BASE}/notebooks/:id`, ({ params }) =>
|
||||
params.id === '1'
|
||||
? HttpResponse.json(sqlNotebook)
|
||||
: HttpResponse.json({ error: 'Notebook not found.' }, { status: 404 })
|
||||
),
|
||||
http.put(`${BASE}/notebooks/:id`, async ({ request }) => {
|
||||
const body = (await request.json()) as Record<string, unknown>
|
||||
return HttpResponse.json({
|
||||
...sqlNotebook,
|
||||
...body,
|
||||
currentVersion: sqlNotebook.currentVersion + 1,
|
||||
})
|
||||
}),
|
||||
http.delete(`${BASE}/notebooks/:id`, () => HttpResponse.json({ deleted: true })),
|
||||
http.post(`${BASE}/notebooks/:id/publish`, async ({ request }) => {
|
||||
const body = (await request.json()) as { shared: boolean }
|
||||
return HttpResponse.json({ ...sqlNotebook, visibility: body.shared ? 'SHARED' : 'PERSONAL' })
|
||||
}),
|
||||
http.post(`${BASE}/notebooks/:id/duplicate`, () =>
|
||||
HttpResponse.json({ ...sqlNotebook, id: 10, title: 'Copy of Monthly signups' })
|
||||
),
|
||||
http.get(`${BASE}/notebooks/:id/versions`, () =>
|
||||
HttpResponse.json({
|
||||
versions: [
|
||||
{
|
||||
versionNumber: 2,
|
||||
savedByName: 'User One',
|
||||
comment: null,
|
||||
createDate: '2026-10-11T00:00:00Z',
|
||||
},
|
||||
{
|
||||
versionNumber: 1,
|
||||
savedByName: 'User One',
|
||||
comment: null,
|
||||
createDate: '2026-10-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
http.post(`${BASE}/notebooks/:id/versions/:n/restore`, () =>
|
||||
HttpResponse.json({ ...sqlNotebook, currentVersion: 3 })
|
||||
),
|
||||
http.get(`${BASE}/notebooks/:id/snapshots`, () => HttpResponse.json({ snapshots: [] })),
|
||||
http.post(`${BASE}/notebooks/:id/snapshots`, async ({ request }) => {
|
||||
const body = (await request.json()) as { cellKey: string; executionUuid: string }
|
||||
return HttpResponse.json({
|
||||
id: 5,
|
||||
cellKey: body.cellKey,
|
||||
executionUuid: body.executionUuid,
|
||||
label: null,
|
||||
backendName: 'postgres',
|
||||
rowCount: 2,
|
||||
truncated: false,
|
||||
createDate: null,
|
||||
result: { columns: sqlSelectResult.columns, rows: sqlSelectResult.rows },
|
||||
})
|
||||
}),
|
||||
http.delete(`${BASE}/notebooks/:id/snapshots/:snapshotId`, () =>
|
||||
HttpResponse.json({ deleted: true })
|
||||
),
|
||||
]
|
||||
@@ -128,3 +128,34 @@ export type {
|
||||
EsbMessage,
|
||||
EsbMessagePage,
|
||||
} from './esb'
|
||||
|
||||
export type {
|
||||
SqlWorkbenchDialect,
|
||||
SqlWorkbenchBackend,
|
||||
SqlWorkbenchLimits,
|
||||
SqlWorkbenchCapabilities,
|
||||
SqlColumnKind,
|
||||
SqlColumn,
|
||||
SqlBinaryValue,
|
||||
SqlValue,
|
||||
SqlStatementView,
|
||||
SqlExecutionStatus,
|
||||
SqlExecuteRequest,
|
||||
SqlExecuteResponse,
|
||||
SqlRowsPage,
|
||||
SqlPlanNode,
|
||||
SqlExplainResponse,
|
||||
SqlSchemaColumn,
|
||||
SqlSchemaTable,
|
||||
SqlQqqTable,
|
||||
SqlSchema,
|
||||
SqlHistoryEntry,
|
||||
SqlNotebookParameter,
|
||||
SqlNotebookCell,
|
||||
SqlNotebookSummary,
|
||||
SqlNotebook,
|
||||
SqlNotebookInput,
|
||||
SqlNotebookVersionSummary,
|
||||
SqlResultSnapshot,
|
||||
SqlExportFormat,
|
||||
} from './sql-workbench'
|
||||
@@ -0,0 +1,316 @@
|
||||
/*
|
||||
* 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 SQL Workbench types — the `/qqq/v1/sql-workbench` contract of the qbit-sql-workbench
|
||||
* QBit (see that repository's docs/DESIGN.md, section 5).
|
||||
*/
|
||||
|
||||
/** Dialect names the backend reports; drive the editor and formatter. */
|
||||
export type SqlWorkbenchDialect = 'postgresql' | 'mysql' | 'sqlite' | 'h2' | 'sql'
|
||||
|
||||
/** One target backend. */
|
||||
export interface SqlWorkbenchBackend {
|
||||
name: string
|
||||
label: string
|
||||
vendor: string | null
|
||||
dialect: SqlWorkbenchDialect
|
||||
/** Read-only sessions use dedicated (least-privilege) credentials. */
|
||||
readOnlyCredentials: boolean
|
||||
/** The database enforces read-only sessions (false on H2 without dedicated credentials). */
|
||||
readOnlyEnforced: boolean
|
||||
/** DDL can be dry-run (rolled back) on this backend. */
|
||||
transactionalDdl: boolean
|
||||
writeAllowed: boolean
|
||||
}
|
||||
|
||||
/** Server limits. */
|
||||
export interface SqlWorkbenchLimits {
|
||||
maxRows: number
|
||||
pageSize: number
|
||||
maxPageSize: number
|
||||
maxResultBytes: number
|
||||
statementTimeoutSeconds: number
|
||||
writeStatementTimeoutSeconds: number
|
||||
maxAffectedRowsWithoutAcknowledgement: number
|
||||
maxAffectedRows: number
|
||||
maxSqlLength: number
|
||||
maxStatementsPerRun: number
|
||||
}
|
||||
|
||||
/** `GET /capabilities`. */
|
||||
export interface SqlWorkbenchCapabilities {
|
||||
version: string
|
||||
canWrite: boolean
|
||||
canShare: boolean
|
||||
writeModeEnabled: boolean
|
||||
limits: SqlWorkbenchLimits
|
||||
backends: SqlWorkbenchBackend[]
|
||||
}
|
||||
|
||||
/** How the UI renders a result column. */
|
||||
export type SqlColumnKind =
|
||||
| 'number'
|
||||
| 'string'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
| 'time'
|
||||
| 'json'
|
||||
| 'binary'
|
||||
| 'array'
|
||||
| 'other'
|
||||
|
||||
/** One result column. */
|
||||
export interface SqlColumn {
|
||||
name: string
|
||||
label: string
|
||||
typeName: string
|
||||
jdbcType: number
|
||||
kind: SqlColumnKind
|
||||
}
|
||||
|
||||
/** A binary value as the backend sends it. */
|
||||
export interface SqlBinaryValue {
|
||||
$binary: true
|
||||
length: number
|
||||
hexPreview: string
|
||||
}
|
||||
|
||||
/** One result value (already JSON-safe). */
|
||||
export type SqlValue = string | number | boolean | null | SqlBinaryValue | SqlValue[]
|
||||
|
||||
/** One statement of a run, as the backend classified it. */
|
||||
export interface SqlStatementView {
|
||||
index: number
|
||||
kind: 'READ' | 'EXPLAIN_ANALYZE' | 'WRITE' | 'DDL' | 'FORBIDDEN'
|
||||
leadingKeyword: string | null
|
||||
text: string
|
||||
affectedRows: number | null
|
||||
}
|
||||
|
||||
/** Outcome of a run. */
|
||||
export type SqlExecutionStatus =
|
||||
| 'SUCCEEDED'
|
||||
| 'FAILED'
|
||||
| 'TIMED_OUT'
|
||||
| 'CANCELLED'
|
||||
| 'REJECTED'
|
||||
| 'PREVIEW'
|
||||
| 'DRIFTED'
|
||||
|
||||
/** `POST /execute` request. */
|
||||
export interface SqlExecuteRequest {
|
||||
backendName: string
|
||||
sql: string
|
||||
params?: Record<string, string | number | boolean | null>
|
||||
mode?: 'READ_ONLY' | 'WRITE'
|
||||
notebookId?: number
|
||||
cellKey?: string
|
||||
pageSize?: number
|
||||
confirmToken?: string
|
||||
acknowledgeRowGuard?: boolean
|
||||
/** Client-chosen id (a UUID), so a running statement can be cancelled before it answers. */
|
||||
executionUuid?: string
|
||||
}
|
||||
|
||||
/** `POST /execute` response (fields depend on the status). */
|
||||
export interface SqlExecuteResponse {
|
||||
executionUuid: string
|
||||
status: SqlExecutionStatus
|
||||
mode: string
|
||||
backendName: string
|
||||
durationMs?: number | null
|
||||
statements?: SqlStatementView[]
|
||||
columns?: SqlColumn[]
|
||||
rows?: SqlValue[][]
|
||||
rowCount?: number
|
||||
hasMore?: boolean
|
||||
truncated?: boolean
|
||||
truncatedReason?: 'ROWS' | 'BYTES' | null
|
||||
rowsAffected?: number
|
||||
error?: string
|
||||
sqlState?: string | null
|
||||
dryRunSupported?: boolean
|
||||
dryRunNote?: string | null
|
||||
totalAffectedRows?: number | null
|
||||
rowGuardExceeded?: boolean
|
||||
rowGuardLimit?: number
|
||||
confirmToken?: string
|
||||
expiresAt?: string
|
||||
previewAffectedRows?: number
|
||||
actualAffectedRows?: number
|
||||
}
|
||||
|
||||
/** `GET /executions/{uuid}/rows`. */
|
||||
export interface SqlRowsPage {
|
||||
executionUuid: string
|
||||
columns: SqlColumn[]
|
||||
rows: SqlValue[][]
|
||||
offset: number
|
||||
rowCount: number
|
||||
truncated: boolean
|
||||
truncatedReason: 'ROWS' | 'BYTES' | null
|
||||
}
|
||||
|
||||
/** One node of a normalized query plan. */
|
||||
export interface SqlPlanNode {
|
||||
label: string
|
||||
detail: string | null
|
||||
startupCost: number | null
|
||||
totalCost: number | null
|
||||
planRows: number | null
|
||||
actualTimeMs: number | null
|
||||
actualRows: number | null
|
||||
loops: number | null
|
||||
extra: Record<string, unknown>
|
||||
children: SqlPlanNode[]
|
||||
}
|
||||
|
||||
/** `POST /explain` response. */
|
||||
export interface SqlExplainResponse {
|
||||
executionUuid: string
|
||||
status: SqlExecutionStatus
|
||||
dialect?: SqlWorkbenchDialect
|
||||
analyze?: boolean
|
||||
plan?: SqlPlanNode
|
||||
raw?: string
|
||||
error?: string
|
||||
durationMs?: number | null
|
||||
}
|
||||
|
||||
/** Schema column. */
|
||||
export interface SqlSchemaColumn {
|
||||
name: string
|
||||
typeName: string
|
||||
nullable: boolean
|
||||
}
|
||||
|
||||
/** Schema table. */
|
||||
export interface SqlSchemaTable {
|
||||
name: string
|
||||
type: string
|
||||
columns: SqlSchemaColumn[]
|
||||
}
|
||||
|
||||
/** A QQQ table on a backend, with its SQL names. */
|
||||
export interface SqlQqqTable {
|
||||
name: string
|
||||
label: string | null
|
||||
sqlName: string
|
||||
fields: { name: string; label: string | null; sqlName: string; type: string | null }[]
|
||||
}
|
||||
|
||||
/** `GET /schema/{backend}`. */
|
||||
export interface SqlSchema {
|
||||
backendName: string
|
||||
dialect: SqlWorkbenchDialect
|
||||
schemas: { name: string; tables: SqlSchemaTable[] }[]
|
||||
truncated: boolean
|
||||
keywords: string[]
|
||||
functions: string[]
|
||||
qqqTables: SqlQqqTable[]
|
||||
cachedAt: string
|
||||
}
|
||||
|
||||
/** One audit row from `GET /history`. */
|
||||
export interface SqlHistoryEntry {
|
||||
executionUuid: string
|
||||
backendName: string
|
||||
mode: string
|
||||
status: string
|
||||
sqlText: string
|
||||
notebookId: number | null
|
||||
cellKey: string | null
|
||||
startTime: string | null
|
||||
durationMs: number | null
|
||||
rowsReturned: number | null
|
||||
rowsAffected: number | null
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
/** A notebook parameter definition. */
|
||||
export interface SqlNotebookParameter {
|
||||
name: string
|
||||
type?: 'STRING' | 'INTEGER' | 'DECIMAL' | 'BOOLEAN' | 'DATE' | 'DATE_TIME'
|
||||
defaultValue?: string | null
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
/** One notebook cell. */
|
||||
export interface SqlNotebookCell {
|
||||
cellKey: string
|
||||
cellType: 'SQL' | 'MARKDOWN'
|
||||
title?: string | null
|
||||
content: string
|
||||
backendName?: string | null
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
/** Notebook list entry. */
|
||||
export interface SqlNotebookSummary {
|
||||
id: number
|
||||
title: string
|
||||
description: string | null
|
||||
visibility: 'PERSONAL' | 'SHARED'
|
||||
ownerUserId: string
|
||||
ownerName: string | null
|
||||
defaultBackendName: string | null
|
||||
currentVersion: number
|
||||
canEdit: boolean
|
||||
createDate: string | null
|
||||
modifyDate: string | null
|
||||
}
|
||||
|
||||
/** A full notebook. */
|
||||
export interface SqlNotebook extends SqlNotebookSummary {
|
||||
parameters: SqlNotebookParameter[]
|
||||
cells: SqlNotebookCell[]
|
||||
}
|
||||
|
||||
/** Notebook save / create body. */
|
||||
export interface SqlNotebookInput {
|
||||
title: string
|
||||
description?: string | null
|
||||
defaultBackendName?: string | null
|
||||
parameters?: SqlNotebookParameter[]
|
||||
cells?: SqlNotebookCell[]
|
||||
comment?: string
|
||||
}
|
||||
|
||||
/** One notebook version (list entry). */
|
||||
export interface SqlNotebookVersionSummary {
|
||||
versionNumber: number
|
||||
savedByName: string | null
|
||||
comment: string | null
|
||||
createDate: string
|
||||
}
|
||||
|
||||
/** A pinned result. */
|
||||
export interface SqlResultSnapshot {
|
||||
id: number
|
||||
cellKey: string | null
|
||||
executionUuid: string | null
|
||||
label: string | null
|
||||
backendName: string | null
|
||||
rowCount: number
|
||||
truncated: boolean
|
||||
createDate: string | null
|
||||
result: { columns?: SqlColumn[]; rows?: SqlValue[][] }
|
||||
}
|
||||
|
||||
/** Export formats. */
|
||||
export type SqlExportFormat = 'csv' | 'xlsx' | 'json'
|
||||
@@ -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,7 +51,10 @@ 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
|
||||
@@ -60,11 +70,18 @@ QQQ_ACCEPTANCE_MODE=standalone pnpm test:acceptance # container-image build
|
||||
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 does not enable it.
|
||||
Only `/app/person[/]`, `/app/person/2[/]`, and `/app/person.bulkEdit[/]` documents on
|
||||
the configured UI/API origins are observed. Requests are exact method/path matches:
|
||||
GET person metadata, session, person record 2 and person.bulkEdit process metadata;
|
||||
POST querySavedView init, person query/count and person.bulkEdit init. The helper emits
|
||||
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
|
||||
@@ -200,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;
|
||||
@@ -366,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<>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect, open, test } from '../../support/fixtures'
|
||||
import { expectNoHorizontalScroll, expectTouchReady, expectTouchTargets, undersizedTargets } from '../../support/touch'
|
||||
import { waitForSampleDashboard } from './sample-dashboard-navigation'
|
||||
import { tabKey } from '../security/support/ui'
|
||||
import { appNavigation, expectBreadcrumbs, recordCollection, waitForShell } from './nav-helpers'
|
||||
|
||||
@@ -39,6 +40,7 @@ test.describe('mobile navigation', () => {
|
||||
if (!navBox || !titleBox) return 0
|
||||
return Math.min(navBox.x + navBox.width, titleBox.x + titleBox.width) - Math.max(navBox.x, titleBox.x)
|
||||
}).toBeGreaterThan(100)
|
||||
await waitForSampleDashboard(page)
|
||||
await open(page, '/app')
|
||||
await waitForShell(page)
|
||||
const drawer = page.locator('[data-qqq-id="sidebar-mobile-drawer"]')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* 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 { Page } from '@playwright/test'
|
||||
import { expect } from '../../support/fixtures'
|
||||
import { sampleDashboardReady } from './sample-dashboard-readiness'
|
||||
|
||||
/** Wait for this fixed fixture's actual request and renderer outputs before leaving it. */
|
||||
export async function waitForSampleDashboard(page: Page) {
|
||||
await expect.poll(() => page.evaluate(sampleDashboardReady), { message: 'Sample dashboard widgets and renderers complete before departure' }).toBe(true)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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
|
||||
*/
|
||||
|
||||
/** Fixed sample dashboard dependency boundary for NAV026. Runs in the document. */
|
||||
export function sampleDashboardReady(): boolean {
|
||||
const home = document.querySelector('[data-qqq-id="app-home-SampleWidgetsDashboard"]')
|
||||
if (!home || home.querySelectorAll('[data-qqq-id^="widget-grid-item-"]').length !== 11) return false
|
||||
if (home.querySelector('[role="alert"], [aria-busy="true"], [aria-label="Loading widget"], [data-qqq-id^="widget-error-"], [data-qqq-id^="widget-no-permission-"], [data-qqq-id^="widget-permission-denied-"], [data-qqq-id^="widget-needs-selection-"]')) return false
|
||||
const outputs: Array<[string, string]> = [
|
||||
['BigNumberBlocks', '[data-block-type="BIG_NUMBER"]'],
|
||||
['MultiStatistics', '[data-qqq-id="multi-statistics-group-SampleMultiStatisticsWidget-0"]'],
|
||||
['Statistics', '[data-qqq-id="statistics-count-SampleStatisticsWidget"]'],
|
||||
['Table', 'tbody tr td'],
|
||||
['Stepper', '[data-qqq-id="stepper-step-SampleStepperWidget-0"]'],
|
||||
['HTML', '[data-qqq-id="html-widget-SampleHTMLWidget"]'],
|
||||
...['PieChart', 'StackedBarChart', 'SmallLineChart', 'LineChart', 'BarChart'].map(name => [name, `[data-qqq-id="chart-data-Sample${name}Widget"] tbody tr td`] as [string, string]),
|
||||
]
|
||||
return outputs.every(([name, selector]) => {
|
||||
const card = home.querySelector(`[data-qqq-id="widget-Sample${name}Widget"][aria-busy="false"]`)
|
||||
return Boolean(card?.querySelector(selector)?.textContent?.trim())
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -215,17 +216,15 @@ test.describe('sample widgets dashboard', () => {
|
||||
for (const viewport of [{ width: 393, height: 851 }, { width: 1440, height: 1000 }]) {
|
||||
await page.setViewportSize(viewport)
|
||||
const labels = card.locator('.recharts-xAxis .recharts-cartesian-axis-tick-value')
|
||||
await expect(async () => {
|
||||
await expect(labels).toHaveText(payload.chartData.labels, { timeout: 0 })
|
||||
for (const label of await labels.all()) await expect(label).not.toHaveAttribute('transform', /rotate/, { timeout: 0 })
|
||||
const boxes = await labels.evaluateAll(nodes => nodes.map(node => node.getBoundingClientRect().toJSON()))
|
||||
expect(boxes).toHaveLength(payload.chartData.labels.length)
|
||||
for (const box of boxes) {
|
||||
expect(box.width).toBeGreaterThan(0)
|
||||
expect(box.height).toBeGreaterThan(0)
|
||||
}
|
||||
for (let i = 1; i < boxes.length; i++) expect(boxes[i].left).toBeGreaterThan(boxes[i - 1].right)
|
||||
}).toPass({ timeout: 5_000 })
|
||||
await expect(labels).toHaveText(payload.chartData.labels)
|
||||
for (const label of await labels.all()) await expect(label).not.toHaveAttribute('transform', /rotate/)
|
||||
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')
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { browserBoundaryInit, createBoundaryBuffer, installBrowserBoundaryCapture } from './browser-boundary-capture'
|
||||
import { browserBoundaryInit, createBoundaryBuffer, installBrowserBoundaryCapture, shouldCaptureBrowserBoundary } from './browser-boundary-capture'
|
||||
|
||||
type EventRecord = Record<string, unknown>
|
||||
|
||||
@@ -69,6 +69,35 @@ function event(overrides: EventRecord = {}) {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -88,7 +117,7 @@ describe('document-local boundary capture', () => {
|
||||
it('rethrows the identical native exception and never reads its message or name', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('GET', '/qqq/v1/manageSession')
|
||||
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
|
||||
@@ -104,7 +133,7 @@ describe('document-local boundary capture', () => {
|
||||
it('preserves failed open and unknown URL objects without coercion or stale request identity', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('GET', '/qqq/v1/manageSession')
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
const url = { toString: vi.fn(() => 'OBJECT_SECRET') }
|
||||
xhr.open('GET', url)
|
||||
xhr.send('BODY_SECRET')
|
||||
@@ -114,7 +143,7 @@ describe('document-local boundary capture', () => {
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
xhr.openFailure = failure
|
||||
expect(() => xhr.open('GET', '/qqq/v1/manageSession')).toThrow(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/)
|
||||
})
|
||||
@@ -122,7 +151,7 @@ describe('document-local boundary capture', () => {
|
||||
it('uses one listener set and distinguishes reuse after the prior loadend', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('GET', '/qqq/v1/manageSession')
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
xhr.send()
|
||||
xhr.state(2, 200)
|
||||
xhr.state(2, 200)
|
||||
@@ -159,7 +188,7 @@ describe('document-local boundary capture', () => {
|
||||
sendResult = xhr.send(body)
|
||||
})
|
||||
const start = events.length
|
||||
expect(xhr.open('GET', '/qqq/v1/manageSession')).toBe(xhr.openResult)
|
||||
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
|
||||
@@ -176,7 +205,7 @@ describe('document-local boundary capture', () => {
|
||||
expect(after[0]).not.toHaveProperty('generation')
|
||||
expect(after[0]).not.toHaveProperty('attempt')
|
||||
// Once ambiguous, reusing that instance cannot silently regain trustworthy attribution.
|
||||
xhr.open('GET', '/qqq/v1/manageSession')
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
xhr.send()
|
||||
expect(events.slice(start)).toEqual(after)
|
||||
const next = new NativeXHR()
|
||||
@@ -195,7 +224,7 @@ describe('document-local boundary capture', () => {
|
||||
xhr.open('POST', '/qqq/v1/table/person/count')
|
||||
xhr.send()
|
||||
})
|
||||
xhr.open('GET', initialURL)
|
||||
xhr.open('POST', initialURL)
|
||||
xhr.send()
|
||||
xhr.state(4, 200)
|
||||
const start = events.length
|
||||
@@ -212,7 +241,7 @@ describe('document-local boundary capture', () => {
|
||||
it('keeps terminal events on the active send when a repeated send throws', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('GET', '/qqq/v1/manageSession')
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
xhr.send()
|
||||
xhr.sendFailure = new DOMException('PRIVATE', 'InvalidStateError')
|
||||
expect(() => xhr.send()).toThrow(DOMException)
|
||||
@@ -237,7 +266,7 @@ describe('document-local boundary capture', () => {
|
||||
install()
|
||||
const xhr = new NativeXHR()
|
||||
vi.stubGlobal('__qqqBrowserBoundary', () => { throw new Error('OBSERVER_SECRET') })
|
||||
expect(xhr.open('GET', '/qqq/v1/manageSession')).toBe(xhr.openResult)
|
||||
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()
|
||||
@@ -255,7 +284,7 @@ describe('document-local boundary capture', () => {
|
||||
install()
|
||||
for (const url of ['/qqq/v1/table/person/PRIVATE', 'https://unrelated.invalid/qqq/v1/manageSession']) {
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('GET', url)
|
||||
xhr.open('POST', url)
|
||||
xhr.send('SECRET')
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
@@ -304,6 +333,19 @@ describe('document-local boundary capture', () => {
|
||||
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)
|
||||
@@ -315,7 +357,7 @@ describe('document-local boundary capture', () => {
|
||||
it('bounds document output with an explicit overflow event', () => {
|
||||
cleanup = browserBoundaryInit({ ...config, maxEvents: 3 })
|
||||
const xhr = new NativeXHR()
|
||||
xhr.open('GET', '/qqq/v1/manageSession')
|
||||
xhr.open('POST', '/qqq/v1/manageSession')
|
||||
xhr.send()
|
||||
xhr.state(4, 200)
|
||||
xhr.dispatchEvent(new Event('loadend'))
|
||||
@@ -325,6 +367,40 @@ describe('document-local boundary capture', () => {
|
||||
})
|
||||
|
||||
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')
|
||||
@@ -339,10 +415,69 @@ describe('Node capture boundary', () => {
|
||||
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 })).toBeUndefined()
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -11,19 +11,32 @@ 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' }
|
||||
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',
|
||||
'GET /qqq/v1/manageSession': 'session',
|
||||
'POST /qqq/v1/manageSession': 'session',
|
||||
}
|
||||
if (config.includeProcessPaths) {
|
||||
Object.assign(pages, { '/app/person/2': 'person-record', '/app/person/2/': 'person-record',
|
||||
@@ -228,7 +241,7 @@ export function createBoundaryBuffer(maxEvents = 4096) {
|
||||
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-record', 'person-bulk-edit'], phase: ['active', 'pagehide'],
|
||||
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'],
|
||||
@@ -266,7 +279,7 @@ export function createBoundaryBuffer(maxEvents = 4096) {
|
||||
/** 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 },
|
||||
config: { enabled: boolean; origins: string[]; includeProcessPaths?: boolean; includeDashboardPaths?: boolean },
|
||||
) {
|
||||
if (!config.enabled) return undefined
|
||||
const buffer = createBoundaryBuffer()
|
||||
@@ -279,6 +292,6 @@ export async function installBrowserBoundaryCapture(
|
||||
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 })
|
||||
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,7 +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 } from './browser-boundary-capture'
|
||||
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'
|
||||
|
||||
@@ -97,9 +97,10 @@ export const test = base.extend<{ persona: Persona; user: SampleUser; backend: B
|
||||
|
||||
_browserBoundaryCapture: async ({ context }, provide, testInfo) => {
|
||||
const capture = await installBrowserBoundaryCapture(context, {
|
||||
enabled: process.env.QQQ_ACCEPTANCE_BROWSER_TRACE === '1',
|
||||
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()
|
||||
|
||||
@@ -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,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 https://www.apache.org/licenses/LICENSE-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, expect, test } from 'vitest'
|
||||
import { sampleDashboardReady } from '../specs/navigation/sample-dashboard-readiness'
|
||||
|
||||
afterEach(() => { document.body.innerHTML = '' })
|
||||
|
||||
test('shell and breadcrumb without AppHome are not a ready dashboard', () => {
|
||||
document.body.innerHTML = '<main id="main-content"><nav aria-label="Breadcrumb">Sample Widgets Dashboard</nav></main>'
|
||||
expect(sampleDashboardReady()).toBe(false)
|
||||
})
|
||||
|
||||
test('an empty mounted AppHome cannot pass vacuously', () => {
|
||||
document.body.innerHTML = '<div data-qqq-id="app-home-SampleWidgetsDashboard"><section data-qqq-id="widget-grid"></section></div>'
|
||||
expect(sampleDashboardReady()).toBe(false)
|
||||
})
|
||||
|
||||
/** Fixture output declared by the sample dashboard and existing WID renderer assertions. */
|
||||
function renderedDashboard() {
|
||||
const contents: Record<string, string> = {
|
||||
SampleBigNumberBlocksWidget: '<div data-block-type="BIG_NUMBER">123</div>',
|
||||
SampleMultiStatisticsWidget: '<div data-qqq-id="multi-statistics-group-SampleMultiStatisticsWidget-0">Statistics</div>',
|
||||
SamplePieChartWidget: '<table data-qqq-id="chart-data-SamplePieChartWidget"><tbody><tr><td>Pie</td></tr></tbody></table>',
|
||||
SampleStatisticsWidget: '<span data-qqq-id="statistics-count-SampleStatisticsWidget">123</span>',
|
||||
SampleTableWidget: '<table><tbody><tr><td>Row 1</td></tr></tbody></table>',
|
||||
SampleStackedBarChartWidget: '<table data-qqq-id="chart-data-SampleStackedBarChartWidget"><tbody><tr><td>Stack</td></tr></tbody></table>',
|
||||
SampleStepperWidget: '<li data-qqq-id="stepper-step-SampleStepperWidget-0">Step 1</li>',
|
||||
SampleHTMLWidget: '<div data-qqq-id="html-widget-SampleHTMLWidget">Purely Custom</div>',
|
||||
SampleSmallLineChartWidget: '<table data-qqq-id="chart-data-SampleSmallLineChartWidget"><tbody><tr><td>Small</td></tr></tbody></table>',
|
||||
SampleLineChartWidget: '<table data-qqq-id="chart-data-SampleLineChartWidget"><tbody><tr><td>Line</td></tr></tbody></table>',
|
||||
SampleBarChartWidget: '<table data-qqq-id="chart-data-SampleBarChartWidget"><tbody><tr><td>Bar</td></tr></tbody></table>',
|
||||
}
|
||||
document.body.innerHTML = `<div data-qqq-id="app-home-SampleWidgetsDashboard"><section data-qqq-id="widget-grid">${Object.entries(contents).map(([name, html]) => `<div data-qqq-id="widget-grid-item-${name}"><section data-qqq-id="widget-${name}" aria-busy="false">${html}</section></div>`).join('')}</section></div>`
|
||||
}
|
||||
|
||||
test('all eleven actual fixture renderers are ready without scrolling offscreen cards', () => {
|
||||
renderedDashboard()
|
||||
expect(sampleDashboardReady()).toBe(true)
|
||||
})
|
||||
|
||||
test('eleven wrappers cannot substitute for the exact missing widget card', () => {
|
||||
renderedDashboard()
|
||||
document.querySelector('[data-qqq-id="widget-SampleTableWidget"]')!.remove()
|
||||
expect(sampleDashboardReady()).toBe(false)
|
||||
})
|
||||
|
||||
test('an actual held table request remains unready even if old rows are present', () => {
|
||||
renderedDashboard()
|
||||
document.querySelector('[data-qqq-id="widget-SampleTableWidget"]')!.setAttribute('aria-busy', 'true')
|
||||
expect(sampleDashboardReady()).toBe(false)
|
||||
})
|
||||
|
||||
test('busy false with an actual widget error is not readiness', () => {
|
||||
renderedDashboard()
|
||||
document.querySelector('[data-qqq-id="widget-SampleTableWidget"]')!.insertAdjacentHTML('beforeend', '<div role="alert" data-qqq-id="widget-error-SampleTableWidget">Failed</div>')
|
||||
expect(sampleDashboardReady()).toBe(false)
|
||||
})
|
||||
|
||||
test('a lazy chart fallback below a nonbusy card is not a rendered chart', () => {
|
||||
renderedDashboard()
|
||||
document.querySelector('[data-qqq-id="widget-SamplePieChartWidget"]')!.innerHTML = '<div class="h-60 animate-pulse rounded bg-muted"></div>'
|
||||
expect(sampleDashboardReady()).toBe(false)
|
||||
})
|
||||
|
||||
test('held table completion requires real rows, not only a cleared busy flag', () => {
|
||||
renderedDashboard()
|
||||
const card = document.querySelector('[data-qqq-id="widget-SampleTableWidget"]')!
|
||||
card.setAttribute('aria-busy', 'true')
|
||||
card.innerHTML = '<div role="status" aria-label="Loading widget"></div>'
|
||||
expect(sampleDashboardReady()).toBe(false)
|
||||
card.setAttribute('aria-busy', 'false')
|
||||
card.innerHTML = ''
|
||||
expect(sampleDashboardReady()).toBe(false)
|
||||
card.innerHTML = '<table><tbody><tr><td>Forwarded row</td></tr></tbody></table>'
|
||||
expect(sampleDashboardReady()).toBe(true)
|
||||
})
|
||||
|
||||
test('one missing declared widget is not accepted as a smaller completed dashboard', () => {
|
||||
renderedDashboard()
|
||||
document.querySelector('[data-qqq-id="widget-grid-item-SampleTableWidget"]')!.remove()
|
||||
expect(sampleDashboardReady()).toBe(false)
|
||||
})
|
||||
@@ -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,241 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// E2E: SQL Workbench — the SQL_WORKBENCH widget on the qbit's app page, with a mocked backend
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { METADATA, setupApiMocks } from './api-mocks'
|
||||
import { checkA11y } from './a11y-helpers'
|
||||
|
||||
const BASE = '**/qqq/v1/sql-workbench'
|
||||
|
||||
const CAPABILITIES = {
|
||||
version: '1.0.0-RC.1',
|
||||
canWrite: true,
|
||||
canShare: true,
|
||||
writeModeEnabled: true,
|
||||
limits: {
|
||||
maxRows: 10000,
|
||||
pageSize: 500,
|
||||
maxPageSize: 2000,
|
||||
maxResultBytes: 20971520,
|
||||
statementTimeoutSeconds: 30,
|
||||
writeStatementTimeoutSeconds: 60,
|
||||
maxAffectedRowsWithoutAcknowledgement: 1000,
|
||||
maxAffectedRows: 100000,
|
||||
maxSqlLength: 100000,
|
||||
maxStatementsPerRun: 50,
|
||||
},
|
||||
backends: [
|
||||
{
|
||||
name: 'postgres',
|
||||
label: 'postgres',
|
||||
vendor: 'postgres',
|
||||
dialect: 'postgresql',
|
||||
readOnlyCredentials: true,
|
||||
readOnlyEnforced: true,
|
||||
transactionalDdl: true,
|
||||
writeAllowed: true,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const RESULT = {
|
||||
executionUuid: 'exec-1',
|
||||
status: 'SUCCEEDED',
|
||||
mode: 'READ_ONLY',
|
||||
backendName: 'postgres',
|
||||
durationMs: 7,
|
||||
statements: [
|
||||
{ index: 0, kind: 'READ', leadingKeyword: 'SELECT', text: 'SELECT', affectedRows: null },
|
||||
],
|
||||
columns: [
|
||||
{ name: 'id', label: 'id', typeName: 'int4', jdbcType: 4, kind: 'number' },
|
||||
{ name: 'first_name', label: 'first_name', typeName: 'text', jdbcType: 12, kind: 'string' },
|
||||
],
|
||||
rows: [
|
||||
[1, 'Alice'],
|
||||
[2, null],
|
||||
],
|
||||
rowCount: 2,
|
||||
hasMore: false,
|
||||
truncated: false,
|
||||
truncatedReason: null,
|
||||
}
|
||||
|
||||
const PREVIEW = {
|
||||
executionUuid: 'exec-2',
|
||||
status: 'PREVIEW',
|
||||
mode: 'WRITE_PREVIEW',
|
||||
backendName: 'postgres',
|
||||
durationMs: 5,
|
||||
statements: [
|
||||
{
|
||||
index: 0,
|
||||
kind: 'WRITE',
|
||||
leadingKeyword: 'DELETE',
|
||||
text: 'DELETE FROM person WHERE id = 2',
|
||||
affectedRows: 1,
|
||||
},
|
||||
],
|
||||
dryRunSupported: true,
|
||||
totalAffectedRows: 1,
|
||||
rowGuardExceeded: false,
|
||||
rowGuardLimit: 1000,
|
||||
confirmToken: 'tok',
|
||||
expiresAt: '',
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the workbench app, widget and endpoints to the mocked backend.
|
||||
*
|
||||
* @param page - The page.
|
||||
* @returns The list of execute request bodies, filled as the page sends them.
|
||||
*/
|
||||
async function setupWorkbench(page: Page): Promise<Record<string, unknown>[]> {
|
||||
const executes: Record<string, unknown>[] = []
|
||||
const metaData = {
|
||||
...METADATA,
|
||||
apps: {
|
||||
...METADATA.apps,
|
||||
sqlWorkbench: {
|
||||
name: 'sqlWorkbench',
|
||||
label: 'SQL Workbench',
|
||||
iconName: 'terminal',
|
||||
widgets: ['sqlWorkbenchWidget'],
|
||||
children: [],
|
||||
sections: [],
|
||||
},
|
||||
},
|
||||
appTree: [
|
||||
...METADATA.appTree,
|
||||
{
|
||||
name: 'sqlWorkbench',
|
||||
label: 'SQL Workbench',
|
||||
type: 'APP',
|
||||
iconName: 'terminal',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
widgets: {
|
||||
sqlWorkbenchWidget: {
|
||||
name: 'sqlWorkbenchWidget',
|
||||
label: 'SQL Workbench',
|
||||
type: 'SQL_WORKBENCH',
|
||||
gridColumns: 12,
|
||||
isCard: false,
|
||||
hasPermission: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
await page.route('**/qqq/v1/metaData**', (route) => {
|
||||
if (new URL(route.request().url()).pathname.endsWith('/metaData/authentication'))
|
||||
return route.fallback()
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(metaData) })
|
||||
})
|
||||
await page.route('**/qqq/v1/widget/sqlWorkbenchWidget**', (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ type: 'SQL_WORKBENCH' }),
|
||||
})
|
||||
)
|
||||
await page.route(`${BASE}/capabilities`, (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify(CAPABILITIES) })
|
||||
)
|
||||
await page.route(`${BASE}/schema/**`, (route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
backendName: 'postgres',
|
||||
dialect: 'postgresql',
|
||||
schemas: [],
|
||||
truncated: false,
|
||||
keywords: [],
|
||||
functions: [],
|
||||
qqqTables: [],
|
||||
cachedAt: '',
|
||||
}),
|
||||
})
|
||||
)
|
||||
await page.route(`${BASE}/notebooks**`, (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ notebooks: [] }) })
|
||||
)
|
||||
await page.route(`${BASE}/history**`, (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ executions: [] }) })
|
||||
)
|
||||
await page.route(`${BASE}/execute`, (route) => {
|
||||
const body = route.request().postDataJSON() as Record<string, unknown>
|
||||
executes.push(body)
|
||||
const response =
|
||||
body.mode === 'WRITE'
|
||||
? body.confirmToken
|
||||
? { ...RESULT, mode: 'WRITE_COMMIT', columns: [], rows: [], rowCount: 0, rowsAffected: 1 }
|
||||
: PREVIEW
|
||||
: RESULT
|
||||
return route.fulfill({ contentType: 'application/json', body: JSON.stringify(response) })
|
||||
})
|
||||
return executes
|
||||
}
|
||||
|
||||
test.describe('SQL Workbench', () => {
|
||||
test('runs read-only SQL from the app page and shows the result grid', async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
const executes = await setupWorkbench(page)
|
||||
await page.goto('/app/sqlWorkbench')
|
||||
|
||||
await expect(page.getByRole('button', { name: '+ New notebook' })).toBeVisible({
|
||||
timeout: 30000,
|
||||
})
|
||||
const editor = page.getByRole('textbox', { name: 'SQL for cell 1' })
|
||||
await editor.click()
|
||||
await page.keyboard.type('SELECT id, first_name FROM person')
|
||||
await page.getByRole('button', { name: 'Run' }).click()
|
||||
|
||||
const grid = page.getByRole('table', { name: 'Query result' })
|
||||
await expect(grid.getByText('Alice')).toBeVisible()
|
||||
await expect(grid.getByText('NULL')).toBeVisible()
|
||||
expect(executes[0]).toMatchObject({
|
||||
backendName: 'postgres',
|
||||
mode: 'READ_ONLY',
|
||||
sql: 'SELECT id, first_name FROM person',
|
||||
})
|
||||
await expect(page.getByRole('link', { name: /XLSX/ })).toHaveAttribute(
|
||||
'href',
|
||||
/executions\/exec-1\/export\?format=xlsx/
|
||||
)
|
||||
await checkA11y(page)
|
||||
})
|
||||
|
||||
test('write mode commits only after the confirmation dialog', async ({ page }) => {
|
||||
await setupApiMocks(page)
|
||||
const executes = await setupWorkbench(page)
|
||||
await page.goto('/app/sqlWorkbench')
|
||||
|
||||
await page.getByRole('textbox', { name: 'SQL for cell 1' }).click()
|
||||
await page.keyboard.type('DELETE FROM person WHERE id = 2')
|
||||
await page.getByLabel('Write mode').check()
|
||||
await page.getByRole('button', { name: 'Preview write' }).click()
|
||||
|
||||
const dialog = page.getByRole('dialog')
|
||||
await expect(dialog.getByText('Dry run (rolled back): 1 rows affected.')).toBeVisible()
|
||||
await expect(dialog.getByRole('button', { name: 'Commit write' })).toBeDisabled()
|
||||
await dialog.getByLabel(/Type postgres to confirm/).fill('postgres')
|
||||
await dialog.getByRole('button', { name: 'Commit write' }).click()
|
||||
|
||||
await expect(page.getByText(/SUCCEEDED · .* · 1 rows affected/)).toBeVisible()
|
||||
expect(executes.map((e) => e.confirmToken ?? null)).toEqual([null, 'tok'])
|
||||
})
|
||||
})
|
||||
@@ -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