Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46057519fc
|
||
|
|
3b98b71abd | ||
|
|
5c0cbbc17c | ||
|
|
07ffe9d0a2 | ||
|
|
30c61bfa4e | ||
|
|
97ac662c48
|
||
|
|
504f61801c | ||
|
|
4fd6294893
|
||
|
|
01ba5b2b05 | ||
|
|
48008c3bac
|
||
|
|
574556296c
|
||
|
|
734c3d49cf
|
||
|
|
e29d30e90a
|
||
|
|
f66ce71607
|
||
|
|
3b35050483
|
||
|
|
d88d2aa94a
|
||
|
|
2b7f1f6d6d
|
||
|
|
ff2ecf9c84
|
||
|
|
4d2d39191c
|
||
|
|
445b23d3be
|
||
|
|
7c62338980
|
||
|
|
520682d452
|
||
|
|
f3e84f5c7d
|
No files matched your search
+11
-57
@@ -1,60 +1,14 @@
|
||||
version: 2.1
|
||||
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.6.9
|
||||
|
||||
commands:
|
||||
configure-package-install:
|
||||
steps:
|
||||
- run:
|
||||
name: Keep package installation unattended
|
||||
command: |
|
||||
# These build machines are discarded after the job.
|
||||
sudo install -d /etc/needrestart/conf.d
|
||||
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
|
||||
|
||||
qqq-orb: kingsrook/qqq-orb@0.7.15
|
||||
workflows:
|
||||
test_only:
|
||||
jobs:
|
||||
- qqq-orb/mvn_test_only:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
filters:
|
||||
branches:
|
||||
ignore: /(develop|release\/.*)/
|
||||
|
||||
publish_snapshot:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: snapshot
|
||||
filters:
|
||||
branches:
|
||||
only: [develop]
|
||||
|
||||
publish_release_candidate:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release_candidate
|
||||
filters:
|
||||
branches:
|
||||
only: [/release\/.*/]
|
||||
|
||||
publish_release:
|
||||
jobs:
|
||||
- qqq-orb/mvn_publish:
|
||||
pre-steps:
|
||||
- configure-package-install
|
||||
context: [qqq-maven-registry-credentials]
|
||||
branch_type: release
|
||||
filters:
|
||||
branches:
|
||||
ignore: /.*/
|
||||
tags:
|
||||
only: [/^v\d+\.\d+\.\d+$/]
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: 0.7.15
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /^v\d+\.\d+\.\d+$/
|
||||
@@ -0,0 +1,23 @@
|
||||
name: CodeQL
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
- release/**
|
||||
- hotfix/**
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: 41 16 * * 0
|
||||
workflow_dispatch: {}
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
packages: read
|
||||
concurrency:
|
||||
group: codeql-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
jobs:
|
||||
analyze:
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
|
||||
@@ -1,25 +1,74 @@
|
||||
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
|
||||
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
|
||||
# the release line and the release/* push runs CircleCI publish_release_candidate.
|
||||
name: Release candidate
|
||||
|
||||
on:
|
||||
'on':
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print the plan without merging or creating branches
|
||||
description: Plan a cut without changing release state
|
||||
type: boolean
|
||||
default: false
|
||||
finalize:
|
||||
description: Retry finalization of an existing cut; do not cut another RC
|
||||
type: boolean
|
||||
default: false
|
||||
recover:
|
||||
description: Recover the same published RC after a deployment timeout; never
|
||||
reupload
|
||||
type: boolean
|
||||
default: false
|
||||
circle_build:
|
||||
description: Original Circle publisher build number (recovery only)
|
||||
type: string
|
||||
default: ''
|
||||
merge_sha:
|
||||
description: Existing cut merge_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
bump_sha:
|
||||
description: Existing cut bump_sha (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
branch:
|
||||
description: Existing cut branch (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
version:
|
||||
description: Existing cut version (finalize retry only)
|
||||
type: string
|
||||
default: ''
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches: [develop]
|
||||
|
||||
types:
|
||||
- closed
|
||||
branches:
|
||||
- develop
|
||||
repository_dispatch:
|
||||
types:
|
||||
- qbit-rc-published
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
actions: read
|
||||
checks: read
|
||||
jobs:
|
||||
release-rc:
|
||||
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
|
||||
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'repository_dispatch'
|
||||
|| (github.event_name == 'pull_request' && github.event.pull_request.merged
|
||||
== true && github.event.pull_request.base.ref == 'develop' && contains(github.event.pull_request.labels.*.name,
|
||||
'release:rc'))
|
||||
uses: QRun-IO/qqq-orb/.github/workflows/qbit-release-rc.yml@cc30f599e63c8e41629c99d23395bac1adb8bc44
|
||||
with:
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
|
||||
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || false
|
||||
}}
|
||||
finalize: ${{ github.event_name == 'repository_dispatch' || (github.event_name
|
||||
== 'workflow_dispatch' && inputs.finalize) }}
|
||||
recover: ${{ github.event_name == 'workflow_dispatch' && inputs.recover || false
|
||||
}}
|
||||
circle_build: ${{ github.event_name == 'workflow_dispatch' && inputs.circle_build
|
||||
|| '' }}
|
||||
merge_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.merge_sha
|
||||
|| inputs.merge_sha || '' }}
|
||||
bump_sha: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.bump_sha
|
||||
|| inputs.bump_sha || '' }}
|
||||
branch: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.branch
|
||||
|| inputs.branch || '' }}
|
||||
version: ${{ github.event_name == 'repository_dispatch' && github.event.client_payload.version
|
||||
|| inputs.version || '' }}
|
||||
secrets: inherit
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
profile: qbit
|
||||
orb_version: 0.7.15
|
||||
default_branch: develop
|
||||
java_version: '21'
|
||||
browsers: true
|
||||
api_checks: false
|
||||
docs_only: false
|
||||
release_on_main: false
|
||||
release_tag_policy: stable
|
||||
test_branch_policy: non-release
|
||||
publish:
|
||||
snapshot: true
|
||||
release_candidate: true
|
||||
release: true
|
||||
asciidoc: false
|
||||
release_completion: true
|
||||
@@ -14,6 +14,19 @@ This file provides guidance to coding agents (Codex and others) working in this
|
||||
./mvnw verify # Checkstyle, unit tests, JaCoCo gate (70% instructions, 90% classes)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry point:** `QaCaptureQBitProducer implements QBitMetaDataProducer<QaCaptureQBitConfig>` in `com.kingsrook.qbits.qacapture`. Packages: `metadata` (tables, permissions, help), `service` (enablement, rules, reports, QA mode), `api` (the `qa-capture` UI API extension), `web` (request ids, `/qa/recorder`, webhooks), `spi` (provider SPI, Jam, Svix). See `docs/DESIGN.md`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- The UI contract is `src/main/resources/openapi/qqq-v1/qa-capture.yaml` (extension `qa-capture` 1.0.0), shared with `QRun-IO/qqq-frontend-next`. `QaCaptureApiContractTest` fails when code and fragment differ; regenerate with `./mvnw -Dtest=QaCaptureApiContractTest -Dqacapture.openapi.update=true test` and bump the semantic version for any change (MINOR additive, never breaking under /qqq/v1).
|
||||
- Never 403 from the capability: `enabled=false` with a reason.
|
||||
- Sanitizer rules are table data; built-in seeds are insert-only; secured-data rules are insert-only and never touch admin edits. Value patterns must be valid in Java and JavaScript (`SanitizerRules.validate`).
|
||||
- Secrets (webhook secrets) only come from the config (host environment); never in tables, logs or responses.
|
||||
- Table reads and writes from the runtime run in the system context; who may do what is decided in `QaCaptureEnablement` / `QaCaptureService`.
|
||||
- Guardrail tests (`QaCaptureGuardrailsTest`, `QaCaptureForeignKeyTest`, `QaCaptureChangelogTest`) keep every table used, seeded or explained, helped, and every reference backed by a foreign key.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
- Tables are lowercase snake_case prefixed `qa_capture_`. The QQQ table name and the backend (SQL) name are identical.
|
||||
|
||||
@@ -6,6 +6,24 @@ All notable changes to this project are documented in this file. The format foll
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
Nothing yet.
|
||||
|
||||
## [0.1.0] - Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Repository scaffold: build, CI, community files.
|
||||
- Tables `qa_capture_settings`, `qa_capture_audience`, `qa_capture_sanitizer_rule` and
|
||||
`qa_capture_report` with their Liquibase changelog (built-in sanitizer rules seeded insert-only,
|
||||
one guarded foreign key), help bundle and an app the host can nest under its Admin app.
|
||||
- Permissions `qa_capture.manage`, `qa_capture.use_qa_mode` and `qa_capture.view_reports`.
|
||||
- Enablement by audience (users and roles, host role resolver) or self-serve QA mode (the
|
||||
`qa-capture` user preference section).
|
||||
- `qa-capture` UI API extension 1.0.0: `qaCaptureCapability` and `qaCaptureReportCreate`.
|
||||
- Request correlation: `X-Request-Id` on `/qqq/*` and `/qa/*`, log4j ThreadContext `requestId`,
|
||||
`QaCaptureRequestIds.current()`.
|
||||
- Public `/qa/recorder` page for Jam's connected domain, with hash-based CSP.
|
||||
- Svix-verified Jam `jam.created` webhook at `/qqq/hooks/qa-capture/jam`.
|
||||
- `QaCaptureCsp.customize(...)` for the Next dashboard Content-Security-Policy.
|
||||
- Provider SPI (`QaCaptureProvider`) with Jam as the first provider.
|
||||
- Secured-data sanitizer rules added at startup from core field metadata.
|
||||
@@ -14,6 +14,19 @@ This file provides guidance to Claude Code when working with code in this reposi
|
||||
./mvnw verify # Checkstyle, unit tests, JaCoCo gate (70% instructions, 90% classes)
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
**Entry point:** `QaCaptureQBitProducer implements QBitMetaDataProducer<QaCaptureQBitConfig>` in `com.kingsrook.qbits.qacapture`. Packages: `metadata` (tables, permissions, help), `service` (enablement, rules, reports, QA mode), `api` (the `qa-capture` UI API extension), `web` (request ids, `/qa/recorder`, webhooks), `spi` (provider SPI, Jam, Svix). See `docs/DESIGN.md`.
|
||||
|
||||
## Invariants
|
||||
|
||||
- The UI contract is `src/main/resources/openapi/qqq-v1/qa-capture.yaml` (extension `qa-capture` 1.0.0), shared with `QRun-IO/qqq-frontend-next`. `QaCaptureApiContractTest` fails when code and fragment differ; regenerate with `./mvnw -Dtest=QaCaptureApiContractTest -Dqacapture.openapi.update=true test` and bump the semantic version for any change (MINOR additive, never breaking under /qqq/v1).
|
||||
- Never 403 from the capability: `enabled=false` with a reason.
|
||||
- Sanitizer rules are table data; built-in seeds are insert-only; secured-data rules are insert-only and never touch admin edits. Value patterns must be valid in Java and JavaScript (`SanitizerRules.validate`).
|
||||
- Secrets (webhook secrets) only come from the config (host environment); never in tables, logs or responses.
|
||||
- Table reads and writes from the runtime run in the system context; who may do what is decided in `QaCaptureEnablement` / `QaCaptureService`.
|
||||
- Guardrail tests (`QaCaptureGuardrailsTest`, `QaCaptureForeignKeyTest`, `QaCaptureChangelogTest`) keep every table used, seeded or explained, helped, and every reference backed by a foreign key.
|
||||
|
||||
## Naming (owner rule)
|
||||
|
||||
- Tables are lowercase snake_case prefixed `qa_capture_`. The QQQ table name and the backend (SQL) name are identical.
|
||||
|
||||
@@ -1,9 +1,160 @@
|
||||
# QBit QA Capture
|
||||
|
||||
Optional, provider-pluggable QA bug capture for QQQ applications. [Jam](https://jam.dev) is the first
|
||||
provider. QA capture settings, audiences, sanitizer rules and QA reports are standard QQQ tables.
|
||||
Optional, provider-pluggable QA bug capture for QQQ applications. Chosen users (an audience, or
|
||||
anyone with the QA mode permission who switches QA mode on) can record a bug with screen,
|
||||
console and network context; secrets and secure values are removed in the browser before
|
||||
anything is sent. [Jam](https://jam.dev) is the first provider. Settings, audiences, sanitizer
|
||||
rules and QA reports are standard QQQ tables, and every API response carries an `X-Request-Id`
|
||||
that reports collect, so a bug links straight to the server log lines.
|
||||
|
||||
Design: [docs/DESIGN.md](docs/DESIGN.md). The implementation lands in the first feature pull request.
|
||||
Design: [docs/DESIGN.md](docs/DESIGN.md). The UI contract is the `qa-capture` UI API extension
|
||||
1.0.0, committed as [`src/main/resources/openapi/qqq-v1/qa-capture.yaml`](src/main/resources/openapi/qqq-v1/qa-capture.yaml);
|
||||
the QQQ Next UI (`QRun-IO/qqq-frontend-next`) implements the client side.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Java 21, QQQ 4.1 (built against `qqq-bom-pom` 4.1.0-RC.11).
|
||||
- `qqq-middleware-javalin` (the UI API extension, request ids, recorder page and webhooks).
|
||||
- An RDBMS backend for the four tables (PostgreSQL in production; the changelog runs as an
|
||||
ordinary application user).
|
||||
|
||||
## Install
|
||||
|
||||
```xml
|
||||
<dependency>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-qa-capture</artifactId>
|
||||
<version>0.1.0</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
Include the changelog from the host's Liquibase master changelog (the QBit never runs DDL):
|
||||
|
||||
```yaml
|
||||
- include: { file: db/liquibase/qbit-qa-capture/qa_capture.changelog.sql }
|
||||
```
|
||||
|
||||
Produce the QBit after the host's Admin app exists:
|
||||
|
||||
```java
|
||||
new QaCaptureQBitProducer()
|
||||
.withQBitConfig(new QaCaptureQBitConfig()
|
||||
.withBackendName("rdbms")
|
||||
.withEnvironment(System.getenv("APP_ENVIRONMENT")) // "prod": selects the settings row
|
||||
.withBackendVersion(buildVersion) // from the host's manifest
|
||||
.withParentAppName("admin") // nest under the Admin app
|
||||
.withJamWebhookSecret(System.getenv("QA_CAPTURE_JAM_WEBHOOK_SECRET"))
|
||||
.withRoleResolver(session -> identityProviderGroups(session))) // optional, for ROLE audience rows
|
||||
.produce(qInstance);
|
||||
```
|
||||
|
||||
## Tables
|
||||
|
||||
| Table | What it holds | How rows get created |
|
||||
|---|---|---|
|
||||
| `qa_capture_settings` | One row per environment: provider, team id, reusable recording link id, mode (`OFF`, `AUDIENCE`, `SELF_SERVE`, `AUDIENCE_AND_SELF_SERVE`), extra blur selectors. | The host's migration or an administrator. Nothing is seeded. |
|
||||
| `qa_capture_audience` | Users (`USER`: user id or email) and roles (`ROLE`) who capture in the audience modes. Rows with source `QA_MODE` hold a user's self-serve QA mode switch when the host has no core key-value preference store; they never grant audience membership. | Administrators; the QA mode preference. |
|
||||
| `qa_capture_sanitizer_rule` | What captures redact: `HEADER`, `BODY_FIELD`, `QUERY_PARAM`, `VALUE_PATTERN`, `DROP_PATH`, `BLUR_SELECTOR`. Active rows are the rule set served to browsers. | Built-in rows from the changelog (insert-only); `SECURED_DATA` rows at startup for fields the instance marks secure; administrators. |
|
||||
| `qa_capture_report` | One reported bug: page, app, table, record, process, recent request ids, user, environment, build, node, browser, the Jam capture and the GitHub issue. | Report a bug (`POST /qqq/v1/qbit/qa-capture/report`) and unmatched provider webhooks. |
|
||||
|
||||
The QQQ table names equal the SQL names. The only foreign key is
|
||||
`qa_capture_audience.settings_id` (cascade). Nothing references a WORM table. Every table has
|
||||
help (including how its rows get created) from `help/qbit-qa-capture.yaml`.
|
||||
|
||||
Secure fields: a field is secure when core metadata marks it (`QFieldMetaData.isSensitive`, or a
|
||||
field-level data classification of PII or PHI through core's `AiFieldMetaData`, which
|
||||
`qbit-secured-data` classifications also set). There is no dependency on `qbit-secured-data`.
|
||||
Startup adds one `BODY_FIELD` rule per secure field name, insert-only: a rule an administrator
|
||||
edits or deactivates is never changed back.
|
||||
|
||||
## Permissions
|
||||
|
||||
| Permission | Grants |
|
||||
|---|---|
|
||||
| `qa_capture.manage` | Read and write settings, audience and sanitizer rules (and reports). |
|
||||
| `qa_capture.view_reports` | Read and triage QA reports; the capability's `reportsHref`. |
|
||||
| `qa_capture.use_qa_mode` | The "QA capture" preference section with the QA mode switch (self-serve modes). |
|
||||
|
||||
They are separately grantable. Capture itself is decided per user by the settings mode and
|
||||
audience; the capability never answers 403 (it returns `enabled: false` and a reason).
|
||||
|
||||
`ROLE` audience rows match the names the config's role resolver returns, case-insensitively.
|
||||
QQQ 4.1 sessions have no role list of their own, so the default resolver returns the session's
|
||||
permission names; hosts pass their identity-provider groups (for example Authentik groups such as
|
||||
`kof22-admins`). `USER` rows match the session user's id reference or email, case-insensitively.
|
||||
|
||||
## Configuration
|
||||
|
||||
| `QaCaptureQBitConfig` | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `backendName` | required | Backend of the four tables. |
|
||||
| `environment` | required | Selects the `qa_capture_settings` row (for example `prod`). |
|
||||
| `backendVersion` | none | Reported in the capability context and on reports. |
|
||||
| `node` | `HOSTNAME` env | Reported in the capability context and on reports. |
|
||||
| `appName`, `appLabel` | `qaCapture`, `QA Capture` | The QBit's app (its four tables, no processes). |
|
||||
| `parentAppName` | none | Nest the app under the host's Admin app (must exist when producing). |
|
||||
| `withJamWebhookSecret(...)` / `withWebhookSecret(provider, ...)` | none | Webhook signing secret; without one the webhook answers 404. Never stored in tables or logged. |
|
||||
| `withRoleResolver(...)` | permission names | Role (group) names for `ROLE` audience rows. |
|
||||
| `withProvider(...)` / `withProviders(...)` | Jam | The provider registry (`QaCaptureProvider` SPI). |
|
||||
| `registerRoutes` | `true` | The UI API extension and the route provider. |
|
||||
| `syncSecuredDataRules` | `true` | Add `SECURED_DATA` rules at startup. |
|
||||
|
||||
Environment variables are read by the host, not the QBit. Suggested names:
|
||||
`QA_CAPTURE_JAM_WEBHOOK_SECRET` (the `whsec_...` secret) and the host's existing environment and
|
||||
build variables.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Auth | What |
|
||||
|---|---|---|
|
||||
| `GET /qqq/v1/qbit/qa-capture/capability` (`qaCaptureCapability`) | session | The user's capability (contract). |
|
||||
| `POST /qqq/v1/qbit/qa-capture/report` (`qaCaptureReportCreate`) | session | Create a report; 403 when capture is off for the user. |
|
||||
| `GET /qa/recorder` | public | Jam's connected-domain page (team meta, both scripts, sanitizers from the active rules), its own CSP with the inline script's sha256, `Cache-Control: no-store`, never a redirect; 404 when capture is off. A matched route, so it wins over the Next dashboard fallback. |
|
||||
| `POST /qqq/hooks/qa-capture/jam` | Svix signature | `jam.created`: attaches the capture to the report named by `recordingLink.reference` (status `CAPTURED`), else creates a report. Idempotent on `jamId`; 1 MB cap; 5-minute timestamp tolerance. |
|
||||
| every `/qqq/*` and `/qa/*` response | | `X-Request-Id` (a valid incoming `[A-Za-z0-9._:-]{8,128}` value is reused, else a UUID). |
|
||||
|
||||
## Adopting in another app
|
||||
|
||||
1. Add the dependency, include the changelog, produce the QBit (above).
|
||||
2. Add the provider origins to the Next dashboard CSP while capture is active:
|
||||
|
||||
```java
|
||||
new QApplicationJavalinServer(...)
|
||||
.withNextDashboardSecurityHeadersCustomizer(headers ->
|
||||
{
|
||||
myOtherCustomizations(headers);
|
||||
QaCaptureCsp.customize(headers); // or QaCaptureCsp.customize(headers, qInstance)
|
||||
})
|
||||
```
|
||||
|
||||
`QaCaptureCsp.customize(NextDashboardSecurityHeaders headers, QInstance instance)` adds
|
||||
`script-src https://js.jam.dev` and `frame-src`/`connect-src https://*.jam.dev` only when the
|
||||
environment's settings row has a mode other than `OFF` and a team id; `'self'` is untouched. It
|
||||
reads the database in the system context and never throws (a failure is logged and the headers
|
||||
stay as they were). The dashboard rebuilds its headers when the QInstance is set, so settings
|
||||
changes reach the dashboard CSP on the next restart or instance hot swap. The capability, the
|
||||
recorder page and the webhook follow the settings immediately.
|
||||
3. Put the request id in your log pattern: `%X{requestId}` (log4j ThreadContext key `requestId`).
|
||||
Code can read it with `QaCaptureRequestIds.current()`.
|
||||
4. Grant the permissions, add a settings row for each environment and choose the audience.
|
||||
5. Optional: define core's key-value preference store
|
||||
(`UserPreferencesKeyValueStoreMetaDataProvider`); the QA mode switch then lives there instead of
|
||||
in `qa_capture_audience`.
|
||||
|
||||
## Jam setup
|
||||
|
||||
1. In Jam, connect your domain: Jam verifies a public URL that must not redirect, must keep query
|
||||
parameters and must contain both scripts and the matching `jam:team` meta. Use
|
||||
`https://<your-host>/qa/recorder` (it answers 404 until a settings row turns capture on).
|
||||
2. Copy the Jam team id (for example `0815d765-0953-4f79-91a3-f5ed435abbdd`) into
|
||||
`qa_capture_settings.team_id` for the environment and choose the mode.
|
||||
3. Create a reusable Recording Link for the connected domain and put its id in
|
||||
`recording_link_id`; Report a bug opens it with `jam-recording`, `jam-title` and
|
||||
`jam-reference`.
|
||||
4. Add a webhook for `jam.created` pointing to `https://<your-host>/qqq/hooks/qa-capture/jam`;
|
||||
pass its signing secret (`whsec_...`) to `withJamWebhookSecret` from your environment.
|
||||
5. Connect Jam's GitHub integration to file issues from captures; paste the issue URL into the
|
||||
report's GitHub Issue field (status `FILED`) while triaging.
|
||||
|
||||
## Build
|
||||
|
||||
@@ -11,6 +162,11 @@ Design: [docs/DESIGN.md](docs/DESIGN.md). The implementation lands in the first
|
||||
./mvnw verify
|
||||
```
|
||||
|
||||
The foreign key guardrail runs PostgreSQL in Docker (Testcontainers) and is skipped without
|
||||
Docker. The JavaScript sanitizer test runs in Node and is skipped without `node`. To regenerate the
|
||||
UI API fragment after a deliberate contract change:
|
||||
`./mvnw -Dtest=QaCaptureApiContractTest -Dqacapture.openapi.update=true test`.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||
+12
-1
@@ -1,3 +1,14 @@
|
||||
# Upgrade notes
|
||||
|
||||
Nothing to upgrade yet: no release has been published.
|
||||
## 0.1.0 (first release)
|
||||
|
||||
Adopting the QBit:
|
||||
|
||||
1. Include `db/liquibase/qbit-qa-capture/qa_capture.changelog.sql` in the host's master changelog
|
||||
and run it (as the application database user).
|
||||
2. Produce `QaCaptureQBitProducer` with `backendName`, `environment` and, if wanted,
|
||||
`parentAppName`, `backendVersion`, `withJamWebhookSecret(...)` and `withRoleResolver(...)`.
|
||||
3. Call `QaCaptureCsp.customize(headers)` from the Next dashboard security headers customizer.
|
||||
4. Add `%X{requestId}` to the log pattern.
|
||||
5. Grant `qa_capture.manage`, `qa_capture.view_reports` and `qa_capture.use_qa_mode` as needed, and
|
||||
add a `qa_capture_settings` row per environment (capture stays off until then).
|
||||
@@ -240,7 +240,7 @@
|
||||
<module name="Header">
|
||||
<property name="headerFile" value="checkstyle/license.txt"/>
|
||||
<property name="fileExtensions" value="java"/>
|
||||
<property name="ignoreLines" value="3"/>
|
||||
<property name="ignoreLines" value="2"/>
|
||||
</module>
|
||||
<module name="SuppressWarningsFilter"/>
|
||||
</module>
|
||||
@@ -1,15 +1,11 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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
|
||||
* 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,
|
||||
|
||||
+79
-2
@@ -1,4 +1,81 @@
|
||||
# QBit QA Capture: design
|
||||
|
||||
Provider-pluggable QA bug capture for QQQ applications (Jam first). The full design is added with
|
||||
the first implementation pull request.
|
||||
Provider-pluggable QA bug capture for QQQ applications, Jam (https://jam.dev) first. This QBit
|
||||
and the QQQ Next UI (`QRun-IO/qqq-frontend-next`) implement one shared contract: the `qa-capture`
|
||||
UI API extension 1.0.0 (`src/main/resources/openapi/qqq-v1/qa-capture.yaml`) plus the raw routes
|
||||
below. Owner priority 2026-10-15.
|
||||
|
||||
## Goals
|
||||
|
||||
- Off by default and zero cost when off: a client that does not see the `qa-capture` extension,
|
||||
or gets `enabled: false`, makes no further calls and loads nothing from the provider.
|
||||
- Capture only for chosen users: an administrator audience (users, roles) or self-serve QA mode
|
||||
for holders of `qa_capture.use_qa_mode`.
|
||||
- Secrets and secure values never leave the browser: sanitizer rules are data (a standard table),
|
||||
served to the client, applied before the provider receives anything (fail closed: Jam redacts
|
||||
when a sanitizer throws).
|
||||
- Every bug links to server logs: `X-Request-Id` on every API response, kept by the client and
|
||||
stored on the report.
|
||||
- All QBit data in standard, visible QQQ tables; no filesystem; DB changes only through the
|
||||
Liquibase changelog.
|
||||
|
||||
## Components
|
||||
|
||||
| Component | Class |
|
||||
|---|---|
|
||||
| Config and producer | `QaCaptureQBitConfig`, `QaCaptureQBitProducer` |
|
||||
| Tables, possible values, app, permissions | `metadata.QaCaptureMetaData`, `metadata.QaCapturePermissionChecker` |
|
||||
| Write validation (rule values, trimming) | `metadata.QaCaptureWriteValidator` |
|
||||
| Help bundle | `help/qbit-qa-capture.yaml`, `metadata.QaCaptureHelp` |
|
||||
| Enablement (pure) | `service.QaCaptureEnablement` |
|
||||
| Capability, reports, webhook context | `service.QaCaptureService` |
|
||||
| Sanitizer rule set | `service.SanitizerRules`, `web.SanitizerScript` |
|
||||
| Secured-data rules | `service.SecuredDataRuleSync` |
|
||||
| QA mode preference | `service.QaModePreference` |
|
||||
| UI API extension | `api.QaCaptureUiApi`, `api.CapabilitySpec`, `api.ReportCreateSpec`, `api.QaCaptureSchemas` |
|
||||
| Raw routes | `web.QaCaptureRouteProvider`, `web.RecorderPage`, `web.QaCaptureRequestIds` |
|
||||
| Dashboard CSP | `QaCaptureCsp` |
|
||||
| Provider SPI | `spi.QaCaptureProvider`, `spi.JamProvider`, `spi.SvixSignatureVerifier` |
|
||||
|
||||
## Enablement
|
||||
|
||||
For the configured environment's settings row: `OFF` mode gives reason `OFF`; no row, no team id
|
||||
or an unknown provider gives `NOT_CONFIGURED`. Otherwise an active administrator audience row
|
||||
matching the user (audience modes) gives `AUDIENCE`; in self-serve modes a user with
|
||||
`qa_capture.use_qa_mode` gets `QA_MODE` when their QA mode is on and `QA_MODE_OFF` when it is off;
|
||||
everyone else gets `NO_ACCESS`.
|
||||
|
||||
QA mode is the `qa-capture` user preference section (one boolean), visible only with
|
||||
`qa_capture.use_qa_mode` (core's section permission). It is stored in core's key-value preference
|
||||
store when the host defines it, else as a `qa_capture_audience` row with source `QA_MODE`. No
|
||||
fifth table.
|
||||
|
||||
## Request correlation
|
||||
|
||||
The route provider adds before and after handlers on `/qqq/*` and `/qa/*`: a valid incoming
|
||||
`X-Request-Id` (`[A-Za-z0-9._:-]{8,128}`) is reused, else a UUID is generated; the id is set as the
|
||||
response header and in the log4j ThreadContext (`requestId`) for the request and removed afterwards
|
||||
(after handlers also run after exceptions). Storing request ids on audits needs core support; see
|
||||
the QRun-IO/qqq issue linked from the pull request.
|
||||
|
||||
## Recorder page and CSP
|
||||
|
||||
`GET /qa/recorder` is a registered route, so the Next dashboard's SPA fallback (which only handles
|
||||
requests no endpoint matched) never serves it, including its 404. Its inline module script is not
|
||||
user dependent (it embeds the active rules), and its CSP allows it by sha256 hash.
|
||||
|
||||
`QaCaptureCsp.customize` adds the provider origins to the dashboard CSP only while capture is
|
||||
active. The dashboard rebuilds its headers when the QInstance is set (startup and hot swap), so
|
||||
settings changes reach the dashboard CSP on the next restart or hot swap.
|
||||
|
||||
## Webhooks
|
||||
|
||||
`POST /qqq/hooks/qa-capture/{provider}` delegates to the provider. Jam: Svix HMAC-SHA256 over
|
||||
`{svix-id}.{svix-timestamp}.{body}` with the `whsec_` secret, constant-time comparison against
|
||||
every `v1` signature, 5-minute timestamp tolerance, 1 MB cap; only `jam.created` for the
|
||||
configured team is processed; idempotent on `jamId` (unique column).
|
||||
|
||||
## Placement
|
||||
|
||||
The QBit adds one app (`qaCapture`) with its four tables and no processes; `parentAppName` nests it
|
||||
under the host's Admin app.
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureSettings;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureCspOrigins;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureProvider;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.routeproviders.NextDashboardSecurityHeaders;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Adds the capture provider's origins to the Next dashboard's
|
||||
** Content-Security-Policy, only while capture is active for the configured
|
||||
** environment (settings row with a mode other than OFF, a team id and a
|
||||
** registered provider). For Jam: script-src https://js.jam.dev, frame-src
|
||||
** and connect-src https://*.jam.dev; 'self' and every other source are left
|
||||
** untouched.
|
||||
**
|
||||
** Host usage (QApplicationJavalinServer):
|
||||
** <pre>
|
||||
** .withNextDashboardSecurityHeadersCustomizer(headers ->
|
||||
** {
|
||||
** myOtherCustomizations(headers);
|
||||
** QaCaptureCsp.customize(headers);
|
||||
** })
|
||||
** </pre>
|
||||
** The dashboard rebuilds its headers when the QInstance is set (startup and
|
||||
** every hot swap), so a settings change takes effect on the next restart or
|
||||
** instance hot swap. Reads the database in the system context; never throws
|
||||
** (a failure is logged and the headers are left as they were).
|
||||
*******************************************************************************/
|
||||
public final class QaCaptureCsp
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QaCaptureCsp.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QaCaptureCsp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Customize with the instance the QBit was most recently produced into or
|
||||
** served from (a no-op before there is one).
|
||||
*******************************************************************************/
|
||||
public static void customize(NextDashboardSecurityHeaders headers)
|
||||
{
|
||||
customize(headers, QaCaptureRuntime.getCurrentInstance());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Customize for an instance.
|
||||
**
|
||||
** @param headers the dashboard's headers (changed in place)
|
||||
** @param qInstance the instance holding the QBit (null: no-op)
|
||||
** @return whether origins were added
|
||||
*******************************************************************************/
|
||||
public static boolean customize(NextDashboardSecurityHeaders headers, QInstance qInstance)
|
||||
{
|
||||
if(headers == null || qInstance == null || QaCaptureRuntime.find(qInstance) == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
QRecord settings = QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureSettings.load(qInstance));
|
||||
if(!QaCaptureSettings.isActive(qInstance, settings))
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
QaCaptureProvider provider = QaCaptureRuntime.of(qInstance).getConfig().getProvider(settings.getValueString("provider"));
|
||||
QaCaptureCspOrigins origins = provider.getCspOrigins();
|
||||
origins.scriptSrc().forEach(origin -> headers.withSources(NextDashboardSecurityHeaders.SCRIPT_SRC, origin));
|
||||
origins.frameSrc().forEach(origin -> headers.withSources("frame-src", origin));
|
||||
origins.connectSrc().forEach(origin -> headers.withSources("connect-src", origin));
|
||||
return (true);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not add the QA capture origins to the dashboard Content-Security-Policy (left unchanged)", e, logPair("exceptionClass", e.getClass().getSimpleName()));
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.qacapture.spi.JamProvider;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Host configuration of the QA capture QBit.
|
||||
**
|
||||
** Required: the backend holding the qa_capture_ tables and the environment
|
||||
** name (it selects the qa_capture_settings row, for example "prod"). The
|
||||
** host supplies its build version and node name (for example from its
|
||||
** manifest and the HOSTNAME variable) and the provider webhook secrets from
|
||||
** its environment; secrets are never stored in tables or logged.
|
||||
**
|
||||
** Placement: the QBit adds one app (appName, default "qaCapture") holding
|
||||
** its four tables. Set parentAppName to the host's Admin (system tooling) app
|
||||
** to nest it there; otherwise the host places the app itself.
|
||||
*******************************************************************************/
|
||||
public class QaCaptureQBitConfig implements QBitConfig
|
||||
{
|
||||
public static final String DEFAULT_APP_NAME = "qaCapture";
|
||||
public static final String HOSTNAME_ENV = "HOSTNAME";
|
||||
|
||||
private static final Pattern NAME = Pattern.compile("[A-Za-z][A-Za-z0-9_]*");
|
||||
private static final Pattern ENVIRONMENT = Pattern.compile("[A-Za-z0-9._-]{1,50}");
|
||||
private static final Pattern PROVIDER = Pattern.compile("[a-z0-9][a-z0-9-]{0,49}");
|
||||
|
||||
private String backendName;
|
||||
private String environment;
|
||||
private String backendVersion;
|
||||
private String node;
|
||||
private String appName = DEFAULT_APP_NAME;
|
||||
private String appLabel = "QA Capture";
|
||||
private String parentAppName;
|
||||
private Boolean registerRoutes = true;
|
||||
private Boolean syncSecuredDataRules = true;
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// code and secrets: never serialized with the instance's metadata //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
private transient Map<String, QaCaptureProvider> providers;
|
||||
private transient Map<String, String> webhookSecrets;
|
||||
private transient Function<QSession, Collection<String>> roleResolver;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the config against the instance it is produced into.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void validate(QInstance qInstance, List<String> errors)
|
||||
{
|
||||
assertCondition(StringUtils.hasContent(backendName), "backendName is required", errors);
|
||||
assertCondition(!StringUtils.hasContent(backendName) || qInstance.getBackend(backendName) != null, "backend " + backendName + " is not defined", errors);
|
||||
assertCondition(environment != null && ENVIRONMENT.matcher(environment).matches(), "environment is required (letters, digits, '.', '_' or '-', at most 50; got " + environment + ")", errors);
|
||||
assertCondition(appName != null && NAME.matcher(appName).matches(), "appName must be alphanumeric (got " + appName + ")", errors);
|
||||
assertCondition(parentAppName == null || qInstance.getApp(parentAppName) != null, "parent app " + parentAppName + " is not defined (define it before producing the QBit)", errors);
|
||||
assertCondition(!getProviders().isEmpty(), "at least one provider is required", errors);
|
||||
for(String name : getProviders().keySet())
|
||||
{
|
||||
assertCondition(PROVIDER.matcher(name).matches(), "provider name " + name + " must be lowercase letters, digits or '-'", errors);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The registered providers by name (Jam unless the host replaced them).
|
||||
*******************************************************************************/
|
||||
public Map<String, QaCaptureProvider> getProviders()
|
||||
{
|
||||
if(providers == null)
|
||||
{
|
||||
providers = new LinkedHashMap<>();
|
||||
providers.put(JamProvider.NAME, new JamProvider());
|
||||
}
|
||||
return (providers);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider by name, or null.
|
||||
*******************************************************************************/
|
||||
public QaCaptureProvider getProvider(String name)
|
||||
{
|
||||
return (name == null ? null : getProviders().get(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register (or replace) a provider.
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withProvider(QaCaptureProvider provider)
|
||||
{
|
||||
getProviders().put(provider.getName(), provider);
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Replace the whole provider registry (for example to drop Jam).
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withProviders(List<QaCaptureProvider> providers)
|
||||
{
|
||||
this.providers = new LinkedHashMap<>();
|
||||
if(providers != null)
|
||||
{
|
||||
providers.forEach(provider -> this.providers.put(provider.getName(), provider));
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set a provider's webhook signing secret (read by the host from its
|
||||
** environment). Without one, the provider's webhook answers 404.
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withWebhookSecret(String providerName, String secret)
|
||||
{
|
||||
if(webhookSecrets == null)
|
||||
{
|
||||
webhookSecrets = new LinkedHashMap<>();
|
||||
}
|
||||
if(StringUtils.hasContent(secret))
|
||||
{
|
||||
webhookSecrets.put(providerName, secret.trim());
|
||||
}
|
||||
else
|
||||
{
|
||||
webhookSecrets.remove(providerName);
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Set Jam's webhook signing secret ("whsec_...", from the Jam webhook).
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withJamWebhookSecret(String secret)
|
||||
{
|
||||
return (withWebhookSecret(JamProvider.NAME, secret));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A provider's webhook secret, or null.
|
||||
*******************************************************************************/
|
||||
public String getWebhookSecret(String providerName)
|
||||
{
|
||||
return (webhookSecrets == null ? null : webhookSecrets.get(providerName));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The names a user's ROLE audience rows match: the host's resolver (for
|
||||
** example the user's identity-provider groups), else the session's
|
||||
** permission names. QQQ 4.1 sessions have no role list of their own.
|
||||
*******************************************************************************/
|
||||
public Collection<String> resolveRoles(QSession session)
|
||||
{
|
||||
if(session == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
if(roleResolver != null)
|
||||
{
|
||||
Collection<String> roles = roleResolver.apply(session);
|
||||
return (roles == null ? List.of() : roles);
|
||||
}
|
||||
return (session.getPermissions() == null ? List.of() : new ArrayList<>(session.getPermissions()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for roleResolver: the role (group) names of a session's
|
||||
** user, matched case-insensitively by ROLE audience rows.
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withRoleResolver(Function<QSession, Collection<String>> roleResolver)
|
||||
{
|
||||
this.roleResolver = roleResolver;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The node name: the configured one, else the HOSTNAME environment variable.
|
||||
*******************************************************************************/
|
||||
public String getEffectiveNode()
|
||||
{
|
||||
return (StringUtils.hasContent(node) ? node : System.getenv(HOSTNAME_ENV));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secrets and code are not printed.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String toString()
|
||||
{
|
||||
return ("QaCaptureQBitConfig{backendName=" + backendName + ", environment=" + environment + ", backendVersion=" + backendVersion
|
||||
+ ", appName=" + appName + ", parentAppName=" + parentAppName + ", registerRoutes=" + registerRoutes
|
||||
+ ", providers=" + getProviders().keySet() + ", webhookSecrets=" + (webhookSecrets == null ? "[]" : webhookSecrets.keySet() + " <redacted>") + "}");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendName
|
||||
*******************************************************************************/
|
||||
public String getBackendName()
|
||||
{
|
||||
return (this.backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for backendName
|
||||
*******************************************************************************/
|
||||
public void setBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendName
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withBackendName(String backendName)
|
||||
{
|
||||
this.backendName = backendName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The backend for the QBit's tables.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getDefaultBackendNameForTables()
|
||||
{
|
||||
return (backendName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for environment
|
||||
*******************************************************************************/
|
||||
public String getEnvironment()
|
||||
{
|
||||
return (this.environment);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for environment
|
||||
*******************************************************************************/
|
||||
public void setEnvironment(String environment)
|
||||
{
|
||||
this.environment = environment;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for environment (selects the qa_capture_settings row).
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withEnvironment(String environment)
|
||||
{
|
||||
this.environment = environment;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for backendVersion
|
||||
*******************************************************************************/
|
||||
public String getBackendVersion()
|
||||
{
|
||||
return (this.backendVersion);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for backendVersion
|
||||
*******************************************************************************/
|
||||
public void setBackendVersion(String backendVersion)
|
||||
{
|
||||
this.backendVersion = backendVersion;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for backendVersion (the host's build version).
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withBackendVersion(String backendVersion)
|
||||
{
|
||||
this.backendVersion = backendVersion;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for node
|
||||
*******************************************************************************/
|
||||
public String getNode()
|
||||
{
|
||||
return (this.node);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for node
|
||||
*******************************************************************************/
|
||||
public void setNode(String node)
|
||||
{
|
||||
this.node = node;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for node (defaults to the HOSTNAME environment variable).
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withNode(String node)
|
||||
{
|
||||
this.node = node;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appName
|
||||
*******************************************************************************/
|
||||
public String getAppName()
|
||||
{
|
||||
return (this.appName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for appName
|
||||
*******************************************************************************/
|
||||
public void setAppName(String appName)
|
||||
{
|
||||
this.appName = appName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appName
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withAppName(String appName)
|
||||
{
|
||||
this.appName = appName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for appLabel
|
||||
*******************************************************************************/
|
||||
public String getAppLabel()
|
||||
{
|
||||
return (this.appLabel);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for appLabel
|
||||
*******************************************************************************/
|
||||
public void setAppLabel(String appLabel)
|
||||
{
|
||||
this.appLabel = appLabel;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for appLabel
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withAppLabel(String appLabel)
|
||||
{
|
||||
this.appLabel = appLabel;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for parentAppName
|
||||
*******************************************************************************/
|
||||
public String getParentAppName()
|
||||
{
|
||||
return (this.parentAppName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for parentAppName
|
||||
*******************************************************************************/
|
||||
public void setParentAppName(String parentAppName)
|
||||
{
|
||||
this.parentAppName = parentAppName;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for parentAppName (the host's Admin app; null to place the
|
||||
** app yourself).
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withParentAppName(String parentAppName)
|
||||
{
|
||||
this.parentAppName = parentAppName;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for registerRoutes
|
||||
*******************************************************************************/
|
||||
public Boolean getRegisterRoutes()
|
||||
{
|
||||
return (this.registerRoutes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for registerRoutes
|
||||
*******************************************************************************/
|
||||
public void setRegisterRoutes(Boolean registerRoutes)
|
||||
{
|
||||
this.registerRoutes = registerRoutes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for registerRoutes: false skips the UI API extension and the
|
||||
** Javalin route provider (request ids, recorder page, webhooks).
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withRegisterRoutes(Boolean registerRoutes)
|
||||
{
|
||||
this.registerRoutes = registerRoutes;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for syncSecuredDataRules
|
||||
*******************************************************************************/
|
||||
public Boolean getSyncSecuredDataRules()
|
||||
{
|
||||
return (this.syncSecuredDataRules);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for syncSecuredDataRules
|
||||
*******************************************************************************/
|
||||
public void setSyncSecuredDataRules(Boolean syncSecuredDataRules)
|
||||
{
|
||||
this.syncSecuredDataRules = syncSecuredDataRules;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for syncSecuredDataRules: whether startup adds BODY_FIELD
|
||||
** rules for fields the instance marks secure (default true).
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig withSyncSecuredDataRules(Boolean syncSecuredDataRules)
|
||||
{
|
||||
this.syncSecuredDataRules = syncSecuredDataRules;
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import com.kingsrook.qbits.qacapture.api.QaCaptureUiApi;
|
||||
import com.kingsrook.qbits.qacapture.metadata.QaCaptureHelp;
|
||||
import com.kingsrook.qbits.qacapture.metadata.QaCaptureMetaData;
|
||||
import com.kingsrook.qbits.qacapture.service.QaModePreference;
|
||||
import com.kingsrook.qbits.qacapture.web.QaCaptureRouteProvider;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Entry point of the QA capture QBit.
|
||||
**
|
||||
** Host usage:
|
||||
** <pre>
|
||||
** new QaCaptureQBitProducer()
|
||||
** .withQBitConfig(new QaCaptureQBitConfig()
|
||||
** .withBackendName("rdbms")
|
||||
** .withEnvironment(System.getenv("APP_ENVIRONMENT")) // "prod"
|
||||
** .withBackendVersion(buildVersion)
|
||||
** .withParentAppName("admin")
|
||||
** .withJamWebhookSecret(System.getenv("QA_CAPTURE_JAM_WEBHOOK_SECRET"))
|
||||
** .withRoleResolver(session -> myGroups(session)))
|
||||
** .produce(qInstance);
|
||||
** </pre>
|
||||
** The tables come from the host-included changelog
|
||||
** (db/liquibase/qbit-qa-capture/qa_capture.changelog.sql); the QBit never
|
||||
** runs DDL. Producing registers the tables and app, the qa-capture UI API
|
||||
** extension, the route provider (request ids, /qa/recorder, webhooks), the
|
||||
** "qa-capture" preference section and the help bundle.
|
||||
*******************************************************************************/
|
||||
public class QaCaptureQBitProducer implements QBitMetaDataProducer<QaCaptureQBitConfig>
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QaCaptureQBitProducer.class);
|
||||
|
||||
public static final String GROUP_ID = "com.kingsrook.qbits";
|
||||
public static final String ARTIFACT_ID = "qbit-qa-capture";
|
||||
public static final String VERSION = loadVersion();
|
||||
|
||||
private QaCaptureQBitConfig qBitConfig;
|
||||
private String namespace;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Read the artifact version from the Maven-filtered properties resource.
|
||||
*******************************************************************************/
|
||||
private static String loadVersion()
|
||||
{
|
||||
try(InputStream inputStream = QaCaptureQBitProducer.class.getClassLoader().getResourceAsStream("qbit-qa-capture.properties"))
|
||||
{
|
||||
if(inputStream == null)
|
||||
{
|
||||
throw (new IllegalStateException("Missing qbit-qa-capture.properties; build with Maven to generate the artifact version"));
|
||||
}
|
||||
Properties properties = new Properties();
|
||||
properties.load(inputStream);
|
||||
String version = properties.getProperty("version");
|
||||
if(version == null || version.isBlank() || version.contains("${"))
|
||||
{
|
||||
throw (new IllegalStateException("Missing or unfiltered version in qbit-qa-capture.properties; build with Maven"));
|
||||
}
|
||||
return (version);
|
||||
}
|
||||
catch(IOException e)
|
||||
{
|
||||
throw (new IllegalStateException("Could not read the artifact version from qbit-qa-capture.properties", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the config.
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitProducer withQBitConfig(QaCaptureQBitConfig config)
|
||||
{
|
||||
this.qBitConfig = config;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QaCaptureQBitConfig getQBitConfig()
|
||||
{
|
||||
return (qBitConfig);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fluent setter for the namespace (recorded only: the table names are fixed).
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitProducer withNamespace(String namespace)
|
||||
{
|
||||
this.namespace = namespace;
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getNamespace()
|
||||
{
|
||||
return (namespace);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QBitMetaData getQBitMetaData()
|
||||
{
|
||||
return (new QBitMetaData()
|
||||
.withGroupId(GROUP_ID)
|
||||
.withArtifactId(ARTIFACT_ID)
|
||||
.withVersion(VERSION)
|
||||
.withNamespace(getNamespace())
|
||||
.withConfig(getQBitConfig()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A producer found by package scanning has no config: skip it.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean isEnabled()
|
||||
{
|
||||
return (qBitConfig != null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validate the config, then add the QBit and its meta-data. Returns an
|
||||
** empty output, so a host that also adds the output adds nothing twice.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public MetaDataProducerMultiOutput produce(QInstance qInstance) throws QException
|
||||
{
|
||||
if(qBitConfig == null)
|
||||
{
|
||||
throw (new QException("QA capture configuration validation failed: no config was supplied (call withQBitConfig)"));
|
||||
}
|
||||
List<String> errors = new ArrayList<>();
|
||||
qBitConfig.validate(qInstance, errors);
|
||||
for(String table : QaCaptureSchema.TABLES)
|
||||
{
|
||||
if(qInstance.getTable(table) != null)
|
||||
{
|
||||
errors.add("table " + table + " already exists in the instance");
|
||||
}
|
||||
}
|
||||
if(qInstance.getApp(qBitConfig.getAppName()) != null)
|
||||
{
|
||||
errors.add("app " + qBitConfig.getAppName() + " already exists in the instance");
|
||||
}
|
||||
if(!errors.isEmpty())
|
||||
{
|
||||
throw (new QException("QA capture configuration validation failed: " + String.join("; ", errors)));
|
||||
}
|
||||
|
||||
qInstance.addQBit(getQBitMetaData());
|
||||
new QaCaptureRuntime(qBitConfig).addSelfToInstance(qInstance);
|
||||
new QaCaptureMetaData(qBitConfig).addTo(qInstance);
|
||||
QaCaptureHelp.apply(qInstance, qBitConfig);
|
||||
HelpBundles.register(qInstance, QaCaptureHelp.RESOURCE);
|
||||
QaModePreference.register(qInstance);
|
||||
if(Boolean.TRUE.equals(qBitConfig.getRegisterRoutes()))
|
||||
{
|
||||
QaCaptureUiApi.register(qInstance);
|
||||
QaCaptureRouteProvider.register(qInstance);
|
||||
}
|
||||
QaCaptureRuntime.setCurrentInstance(qInstance);
|
||||
|
||||
LOG.info("QA capture registered", logPair("environment", qBitConfig.getEnvironment()), logPair("app", qBitConfig.getAppName()),
|
||||
logPair("parentApp", qBitConfig.getParentAppName()), logPair("providers", qBitConfig.getProviders().keySet()),
|
||||
logPair("routes", qBitConfig.getRegisterRoutes()));
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.time.Clock;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import com.kingsrook.qqq.backend.core.context.CapturedContext;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSystemUserSession;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The QBit's state on a QInstance (supplemental meta-data "qaCapture"): its
|
||||
** config and clock. It also remembers the most recent instance the QBit was
|
||||
** produced into or served from, for host hooks that are not handed one (the
|
||||
** Next dashboard security headers customizer).
|
||||
*******************************************************************************/
|
||||
public class QaCaptureRuntime implements QSupplementalInstanceMetaData
|
||||
{
|
||||
public static final String NAME = "qaCapture";
|
||||
|
||||
private static final AtomicReference<QInstance> CURRENT = new AtomicReference<>();
|
||||
|
||||
private final QaCaptureQBitConfig config;
|
||||
private Clock clock = Clock.systemUTC();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A unit of work run in the system context.
|
||||
*******************************************************************************/
|
||||
@FunctionalInterface
|
||||
public interface SystemWork<R>
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Do the work.
|
||||
*******************************************************************************/
|
||||
R run() throws QException;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public QaCaptureRuntime(QaCaptureQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime on an instance, or null when the QBit is not produced there.
|
||||
*******************************************************************************/
|
||||
public static QaCaptureRuntime find(QInstance qInstance)
|
||||
{
|
||||
return (qInstance == null ? null : QSupplementalInstanceMetaData.of(qInstance, NAME));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime on an instance.
|
||||
**
|
||||
** @throws IllegalStateException when the QBit is not produced there
|
||||
*******************************************************************************/
|
||||
public static QaCaptureRuntime of(QInstance qInstance)
|
||||
{
|
||||
QaCaptureRuntime runtime = find(qInstance);
|
||||
if(runtime == null)
|
||||
{
|
||||
throw (new IllegalStateException("qbit-qa-capture is not registered on this QInstance"));
|
||||
}
|
||||
return (runtime);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The runtime on the current context's instance.
|
||||
*******************************************************************************/
|
||||
public static QaCaptureRuntime current()
|
||||
{
|
||||
return (of(QContext.getQInstance()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remember the instance the QBit lives in now (production, server start and
|
||||
** hot swaps).
|
||||
*******************************************************************************/
|
||||
public static void setCurrentInstance(QInstance qInstance)
|
||||
{
|
||||
if(find(qInstance) != null)
|
||||
{
|
||||
CURRENT.set(qInstance);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The most recent instance the QBit lives in, or null.
|
||||
*******************************************************************************/
|
||||
public static QInstance getCurrentInstance()
|
||||
{
|
||||
return (CURRENT.get());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run work in the system context of an instance, restoring the caller's
|
||||
** context afterwards.
|
||||
*******************************************************************************/
|
||||
public static <R> R asSystem(QInstance qInstance, SystemWork<R> work) throws QException
|
||||
{
|
||||
Object[] result = new Object[1];
|
||||
QContext.withTemporaryContext(new CapturedContext(qInstance, new QSystemUserSession(qInstance)), () ->
|
||||
{
|
||||
result[0] = work.run();
|
||||
});
|
||||
@SuppressWarnings("unchecked")
|
||||
R typed = (R) result[0];
|
||||
return (typed);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for config
|
||||
*******************************************************************************/
|
||||
public QaCaptureQBitConfig getConfig()
|
||||
{
|
||||
return (this.config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for clock
|
||||
*******************************************************************************/
|
||||
public Clock getClock()
|
||||
{
|
||||
return (this.clock);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Setter for clock (tests).
|
||||
*******************************************************************************/
|
||||
public void setClock(Clock clock)
|
||||
{
|
||||
this.clock = clock == null ? Clock.systemUTC() : clock;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Names shared by the metadata, the runtime and the Liquibase changelog
|
||||
** (db/liquibase/qbit-qa-capture/qa_capture.changelog.sql): the four tables,
|
||||
** their stored enum values and the permission names. QQQ table names equal
|
||||
** the backend (SQL) names.
|
||||
*******************************************************************************/
|
||||
public final class QaCaptureSchema
|
||||
{
|
||||
public static final String CHANGELOG = "db/liquibase/qbit-qa-capture/qa_capture.changelog.sql";
|
||||
|
||||
public static final String SETTINGS = "qa_capture_settings";
|
||||
public static final String AUDIENCE = "qa_capture_audience";
|
||||
public static final String SANITIZER_RULE = "qa_capture_sanitizer_rule";
|
||||
public static final String REPORT = "qa_capture_report";
|
||||
|
||||
/** every table of the QBit, in navigation order. */
|
||||
public static final List<String> TABLES = List.of(REPORT, SETTINGS, AUDIENCE, SANITIZER_RULE);
|
||||
|
||||
public static final String PERMISSION_MANAGE = "qa_capture.manage";
|
||||
public static final String PERMISSION_USE_QA_MODE = "qa_capture.use_qa_mode";
|
||||
public static final String PERMISSION_VIEW_REPORTS = "qa_capture.view_reports";
|
||||
|
||||
/** every permission of the QBit (separately grantable). */
|
||||
public static final List<String> PERMISSIONS = List.of(PERMISSION_MANAGE, PERMISSION_USE_QA_MODE, PERMISSION_VIEW_REPORTS);
|
||||
|
||||
public static final String PVS_PROVIDER = "qa_capture_provider";
|
||||
public static final String PVS_MODE = "qa_capture_mode";
|
||||
public static final String PVS_AUDIENCE_TYPE = "qa_capture_audience_type";
|
||||
public static final String PVS_AUDIENCE_SOURCE = "qa_capture_audience_source";
|
||||
public static final String PVS_RULE_TYPE = "qa_capture_rule_type";
|
||||
public static final String PVS_RULE_SOURCE = "qa_capture_rule_source";
|
||||
public static final String PVS_REPORT_STATUS = "qa_capture_report_status";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constants only.
|
||||
*******************************************************************************/
|
||||
private QaCaptureSchema()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Capture modes of a settings row.
|
||||
*******************************************************************************/
|
||||
public enum Mode
|
||||
{
|
||||
OFF("Off"),
|
||||
AUDIENCE("Audience only"),
|
||||
SELF_SERVE("Self-serve QA mode"),
|
||||
AUDIENCE_AND_SELF_SERVE("Audience and self-serve QA mode");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
Mode(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether audience membership turns capture on.
|
||||
*******************************************************************************/
|
||||
public boolean includesAudience()
|
||||
{
|
||||
return (this == AUDIENCE || this == AUDIENCE_AND_SELF_SERVE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a user's own QA mode switch turns capture on.
|
||||
*******************************************************************************/
|
||||
public boolean includesSelfServe()
|
||||
{
|
||||
return (this == SELF_SERVE || this == AUDIENCE_AND_SELF_SERVE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The stored value, or OFF for null or an unknown value (fail closed).
|
||||
*******************************************************************************/
|
||||
public static Mode parse(String value)
|
||||
{
|
||||
for(Mode mode : values())
|
||||
{
|
||||
if(mode.name().equals(value))
|
||||
{
|
||||
return (mode);
|
||||
}
|
||||
}
|
||||
return (OFF);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Kinds of audience members.
|
||||
*******************************************************************************/
|
||||
public enum AudienceType
|
||||
{
|
||||
USER("User"),
|
||||
ROLE("Role");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
AudienceType(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where an audience row came from.
|
||||
*******************************************************************************/
|
||||
public enum AudienceSource
|
||||
{
|
||||
ADMIN("Administrator"),
|
||||
QA_MODE("User's QA mode switch");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
AudienceSource(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Kinds of sanitizer rules.
|
||||
*******************************************************************************/
|
||||
public enum RuleType
|
||||
{
|
||||
HEADER("Header"),
|
||||
BODY_FIELD("Body field"),
|
||||
QUERY_PARAM("Query parameter"),
|
||||
VALUE_PATTERN("Value pattern"),
|
||||
DROP_PATH("Dropped path prefix"),
|
||||
BLUR_SELECTOR("Blur selector");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
RuleType(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Where a sanitizer rule came from.
|
||||
*******************************************************************************/
|
||||
public enum RuleSource
|
||||
{
|
||||
BUILT_IN("Built in"),
|
||||
SECURED_DATA("Secured data"),
|
||||
ADMIN("Administrator");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
RuleSource(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Lifecycle of a QA report.
|
||||
*******************************************************************************/
|
||||
public enum ReportStatus
|
||||
{
|
||||
OPEN("Open"),
|
||||
CAPTURED("Captured"),
|
||||
FILED("Filed"),
|
||||
CLOSED("Closed");
|
||||
|
||||
private final String label;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
ReportStatus(String label)
|
||||
{
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for label
|
||||
*******************************************************************************/
|
||||
public String getLabel()
|
||||
{
|
||||
return (this.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.api;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /qqq/v1/qbit/qa-capture/capability: what QA capture means for the
|
||||
** signed-in user. Never 403 for "not allowed": enabled=false with a reason.
|
||||
*******************************************************************************/
|
||||
public class CapabilitySpec extends QaCaptureJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "qaCaptureCapability";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.GET).withPath(QaCaptureUiApi.PATH_PREFIX + "/capability")
|
||||
.withShortSummary("Read the QA capture capability")
|
||||
.withLongDescription("Whether QA capture is on for the signed-in user and why, the provider settings, sanitizer rules, blur selectors, "
|
||||
+ "Content-Security-Policy origins and server context. Requires a session; a user without access gets enabled=false, never 403."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return (QaCaptureSchemas.CAPABILITY);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish the shared components once (the extension's specs register
|
||||
** together).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Schema> defineComponentSchemas()
|
||||
{
|
||||
return (QaCaptureSchemas.components());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (service().capability(QContext.getQSession()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureService;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicResponse;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.AbstractJsonEndpointSpec;
|
||||
import com.kingsrook.qqq.openapi.model.Method;
|
||||
import io.javalin.http.HttpStatus;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared JSON transport of the qa-capture operations; QaCaptureService owns
|
||||
** the behavior and decides who may do what.
|
||||
*******************************************************************************/
|
||||
abstract class QaCaptureJsonSpec extends AbstractJsonEndpointSpec
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The stable operation identifier exposed to clients.
|
||||
*******************************************************************************/
|
||||
protected abstract String operationId();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Publish operation identifiers in both the fragment and served document.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Method defineMethod()
|
||||
{
|
||||
return (super.defineMethod().withOperationId(operationId()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Error responses.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<BasicResponse> defineAdditionalBasicResponses()
|
||||
{
|
||||
return (List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid input", QaCaptureSchemas.ERROR),
|
||||
new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign in required", QaCaptureSchemas.ERROR),
|
||||
new BasicResponse(HttpStatus.FORBIDDEN, "QA capture is not enabled for this user", QaCaptureSchemas.ERROR)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The service on the caller's instance.
|
||||
*******************************************************************************/
|
||||
protected QaCaptureService service()
|
||||
{
|
||||
return (new QaCaptureService(QContext.getQInstance()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.api;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureEnablement;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
import com.kingsrook.qqq.openapi.model.Type;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The versioned wire schemas of the qa-capture UI API (contract v1.0.0),
|
||||
** independent of the tables.
|
||||
*******************************************************************************/
|
||||
public final class QaCaptureSchemas
|
||||
{
|
||||
public static final String CAPABILITY = "QaCaptureCapabilityV1";
|
||||
public static final String PROVIDER_CONFIG = "QaCaptureProviderConfigV1";
|
||||
public static final String QA_MODE = "QaCaptureQaModeV1";
|
||||
public static final String SANITIZER = "QaCaptureSanitizerV1";
|
||||
public static final String CSP_ORIGINS = "QaCaptureCspOriginsV1";
|
||||
public static final String CONTEXT = "QaCaptureContextV1";
|
||||
public static final String REPORT_REQUEST = "QaCaptureReportRequestV1";
|
||||
public static final String REPORT_RESULT = "QaCaptureReportResultV1";
|
||||
public static final String ERROR = "QaCaptureErrorV1";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QaCaptureSchemas()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every component schema (both operations publish the same set).
|
||||
*******************************************************************************/
|
||||
public static Map<String, Schema> components()
|
||||
{
|
||||
Map<String, Schema> components = new TreeMap<>();
|
||||
components.put(CAPABILITY, object("What QA capture means for the signed-in user. Clients that see enabled=false do nothing else.", Map.ofEntries(
|
||||
Map.entry("enabled", bool("Whether capture is on for this user.")),
|
||||
Map.entry("reason", new Schema().withType(Type.STRING).withEnumValues(reasons()).withDescription("Why capture is on or off.")),
|
||||
Map.entry("provider", string("The provider name (for example jam); null when not enabled.").withNullable(true)),
|
||||
Map.entry("providerConfig", nullableRef(PROVIDER_CONFIG).withDescription("Provider settings; null when not enabled.")),
|
||||
Map.entry("qaMode", ref(QA_MODE)),
|
||||
Map.entry("canReport", bool("Whether the user can file a report (Report a bug).")),
|
||||
Map.entry("reportsHref", string("The QA reports screen, only when the user can view reports. Clients follow it, never build it.").withNullable(true)),
|
||||
Map.entry("sanitizer", nullableRef(SANITIZER).withDescription("Redaction rules; null when not enabled.")),
|
||||
Map.entry("blurSelectors", stringArray("CSS selectors to blur in recordings; empty when not enabled.")),
|
||||
Map.entry("cspOrigins", ref(CSP_ORIGINS)),
|
||||
Map.entry("context", ref(CONTEXT)),
|
||||
Map.entry("requestIdHeader", string("The response header carrying the request id (X-Request-Id).")))));
|
||||
components.put(PROVIDER_CONFIG, object("Provider settings for the browser. For Jam: teamId, recordingLinkId and the script URLs.", Map.ofEntries(
|
||||
Map.entry("teamId", string("The provider team id.")),
|
||||
Map.entry("recordingLinkId", string("A reusable recording link id for in-app Report a bug; null when none is set.").withNullable(true)),
|
||||
Map.entry("scriptUrls", stringArray("The provider's scripts, in load order.")))));
|
||||
components.put(QA_MODE, object("The user's self-serve QA mode switch.", Map.ofEntries(
|
||||
Map.entry("available", bool("Whether the user can switch QA mode (self-serve mode and the qa_capture.use_qa_mode permission).")),
|
||||
Map.entry("active", bool("Whether the user's QA mode is on.")),
|
||||
Map.entry("preferenceSection", string("The user preference section holding the switch; null when unavailable.").withNullable(true)))));
|
||||
components.put(SANITIZER, object("Redaction rules applied in the browser before anything is sent to the provider.", Map.ofEntries(
|
||||
Map.entry("redaction", string("The replacement text.")),
|
||||
Map.entry("headerNames", stringArray("Lowercase header names deleted from request and response headers.")),
|
||||
Map.entry("bodyFieldNames", stringArray("JSON keys (any depth, case-insensitive) and form fields replaced by the redaction text.")),
|
||||
Map.entry("queryParamNames", stringArray("Query parameters redacted in request URLs and the page URL.")),
|
||||
Map.entry("valuePatterns", stringArray("JavaScript RegExp sources applied with flag g to bodies and console arguments.")),
|
||||
Map.entry("dropPathPrefixes", stringArray("Same-origin path prefixes whose request and response bodies are dropped.")))));
|
||||
components.put(CSP_ORIGINS, object("Content-Security-Policy origins the provider needs; empty when not enabled.", Map.ofEntries(
|
||||
Map.entry("scriptSrc", stringArray("script-src sources.")),
|
||||
Map.entry("frameSrc", stringArray("frame-src sources.")),
|
||||
Map.entry("connectSrc", stringArray("connect-src sources.")))));
|
||||
components.put(CONTEXT, object("Server context for capture metadata.", Map.ofEntries(
|
||||
Map.entry("environment", string("The environment name.")),
|
||||
Map.entry("backendVersion", string("The backend build version.").withNullable(true)),
|
||||
Map.entry("node", string("The serving node.").withNullable(true)))));
|
||||
components.put(REPORT_REQUEST, object("A new QA report. Only pageUrl is required.", Map.ofEntries(
|
||||
Map.entry("pageUrl", string("The page the user is on; sanitizer query parameters are removed.")),
|
||||
Map.entry("title", string("A short title; defaults to the table and record, process, app or page.").withNullable(true)),
|
||||
Map.entry("appName", string("The current app.").withNullable(true)),
|
||||
Map.entry("tableName", string("The current table.").withNullable(true)),
|
||||
Map.entry("recordId", string("The current record id.").withNullable(true)),
|
||||
Map.entry("processName", string("The current process.").withNullable(true)),
|
||||
Map.entry("requestIds", stringArray("Recent X-Request-Id values (at most 50 are kept, each at most 128 characters).").withNullable(true)),
|
||||
Map.entry("frontendVersion", string("The client build version.").withNullable(true)),
|
||||
Map.entry("userAgent", string("The browser user agent.").withNullable(true)),
|
||||
Map.entry("viewport", string("The viewport size, for example 1440x900.").withNullable(true)))));
|
||||
components.put(REPORT_RESULT, object("The created report and how to open the provider's recorder for it.", Map.ofEntries(
|
||||
Map.entry("id", new Schema().withType(Type.INTEGER).withDescription("The report id.")),
|
||||
Map.entry("reference", string("The report reference (qa-<uuid>), matched by the provider webhook.")),
|
||||
Map.entry("title", string("The report title.")),
|
||||
Map.entry("recordingParams", new Schema().withType(Type.OBJECT).withAdditionalProperties(new Schema().withType(Type.STRING)).withNullable(true)
|
||||
.withDescription("Query parameters that open the recorder (Jam: jam-recording, jam-title, jam-reference); null when no recording link is configured.")))));
|
||||
components.put(ERROR, object("An error.", Map.ofEntries(
|
||||
Map.entry("error", string("What went wrong.")))));
|
||||
return (components);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static List<String> reasons()
|
||||
{
|
||||
return (java.util.Arrays.stream(QaCaptureEnablement.Reason.values()).map(Enum::name).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Schema object(String description, Map<String, Schema> properties)
|
||||
{
|
||||
return (new Schema().withType(Type.OBJECT).withDescription(description).withProperties(new TreeMap<>(properties)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Schema string(String description)
|
||||
{
|
||||
return (new Schema().withType(Type.STRING).withDescription(description));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Schema bool(String description)
|
||||
{
|
||||
return (new Schema().withType(Type.BOOLEAN).withDescription(description));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Schema stringArray(String description)
|
||||
{
|
||||
return (new Schema().withType(Type.ARRAY).withItems(new Schema().withType(Type.STRING)).withDescription(description));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A nullable reference (allOf, since siblings of $ref are ignored).
|
||||
*******************************************************************************/
|
||||
private static Schema nullableRef(String name)
|
||||
{
|
||||
return (new Schema().withAllOf(List.of(ref(name))).withNullable(true));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static Schema ref(String name)
|
||||
{
|
||||
return (new Schema().withRefToSchema(name));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtension;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The qa-capture UI API extension (contract v1.0.0): independently
|
||||
** versioned, discoverable through /qqq/v1/version, served under
|
||||
** /qqq/v1/qbit/qa-capture. Committed fragment:
|
||||
** src/main/resources/openapi/qqq-v1/qa-capture.yaml.
|
||||
*******************************************************************************/
|
||||
public final class QaCaptureUiApi
|
||||
{
|
||||
public static final String NAME = "qa-capture";
|
||||
public static final String SEMANTIC_VERSION = "1.0.0";
|
||||
public static final String PATH_PREFIX = "/qbit/" + NAME;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QaCaptureUiApi()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the extension (idempotent: same name replaces).
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
{
|
||||
MiddlewareSpecExtensionsMetaData.ofOrWithNew(qInstance).withExtension(extension());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The extension.
|
||||
*******************************************************************************/
|
||||
public static MiddlewareSpecExtension extension()
|
||||
{
|
||||
return (new MiddlewareSpecExtension().withName(NAME).withSemanticVersion(SEMANTIC_VERSION)
|
||||
.withDescription("QA bug capture: the user's capture capability and QA report creation")
|
||||
.withSpec(CapabilitySpec.class).withSpec(ReportCreateSpec.class));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.api;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.BasicOperation;
|
||||
import com.kingsrook.qqq.openapi.model.HttpMethod;
|
||||
import com.kingsrook.qqq.openapi.model.Schema;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /qqq/v1/qbit/qa-capture/report: create a QA report for the signed-in
|
||||
** user and return how to open the provider's recorder for it. 403 when
|
||||
** capture is not on for the user.
|
||||
*******************************************************************************/
|
||||
public class ReportCreateSpec extends QaCaptureJsonSpec
|
||||
{
|
||||
public static final String OPERATION_ID = "qaCaptureReportCreate";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Method and path.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public BasicOperation defineBasicOperation()
|
||||
{
|
||||
return (new BasicOperation().withHttpMethod(HttpMethod.POST).withPath(QaCaptureUiApi.PATH_PREFIX + "/report")
|
||||
.withShortSummary("Create a QA report")
|
||||
.withLongDescription("Stores the report (page, app, table, record, process, recent request ids, client build) with the user, environment, "
|
||||
+ "backend version, node and provider filled in by the server, and returns its reference and the recorder parameters."));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String operationId()
|
||||
{
|
||||
return (OPERATION_ID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Schema defineRequestBodySchema()
|
||||
{
|
||||
return (new Schema().withRefToSchema(QaCaptureSchemas.REPORT_REQUEST));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected String getResponseSchemaName()
|
||||
{
|
||||
return (QaCaptureSchemas.REPORT_RESULT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
protected Object handle(JsonInput input) throws Exception
|
||||
{
|
||||
return (service().createReport(QContext.getQSession(), input.getBody()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.metadata;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureQBitConfig;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Applies the table and app help of help/qbit-qa-capture.yaml (the QQQ
|
||||
** help-bundle format) directly to the metadata: what each table is for and
|
||||
** how its rows get created, shown on the table screen and when it is empty,
|
||||
** even without the host's help tables. A host's own help is kept. The
|
||||
** producer also registers the bundle with the core registry.
|
||||
*******************************************************************************/
|
||||
public final class QaCaptureHelp
|
||||
{
|
||||
public static final String RESOURCE = "help/qbit-qa-capture.yaml";
|
||||
public static final String BUNDLE_ID = "qbit-qa-capture";
|
||||
public static final String SLOT = "help";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QaCaptureHelp()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundle's table and app help entries by key (fails if malformed).
|
||||
*******************************************************************************/
|
||||
public static Map<String, JsonNode> entries() throws QException
|
||||
{
|
||||
try(InputStream stream = QaCaptureHelp.class.getClassLoader().getResourceAsStream(RESOURCE))
|
||||
{
|
||||
if(stream == null)
|
||||
{
|
||||
throw (new IOException("missing resource"));
|
||||
}
|
||||
JsonNode bundle = new ObjectMapper(new YAMLFactory()).readTree(stream);
|
||||
if(!BUNDLE_ID.equals(bundle.path("bundle").asText()) || !"QBIT".equals(bundle.path("layer").asText()))
|
||||
{
|
||||
throw (new IOException("invalid bundle header"));
|
||||
}
|
||||
Map<String, JsonNode> entries = new LinkedHashMap<>();
|
||||
for(JsonNode entry : bundle.path("entries"))
|
||||
{
|
||||
String key = entry.path("key").asText();
|
||||
if(!key.endsWith(";slot:help") || !(key.startsWith("table:") || key.startsWith("app:")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String summary = entry.path("summary").asText();
|
||||
if(summary.isBlank() || summary.length() > 120 || entry.path("content").asText().isBlank() || entries.put(key, entry) != null)
|
||||
{
|
||||
throw (new IOException("invalid or duplicate entry: " + key));
|
||||
}
|
||||
}
|
||||
return (entries);
|
||||
}
|
||||
catch(IOException | RuntimeException e)
|
||||
{
|
||||
throw (new QException("Could not load " + RESOURCE, e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fill the help slot of the app and the qa_capture_ tables (only where the
|
||||
** host has none).
|
||||
*******************************************************************************/
|
||||
public static void apply(QInstance qInstance, QaCaptureQBitConfig config) throws QException
|
||||
{
|
||||
for(Map.Entry<String, JsonNode> entry : entries().entrySet())
|
||||
{
|
||||
String key = entry.getKey();
|
||||
String name = key.substring(key.indexOf(':') + 1, key.indexOf(';'));
|
||||
if(key.startsWith("table:"))
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(name);
|
||||
if(table != null && (table.getHelpContent() == null || !table.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
table.withHelpContent(SLOT, content(entry.getValue()));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
QAppMetaData app = qInstance.getApp(config.getAppName());
|
||||
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
|
||||
{
|
||||
app.withHelpContent(SLOT, content(entry.getValue()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Summary as the first paragraph, then the content, as markdown.
|
||||
*******************************************************************************/
|
||||
static QHelpContent content(JsonNode entry)
|
||||
{
|
||||
return (new QHelpContent().withContentAsMarkdown(entry.path("summary").asText().strip() + "\n\n" + entry.path("content").asText().strip()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.metadata;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureQBitConfig;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.AudienceSource;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.AudienceType;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.Mode;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.ReportStatus;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.RuleSource;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.RuleType;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureProvider;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
|
||||
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.fields.AdornmentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.FieldAdornment;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The QBit's tables, possible-value sources and app. Field names are
|
||||
** camelCase with snake_case backend names; table names equal the backend
|
||||
** names. Settings, audience and sanitizer rules need qa_capture.manage;
|
||||
** reports need qa_capture.view_reports (or manage). Nothing is hidden.
|
||||
*******************************************************************************/
|
||||
public class QaCaptureMetaData
|
||||
{
|
||||
private final QaCaptureQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
*******************************************************************************/
|
||||
public QaCaptureMetaData(QaCaptureQBitConfig config)
|
||||
{
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add everything to the instance.
|
||||
*******************************************************************************/
|
||||
public void addTo(QInstance qInstance)
|
||||
{
|
||||
addPossibleValueSources(qInstance);
|
||||
qInstance.addTable(settings());
|
||||
qInstance.addTable(audience());
|
||||
qInstance.addTable(sanitizerRule());
|
||||
qInstance.addTable(report());
|
||||
qInstance.addPossibleValueSource(QPossibleValueSource.newForTable(QaCaptureSchema.SETTINGS));
|
||||
|
||||
QAppMetaData app = new QAppMetaData()
|
||||
.withName(config.getAppName())
|
||||
.withLabel(config.getAppLabel())
|
||||
.withIcon(new QIcon("bug_report"))
|
||||
.withPermissionRules(QaCapturePermissionChecker.rules(QaCaptureSchema.PERMISSION_VIEW_REPORTS, QaCaptureSchema.PERMISSION_MANAGE));
|
||||
for(String table : QaCaptureSchema.TABLES)
|
||||
{
|
||||
app.withChild(qInstance.getTable(table));
|
||||
}
|
||||
app.withSection(new QAppSection("qaCapture", config.getAppLabel(), new QIcon("bug_report"), new ArrayList<>(QaCaptureSchema.TABLES), null, null));
|
||||
qInstance.addApp(app);
|
||||
if(config.getParentAppName() != null)
|
||||
{
|
||||
qInstance.getApp(config.getParentAppName()).withChild(app);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Enum sources for the stored values, and the registered providers.
|
||||
*******************************************************************************/
|
||||
private void addPossibleValueSources(QInstance qInstance)
|
||||
{
|
||||
List<QPossibleValue<?>> providers = new ArrayList<>();
|
||||
for(QaCaptureProvider provider : config.getProviders().values())
|
||||
{
|
||||
providers.add(new QPossibleValue<>(provider.getName(), provider.getLabel()));
|
||||
}
|
||||
qInstance.addPossibleValueSource(enumSource(QaCaptureSchema.PVS_PROVIDER, providers));
|
||||
|
||||
List<QPossibleValue<?>> modes = new ArrayList<>();
|
||||
for(Mode mode : Mode.values())
|
||||
{
|
||||
modes.add(new QPossibleValue<>(mode.name(), mode.getLabel()));
|
||||
}
|
||||
qInstance.addPossibleValueSource(enumSource(QaCaptureSchema.PVS_MODE, modes));
|
||||
|
||||
List<QPossibleValue<?>> audienceTypes = new ArrayList<>();
|
||||
for(AudienceType type : AudienceType.values())
|
||||
{
|
||||
audienceTypes.add(new QPossibleValue<>(type.name(), type.getLabel()));
|
||||
}
|
||||
qInstance.addPossibleValueSource(enumSource(QaCaptureSchema.PVS_AUDIENCE_TYPE, audienceTypes));
|
||||
|
||||
List<QPossibleValue<?>> audienceSources = new ArrayList<>();
|
||||
for(AudienceSource source : AudienceSource.values())
|
||||
{
|
||||
audienceSources.add(new QPossibleValue<>(source.name(), source.getLabel()));
|
||||
}
|
||||
qInstance.addPossibleValueSource(enumSource(QaCaptureSchema.PVS_AUDIENCE_SOURCE, audienceSources));
|
||||
|
||||
List<QPossibleValue<?>> ruleTypes = new ArrayList<>();
|
||||
for(RuleType type : RuleType.values())
|
||||
{
|
||||
ruleTypes.add(new QPossibleValue<>(type.name(), type.getLabel()));
|
||||
}
|
||||
qInstance.addPossibleValueSource(enumSource(QaCaptureSchema.PVS_RULE_TYPE, ruleTypes));
|
||||
|
||||
List<QPossibleValue<?>> ruleSources = new ArrayList<>();
|
||||
for(RuleSource source : RuleSource.values())
|
||||
{
|
||||
ruleSources.add(new QPossibleValue<>(source.name(), source.getLabel()));
|
||||
}
|
||||
qInstance.addPossibleValueSource(enumSource(QaCaptureSchema.PVS_RULE_SOURCE, ruleSources));
|
||||
|
||||
List<QPossibleValue<?>> statuses = new ArrayList<>();
|
||||
for(ReportStatus status : ReportStatus.values())
|
||||
{
|
||||
statuses.add(new QPossibleValue<>(status.name(), status.getLabel()));
|
||||
}
|
||||
qInstance.addPossibleValueSource(enumSource(QaCaptureSchema.PVS_REPORT_STATUS, statuses));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** qa_capture_settings.
|
||||
*******************************************************************************/
|
||||
QTableMetaData settings()
|
||||
{
|
||||
return (table(QaCaptureSchema.SETTINGS, "QA Capture Settings", "tune", QaCaptureSchema.PERMISSION_MANAGE)
|
||||
.withRecordLabelFormatAndFields("%s", "environment")
|
||||
.withUniqueKey(new UniqueKey("environment"))
|
||||
.withField(field("environment", QFieldType.STRING).withLabel("Environment").withIsRequired(true).withMaxLength(50))
|
||||
.withField(field("provider", QFieldType.STRING).withLabel("Provider").withIsRequired(true).withMaxLength(50)
|
||||
.withPossibleValueSourceName(QaCaptureSchema.PVS_PROVIDER).withDefaultValue(config.getProviders().keySet().iterator().next()))
|
||||
.withField(field("teamId", QFieldType.STRING).withLabel("Team ID").withMaxLength(100))
|
||||
.withField(field("recordingLinkId", QFieldType.STRING).withLabel("Recording Link ID").withMaxLength(100))
|
||||
.withField(field("mode", QFieldType.STRING).withLabel("Mode").withIsRequired(true).withMaxLength(30)
|
||||
.withPossibleValueSourceName(QaCaptureSchema.PVS_MODE).withDefaultValue(Mode.OFF.name()))
|
||||
.withField(field("extraBlurSelectors", QFieldType.TEXT).withLabel("Extra Blur Selectors"))
|
||||
.withField(field("notes", QFieldType.TEXT).withLabel("Notes"))
|
||||
.withSection(section("identity", "Settings", "tune", Tier.T1, "id", "environment", "provider", "mode"))
|
||||
.withSection(section("provider", "Provider", "link", Tier.T2, "teamId", "recordingLinkId", "extraBlurSelectors", "notes"))
|
||||
.withSection(section("dates", "Dates", "calendar_month", Tier.T3, "createDate", "modifyDate"))
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(QaCaptureWriteValidator.class))
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(QaCaptureWriteValidator.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** qa_capture_audience.
|
||||
*******************************************************************************/
|
||||
QTableMetaData audience()
|
||||
{
|
||||
return (table(QaCaptureSchema.AUDIENCE, "QA Capture Audience", "group", QaCaptureSchema.PERMISSION_MANAGE)
|
||||
.withRecordLabelFormatAndFields("%s %s", "audienceType", "value")
|
||||
.withUniqueKey(new UniqueKey("settingsId", "audienceType", "value", "source"))
|
||||
.withField(field("settingsId", QFieldType.INTEGER).withLabel("Settings").withIsRequired(true).withPossibleValueSourceName(QaCaptureSchema.SETTINGS))
|
||||
.withField(field("audienceType", QFieldType.STRING).withLabel("Audience Type").withIsRequired(true).withMaxLength(10)
|
||||
.withPossibleValueSourceName(QaCaptureSchema.PVS_AUDIENCE_TYPE).withDefaultValue(AudienceType.USER.name()))
|
||||
.withField(field("value", QFieldType.STRING).withLabel("Value").withIsRequired(true).withMaxLength(250))
|
||||
.withField(field("source", QFieldType.STRING).withLabel("Source").withIsRequired(true).withMaxLength(20)
|
||||
.withPossibleValueSourceName(QaCaptureSchema.PVS_AUDIENCE_SOURCE).withDefaultValue(AudienceSource.ADMIN.name()))
|
||||
.withField(field("active", QFieldType.BOOLEAN).withLabel("Active").withIsRequired(true).withDefaultValue(true))
|
||||
.withField(field("notes", QFieldType.TEXT).withLabel("Notes"))
|
||||
.withSection(section("member", "Member", "group", Tier.T1, "id", "settingsId", "audienceType", "value", "source", "active", "notes"))
|
||||
.withSection(section("dates", "Dates", "calendar_month", Tier.T3, "createDate", "modifyDate"))
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(QaCaptureWriteValidator.class))
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(QaCaptureWriteValidator.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** qa_capture_sanitizer_rule.
|
||||
*******************************************************************************/
|
||||
QTableMetaData sanitizerRule()
|
||||
{
|
||||
return (table(QaCaptureSchema.SANITIZER_RULE, "QA Capture Sanitizer Rules", "shield", QaCaptureSchema.PERMISSION_MANAGE)
|
||||
.withRecordLabelFormatAndFields("%s %s", "ruleType", "value")
|
||||
.withUniqueKey(new UniqueKey("ruleType", "value"))
|
||||
.withField(field("ruleType", QFieldType.STRING).withLabel("Rule Type").withIsRequired(true).withMaxLength(20)
|
||||
.withPossibleValueSourceName(QaCaptureSchema.PVS_RULE_TYPE))
|
||||
.withField(field("value", QFieldType.STRING).withLabel("Value").withIsRequired(true).withMaxLength(500))
|
||||
.withField(field("source", QFieldType.STRING).withLabel("Source").withIsRequired(true).withMaxLength(20)
|
||||
.withPossibleValueSourceName(QaCaptureSchema.PVS_RULE_SOURCE).withDefaultValue(RuleSource.ADMIN.name()))
|
||||
.withField(field("sourceRef", QFieldType.STRING).withLabel("Source Reference").withMaxLength(250))
|
||||
.withField(field("active", QFieldType.BOOLEAN).withLabel("Active").withIsRequired(true).withDefaultValue(true))
|
||||
.withField(field("description", QFieldType.STRING).withLabel("Description").withMaxLength(500))
|
||||
.withSection(section("rule", "Rule", "shield", Tier.T1, "id", "ruleType", "value", "active", "source", "sourceRef", "description"))
|
||||
.withSection(section("dates", "Dates", "calendar_month", Tier.T3, "createDate", "modifyDate"))
|
||||
.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(QaCaptureWriteValidator.class))
|
||||
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(QaCaptureWriteValidator.class)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** qa_capture_report.
|
||||
*******************************************************************************/
|
||||
QTableMetaData report()
|
||||
{
|
||||
return (table(QaCaptureSchema.REPORT, "QA Reports", "bug_report", QaCaptureSchema.PERMISSION_VIEW_REPORTS, QaCaptureSchema.PERMISSION_MANAGE)
|
||||
.withRecordLabelFormatAndFields("%s", "title")
|
||||
.withUniqueKey(new UniqueKey("reference"))
|
||||
.withUniqueKey(new UniqueKey("jamId"))
|
||||
.withField(readOnly("reference", "Reference", 50).withIsRequired(true))
|
||||
.withField(field("title", QFieldType.STRING).withLabel("Title").withMaxLength(250))
|
||||
.withField(field("status", QFieldType.STRING).withLabel("Status").withIsRequired(true).withMaxLength(20)
|
||||
.withPossibleValueSourceName(QaCaptureSchema.PVS_REPORT_STATUS).withDefaultValue(ReportStatus.OPEN.name()))
|
||||
.withField(readOnly("pageUrl", "Page URL", 2000))
|
||||
.withField(readOnly("appName", "App", 100))
|
||||
.withField(readOnly("tableName", "Table", 100))
|
||||
.withField(readOnly("recordId", "Record ID", 100))
|
||||
.withField(readOnly("processName", "Process", 100))
|
||||
.withField(field("requestIds", QFieldType.TEXT).withLabel("Request IDs").withIsEditable(false))
|
||||
.withField(readOnly("userId", "User ID", 250))
|
||||
.withField(readOnly("userLabel", "User", 250))
|
||||
.withField(readOnly("environment", "Environment", 50))
|
||||
.withField(readOnly("provider", "Provider", 50).withPossibleValueSourceName(QaCaptureSchema.PVS_PROVIDER))
|
||||
.withField(readOnly("frontendVersion", "Frontend Version", 100))
|
||||
.withField(readOnly("backendVersion", "Backend Version", 100))
|
||||
.withField(readOnly("node", "Node", 250))
|
||||
.withField(readOnly("userAgent", "User Agent", 1000))
|
||||
.withField(readOnly("viewport", "Viewport", 30))
|
||||
.withField(readOnly("jamId", "Jam ID", 100))
|
||||
.withField(readOnly("jamUrl", "Jam", 1000).withFieldAdornment(link()))
|
||||
.withField(field("githubIssueUrl", QFieldType.STRING).withLabel("GitHub Issue").withMaxLength(1000).withFieldAdornment(link()))
|
||||
.withField(field("submitterComment", QFieldType.TEXT).withLabel("Submitter Comment"))
|
||||
.withSection(section("report", "Report", "bug_report", Tier.T1, "id", "reference", "title", "status", "jamUrl", "githubIssueUrl", "submitterComment"))
|
||||
.withSection(section("where", "Where", "place", Tier.T2, "pageUrl", "appName", "tableName", "recordId", "processName", "requestIds"))
|
||||
.withSection(section("who", "Who and Build", "person", Tier.T2, "userId", "userLabel", "environment", "provider", "jamId",
|
||||
"frontendVersion", "backendVersion", "node", "userAgent", "viewport"))
|
||||
.withSection(section("dates", "Dates", "calendar_month", Tier.T3, "createDate", "modifyDate")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A table with id and dates, gated on the permissions.
|
||||
*******************************************************************************/
|
||||
private QTableMetaData table(String name, String label, String icon, String... permissions)
|
||||
{
|
||||
return (new QTableMetaData()
|
||||
.withName(name)
|
||||
.withLabel(label)
|
||||
.withBackendName(config.getBackendName())
|
||||
.withIcon(new QIcon(icon))
|
||||
.withPrimaryKeyField("id")
|
||||
.withPermissionRules(QaCapturePermissionChecker.rules(permissions))
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("ID").withIsEditable(false))
|
||||
.withField(field("createDate", QFieldType.DATE_TIME).withLabel("Create Date").withIsEditable(false))
|
||||
.withField(field("modifyDate", QFieldType.DATE_TIME).withLabel("Modify Date").withIsEditable(false)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A field with its snake_case backend name.
|
||||
*******************************************************************************/
|
||||
static QFieldMetaData field(String name, QFieldType type)
|
||||
{
|
||||
return (new QFieldMetaData(name, type).withBackendName(snakeCase(name)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A read-only string field.
|
||||
*******************************************************************************/
|
||||
private static QFieldMetaData readOnly(String name, String label, int maxLength)
|
||||
{
|
||||
return (field(name, QFieldType.STRING).withLabel(label).withMaxLength(maxLength).withIsEditable(false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A link opening in a new tab.
|
||||
*******************************************************************************/
|
||||
private static FieldAdornment link()
|
||||
{
|
||||
return (new FieldAdornment(AdornmentType.LINK).withValue(AdornmentType.LinkValues.TARGET, "_blank"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QFieldSection section(String name, String label, String icon, Tier tier, String... fields)
|
||||
{
|
||||
return (new QFieldSection(name, label, new QIcon(icon), tier, List.of(fields)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QPossibleValueSource enumSource(String name, List<QPossibleValue<?>> values)
|
||||
{
|
||||
return (new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.ENUM).withEnumValues(values));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** camelCase to snake_case.
|
||||
*******************************************************************************/
|
||||
static String snakeCase(String name)
|
||||
{
|
||||
return (name.replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(java.util.Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.metadata;
|
||||
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.AvailablePermission;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
|
||||
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionSubType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.InitializableViaCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReferenceWithProperties;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithName;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
|
||||
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;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Gates a QBit table or the app on the QBit's own permission names (any of
|
||||
** the listed ones; fail closed) instead of QQQ's per-table names, and lists
|
||||
** the first one among the instance's available permissions so hosts can
|
||||
** grant it. A QQQ custom checker applies to every operation of the table.
|
||||
*******************************************************************************/
|
||||
public class QaCapturePermissionChecker implements CustomPermissionChecker, InitializableViaCodeReference
|
||||
{
|
||||
public static final String PERMISSIONS_PROPERTY = "permissionNames";
|
||||
|
||||
private List<String> permissionNames = List.of();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rules requiring any of the named permissions.
|
||||
*******************************************************************************/
|
||||
public static QPermissionRules rules(String... permissionNames)
|
||||
{
|
||||
return (new QPermissionRules()
|
||||
.withLevel(PermissionLevel.HAS_ACCESS_PERMISSION)
|
||||
.withCustomPermissionChecker(new QCodeReferenceWithProperties(QaCapturePermissionChecker.class,
|
||||
Map.of(PERMISSIONS_PROPERTY, String.join(",", permissionNames)))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void initialize(QCodeReference codeReference)
|
||||
{
|
||||
if(codeReference instanceof QCodeReferenceWithProperties withProperties)
|
||||
{
|
||||
String names = ValueUtils.getValueAsString(withProperties.getProperties().get(PERMISSIONS_PROPERTY));
|
||||
this.permissionNames = names == null ? List.of() : Arrays.stream(names.split(",")).map(String::trim).filter(name -> !name.isEmpty()).toList();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Allow when the session holds any of the permissions.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void checkPermissionsThrowing(AbstractActionInput actionInput, MetaDataWithPermissionRules metaDataWithPermissionRules) throws QPermissionDeniedException
|
||||
{
|
||||
QSession session = QContext.getQSession();
|
||||
if(session == null || permissionNames.stream().noneMatch(session::hasPermission))
|
||||
{
|
||||
throw (new QPermissionDeniedException("Permission denied."));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public boolean handlesBuildAvailablePermission()
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first permission name (the one that governs the object).
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public AvailablePermission buildAvailablePermission(QPermissionRules rules, PermissionSubType permissionSubType, String baseName, MetaDataWithName metaDataWithName, String objectType)
|
||||
{
|
||||
AvailablePermission permission = buildBaseAvailablePermission(rules, permissionSubType, metaDataWithName, objectType);
|
||||
return (permission == null || permissionNames.isEmpty() ? null : permission.withName(permissionNames.get(0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.metadata;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.RuleType;
|
||||
import com.kingsrook.qbits.qacapture.service.SanitizerRules;
|
||||
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Validates and normalizes writes to the settings, audience and sanitizer
|
||||
** rule tables, from the UI, the API and the QBit alike:
|
||||
** - text values are trimmed;
|
||||
** - a sanitizer rule must be usable (SanitizerRules.validate: a value
|
||||
** pattern valid in Java and JavaScript, a drop path starting with "/",
|
||||
** a header name); header names are stored lowercase.
|
||||
*******************************************************************************/
|
||||
public class QaCaptureWriteValidator implements TableCustomizerInterface
|
||||
{
|
||||
private static final List<String> TRIMMED = List.of("environment", "teamId", "recordingLinkId", "value");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preInsert(InsertInput insertInput, List<QRecord> records, boolean isPreview) throws QException
|
||||
{
|
||||
return (check(insertInput.getTableName(), records, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<QRecord> preUpdate(UpdateInput updateInput, List<QRecord> records, boolean isPreview, Optional<List<QRecord>> oldRecordList) throws QException
|
||||
{
|
||||
return (check(updateInput.getTableName(), records, oldRecordList.orElse(null)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private List<QRecord> check(String tableName, List<QRecord> records, List<QRecord> oldRecords)
|
||||
{
|
||||
for(QRecord record : records)
|
||||
{
|
||||
for(String field : TRIMMED)
|
||||
{
|
||||
if(record.getValues().containsKey(field) && record.getValue(field) instanceof String text)
|
||||
{
|
||||
record.setValue(field, text.isBlank() ? null : text.trim());
|
||||
}
|
||||
}
|
||||
|
||||
if(QaCaptureSchema.SANITIZER_RULE.equals(tableName) && (record.getValues().containsKey("value") || record.getValues().containsKey("ruleType")))
|
||||
{
|
||||
QRecord old = findOld(oldRecords, record);
|
||||
String type = record.getValues().containsKey("ruleType") ? record.getValueString("ruleType") : old == null ? null : old.getValueString("ruleType");
|
||||
String value = record.getValues().containsKey("value") ? record.getValueString("value") : old == null ? null : old.getValueString("value");
|
||||
String problem = SanitizerRules.validate(type, value);
|
||||
if(problem != null)
|
||||
{
|
||||
record.addError(new BadInputStatusMessage(problem));
|
||||
}
|
||||
else if(RuleType.HEADER.name().equals(type) && record.getValues().containsKey("value"))
|
||||
{
|
||||
record.setValue("value", value.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (records);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QRecord findOld(List<QRecord> oldRecords, QRecord record)
|
||||
{
|
||||
if(oldRecords == null || record.getValue("id") == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String id = String.valueOf(record.getValue("id"));
|
||||
return (oldRecords.stream().filter(old -> id.equals(String.valueOf(old.getValue("id")))).findFirst().orElse(null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.service;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Standard QQQ table actions on the QBit's tables, in the caller's context
|
||||
** (the QBit runs them in the system context). Writes fail loudly when a
|
||||
** record comes back with errors.
|
||||
*******************************************************************************/
|
||||
public final class QaCaptureDb
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QaCaptureDb()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A filter: field equals value.
|
||||
*******************************************************************************/
|
||||
public static QQueryFilter eq(String field, Serializable value)
|
||||
{
|
||||
return (new QQueryFilter().withCriteria(field, QCriteriaOperator.EQUALS, value));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rows of a table matching a filter, by id.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> query(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
QQueryFilter ordered = (filter == null ? new QQueryFilter() : filter);
|
||||
if(CollectionUtils.nullSafeIsEmpty(ordered.getOrderBys()))
|
||||
{
|
||||
ordered.withOrderBy(new QFilterOrderBy("id"));
|
||||
}
|
||||
return (new QueryAction().execute(new QueryInput(table).withFilter(ordered)).getRecords());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first row matching a filter, or null.
|
||||
*******************************************************************************/
|
||||
public static QRecord first(String table, QQueryFilter filter) throws QException
|
||||
{
|
||||
List<QRecord> records = query(table, filter.withLimit(1));
|
||||
return (records.isEmpty() ? null : records.get(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert one row; returns it as stored.
|
||||
*******************************************************************************/
|
||||
public static QRecord insert(String table, QRecord record) throws QException
|
||||
{
|
||||
QRecord stored = new InsertAction().execute(new InsertInput(table).withRecord(record)).getRecords().get(0);
|
||||
failOnErrors(table, stored);
|
||||
return (stored);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Update one row by id; returns it as stored.
|
||||
*******************************************************************************/
|
||||
public static QRecord update(String table, QRecord record) throws QException
|
||||
{
|
||||
QRecord stored = new UpdateAction().execute(new UpdateInput(table).withRecord(record)).getRecords().get(0);
|
||||
failOnErrors(table, stored);
|
||||
return (stored);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void failOnErrors(String table, QRecord record) throws QException
|
||||
{
|
||||
if(CollectionUtils.nullSafeHasContents(record.getErrors()))
|
||||
{
|
||||
throw (new QException("Could not write " + table + ": " + record.getErrors()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.service;
|
||||
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureQBitConfig;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.AudienceSource;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.AudienceType;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.Mode;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureProvider;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether QA capture is on for a user, and why (the capability reason).
|
||||
**
|
||||
** Capture is on iff the environment's settings row has a mode other than
|
||||
** OFF, a team id and a registered provider, and either
|
||||
** - the mode includes AUDIENCE and an active administrator audience row
|
||||
** names the user (USER: user id or email, case-insensitive) or one of the
|
||||
** user's roles (ROLE: the config's role resolver, by default the session's
|
||||
** permission names, case-insensitive); or
|
||||
** - the mode includes SELF_SERVE, the user holds qa_capture.use_qa_mode and
|
||||
** their QA mode preference is on.
|
||||
**
|
||||
** Pure: callers load the settings, audience and QA mode state.
|
||||
*******************************************************************************/
|
||||
public final class QaCaptureEnablement
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QaCaptureEnablement()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The capability reason values.
|
||||
*******************************************************************************/
|
||||
public enum Reason
|
||||
{
|
||||
AUDIENCE,
|
||||
QA_MODE,
|
||||
OFF,
|
||||
NOT_CONFIGURED,
|
||||
NO_ACCESS,
|
||||
QA_MODE_OFF
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A decision.
|
||||
**
|
||||
** @param enabled whether capture is on for the user
|
||||
** @param reason why
|
||||
** @param settings the settings row (null when there is none)
|
||||
** @param provider the settings' provider (null when not configured)
|
||||
** @param qaModeAvailable whether the user can switch QA mode in this mode
|
||||
** @param qaModeActive whether the user's QA mode preference is on
|
||||
*******************************************************************************/
|
||||
public record Decision(boolean enabled, Reason reason, QRecord settings, QaCaptureProvider provider, boolean qaModeAvailable, boolean qaModeActive)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decide for a session.
|
||||
**
|
||||
** @param config the QBit config (providers, role resolver)
|
||||
** @param session the user's session
|
||||
** @param settings the environment's settings row, or null
|
||||
** @param audience the settings' audience rows (any state)
|
||||
** @param qaModeOn the user's QA mode preference
|
||||
*******************************************************************************/
|
||||
public static Decision decide(QaCaptureQBitConfig config, QSession session, QRecord settings, List<QRecord> audience, boolean qaModeOn)
|
||||
{
|
||||
if(settings == null)
|
||||
{
|
||||
return (new Decision(false, Reason.NOT_CONFIGURED, null, null, false, false));
|
||||
}
|
||||
|
||||
Mode mode = Mode.parse(settings.getValueString("mode"));
|
||||
if(mode == Mode.OFF)
|
||||
{
|
||||
return (new Decision(false, Reason.OFF, settings, null, false, false));
|
||||
}
|
||||
|
||||
QaCaptureProvider provider = config.getProvider(settings.getValueString("provider"));
|
||||
if(provider == null || !StringUtils.hasContent(settings.getValueString("teamId")))
|
||||
{
|
||||
return (new Decision(false, Reason.NOT_CONFIGURED, settings, null, false, false));
|
||||
}
|
||||
|
||||
boolean mayUseQaMode = session != null && session.hasPermission(QaCaptureSchema.PERMISSION_USE_QA_MODE);
|
||||
boolean qaModeAvailable = mode.includesSelfServe() && mayUseQaMode;
|
||||
boolean qaModeActive = qaModeAvailable && qaModeOn;
|
||||
|
||||
if(mode.includesAudience() && inAudience(config, session, audience))
|
||||
{
|
||||
return (new Decision(true, Reason.AUDIENCE, settings, provider, qaModeAvailable, qaModeActive));
|
||||
}
|
||||
if(qaModeAvailable)
|
||||
{
|
||||
return (qaModeActive
|
||||
? new Decision(true, Reason.QA_MODE, settings, provider, true, true)
|
||||
: new Decision(false, Reason.QA_MODE_OFF, settings, provider, true, false));
|
||||
}
|
||||
return (new Decision(false, Reason.NO_ACCESS, settings, provider, false, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether an active administrator audience row names the user or a role.
|
||||
*******************************************************************************/
|
||||
public static boolean inAudience(QaCaptureQBitConfig config, QSession session, List<QRecord> audience)
|
||||
{
|
||||
if(session == null || session.getUser() == null || audience == null || audience.isEmpty())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
|
||||
Set<String> userKeys = new LinkedHashSet<>();
|
||||
addLower(userKeys, session.getUser().getIdReference());
|
||||
addLower(userKeys, session.getUser().getEmail());
|
||||
|
||||
Set<String> roles = null;
|
||||
for(QRecord row : audience)
|
||||
{
|
||||
String source = row.getValueString("source");
|
||||
if(!Boolean.TRUE.equals(row.getValueBoolean("active")) || (source != null && !AudienceSource.ADMIN.name().equals(source)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String value = lower(row.getValueString("value"));
|
||||
if(value == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String type = row.getValueString("audienceType");
|
||||
if(AudienceType.USER.name().equals(type) && userKeys.contains(value))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
if(AudienceType.ROLE.name().equals(type))
|
||||
{
|
||||
if(roles == null)
|
||||
{
|
||||
roles = new LinkedHashSet<>();
|
||||
Collection<String> resolved = config.resolveRoles(session);
|
||||
for(String role : resolved)
|
||||
{
|
||||
addLower(roles, role);
|
||||
}
|
||||
}
|
||||
if(roles.contains(value))
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static void addLower(Set<String> set, String value)
|
||||
{
|
||||
String lower = lower(value);
|
||||
if(lower != null)
|
||||
{
|
||||
set.add(lower);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String lower(String value)
|
||||
{
|
||||
return (value == null || value.isBlank() ? null : value.trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.service;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureQBitConfig;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureRuntime;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.ReportStatus;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureCspOrigins;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureProvider;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureWebhookContext;
|
||||
import com.kingsrook.qbits.qacapture.web.QaCaptureRequestIds;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The QA capture behaviors behind the UI API and the webhooks: the
|
||||
** capability of the signed-in user, report creation, and the context a
|
||||
** provider webhook works in. Table reads and writes run in the system
|
||||
** context; who may do what is decided here (QaCaptureEnablement).
|
||||
*******************************************************************************/
|
||||
public class QaCaptureService
|
||||
{
|
||||
public static final int MAX_REQUEST_IDS = 50;
|
||||
public static final int MAX_PAGE_URL = 2000;
|
||||
|
||||
private static final Pattern REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}");
|
||||
|
||||
private final QInstance qInstance;
|
||||
private final QaCaptureQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The service of an instance.
|
||||
*******************************************************************************/
|
||||
public QaCaptureService(QInstance qInstance)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
this.config = QaCaptureRuntime.of(qInstance).getConfig();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What the user's session gets: the decision and, when enabled, the rules.
|
||||
**
|
||||
** @param decision the enablement decision
|
||||
** @param rules the sanitizer rules (null unless enabled)
|
||||
*******************************************************************************/
|
||||
public record State(QaCaptureEnablement.Decision decision, SanitizerRules rules)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Decide for a session (reads in the system context).
|
||||
*******************************************************************************/
|
||||
public State state(QSession session) throws QException
|
||||
{
|
||||
String userId = session == null || session.getUser() == null ? null : session.getUser().getIdReference();
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () ->
|
||||
{
|
||||
QRecord settings = QaCaptureSettings.load(qInstance);
|
||||
boolean mayUseQaMode = session != null && session.hasPermission(QaCaptureSchema.PERMISSION_USE_QA_MODE);
|
||||
boolean qaModeOn = settings != null && mayUseQaMode && QaModePreference.read(userId, settings);
|
||||
QaCaptureEnablement.Decision decision = QaCaptureEnablement.decide(config, session, settings, QaCaptureSettings.audience(settings), qaModeOn);
|
||||
SanitizerRules rules = decision.enabled() ? SanitizerRules.load().withExtraBlurSelectors(settings.getValueString("extraBlurSelectors")) : null;
|
||||
return (new State(decision, rules));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The capability response (contract qaCaptureCapability).
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> capability(QSession session) throws QException
|
||||
{
|
||||
State state = state(session);
|
||||
QaCaptureEnablement.Decision decision = state.decision();
|
||||
boolean enabled = decision.enabled();
|
||||
|
||||
Map<String, Object> qaMode = new LinkedHashMap<>();
|
||||
qaMode.put("available", decision.qaModeAvailable());
|
||||
qaMode.put("active", decision.qaModeActive());
|
||||
qaMode.put("preferenceSection", decision.qaModeAvailable() && QaModePreference.isRegistered(qInstance) ? QaModePreference.SECTION_NAME : null);
|
||||
|
||||
boolean canViewReports = session != null && (session.hasPermission(QaCaptureSchema.PERMISSION_VIEW_REPORTS) || session.hasPermission(QaCaptureSchema.PERMISSION_MANAGE));
|
||||
|
||||
Map<String, Object> context = new LinkedHashMap<>();
|
||||
context.put("environment", config.getEnvironment());
|
||||
context.put("backendVersion", config.getBackendVersion());
|
||||
context.put("node", config.getEffectiveNode());
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("enabled", enabled);
|
||||
result.put("reason", decision.reason().name());
|
||||
result.put("provider", enabled ? decision.provider().getName() : null);
|
||||
result.put("providerConfig", enabled ? decision.provider().providerConfig(decision.settings()) : null);
|
||||
result.put("qaMode", qaMode);
|
||||
result.put("canReport", enabled);
|
||||
result.put("reportsHref", canViewReports ? "/app/" + QaCaptureSchema.REPORT : null);
|
||||
result.put("sanitizer", enabled ? state.rules().toCapabilityMap() : null);
|
||||
result.put("blurSelectors", enabled ? state.rules().getBlurSelectors() : List.of());
|
||||
result.put("cspOrigins", (enabled ? decision.provider().getCspOrigins() : QaCaptureCspOrigins.none()).toMap());
|
||||
result.put("context", context);
|
||||
result.put("requestIdHeader", QaCaptureRequestIds.HEADER);
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Create a report for the signed-in user (contract qaCaptureReportCreate).
|
||||
**
|
||||
** @throws QPermissionDeniedException when capture is not on for the user
|
||||
** @throws QBadRequestException when the body is invalid
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> createReport(QSession session, Map<String, Object> body) throws QException
|
||||
{
|
||||
State state = state(session);
|
||||
if(!state.decision().enabled())
|
||||
{
|
||||
throw (new QPermissionDeniedException("QA capture is not enabled for you."));
|
||||
}
|
||||
|
||||
String pageUrl = optionalString(body, "pageUrl", MAX_PAGE_URL * 2);
|
||||
if(pageUrl == null)
|
||||
{
|
||||
throw (new QBadRequestException("pageUrl is required."));
|
||||
}
|
||||
|
||||
String tableName = optionalString(body, "tableName", 100);
|
||||
String recordId = optionalString(body, "recordId", 100);
|
||||
String process = optionalString(body, "processName", 100);
|
||||
String appName = optionalString(body, "appName", 100);
|
||||
String title = optionalString(body, "title", 250);
|
||||
if(title == null)
|
||||
{
|
||||
title = defaultTitle(tableName, recordId, process, appName, state.rules().stripQueryParams(pageUrl, MAX_PAGE_URL));
|
||||
}
|
||||
|
||||
QaCaptureProvider provider = state.decision().provider();
|
||||
QRecord settings = state.decision().settings();
|
||||
QRecord report = new QRecord()
|
||||
.withValue("reference", newReference())
|
||||
.withValue("title", truncate(title, 250))
|
||||
.withValue("status", ReportStatus.OPEN.name())
|
||||
.withValue("pageUrl", state.rules().stripQueryParams(pageUrl, MAX_PAGE_URL))
|
||||
.withValue("appName", appName)
|
||||
.withValue("tableName", tableName)
|
||||
.withValue("recordId", recordId)
|
||||
.withValue("processName", process)
|
||||
.withValue("requestIds", requestIds(body.get("requestIds")))
|
||||
.withValue("userId", truncate(session.getUser().getIdReference(), 250))
|
||||
.withValue("userLabel", truncate(userLabel(session), 250))
|
||||
.withValue("environment", config.getEnvironment())
|
||||
.withValue("provider", provider.getName())
|
||||
.withValue("frontendVersion", optionalString(body, "frontendVersion", 100))
|
||||
.withValue("backendVersion", truncate(config.getBackendVersion(), 100))
|
||||
.withValue("node", truncate(config.getEffectiveNode(), 250))
|
||||
.withValue("userAgent", optionalString(body, "userAgent", 1000))
|
||||
.withValue("viewport", optionalString(body, "viewport", 30));
|
||||
|
||||
QRecord stored = QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.insert(QaCaptureSchema.REPORT, report));
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("id", stored.getValue("id"));
|
||||
result.put("reference", stored.getValueString("reference"));
|
||||
result.put("title", stored.getValueString("title"));
|
||||
result.put("recordingParams", provider.recordingParams(stored, settings));
|
||||
return (result);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The context a provider's webhook handler works in.
|
||||
*******************************************************************************/
|
||||
public QaCaptureWebhookContext webhookContext(QaCaptureProvider provider)
|
||||
{
|
||||
return (new QaCaptureWebhookContext()
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getWebhookSecret()
|
||||
{
|
||||
return (config.getWebhookSecret(provider.getName()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QRecord getSettings() throws QException
|
||||
{
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureSettings.load(qInstance)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getEnvironment()
|
||||
{
|
||||
return (config.getEnvironment());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String sanitizeUrl(String url) throws QException
|
||||
{
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () -> SanitizerRules.load().stripQueryParams(url, MAX_PAGE_URL)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QRecord findReportByReference(String reference) throws QException
|
||||
{
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.first(QaCaptureSchema.REPORT, QaCaptureDb.eq("reference", reference))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QRecord findReportByCaptureId(String captureId) throws QException
|
||||
{
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.first(QaCaptureSchema.REPORT, QaCaptureDb.eq("jamId", captureId))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QRecord insertReport(QRecord report) throws QException
|
||||
{
|
||||
if(!StringUtils.hasContent(report.getValueString("reference")))
|
||||
{
|
||||
report.setValue("reference", newReference());
|
||||
}
|
||||
if(!StringUtils.hasContent(report.getValueString("environment")))
|
||||
{
|
||||
report.setValue("environment", config.getEnvironment());
|
||||
}
|
||||
if(!StringUtils.hasContent(report.getValueString("status")))
|
||||
{
|
||||
report.setValue("status", ReportStatus.OPEN.name());
|
||||
}
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.insert(QaCaptureSchema.REPORT, report)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QRecord updateReport(QRecord report) throws QException
|
||||
{
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.update(QaCaptureSchema.REPORT, report)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A new report reference: "qa-" and a random UUID.
|
||||
*******************************************************************************/
|
||||
public static String newReference()
|
||||
{
|
||||
return ("qa-" + UUID.randomUUID());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Bug on {table} {record}", else on the process, the app or the page.
|
||||
*******************************************************************************/
|
||||
static String defaultTitle(String tableName, String recordId, String processName, String appName, String pageUrl)
|
||||
{
|
||||
if(tableName != null)
|
||||
{
|
||||
return ("Bug on " + tableName + (recordId == null ? "" : " " + recordId));
|
||||
}
|
||||
if(processName != null)
|
||||
{
|
||||
return ("Bug in " + processName);
|
||||
}
|
||||
if(appName != null)
|
||||
{
|
||||
return ("Bug in " + appName);
|
||||
}
|
||||
String path = pageUrl;
|
||||
try
|
||||
{
|
||||
String parsed = java.net.URI.create(pageUrl).getPath();
|
||||
if(StringUtils.hasContent(parsed))
|
||||
{
|
||||
path = parsed;
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
// keep the URL as given
|
||||
}
|
||||
return ("Bug on " + path);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The request ids to keep: valid ids only, at most 50, newline separated.
|
||||
*******************************************************************************/
|
||||
static String requestIds(Object value) throws QBadRequestException
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(!(value instanceof List<?> list))
|
||||
{
|
||||
throw (new QBadRequestException("requestIds must be an array of strings."));
|
||||
}
|
||||
List<String> kept = new ArrayList<>();
|
||||
for(Object item : list)
|
||||
{
|
||||
if(item instanceof String id && REQUEST_ID.matcher(id.trim()).matches() && !kept.contains(id.trim()))
|
||||
{
|
||||
kept.add(id.trim());
|
||||
if(kept.size() == MAX_REQUEST_IDS)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (kept.isEmpty() ? null : String.join("\n", kept));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An optional string property (blank is null), capped at maxLength.
|
||||
*******************************************************************************/
|
||||
static String optionalString(Map<String, Object> body, String name, int maxLength) throws QBadRequestException
|
||||
{
|
||||
Object value = body == null ? null : body.get(name);
|
||||
if(value == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
if(!(value instanceof String string))
|
||||
{
|
||||
throw (new QBadRequestException(name + " must be a string."));
|
||||
}
|
||||
return (string.isBlank() ? null : truncate(string.trim(), maxLength));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String userLabel(QSession session)
|
||||
{
|
||||
if(StringUtils.hasContent(session.getUser().getFullName()))
|
||||
{
|
||||
return (session.getUser().getFullName());
|
||||
}
|
||||
return (StringUtils.hasContent(session.getUser().getEmail()) ? session.getUser().getEmail() : session.getUser().getIdReference());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String truncate(String value, int length)
|
||||
{
|
||||
return (value == null || value.length() <= length ? value : value.substring(0, length));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.service;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureRuntime;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.Mode;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reads of the environment's settings row and its audience (call in the
|
||||
** system context).
|
||||
*******************************************************************************/
|
||||
public final class QaCaptureSettings
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QaCaptureSettings()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The settings row of the instance's configured environment, or null.
|
||||
*******************************************************************************/
|
||||
public static QRecord load(QInstance qInstance) throws QException
|
||||
{
|
||||
String environment = QaCaptureRuntime.of(qInstance).getConfig().getEnvironment();
|
||||
return (QaCaptureDb.first(QaCaptureSchema.SETTINGS, QaCaptureDb.eq("environment", environment)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The audience rows of a settings row.
|
||||
*******************************************************************************/
|
||||
public static List<QRecord> audience(QRecord settings) throws QException
|
||||
{
|
||||
if(settings == null)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
return (QaCaptureDb.query(QaCaptureSchema.AUDIENCE, QaCaptureDb.eq("settingsId", settings.getValue("id"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a settings row turns capture on for someone at all: a mode other
|
||||
** than OFF, a team id and a registered provider. Decides the recorder page
|
||||
** and the Content-Security-Policy additions.
|
||||
*******************************************************************************/
|
||||
public static boolean isActive(QInstance qInstance, QRecord settings)
|
||||
{
|
||||
return (settings != null
|
||||
&& Mode.parse(settings.getValueString("mode")) != Mode.OFF
|
||||
&& StringUtils.hasContent(settings.getValueString("teamId"))
|
||||
&& QaCaptureRuntime.of(qInstance).getConfig().getProvider(settings.getValueString("provider")) != null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.service;
|
||||
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureRuntime;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.AudienceSource;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.AudienceType;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
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.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceChange;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceRequestContext;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceValues;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.ValueUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The "qa-capture" user preference section: one boolean, QA mode, visible
|
||||
** and patchable only by holders of qa_capture.use_qa_mode (core's
|
||||
** section-level permission gate).
|
||||
**
|
||||
** Storage: core's key-value preference store when the host defines it
|
||||
** (UserPreferencesKeyValueStoreMetaDataProvider); otherwise a
|
||||
** qa_capture_audience row with source QA_MODE, audience type USER and the
|
||||
** user id as value on the environment's settings row (active = QA mode on).
|
||||
** Those rows never grant audience membership; they are visible to
|
||||
** administrators like every other row.
|
||||
*******************************************************************************/
|
||||
public class QaModePreference implements UserPreferenceProviderInterface
|
||||
{
|
||||
public static final String SECTION_NAME = "qa-capture";
|
||||
public static final String FIELD_NAME = "qaMode";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register the section once.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
{
|
||||
if(isRegistered(qInstance))
|
||||
{
|
||||
return;
|
||||
}
|
||||
UserPreferencesMetaData.ofOrWithNew(qInstance).withSection(new UserPreferenceSectionMetaData()
|
||||
.withName(SECTION_NAME).withLabel("QA capture").withIcon(new QIcon("bug_report")).withOrder(80)
|
||||
.withDescription("Record bug reports with screen, console and network context while you test.")
|
||||
.withPermissionName(QaCaptureSchema.PERMISSION_USE_QA_MODE)
|
||||
.withProvider(new QCodeReference(QaModePreference.class))
|
||||
.withField(new QFieldMetaData(FIELD_NAME, QFieldType.BOOLEAN).withLabel("QA mode").withDefaultValue(false)
|
||||
.withHelpContent(new QHelpContent("Turn on to show Report a bug and capture recordings while you test. Secure values are blurred and secrets are removed before anything leaves your browser."))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether the section is installed in an instance.
|
||||
*******************************************************************************/
|
||||
public static boolean isRegistered(QInstance qInstance)
|
||||
{
|
||||
UserPreferencesMetaData metaData = UserPreferencesMetaData.of(qInstance);
|
||||
return (metaData != null && metaData.getSections().stream().anyMatch(section -> SECTION_NAME.equals(section.getName())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The key-value store table name when the host defines the store, else null.
|
||||
*******************************************************************************/
|
||||
public static String keyValueTableName(QInstance qInstance)
|
||||
{
|
||||
UserPreferencesMetaData metaData = UserPreferencesMetaData.of(qInstance);
|
||||
String table = metaData == null ? null : metaData.getKeyValueTableName();
|
||||
return (table != null && qInstance.getTable(table) != null ? table : null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A user's QA mode (call in the system context).
|
||||
**
|
||||
** @param userId the user's id reference
|
||||
** @param settings the environment's settings row (for the fallback store)
|
||||
*******************************************************************************/
|
||||
public static boolean read(String userId, QRecord settings) throws QException
|
||||
{
|
||||
if(userId == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
String keyValueTable = keyValueTableName(QContext.getQInstance());
|
||||
if(keyValueTable != null)
|
||||
{
|
||||
QRecord stored = QaCaptureDb.first(keyValueTable, QaCaptureDb.eq("userId", userId)
|
||||
.withCriteria("sectionName", QCriteriaOperator.EQUALS, SECTION_NAME)
|
||||
.withCriteria("preferenceName", QCriteriaOperator.EQUALS, FIELD_NAME));
|
||||
if(stored == null)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
try
|
||||
{
|
||||
return (Boolean.TRUE.equals(ValueUtils.getValueAsBoolean(JsonUtils.toObject(stored.getValueString("value"), Object.class))));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
QRecord row = fallbackRow(userId, settings);
|
||||
return (row != null && Boolean.TRUE.equals(row.getValueBoolean("active")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store a user's QA mode in the fallback store (call in the system context).
|
||||
*******************************************************************************/
|
||||
public static void writeFallback(String userId, QRecord settings, boolean on) throws QException
|
||||
{
|
||||
if(settings == null)
|
||||
{
|
||||
throw (new QException("QA capture is not set up for this environment yet, so QA mode can't be saved."));
|
||||
}
|
||||
QRecord row = fallbackRow(userId, settings);
|
||||
if(row == null)
|
||||
{
|
||||
QaCaptureDb.insert(QaCaptureSchema.AUDIENCE, new QRecord()
|
||||
.withValue("settingsId", settings.getValue("id"))
|
||||
.withValue("audienceType", AudienceType.USER.name())
|
||||
.withValue("value", userId)
|
||||
.withValue("source", AudienceSource.QA_MODE.name())
|
||||
.withValue("active", on)
|
||||
.withValue("notes", "The user's own QA mode switch (preferences). It does not add the user to the audience."));
|
||||
}
|
||||
else
|
||||
{
|
||||
QaCaptureDb.update(QaCaptureSchema.AUDIENCE, new QRecord().withValue("id", row.getValue("id")).withValue("active", on));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QRecord fallbackRow(String userId, QRecord settings) throws QException
|
||||
{
|
||||
if(settings == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
return (QaCaptureDb.first(QaCaptureSchema.AUDIENCE, QaCaptureDb.eq("settingsId", settings.getValue("id"))
|
||||
.withCriteria("source", QCriteriaOperator.EQUALS, AudienceSource.QA_MODE.name())
|
||||
.withCriteria("audienceType", QCriteriaOperator.EQUALS, AudienceType.USER.name())
|
||||
.withCriteria("value", QCriteriaOperator.EQUALS, userId)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Load the session user's QA mode.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public UserPreferenceValues load(UserPreferenceRequestContext context) throws QException
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
boolean on = QaCaptureRuntime.asSystem(qInstance, () -> read(context.getUserId(), QaCaptureSettings.load(qInstance)));
|
||||
UserPreferenceValues values = new UserPreferenceValues();
|
||||
values.getValues().put(FIELD_NAME, on);
|
||||
return (values);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fallback store needs the environment's settings row.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> validate(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
|
||||
{
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
if(keyValueTableName(qInstance) == null && QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureSettings.load(qInstance)) == null)
|
||||
{
|
||||
return (List.of("QA capture is not set up for this environment yet, so QA mode can't be saved."));
|
||||
}
|
||||
return (List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Store the session user's QA mode.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void store(UserPreferenceRequestContext context, UserPreferenceChange change) throws QException
|
||||
{
|
||||
if(!change.getValues().containsKey(FIELD_NAME))
|
||||
{
|
||||
return;
|
||||
}
|
||||
Serializable value = change.getValues().get(FIELD_NAME);
|
||||
boolean on = Boolean.TRUE.equals(ValueUtils.getValueAsBoolean(value));
|
||||
QInstance qInstance = QContext.getQInstance();
|
||||
String userId = context.getUserId();
|
||||
QaCaptureRuntime.asSystem(qInstance, () ->
|
||||
{
|
||||
String keyValueTable = keyValueTableName(qInstance);
|
||||
if(keyValueTable != null)
|
||||
{
|
||||
QRecord existing = QaCaptureDb.first(keyValueTable, QaCaptureDb.eq("userId", userId)
|
||||
.withCriteria("sectionName", QCriteriaOperator.EQUALS, SECTION_NAME)
|
||||
.withCriteria("preferenceName", QCriteriaOperator.EQUALS, FIELD_NAME));
|
||||
QRecord record = new QRecord().withValue("value", JsonUtils.toJson(on));
|
||||
if(existing == null)
|
||||
{
|
||||
QaCaptureDb.insert(keyValueTable, record.withValue("userId", userId).withValue("sectionName", SECTION_NAME).withValue("preferenceName", FIELD_NAME));
|
||||
}
|
||||
else
|
||||
{
|
||||
QaCaptureDb.update(keyValueTable, record.withValue("id", existing.getValue("id")));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
writeFallback(userId, QaCaptureSettings.load(qInstance), on);
|
||||
}
|
||||
return (null);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.service;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.RuleType;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The rule set served to clients: the active rows of
|
||||
** qa_capture_sanitizer_rule (built-in, secured-data and administrator rules
|
||||
** alike), grouped by type. Invalid rows are skipped (and logged), never
|
||||
** served: a value pattern must compile in Java and use only syntax
|
||||
** JavaScript's RegExp shares; a drop path must start with "/".
|
||||
*******************************************************************************/
|
||||
public final class SanitizerRules
|
||||
{
|
||||
public static final String REDACTION = "[redacted]";
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// Java-only regex syntax (JavaScript reads these differently or not at //
|
||||
// all): possessive quantifiers, atomic groups, inline flags, \A \Z \z //
|
||||
// \G \h \Q..\E, \p{..} (needs JS's u flag), class intersection (&&). //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
private static final Pattern JAVA_ONLY = Pattern.compile("[*+?}]\\+|\\(\\?[>a-zA-Z]|\\\\[AZzGhHQEpP]|&&");
|
||||
|
||||
private final List<String> headerNames = new ArrayList<>();
|
||||
private final List<String> bodyFieldNames = new ArrayList<>();
|
||||
private final List<String> queryParamNames = new ArrayList<>();
|
||||
private final List<String> valuePatterns = new ArrayList<>();
|
||||
private final List<String> dropPathPrefixes = new ArrayList<>();
|
||||
private final List<String> blurSelectors = new ArrayList<>();
|
||||
|
||||
private static final QLogger LOG = QLogger.getLogger(SanitizerRules.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Use load or fromRecords.
|
||||
*******************************************************************************/
|
||||
private SanitizerRules()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The active rules from the table (call in the system context).
|
||||
*******************************************************************************/
|
||||
public static SanitizerRules load() throws QException
|
||||
{
|
||||
return (fromRecords(QaCaptureDb.query(QaCaptureSchema.SANITIZER_RULE, QaCaptureDb.eq("active", true))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Group rule rows (inactive and invalid rows are skipped).
|
||||
*******************************************************************************/
|
||||
public static SanitizerRules fromRecords(List<QRecord> records)
|
||||
{
|
||||
SanitizerRules rules = new SanitizerRules();
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
for(QRecord record : records)
|
||||
{
|
||||
if(!Boolean.TRUE.equals(record.getValueBoolean("active")))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
String type = record.getValueString("ruleType");
|
||||
String value = record.getValueString("value");
|
||||
if(type == null || value == null || value.isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
value = value.trim();
|
||||
String problem = validate(type, value);
|
||||
if(problem != null)
|
||||
{
|
||||
LOG.warn("Skipping an invalid QA capture sanitizer rule", logPair("id", record.getValue("id")), logPair("ruleType", type), logPair("problem", problem));
|
||||
continue;
|
||||
}
|
||||
|
||||
String normalized = RuleType.HEADER.name().equals(type) ? value.toLowerCase(Locale.ROOT) : value;
|
||||
if(!seen.add(type + "\n" + normalized))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
switch(RuleType.valueOf(type))
|
||||
{
|
||||
case HEADER -> rules.headerNames.add(normalized);
|
||||
case BODY_FIELD -> rules.bodyFieldNames.add(normalized);
|
||||
case QUERY_PARAM -> rules.queryParamNames.add(normalized);
|
||||
case VALUE_PATTERN -> rules.valuePatterns.add(normalized);
|
||||
case DROP_PATH -> rules.dropPathPrefixes.add(normalized);
|
||||
case BLUR_SELECTOR -> rules.blurSelectors.add(normalized);
|
||||
default -> LOG.warn("Unhandled sanitizer rule type", logPair("ruleType", type));
|
||||
}
|
||||
}
|
||||
return (rules);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Why a rule value is not usable, or null when it is.
|
||||
*******************************************************************************/
|
||||
public static String validate(String ruleType, String value)
|
||||
{
|
||||
RuleType type;
|
||||
try
|
||||
{
|
||||
type = RuleType.valueOf(ruleType);
|
||||
}
|
||||
catch(IllegalArgumentException | NullPointerException e)
|
||||
{
|
||||
return ("Unknown rule type " + ruleType + ".");
|
||||
}
|
||||
if(value == null || value.isBlank())
|
||||
{
|
||||
return ("A value is required.");
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
switch(type)
|
||||
{
|
||||
case VALUE_PATTERN ->
|
||||
{
|
||||
try
|
||||
{
|
||||
Pattern.compile(trimmed);
|
||||
}
|
||||
catch(PatternSyntaxException e)
|
||||
{
|
||||
return ("The pattern is not a valid regular expression: " + e.getDescription() + ".");
|
||||
}
|
||||
if(JAVA_ONLY.matcher(trimmed).find())
|
||||
{
|
||||
return ("The pattern uses syntax JavaScript does not share (possessive or atomic groups, inline flags, \\A, \\Z, \\z, \\G, \\h, \\Q, \\p or &&).");
|
||||
}
|
||||
if(Pattern.compile(trimmed).matcher("").matches())
|
||||
{
|
||||
return ("The pattern matches empty text, which would redact everything.");
|
||||
}
|
||||
}
|
||||
case DROP_PATH ->
|
||||
{
|
||||
if(!trimmed.startsWith("/"))
|
||||
{
|
||||
return ("A dropped path prefix starts with / (a same-origin path, for example /qqq/v1/secret).");
|
||||
}
|
||||
}
|
||||
case HEADER ->
|
||||
{
|
||||
if(!trimmed.matches("[A-Za-z0-9!#$%&'*+.^_`|~-]+"))
|
||||
{
|
||||
return ("A header name has letters, digits and - only.");
|
||||
}
|
||||
}
|
||||
default ->
|
||||
{
|
||||
// body fields, query parameters and blur selectors take any text
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Add blur selectors (one per line) from a settings row's
|
||||
** extra_blur_selectors.
|
||||
*******************************************************************************/
|
||||
public SanitizerRules withExtraBlurSelectors(String text)
|
||||
{
|
||||
if(text != null)
|
||||
{
|
||||
for(String line : text.split("\\R"))
|
||||
{
|
||||
String selector = line.trim();
|
||||
if(!selector.isEmpty() && !blurSelectors.contains(selector))
|
||||
{
|
||||
blurSelectors.add(selector);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The capability response's sanitizer object.
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> toCapabilityMap()
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("redaction", REDACTION);
|
||||
map.put("headerNames", List.copyOf(headerNames));
|
||||
map.put("bodyFieldNames", List.copyOf(bodyFieldNames));
|
||||
map.put("queryParamNames", List.copyOf(queryParamNames));
|
||||
map.put("valuePatterns", List.copyOf(valuePatterns));
|
||||
map.put("dropPathPrefixes", List.copyOf(dropPathPrefixes));
|
||||
return (map);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A URL with the query parameters named by QUERY_PARAM rules removed
|
||||
** (case-insensitive), capped at maxLength. Other parameters, their order
|
||||
** and the fragment are kept.
|
||||
*******************************************************************************/
|
||||
public String stripQueryParams(String url, int maxLength)
|
||||
{
|
||||
if(url == null || url.isBlank())
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
String trimmed = url.trim();
|
||||
String fragment = "";
|
||||
int hash = trimmed.indexOf('#');
|
||||
if(hash >= 0)
|
||||
{
|
||||
fragment = trimmed.substring(hash);
|
||||
trimmed = trimmed.substring(0, hash);
|
||||
}
|
||||
int question = trimmed.indexOf('?');
|
||||
if(question >= 0)
|
||||
{
|
||||
Set<String> names = new LinkedHashSet<>();
|
||||
queryParamNames.forEach(name -> names.add(name.toLowerCase(Locale.ROOT)));
|
||||
List<String> kept = new ArrayList<>();
|
||||
for(String pair : trimmed.substring(question + 1).split("&"))
|
||||
{
|
||||
if(pair.isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
int equals = pair.indexOf('=');
|
||||
String name = java.net.URLDecoder.decode(equals < 0 ? pair : pair.substring(0, equals), java.nio.charset.StandardCharsets.UTF_8);
|
||||
if(!names.contains(name.toLowerCase(Locale.ROOT)))
|
||||
{
|
||||
kept.add(pair);
|
||||
}
|
||||
}
|
||||
trimmed = trimmed.substring(0, question) + (kept.isEmpty() ? "" : "?" + String.join("&", kept));
|
||||
}
|
||||
String result = trimmed + fragment;
|
||||
return (result.length() <= maxLength ? result : result.substring(0, maxLength));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for headerNames
|
||||
*******************************************************************************/
|
||||
public List<String> getHeaderNames()
|
||||
{
|
||||
return (List.copyOf(headerNames));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for bodyFieldNames
|
||||
*******************************************************************************/
|
||||
public List<String> getBodyFieldNames()
|
||||
{
|
||||
return (List.copyOf(bodyFieldNames));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for queryParamNames
|
||||
*******************************************************************************/
|
||||
public List<String> getQueryParamNames()
|
||||
{
|
||||
return (List.copyOf(queryParamNames));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for valuePatterns
|
||||
*******************************************************************************/
|
||||
public List<String> getValuePatterns()
|
||||
{
|
||||
return (List.copyOf(valuePatterns));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for dropPathPrefixes
|
||||
*******************************************************************************/
|
||||
public List<String> getDropPathPrefixes()
|
||||
{
|
||||
return (List.copyOf(dropPathPrefixes));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for blurSelectors
|
||||
*******************************************************************************/
|
||||
public List<String> getBlurSelectors()
|
||||
{
|
||||
return (List.copyOf(blurSelectors));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.service;
|
||||
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureRuntime;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.RuleSource;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.RuleType;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.actions.ai.DataClassification;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Adds a BODY_FIELD sanitizer rule (source SECURED_DATA) for every field the
|
||||
** instance marks secure, so captures redact those values in API bodies and
|
||||
** console output. A field is secure when core metadata says so:
|
||||
** - QFieldMetaData.isSensitive (excluded from exports), or
|
||||
** - a field-level data classification of PII or PHI (core's
|
||||
** AiFieldMetaData, which qbit-secured-data's classifications also set).
|
||||
** Table-level classifications alone do not add rules (they would redact
|
||||
** every field of the table, ids included).
|
||||
**
|
||||
** Insert-only and idempotent: a rule that exists for the field name (any
|
||||
** source, active or not) is never changed or deleted, so administrators can
|
||||
** deactivate a secured-data rule and it stays deactivated.
|
||||
*******************************************************************************/
|
||||
public final class SecuredDataRuleSync
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(SecuredDataRuleSync.class);
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SecuredDataRuleSync()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The secure fields of an instance: field name to "table.field" (the
|
||||
** first table that marks it).
|
||||
*******************************************************************************/
|
||||
public static Map<String, String> secureFields(QInstance qInstance)
|
||||
{
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
for(QTableMetaData table : qInstance.getTables().values())
|
||||
{
|
||||
if(table.getName().startsWith("qa_capture_") || table.getFields() == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(isSecure(field) && !fields.containsKey(field.getName()))
|
||||
{
|
||||
fields.put(field.getName(), table.getName() + "." + field.getName());
|
||||
}
|
||||
}
|
||||
}
|
||||
return (fields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether core metadata marks a field secure.
|
||||
*******************************************************************************/
|
||||
public static boolean isSecure(QFieldMetaData field)
|
||||
{
|
||||
if(field.getIsSensitive())
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
AiFieldMetaData ai = AiFieldMetaData.of(field);
|
||||
return (ai != null && ai.getClassification() != null && ai.getClassification().isAtLeast(DataClassification.PII));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert the missing rules (in the system context). Returns how many were
|
||||
** added.
|
||||
*******************************************************************************/
|
||||
public static int sync(QInstance qInstance) throws QException
|
||||
{
|
||||
Map<String, String> secure = secureFields(qInstance);
|
||||
if(secure.isEmpty())
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () ->
|
||||
{
|
||||
Set<String> existing = new HashSet<>();
|
||||
for(QRecord rule : QaCaptureDb.query(QaCaptureSchema.SANITIZER_RULE, QaCaptureDb.eq("ruleType", RuleType.BODY_FIELD.name())))
|
||||
{
|
||||
existing.add(rule.getValueString("value").toLowerCase(Locale.ROOT));
|
||||
}
|
||||
int added = 0;
|
||||
for(Map.Entry<String, String> entry : secure.entrySet())
|
||||
{
|
||||
if(existing.add(entry.getKey().toLowerCase(Locale.ROOT)))
|
||||
{
|
||||
try
|
||||
{
|
||||
QaCaptureDb.insert(QaCaptureSchema.SANITIZER_RULE, new QRecord()
|
||||
.withValue("ruleType", RuleType.BODY_FIELD.name())
|
||||
.withValue("value", entry.getKey())
|
||||
.withValue("source", RuleSource.SECURED_DATA.name())
|
||||
.withValue("sourceRef", entry.getValue())
|
||||
.withValue("active", true)
|
||||
.withValue("description", "Marked secure in the instance metadata (" + entry.getValue() + ")."));
|
||||
added++;
|
||||
}
|
||||
catch(QException e)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////
|
||||
// another replica inserted it first (unique rule): keep on //
|
||||
///////////////////////////////////////////////////////////////
|
||||
LOG.info("Could not add a secured-data sanitizer rule (it may exist already)", logPair("field", entry.getValue()), logPair("reason", e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if(added > 0)
|
||||
{
|
||||
LOG.info("Added QA capture sanitizer rules for secure fields", logPair("added", added));
|
||||
}
|
||||
return (added);
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.spi;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureSchema.ReportStatus;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.StringUtils;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Jam (https://jam.dev): a connected domain identifies the team with a
|
||||
** jam:team meta tag and loads recorder.js and capture.js from js.jam.dev;
|
||||
** a reusable Recording Link opens the recorder on any same-origin page with
|
||||
** jam-recording, jam-title and jam-reference query parameters; the
|
||||
** Svix-signed jam.created webhook reports the finished capture, matched to
|
||||
** the QA report by recordingLink.reference.
|
||||
*******************************************************************************/
|
||||
public class JamProvider implements QaCaptureProvider
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(JamProvider.class);
|
||||
|
||||
public static final String NAME = "jam";
|
||||
public static final String RECORDER_JS = "https://js.jam.dev/recorder.js";
|
||||
public static final String CAPTURE_JS = "https://js.jam.dev/capture.js";
|
||||
public static final String EVENT_CREATED = "jam.created";
|
||||
|
||||
private static final List<String> SCRIPT_URLS = List.of(RECORDER_JS, CAPTURE_JS);
|
||||
private static final QaCaptureCspOrigins CSP_ORIGINS = new QaCaptureCspOrigins(
|
||||
List.of("https://js.jam.dev"), List.of("https://*.jam.dev"), List.of("https://*.jam.dev"));
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return (NAME);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String getLabel()
|
||||
{
|
||||
return ("Jam");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public List<String> getScriptUrls()
|
||||
{
|
||||
return (SCRIPT_URLS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QaCaptureCspOrigins getCspOrigins()
|
||||
{
|
||||
return (CSP_ORIGINS);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** teamId, recordingLinkId (nullable) and scriptUrls.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, Object> providerConfig(QRecord settings)
|
||||
{
|
||||
Map<String, Object> config = new LinkedHashMap<>();
|
||||
config.put("teamId", settings.getValueString("teamId"));
|
||||
config.put("recordingLinkId", blankToNull(settings.getValueString("recordingLinkId")));
|
||||
config.put("scriptUrls", SCRIPT_URLS);
|
||||
return (config);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** jam-recording, jam-title and jam-reference; null without a recording link.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public Map<String, String> recordingParams(QRecord report, QRecord settings)
|
||||
{
|
||||
String linkId = blankToNull(settings.getValueString("recordingLinkId"));
|
||||
if(linkId == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
Map<String, String> params = new LinkedHashMap<>();
|
||||
params.put("jam-recording", linkId);
|
||||
params.put("jam-title", report.getValueString("title"));
|
||||
params.put("jam-reference", report.getValueString("reference"));
|
||||
return (params);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The connected-domain markup Jam verifies (team meta and both module
|
||||
** scripts), then the sanitizers in a module script right after capture.js,
|
||||
** as Jam documents for script-tag installs.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public String recorderHead(QRecord settings, String sanitizersExpression)
|
||||
{
|
||||
return ("<meta name=\"jam:team\" content=\"" + escapeAttribute(settings.getValueString("teamId")) + "\">\n"
|
||||
+ "<script type=\"module\" src=\"" + RECORDER_JS + "\"></script>\n"
|
||||
+ "<script type=\"module\" src=\"" + CAPTURE_JS + "\"></script>\n"
|
||||
+ "<script type=\"module\">" + sanitizerInstaller(sanitizersExpression) + "</script>\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The inline module script body: install the sanitizers through
|
||||
** window.jam.capture.setSanitizers, or say clearly that capture.js did not
|
||||
** load first.
|
||||
*******************************************************************************/
|
||||
public static String sanitizerInstaller(String sanitizersExpression)
|
||||
{
|
||||
return ("\nconst sanitizers = " + sanitizersExpression + ";\n"
|
||||
+ "if (window.jam && window.jam.capture && typeof window.jam.capture.setSanitizers === \"function\") {\n"
|
||||
+ " window.jam.capture.setSanitizers(sanitizers);\n"
|
||||
+ "} else {\n"
|
||||
+ " console.error(\"Jam capture.js did not load first: sanitizers are not set.\");\n"
|
||||
+ "}\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** jam.created: verify the Svix signature, then attach the capture to the
|
||||
** report named by recordingLink.reference, or record an unmatched capture
|
||||
** as a new report. Idempotent on jamId.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public QaCaptureWebhookResult handleWebhook(QaCaptureWebhookRequest request, QaCaptureWebhookContext context) throws QException
|
||||
{
|
||||
String secret = context.getWebhookSecret();
|
||||
if(!StringUtils.hasContent(secret))
|
||||
{
|
||||
return (QaCaptureWebhookResult.notFound());
|
||||
}
|
||||
|
||||
SvixSignatureVerifier.Outcome outcome = SvixSignatureVerifier.verify(secret, request.getHeader("svix-id"), request.getHeader("svix-timestamp"),
|
||||
request.getHeader("svix-signature"), request.getBody(), request.getReceivedAt());
|
||||
if(outcome != SvixSignatureVerifier.Outcome.VALID)
|
||||
{
|
||||
LOG.info("Rejected a Jam webhook", logPair("outcome", outcome), logPair("svixId", request.getHeader("svix-id")));
|
||||
return (QaCaptureWebhookResult.error(401, "Invalid webhook signature"));
|
||||
}
|
||||
|
||||
Map<String, Object> payload;
|
||||
try
|
||||
{
|
||||
payload = JsonUtils.toObject(request.getBody(), Map.class);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
return (QaCaptureWebhookResult.error(400, "The body is not a JSON object"));
|
||||
}
|
||||
|
||||
if(!EVENT_CREATED.equals(string(payload.get("type"))))
|
||||
{
|
||||
return (QaCaptureWebhookResult.ok("ignored", null));
|
||||
}
|
||||
|
||||
QRecord settings = context.getSettings();
|
||||
String teamId = string(payload.get("teamId"));
|
||||
if(settings == null || teamId == null || !teamId.equals(settings.getValueString("teamId")))
|
||||
{
|
||||
LOG.info("Ignored a Jam webhook for another team or an unconfigured environment", logPair("environment", context.getEnvironment()));
|
||||
return (QaCaptureWebhookResult.ok("ignored", null));
|
||||
}
|
||||
|
||||
String jamId = string(payload.get("jamId"));
|
||||
String jamUrl = string(payload.get("jamUrl"));
|
||||
if(jamId == null)
|
||||
{
|
||||
return (QaCaptureWebhookResult.error(400, "jamId is required"));
|
||||
}
|
||||
|
||||
QRecord existing = context.findReportByCaptureId(jamId);
|
||||
if(existing != null)
|
||||
{
|
||||
return (QaCaptureWebhookResult.ok("duplicate", existing.getValueString("reference")));
|
||||
}
|
||||
|
||||
Map<String, Object> link = payload.get("recordingLink") instanceof Map<?, ?> map ? (Map<String, Object>) map : Map.of();
|
||||
String reference = string(link.get("reference"));
|
||||
String comment = string(link.get("submitterComment"));
|
||||
|
||||
QRecord report = reference == null ? null : context.findReportByReference(reference);
|
||||
if(report != null)
|
||||
{
|
||||
QRecord update = new QRecord()
|
||||
.withValue("id", report.getValue("id"))
|
||||
.withValue("jamId", jamId)
|
||||
.withValue("jamUrl", jamUrl);
|
||||
if(ReportStatus.OPEN.name().equals(report.getValueString("status")))
|
||||
{
|
||||
update.withValue("status", ReportStatus.CAPTURED.name());
|
||||
}
|
||||
if(comment != null && !StringUtils.hasContent(report.getValueString("submitterComment")))
|
||||
{
|
||||
update.withValue("submitterComment", comment);
|
||||
}
|
||||
if(!StringUtils.hasContent(report.getValueString("title")) && string(payload.get("title")) != null)
|
||||
{
|
||||
update.withValue("title", truncate(string(payload.get("title")), 250));
|
||||
}
|
||||
context.updateReport(update);
|
||||
return (QaCaptureWebhookResult.ok("matched", report.getValueString("reference")));
|
||||
}
|
||||
|
||||
Map<String, Object> author = payload.get("author") instanceof Map<?, ?> map ? (Map<String, Object>) map : Map.of();
|
||||
String title = string(payload.get("title"));
|
||||
QRecord created = context.insertReport(new QRecord()
|
||||
.withValue("title", truncate(title == null ? "Jam " + jamId : title, 250))
|
||||
.withValue("status", ReportStatus.CAPTURED.name())
|
||||
.withValue("pageUrl", context.sanitizeUrl(string(payload.get("originalUrl"))))
|
||||
.withValue("userId", truncate(string(author.get("email")), 250))
|
||||
.withValue("userLabel", truncate(string(author.get("name")), 250))
|
||||
.withValue("provider", NAME)
|
||||
.withValue("jamId", truncate(jamId, 100))
|
||||
.withValue("jamUrl", truncate(jamUrl, 1000))
|
||||
.withValue("submitterComment", comment));
|
||||
return (QaCaptureWebhookResult.ok("created", created.getValueString("reference")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A non-blank string value, or null.
|
||||
*******************************************************************************/
|
||||
private static String string(Object value)
|
||||
{
|
||||
return (value == null ? null : blankToNull(String.valueOf(value)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String blankToNull(String value)
|
||||
{
|
||||
return (value == null || value.isBlank() ? null : value.trim());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String truncate(String value, int length)
|
||||
{
|
||||
return (value == null || value.length() <= length ? value : value.substring(0, length));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Escape a value for a double-quoted HTML attribute.
|
||||
*******************************************************************************/
|
||||
static String escapeAttribute(String value)
|
||||
{
|
||||
if(value == null)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
return (value.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.spi;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Content-Security-Policy origins a provider needs, per directive.
|
||||
**
|
||||
** @param scriptSrc script-src sources
|
||||
** @param frameSrc frame-src sources
|
||||
** @param connectSrc connect-src sources
|
||||
*******************************************************************************/
|
||||
public record QaCaptureCspOrigins(List<String> scriptSrc, List<String> frameSrc, List<String> connectSrc)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Immutable copies (null becomes empty).
|
||||
*******************************************************************************/
|
||||
public QaCaptureCspOrigins
|
||||
{
|
||||
scriptSrc = scriptSrc == null ? List.of() : List.copyOf(scriptSrc);
|
||||
frameSrc = frameSrc == null ? List.of() : List.copyOf(frameSrc);
|
||||
connectSrc = connectSrc == null ? List.of() : List.copyOf(connectSrc);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No origins.
|
||||
*******************************************************************************/
|
||||
public static QaCaptureCspOrigins none()
|
||||
{
|
||||
return (new QaCaptureCspOrigins(List.of(), List.of(), List.of()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The contract's cspOrigins object (scriptSrc, frameSrc, connectSrc).
|
||||
*******************************************************************************/
|
||||
public Map<String, Object> toMap()
|
||||
{
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("scriptSrc", scriptSrc);
|
||||
map.put("frameSrc", frameSrc);
|
||||
map.put("connectSrc", connectSrc);
|
||||
return (map);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.spi;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A QA capture provider (Jam is the first). The host registers providers on
|
||||
** QaCaptureQBitConfig; a settings row names one of them. A provider says which
|
||||
** scripts and Content-Security-Policy origins it needs, what clients receive
|
||||
** as providerConfig, how a report opens its recorder (recordingParams), what
|
||||
** the public recorder page carries, and how its webhook is handled.
|
||||
**
|
||||
** Implementations must be stateless and thread safe, and must never log or
|
||||
** return secrets.
|
||||
*******************************************************************************/
|
||||
public interface QaCaptureProvider
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Stable name stored in qa_capture_settings.provider (lowercase, for
|
||||
** example "jam"); also the webhook path segment.
|
||||
*******************************************************************************/
|
||||
String getName();
|
||||
|
||||
/*******************************************************************************
|
||||
** Label shown in the provider possible values.
|
||||
*******************************************************************************/
|
||||
String getLabel();
|
||||
|
||||
/*******************************************************************************
|
||||
** The provider's browser scripts, in load order.
|
||||
*******************************************************************************/
|
||||
List<String> getScriptUrls();
|
||||
|
||||
/*******************************************************************************
|
||||
** Origins the provider needs in a Content-Security-Policy.
|
||||
*******************************************************************************/
|
||||
QaCaptureCspOrigins getCspOrigins();
|
||||
|
||||
/*******************************************************************************
|
||||
** The capability response's providerConfig for an enabled settings row.
|
||||
*******************************************************************************/
|
||||
Map<String, Object> providerConfig(QRecord settings);
|
||||
|
||||
/*******************************************************************************
|
||||
** Query parameters that open the provider's recorder for a report, or null
|
||||
** when the settings cannot open one (for example no recording link).
|
||||
*******************************************************************************/
|
||||
Map<String, String> recordingParams(QRecord report, QRecord settings);
|
||||
|
||||
/*******************************************************************************
|
||||
** The head content of the public recorder page: the provider's
|
||||
** identification, its scripts and one inline module script that installs
|
||||
** the sanitizers built by the given JavaScript expression.
|
||||
*******************************************************************************/
|
||||
String recorderHead(QRecord settings, String sanitizersExpression);
|
||||
|
||||
/*******************************************************************************
|
||||
** Handle a provider webhook (POST /qqq/hooks/qa-capture/{name}). The default
|
||||
** has no webhook (404).
|
||||
*******************************************************************************/
|
||||
default QaCaptureWebhookResult handleWebhook(QaCaptureWebhookRequest request, QaCaptureWebhookContext context) throws QException
|
||||
{
|
||||
return (QaCaptureWebhookResult.notFound());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.spi;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What a provider's webhook handler may use: its configured secret, the
|
||||
** environment's settings row and the QA reports (read and written in the
|
||||
** system context; field names are the qa_capture_report QQQ field names).
|
||||
*******************************************************************************/
|
||||
public interface QaCaptureWebhookContext
|
||||
{
|
||||
/*******************************************************************************
|
||||
** The provider's webhook signing secret from the QBit config, or null when
|
||||
** none is configured (the webhook then answers 404).
|
||||
*******************************************************************************/
|
||||
String getWebhookSecret();
|
||||
|
||||
/*******************************************************************************
|
||||
** The settings row of the configured environment, or null.
|
||||
*******************************************************************************/
|
||||
QRecord getSettings() throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** The configured environment name.
|
||||
*******************************************************************************/
|
||||
String getEnvironment();
|
||||
|
||||
/*******************************************************************************
|
||||
** A URL with the active sanitizer rules' query parameters redacted (and
|
||||
** capped in length), for storing page URLs a provider reports.
|
||||
*******************************************************************************/
|
||||
String sanitizeUrl(String url) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** The report with this reference, or null.
|
||||
*******************************************************************************/
|
||||
QRecord findReportByReference(String reference) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** The report holding this provider capture id (jamId), or null.
|
||||
*******************************************************************************/
|
||||
QRecord findReportByCaptureId(String captureId) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert a report (reference, environment and status are filled when
|
||||
** missing); returns the stored row.
|
||||
*******************************************************************************/
|
||||
QRecord insertReport(QRecord report) throws QException;
|
||||
|
||||
/*******************************************************************************
|
||||
** Update a report by id; returns the stored row.
|
||||
*******************************************************************************/
|
||||
QRecord updateReport(QRecord report) throws QException;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.spi;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One provider webhook delivery: its headers (names lowercase), raw body and
|
||||
** the time it was received.
|
||||
*******************************************************************************/
|
||||
public class QaCaptureWebhookRequest
|
||||
{
|
||||
private final Map<String, String> headers = new TreeMap<>();
|
||||
private final String body;
|
||||
private final Instant receivedAt;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Constructor.
|
||||
**
|
||||
** @param headers request headers (any case)
|
||||
** @param body the raw body
|
||||
** @param receivedAt when it arrived
|
||||
*******************************************************************************/
|
||||
public QaCaptureWebhookRequest(Map<String, String> headers, String body, Instant receivedAt)
|
||||
{
|
||||
if(headers != null)
|
||||
{
|
||||
headers.forEach((name, value) -> this.headers.put(name.toLowerCase(Locale.ROOT), value));
|
||||
}
|
||||
this.body = body == null ? "" : body;
|
||||
this.receivedAt = receivedAt == null ? Instant.now() : receivedAt;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A header by (case-insensitive) name, or null.
|
||||
*******************************************************************************/
|
||||
public String getHeader(String name)
|
||||
{
|
||||
return (name == null ? null : headers.get(name.toLowerCase(Locale.ROOT)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for body
|
||||
*******************************************************************************/
|
||||
public String getBody()
|
||||
{
|
||||
return (this.body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for receivedAt
|
||||
*******************************************************************************/
|
||||
public Instant getReceivedAt()
|
||||
{
|
||||
return (this.receivedAt);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.spi;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The HTTP answer to a provider webhook: a status and a small JSON object.
|
||||
**
|
||||
** @param status HTTP status
|
||||
** @param body JSON body
|
||||
*******************************************************************************/
|
||||
public record QaCaptureWebhookResult(int status, Map<String, Object> body)
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Copy the body, keeping its order.
|
||||
*******************************************************************************/
|
||||
public QaCaptureWebhookResult
|
||||
{
|
||||
body = body == null ? Map.of() : new LinkedHashMap<>(body);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 200 with an outcome ("matched", "created", "duplicate", "ignored").
|
||||
*******************************************************************************/
|
||||
public static QaCaptureWebhookResult ok(String outcome, String reference)
|
||||
{
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("outcome", outcome);
|
||||
body.put("reference", reference);
|
||||
return (new QaCaptureWebhookResult(200, body));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An error status with a message (never echoing secrets or the payload).
|
||||
*******************************************************************************/
|
||||
public static QaCaptureWebhookResult error(int status, String message)
|
||||
{
|
||||
return (new QaCaptureWebhookResult(status, Map.of("error", message)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 404: the provider has no webhook, or it is not configured.
|
||||
*******************************************************************************/
|
||||
public static QaCaptureWebhookResult notFound()
|
||||
{
|
||||
return (error(404, "Not found"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.spi;
|
||||
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verifies Svix-signed webhooks (Jam uses Svix): the signature is
|
||||
** base64(HMAC-SHA256(key, "{svix-id}.{svix-timestamp}.{body}")), the key is
|
||||
** the base64 part of a "whsec_..." secret, and svix-signature carries one or
|
||||
** more space-separated "v1,{signature}" entries (several during secret
|
||||
** rotation). Timestamps more than five minutes away from now are rejected
|
||||
** (replay protection); signatures are compared in constant time.
|
||||
*******************************************************************************/
|
||||
public final class SvixSignatureVerifier
|
||||
{
|
||||
public static final Duration TOLERANCE = Duration.ofMinutes(5);
|
||||
|
||||
private static final String SECRET_PREFIX = "whsec_";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SvixSignatureVerifier()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Outcome of a verification.
|
||||
*******************************************************************************/
|
||||
public enum Outcome
|
||||
{
|
||||
VALID,
|
||||
MISSING_HEADERS,
|
||||
BAD_TIMESTAMP,
|
||||
BAD_SIGNATURE
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Verify one delivery.
|
||||
**
|
||||
** @param secret the endpoint secret ("whsec_" + base64, or plain base64)
|
||||
** @param id the svix-id header
|
||||
** @param timestamp the svix-timestamp header (unix seconds)
|
||||
** @param signature the svix-signature header
|
||||
** @param body the raw body
|
||||
** @param now the current time
|
||||
** @return the outcome
|
||||
*******************************************************************************/
|
||||
public static Outcome verify(String secret, String id, String timestamp, String signature, String body, Instant now)
|
||||
{
|
||||
if(isBlank(secret) || isBlank(id) || isBlank(timestamp) || isBlank(signature))
|
||||
{
|
||||
return (Outcome.MISSING_HEADERS);
|
||||
}
|
||||
|
||||
long seconds;
|
||||
try
|
||||
{
|
||||
seconds = Long.parseLong(timestamp.trim());
|
||||
}
|
||||
catch(NumberFormatException e)
|
||||
{
|
||||
return (Outcome.BAD_TIMESTAMP);
|
||||
}
|
||||
if(Math.abs(now.getEpochSecond() - seconds) > TOLERANCE.toSeconds())
|
||||
{
|
||||
return (Outcome.BAD_TIMESTAMP);
|
||||
}
|
||||
|
||||
byte[] expected;
|
||||
try
|
||||
{
|
||||
expected = sign(secret, id.trim(), timestamp.trim(), body == null ? "" : body);
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
return (Outcome.BAD_SIGNATURE);
|
||||
}
|
||||
|
||||
boolean matched = false;
|
||||
for(String entry : signature.trim().split("\\s+"))
|
||||
{
|
||||
int comma = entry.indexOf(',');
|
||||
if(comma < 0 || !"v1".equals(entry.substring(0, comma)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
byte[] candidate;
|
||||
try
|
||||
{
|
||||
candidate = Base64.getDecoder().decode(entry.substring(comma + 1));
|
||||
}
|
||||
catch(IllegalArgumentException e)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
//////////////////////////////////////////////////////////////////
|
||||
// constant time per candidate; keep checking all entries so the //
|
||||
// timing does not reveal which entry matched //
|
||||
//////////////////////////////////////////////////////////////////
|
||||
matched |= MessageDigest.isEqual(expected, candidate);
|
||||
}
|
||||
return (matched ? Outcome.VALID : Outcome.BAD_SIGNATURE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The raw signature bytes for a delivery (also used by tests to sign).
|
||||
**
|
||||
** @throws IllegalArgumentException when the secret is not valid base64
|
||||
*******************************************************************************/
|
||||
public static byte[] sign(String secret, String id, String timestamp, String body)
|
||||
{
|
||||
String encodedKey = secret.trim().startsWith(SECRET_PREFIX) ? secret.trim().substring(SECRET_PREFIX.length()) : secret.trim();
|
||||
byte[] key = Base64.getDecoder().decode(encodedKey);
|
||||
try
|
||||
{
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
return (mac.doFinal((id + "." + timestamp + "." + body).getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
catch(GeneralSecurityException e)
|
||||
{
|
||||
throw (new IllegalStateException("HmacSHA256 is not available", e));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static boolean isBlank(String value)
|
||||
{
|
||||
return (value == null || value.isBlank());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.web;
|
||||
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
import org.apache.logging.log4j.ThreadContext;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Request correlation ids: every /qqq/* and /qa/* response carries
|
||||
** X-Request-Id (a valid incoming value is reused, else a new UUID), and the
|
||||
** id is in the log4j ThreadContext under "requestId" while the request runs
|
||||
** (log pattern %X{requestId}). Other code reads it with current().
|
||||
*******************************************************************************/
|
||||
public final class QaCaptureRequestIds
|
||||
{
|
||||
public static final String HEADER = "X-Request-Id";
|
||||
public static final String THREAD_CONTEXT_KEY = "requestId";
|
||||
|
||||
private static final Pattern VALID = Pattern.compile("[A-Za-z0-9._:-]{8,128}");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private QaCaptureRequestIds()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The current request's id, or null outside a request.
|
||||
*******************************************************************************/
|
||||
public static String current()
|
||||
{
|
||||
return (ThreadContext.get(THREAD_CONTEXT_KEY));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The id to use for a request: the incoming value when it is valid, else a
|
||||
** new random UUID.
|
||||
*******************************************************************************/
|
||||
public static String resolve(String incoming)
|
||||
{
|
||||
if(incoming != null && VALID.matcher(incoming.trim()).matches())
|
||||
{
|
||||
return (incoming.trim());
|
||||
}
|
||||
return (UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Whether a value is a valid request id.
|
||||
*******************************************************************************/
|
||||
public static boolean isValid(String value)
|
||||
{
|
||||
return (value != null && VALID.matcher(value).matches());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Put an id in the ThreadContext.
|
||||
*******************************************************************************/
|
||||
static void bind(String requestId)
|
||||
{
|
||||
ThreadContext.put(THREAD_CONTEXT_KEY, requestId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remove the id from the ThreadContext.
|
||||
*******************************************************************************/
|
||||
static void clear()
|
||||
{
|
||||
ThreadContext.remove(THREAD_CONTEXT_KEY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.web;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureQBitConfig;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureRuntime;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureService;
|
||||
import com.kingsrook.qbits.qacapture.service.SecuredDataRuleSync;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureProvider;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureWebhookRequest;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureWebhookResult;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.logging.QLogger;
|
||||
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.utils.CollectionUtils;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import io.javalin.http.ContentType;
|
||||
import io.javalin.http.Context;
|
||||
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The QBit's raw Javalin routes (the UI routes are the qa-capture UI API
|
||||
** extension):
|
||||
** - before and after handlers on /qqq/* and /qa/* for request ids
|
||||
** (QaCaptureRequestIds);
|
||||
** - GET /qa/recorder, the public recorder page (404 when capture is off);
|
||||
** - POST /qqq/hooks/qa-capture/{provider}, provider webhooks (Jam: Svix
|
||||
** signed jam.created), at most 1 MB.
|
||||
** Setting the instance also adds the secured-data sanitizer rules (once per
|
||||
** instance, never failing startup).
|
||||
*******************************************************************************/
|
||||
public class QaCaptureRouteProvider implements QJavalinRouteProviderInterface
|
||||
{
|
||||
private static final QLogger LOG = QLogger.getLogger(QaCaptureRouteProvider.class);
|
||||
|
||||
public static final String WEBHOOK_PREFIX = "/qqq/hooks/qa-capture/";
|
||||
public static final List<String> REQUEST_ID_PATHS = List.of("/qqq/*", "/qa/*");
|
||||
public static final int MAX_WEBHOOK_BYTES = 1024 * 1024;
|
||||
|
||||
private static final String REQUEST_ID_ATTRIBUTE = "qaCaptureRequestId";
|
||||
|
||||
private volatile QInstance qInstance;
|
||||
private volatile QInstance syncedInstance;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Register this provider once with the instance's Javalin meta-data.
|
||||
*******************************************************************************/
|
||||
public static void register(QInstance qInstance)
|
||||
{
|
||||
QJavalinMetaData javalinMetaData = QJavalinMetaData.ofOrWithNew(qInstance);
|
||||
boolean registered = CollectionUtils.nonNullList(javalinMetaData.getAdditionalRouteProviderReferences()).stream()
|
||||
.anyMatch(reference -> reference != null && QaCaptureRouteProvider.class.getName().equals(reference.getName()));
|
||||
if(!registered)
|
||||
{
|
||||
javalinMetaData.withAdditionalRouteProviderReference(new QCodeReference(QaCaptureRouteProvider.class));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Remember the (new) instance; add secured-data rules once per instance.
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setQInstance(QInstance qInstance)
|
||||
{
|
||||
this.qInstance = qInstance;
|
||||
QaCaptureRuntime.setCurrentInstance(qInstance);
|
||||
QaCaptureRuntime runtime = QaCaptureRuntime.find(qInstance);
|
||||
if(runtime != null && Boolean.TRUE.equals(runtime.getConfig().getSyncSecuredDataRules()) && syncedInstance != qInstance)
|
||||
{
|
||||
syncedInstance = qInstance;
|
||||
try
|
||||
{
|
||||
SecuredDataRuleSync.sync(qInstance);
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not add QA capture sanitizer rules for secure fields (capture still uses the existing rules)", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
for(String path : REQUEST_ID_PATHS)
|
||||
{
|
||||
config.routes.before(path, this::beforeRequest);
|
||||
config.routes.after(path, this::afterRequest);
|
||||
}
|
||||
config.routes.get(RecorderPage.PATH, this::recorder);
|
||||
config.routes.post(WEBHOOK_PREFIX + "{provider}", this::webhook);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Assign the request id: response header and ThreadContext.
|
||||
*******************************************************************************/
|
||||
void beforeRequest(Context context)
|
||||
{
|
||||
String requestId = QaCaptureRequestIds.resolve(context.header(QaCaptureRequestIds.HEADER));
|
||||
context.attribute(REQUEST_ID_ATTRIBUTE, requestId);
|
||||
context.header(QaCaptureRequestIds.HEADER, requestId);
|
||||
QaCaptureRequestIds.bind(requestId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Keep the header (an error handler may have replaced the response) and
|
||||
** clear the ThreadContext; after handlers run after exceptions too.
|
||||
*******************************************************************************/
|
||||
void afterRequest(Context context)
|
||||
{
|
||||
try
|
||||
{
|
||||
String requestId = context.attribute(REQUEST_ID_ATTRIBUTE);
|
||||
if(requestId != null && context.res().getHeader(QaCaptureRequestIds.HEADER) == null)
|
||||
{
|
||||
context.header(QaCaptureRequestIds.HEADER, requestId);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
QaCaptureRequestIds.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** GET /qa/recorder: 200 with the page (never a redirect), or 404.
|
||||
*******************************************************************************/
|
||||
void recorder(Context context)
|
||||
{
|
||||
context.header("Cache-Control", "no-store");
|
||||
context.header("X-Content-Type-Options", "nosniff");
|
||||
context.header("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
try
|
||||
{
|
||||
RecorderPage.Page page = RecorderPage.build(qInstance);
|
||||
if(page == null)
|
||||
{
|
||||
context.status(404).contentType(ContentType.TEXT_PLAIN).result("Not found");
|
||||
return;
|
||||
}
|
||||
context.header("Content-Security-Policy", page.contentSecurityPolicy());
|
||||
context.status(200).contentType(ContentType.TEXT_HTML).result(page.html());
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("Could not render the QA recorder page", e);
|
||||
context.status(500).contentType(ContentType.TEXT_PLAIN).result("QA recorder unavailable");
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** POST /qqq/hooks/qa-capture/{provider}.
|
||||
*******************************************************************************/
|
||||
void webhook(Context context)
|
||||
{
|
||||
QaCaptureWebhookResult result;
|
||||
try
|
||||
{
|
||||
QaCaptureQBitConfig config = QaCaptureRuntime.of(qInstance).getConfig();
|
||||
QaCaptureProvider provider = config.getProvider(context.pathParam("provider"));
|
||||
if(provider == null)
|
||||
{
|
||||
result = QaCaptureWebhookResult.notFound();
|
||||
}
|
||||
else if(context.contentLength() > MAX_WEBHOOK_BYTES)
|
||||
{
|
||||
result = QaCaptureWebhookResult.error(413, "The payload is larger than 1 MB.");
|
||||
}
|
||||
else
|
||||
{
|
||||
byte[] bytes = context.bodyAsBytes();
|
||||
if(bytes.length > MAX_WEBHOOK_BYTES)
|
||||
{
|
||||
result = QaCaptureWebhookResult.error(413, "The payload is larger than 1 MB.");
|
||||
}
|
||||
else
|
||||
{
|
||||
QaCaptureWebhookRequest request = new QaCaptureWebhookRequest(context.headerMap(), new String(bytes, StandardCharsets.UTF_8),
|
||||
QaCaptureRuntime.of(qInstance).getClock().instant());
|
||||
result = provider.handleWebhook(request, new QaCaptureService(qInstance).webhookContext(provider));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
LOG.warn("QA capture webhook failed", e, logPair("provider", context.pathParam("provider")));
|
||||
result = QaCaptureWebhookResult.error(500, "The webhook could not be processed.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
context.status(result.status()).contentType(ContentType.APPLICATION_JSON).result(JsonUtils.toJson(result.body() == null ? Map.of() : result.body()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.web;
|
||||
|
||||
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.qacapture.QaCaptureRuntime;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureSettings;
|
||||
import com.kingsrook.qbits.qacapture.service.SanitizerRules;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureCspOrigins;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureProvider;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.routeproviders.NextDashboardRouteProvider;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The public recorder page (GET /qa/recorder): the page a provider verifies
|
||||
** a connected domain with, and a landing page for recording links. It holds
|
||||
** the provider's identification and scripts and one inline module script
|
||||
** that installs the sanitizers built from every active rule (built-in,
|
||||
** secured-data and administrator rules; nothing user dependent). Its own
|
||||
** Content-Security-Policy allows exactly that inline script (by its sha256
|
||||
** hash) and the provider's origins.
|
||||
*******************************************************************************/
|
||||
public final class RecorderPage
|
||||
{
|
||||
public static final String PATH = "/qa/recorder";
|
||||
|
||||
/**
|
||||
** The page.
|
||||
**
|
||||
** @param html the document
|
||||
** @param contentSecurityPolicy its Content-Security-Policy
|
||||
*/
|
||||
public record Page(String html, String contentSecurityPolicy)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private RecorderPage()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The page for the instance's environment, or null when capture is off or
|
||||
** not configured there.
|
||||
*******************************************************************************/
|
||||
public static Page build(QInstance qInstance) throws QException
|
||||
{
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () ->
|
||||
{
|
||||
QRecord settings = QaCaptureSettings.load(qInstance);
|
||||
if(!QaCaptureSettings.isActive(qInstance, settings))
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
QaCaptureProvider provider = QaCaptureRuntime.of(qInstance).getConfig().getProvider(settings.getValueString("provider"));
|
||||
SanitizerRules rules = SanitizerRules.load();
|
||||
String html = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
|
||||
+ "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
|
||||
+ "<meta name=\"robots\" content=\"noindex, nofollow\">\n"
|
||||
+ "<title>QA recorder</title>\n"
|
||||
+ provider.recorderHead(settings, SanitizerScript.expression(rules))
|
||||
+ "</head>\n<body>\n<main>\n<h1>QA recorder</h1>\n"
|
||||
+ "<p>This page connects the bug recorder to this site. Open the app and use Report a bug to record an issue.</p>\n"
|
||||
+ "</main>\n</body>\n</html>\n";
|
||||
return (new Page(html, contentSecurityPolicy(provider.getCspOrigins(), NextDashboardRouteProvider.inlineScriptHashes(html))));
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The page's policy: same-origin by default, the provider's script, frame
|
||||
** and connect origins, and the inline script hashes.
|
||||
*******************************************************************************/
|
||||
static String contentSecurityPolicy(QaCaptureCspOrigins origins, List<String> scriptHashes)
|
||||
{
|
||||
Set<String> script = new LinkedHashSet<>(List.of("'self'"));
|
||||
script.addAll(origins.scriptSrc());
|
||||
script.addAll(scriptHashes);
|
||||
Set<String> frame = new LinkedHashSet<>(origins.frameSrc());
|
||||
if(frame.isEmpty())
|
||||
{
|
||||
frame.add("'none'");
|
||||
}
|
||||
Set<String> connect = new LinkedHashSet<>(List.of("'self'"));
|
||||
connect.addAll(origins.connectSrc());
|
||||
return ("default-src 'self'; script-src " + String.join(" ", script) + "; frame-src " + String.join(" ", frame)
|
||||
+ "; connect-src " + String.join(" ", connect) + "; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline'"
|
||||
+ "; worker-src 'self' blob:; media-src 'self' data: blob:"
|
||||
+ "; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture.web;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.qacapture.service.SanitizerRules;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A JavaScript expression that builds Jam-style sanitizers (network
|
||||
** request, response and WebSocket sanitizers and a console log sanitizer)
|
||||
** from a rule set, with the same semantics the capability contract gives
|
||||
** clients:
|
||||
** - headerNames: deleted from request and response headers (lowercase);
|
||||
** - bodyFieldNames: JSON keys at any depth and form fields, case-insensitive,
|
||||
** replaced by the redaction text (a JSON body stays JSON);
|
||||
** - queryParamNames: redacted in request URLs;
|
||||
** - valuePatterns: applied with the "g" flag to bodies, header values,
|
||||
** WebSocket data and console arguments;
|
||||
** - dropPathPrefixes: same-origin requests under these paths lose their
|
||||
** request and response bodies.
|
||||
** The sanitizers are synchronous, never log or fetch, and return copies.
|
||||
** The rules are embedded as JSON escaped for an inline script element.
|
||||
*******************************************************************************/
|
||||
public final class SanitizerScript
|
||||
{
|
||||
private static final String FUNCTION = """
|
||||
(function (R) {
|
||||
var lower = function (s) { return String(s).toLowerCase(); };
|
||||
var H = new Set(R.headerNames.map(lower));
|
||||
var B = new Set(R.bodyFieldNames.map(lower));
|
||||
var Q = new Set(R.queryParamNames.map(lower));
|
||||
var P = R.valuePatterns.map(function (p) { return new RegExp(p, "g"); });
|
||||
var scrub = function (t) {
|
||||
if (typeof t !== "string") { return t; }
|
||||
for (var i = 0; i < P.length; i++) { P[i].lastIndex = 0; t = t.replace(P[i], R.redaction); }
|
||||
return t;
|
||||
};
|
||||
var scrubValue = function (v) {
|
||||
if (Array.isArray(v)) { return v.map(scrubValue); }
|
||||
if (v && typeof v === "object") {
|
||||
var o = {};
|
||||
Object.keys(v).forEach(function (k) { o[k] = B.has(lower(k)) ? R.redaction : scrubValue(v[k]); });
|
||||
return o;
|
||||
}
|
||||
return scrub(v);
|
||||
};
|
||||
var body = function (b) {
|
||||
if (typeof b !== "string" || b === "") { return b; }
|
||||
try { return JSON.stringify(scrubValue(JSON.parse(b))); } catch (e) { /* not JSON */ }
|
||||
if (/^[^\\s=&]+=[^\\s]*$/.test(b)) {
|
||||
try {
|
||||
var form = new URLSearchParams(b);
|
||||
Array.from(form.keys()).forEach(function (k) { form.set(k, B.has(lower(k)) ? R.redaction : scrub(form.get(k))); });
|
||||
return form.toString();
|
||||
} catch (e) { /* not a form */ }
|
||||
}
|
||||
return scrub(b);
|
||||
};
|
||||
var url = function (u) {
|
||||
try {
|
||||
var x = new URL(u, location.href);
|
||||
var changed = false;
|
||||
Array.from(x.searchParams.keys()).forEach(function (k) { if (Q.has(lower(k))) { x.searchParams.set(k, R.redaction); changed = true; } });
|
||||
return changed ? x.toString() : u;
|
||||
} catch (e) { return u; }
|
||||
};
|
||||
var headers = function (h) {
|
||||
var o = {};
|
||||
Object.keys(h || {}).forEach(function (k) { if (!H.has(lower(k))) { o[k] = scrub(h[k]); } });
|
||||
return o;
|
||||
};
|
||||
var dropped = function (u) {
|
||||
try {
|
||||
var x = new URL(u, location.href);
|
||||
return x.origin === location.origin && R.dropPathPrefixes.some(function (p) { return x.pathname.indexOf(p) === 0; });
|
||||
} catch (e) { return false; }
|
||||
};
|
||||
return {
|
||||
network: {
|
||||
requestSanitizer: function (request) {
|
||||
var drop = dropped(request.url);
|
||||
request.url = url(request.url);
|
||||
request.headers = headers(request.headers);
|
||||
request.body = drop ? undefined : body(request.body);
|
||||
return request;
|
||||
},
|
||||
responseSanitizer: function (response) {
|
||||
response.headers = headers(response.headers);
|
||||
response.body = dropped(response.url) ? undefined : body(response.body);
|
||||
return response;
|
||||
},
|
||||
webSocketSanitizer: function (message) {
|
||||
message.data = body(message.data);
|
||||
return message;
|
||||
}
|
||||
},
|
||||
console: {
|
||||
logSanitizer: function (log) {
|
||||
log.args = (log.args || []).map(scrubValue);
|
||||
return log;
|
||||
}
|
||||
}
|
||||
};
|
||||
})""";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Utility class.
|
||||
*******************************************************************************/
|
||||
private SanitizerScript()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The expression (an immediately invoked function returning the sanitizers
|
||||
** object) for these rules.
|
||||
*******************************************************************************/
|
||||
public static String expression(SanitizerRules rules)
|
||||
{
|
||||
Map<String, Object> config = new LinkedHashMap<>(rules.toCapabilityMap());
|
||||
return (FUNCTION + "(" + scriptSafeJson(config) + ")");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** JSON that cannot end or confuse an inline script element: "<", ">", "&"
|
||||
** and the JavaScript line separators are written as unicode escapes.
|
||||
*******************************************************************************/
|
||||
static String scriptSafeJson(Object value)
|
||||
{
|
||||
return (JsonUtils.toJson(value)
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
.replace("
", "\\u2028")
|
||||
.replace("
", "\\u2029"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
-- liquibase formatted sql
|
||||
|
||||
-- qbit-qa-capture schema. The host includes this file from its master changelog:
|
||||
--
|
||||
-- - include: { file: db/liquibase/qbit-qa-capture/qa_capture.changelog.sql }
|
||||
--
|
||||
-- Rules for this file: the host runs it as its application database user (no
|
||||
-- superuser features, no extensions); the QBit never runs DDL; additive only
|
||||
-- (nothing is ever dropped or rewritten); every statement is idempotent
|
||||
-- (IF NOT EXISTS, guarded constraints, insert-only seeds). The QQQ table names
|
||||
-- equal these backend names. Nothing here references a WORM table.
|
||||
|
||||
-- changeset qbit-qa-capture:1
|
||||
-- comment: QA Capture Settings. One row per environment (matching the QBit config's environment name, for example prod): the provider, its team id and reusable recording link, the capture mode and extra blur selectors. Not seeded: the host adds its own row.
|
||||
CREATE TABLE IF NOT EXISTS qa_capture_settings (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
environment VARCHAR(50) NOT NULL,
|
||||
provider VARCHAR(50) DEFAULT 'jam' NOT NULL,
|
||||
team_id VARCHAR(100),
|
||||
recording_link_id VARCHAR(100),
|
||||
mode VARCHAR(30) DEFAULT 'OFF' NOT NULL,
|
||||
extra_blur_selectors TEXT,
|
||||
notes TEXT,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_qa_capture_settings PRIMARY KEY (id),
|
||||
CONSTRAINT uq_qa_capture_settings_environment UNIQUE (environment)
|
||||
);
|
||||
|
||||
-- changeset qbit-qa-capture:2
|
||||
-- comment: QA Capture Audience. Users (USER: user id or email) and roles (ROLE: a name the host's role resolver returns, by default the session's permission names) for whom capture is on in the AUDIENCE modes. Rows with source QA_MODE hold a user's self-serve QA mode switch when the host has no core key-value preference store.
|
||||
CREATE TABLE IF NOT EXISTS qa_capture_audience (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
settings_id BIGINT NOT NULL,
|
||||
audience_type VARCHAR(10) NOT NULL,
|
||||
value VARCHAR(250) NOT NULL,
|
||||
source VARCHAR(20) DEFAULT 'ADMIN' NOT NULL,
|
||||
active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
notes TEXT,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_qa_capture_audience PRIMARY KEY (id),
|
||||
CONSTRAINT uq_qa_capture_audience_member UNIQUE (settings_id, audience_type, value, source)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_qa_capture_audience_settings ON qa_capture_audience (settings_id, active);
|
||||
|
||||
-- changeset qbit-qa-capture:3
|
||||
-- comment: QA Capture Sanitizer Rules. What captures redact: header names, JSON/form body fields, URL query parameters, value patterns (valid in Java and JavaScript), dropped same-origin path prefixes and blur selectors. BUILT_IN rows are seeded by changeset 5, SECURED_DATA rows are added at startup for fields the instance marks secure, ADMIN rows by administrators. Active rows are the rule set served to clients.
|
||||
CREATE TABLE IF NOT EXISTS qa_capture_sanitizer_rule (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
rule_type VARCHAR(20) NOT NULL,
|
||||
value VARCHAR(500) NOT NULL,
|
||||
source VARCHAR(20) DEFAULT 'ADMIN' NOT NULL,
|
||||
source_ref VARCHAR(250),
|
||||
active BOOLEAN DEFAULT TRUE NOT NULL,
|
||||
description VARCHAR(500),
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_qa_capture_sanitizer_rule PRIMARY KEY (id),
|
||||
CONSTRAINT uq_qa_capture_sanitizer_rule UNIQUE (rule_type, value)
|
||||
);
|
||||
|
||||
-- changeset qbit-qa-capture:4
|
||||
-- comment: QA Reports. One reported bug: where it happened (page, app, table, record, process), the recent request ids, who reported it from which environment and build, and the provider capture (jam_id, jam_url) once the recording arrives by webhook. Created by Report a bug (POST /qqq/v1/qbit/qa-capture/report) or by an unmatched provider webhook.
|
||||
CREATE TABLE IF NOT EXISTS qa_capture_report (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
|
||||
reference VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(250),
|
||||
status VARCHAR(20) DEFAULT 'OPEN' NOT NULL,
|
||||
page_url VARCHAR(2000),
|
||||
app_name VARCHAR(100),
|
||||
table_name VARCHAR(100),
|
||||
record_id VARCHAR(100),
|
||||
process_name VARCHAR(100),
|
||||
request_ids TEXT,
|
||||
user_id VARCHAR(250),
|
||||
user_label VARCHAR(250),
|
||||
environment VARCHAR(50),
|
||||
provider VARCHAR(50),
|
||||
frontend_version VARCHAR(100),
|
||||
backend_version VARCHAR(100),
|
||||
node VARCHAR(250),
|
||||
user_agent VARCHAR(1000),
|
||||
viewport VARCHAR(30),
|
||||
jam_id VARCHAR(100),
|
||||
jam_url VARCHAR(1000),
|
||||
github_issue_url VARCHAR(1000),
|
||||
submitter_comment TEXT,
|
||||
create_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
modify_date TIMESTAMP WITHOUT TIME ZONE,
|
||||
CONSTRAINT pk_qa_capture_report PRIMARY KEY (id),
|
||||
CONSTRAINT uq_qa_capture_report_reference UNIQUE (reference),
|
||||
CONSTRAINT uq_qa_capture_report_jam_id UNIQUE (jam_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_qa_capture_report_status ON qa_capture_report (status, create_date);
|
||||
|
||||
-- changeset qbit-qa-capture:5
|
||||
-- comment: Built-in sanitizer rules, insert-only: a rule an administrator edited or deactivated is never changed back, and a deleted one is not re-added by this changeset (it runs once). Value patterns are valid in both Java and JavaScript.
|
||||
INSERT INTO qa_capture_sanitizer_rule (rule_type, value, source, active, description, create_date, modify_date)
|
||||
SELECT v.rule_type, v.value, 'BUILT_IN', TRUE, v.description, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
|
||||
FROM (VALUES
|
||||
('HEADER', 'authorization', 'Credentials'),
|
||||
('HEADER', 'cookie', 'Session cookies'),
|
||||
('HEADER', 'set-cookie', 'Session cookies'),
|
||||
('HEADER', 'proxy-authorization', 'Proxy credentials'),
|
||||
('HEADER', 'x-api-key', 'API key'),
|
||||
('HEADER', 'x-auth-token', 'Auth token'),
|
||||
('HEADER', 'x-csrf-token', 'CSRF token'),
|
||||
('HEADER', 'x-xsrf-token', 'XSRF token'),
|
||||
('BODY_FIELD', 'password', 'Password'),
|
||||
('BODY_FIELD', 'newPassword', 'Password'),
|
||||
('BODY_FIELD', 'currentPassword', 'Password'),
|
||||
('BODY_FIELD', 'confirmPassword', 'Password'),
|
||||
('BODY_FIELD', 'passwd', 'Password'),
|
||||
('BODY_FIELD', 'secret', 'Secret'),
|
||||
('BODY_FIELD', 'clientSecret', 'OAuth client secret'),
|
||||
('BODY_FIELD', 'client_secret', 'OAuth client secret'),
|
||||
('BODY_FIELD', 'token', 'Token'),
|
||||
('BODY_FIELD', 'accessToken', 'OAuth access token'),
|
||||
('BODY_FIELD', 'access_token', 'OAuth access token'),
|
||||
('BODY_FIELD', 'refreshToken', 'OAuth refresh token'),
|
||||
('BODY_FIELD', 'refresh_token', 'OAuth refresh token'),
|
||||
('BODY_FIELD', 'idToken', 'OIDC id token'),
|
||||
('BODY_FIELD', 'id_token', 'OIDC id token'),
|
||||
('BODY_FIELD', 'sessionUUID', 'QQQ session id'),
|
||||
('BODY_FIELD', 'sessionId', 'Session id'),
|
||||
('BODY_FIELD', 'session_id', 'Session id'),
|
||||
('BODY_FIELD', 'apiKey', 'API key'),
|
||||
('BODY_FIELD', 'api_key', 'API key'),
|
||||
('BODY_FIELD', 'authorization', 'Credentials'),
|
||||
('BODY_FIELD', 'code_verifier', 'PKCE code verifier'),
|
||||
('BODY_FIELD', 'otp', 'One-time password'),
|
||||
('BODY_FIELD', 'totp', 'One-time password'),
|
||||
('BODY_FIELD', 'mfaCode', 'MFA code'),
|
||||
('BODY_FIELD', 'ssn', 'Social security number'),
|
||||
('BODY_FIELD', 'cardNumber', 'Payment card number'),
|
||||
('BODY_FIELD', 'cvv', 'Payment card security code'),
|
||||
('QUERY_PARAM', 'code', 'OAuth authorization code'),
|
||||
('QUERY_PARAM', 'state', 'OAuth state'),
|
||||
('QUERY_PARAM', 'token', 'Token'),
|
||||
('QUERY_PARAM', 'access_token', 'OAuth access token'),
|
||||
('QUERY_PARAM', 'id_token', 'OIDC id token'),
|
||||
('QUERY_PARAM', 'refresh_token', 'OAuth refresh token'),
|
||||
('QUERY_PARAM', 'sessionUUID', 'QQQ session id'),
|
||||
('QUERY_PARAM', 'session_id', 'Session id'),
|
||||
('QUERY_PARAM', 'apiKey', 'API key'),
|
||||
('QUERY_PARAM', 'api_key', 'API key'),
|
||||
('VALUE_PATTERN', 'eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+', 'JSON web token'),
|
||||
('VALUE_PATTERN', '[Bb]earer\s+[A-Za-z0-9._~+/=-]+', 'Bearer credential'),
|
||||
('BLUR_SELECTOR', '[data-qqq-secure]', 'Values QQQ marks secure'),
|
||||
('BLUR_SELECTOR', 'input[type=password]', 'Password inputs')
|
||||
) AS v (rule_type, value, description)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM qa_capture_sanitizer_rule r WHERE r.rule_type = v.rule_type AND r.value = v.value);
|
||||
-- rollback SELECT 1;
|
||||
|
||||
-- changeset qbit-qa-capture:6
|
||||
-- comment: An audience row belongs to its settings row: deleting the settings deletes its audience. The tables are new in this changelog, so there are no orphans to quarantine; the precondition makes this a no-op for a host that added the constraint itself.
|
||||
-- preconditions onFail:MARK_RAN onError:HALT
|
||||
-- precondition-sql-check expectedResult:0 SELECT COUNT(*) FROM information_schema.table_constraints WHERE LOWER(constraint_name) = 'fk_qa_capture_audience_settings_id'
|
||||
ALTER TABLE qa_capture_audience ADD CONSTRAINT fk_qa_capture_audience_settings_id FOREIGN KEY (settings_id) REFERENCES qa_capture_settings (id) ON DELETE CASCADE;
|
||||
-- rollback ALTER TABLE qa_capture_audience DROP CONSTRAINT IF EXISTS fk_qa_capture_audience_settings_id;
|
||||
@@ -0,0 +1,137 @@
|
||||
bundle: qbit-qa-capture
|
||||
version: ${project.version}
|
||||
layer: QBIT
|
||||
entries:
|
||||
- key: app:qaCapture;slot:help
|
||||
summary: Bug capture for QA testing, with redaction rules and the reports testers file.
|
||||
content: |
|
||||
QA capture lets chosen users record a bug report with screen, console and network context while they use the app. Jam is the first provider.
|
||||
|
||||
Start with **QA Capture Settings**: add a row for this environment with the provider team id and set the mode. Choose who captures with **QA Capture Audience** (audience modes), or let users with the QA mode permission switch it on in their preferences (self-serve modes). **QA Capture Sanitizer Rules** decide what is removed before anything leaves the browser. Filed bugs arrive in **QA Reports**.
|
||||
|
||||
Permissions: `qa_capture.manage` edits settings, audience and sanitizer rules; `qa_capture.view_reports` reads and triages QA reports; `qa_capture.use_qa_mode` lets a user switch QA mode on for themselves.
|
||||
- key: table:qa_capture_settings;slot:help
|
||||
summary: How QA capture works in each environment, such as prod, with the provider team and capture mode.
|
||||
content: |
|
||||
**How rows get created:** An administrator adds one row per environment with New (or the host adds it in its own migration). The QBit seeds nothing here, because team ids belong to each installation.
|
||||
|
||||
The row whose environment matches the server's configured environment is the one in use. Capture is on only when the mode is not Off and a team id is set. Content-Security-Policy changes for the dashboard take effect on the next restart; the recorder page and the capability follow the row immediately.
|
||||
|
||||
For Jam: verify your domain with the recorder page at /qa/recorder, copy the team id from Jam's settings, create a reusable Recording Link for in-app Report a bug and paste its id here.
|
||||
|
||||
Requires `qa_capture.manage`.
|
||||
- key: table:qa_capture_settings;field:environment
|
||||
summary: The environment this row configures. It must match the server's configured environment name, for example prod.
|
||||
- key: table:qa_capture_settings;field:provider
|
||||
summary: The capture provider. Jam is the first provider.
|
||||
- key: table:qa_capture_settings;field:teamId
|
||||
summary: The provider team id. For Jam, the team id from the Jam workspace settings. Capture stays off without it.
|
||||
- key: table:qa_capture_settings;field:recordingLinkId
|
||||
summary: The id of a reusable provider recording link used by Report a bug. Optional.
|
||||
content: |
|
||||
Without it, users are told that an administrator must set a recording link before Report a bug can open the recorder.
|
||||
- key: table:qa_capture_settings;field:mode
|
||||
summary: Who can capture. Off, an audience only, self-serve QA mode, or both.
|
||||
content: |
|
||||
- **Off**: nobody; the browser loads nothing from the provider.
|
||||
- **Audience only**: the users and roles in QA Capture Audience.
|
||||
- **Self-serve QA mode**: users with the QA mode permission who switch QA mode on in their preferences.
|
||||
- **Audience and self-serve QA mode**: both.
|
||||
- key: table:qa_capture_settings;field:extraBlurSelectors
|
||||
summary: More CSS selectors to blur in recordings, one per line. Optional.
|
||||
- key: table:qa_capture_settings;field:notes
|
||||
summary: Notes for administrators. Optional.
|
||||
- key: table:qa_capture_audience;slot:help
|
||||
summary: The users and roles who capture bugs when the settings mode includes an audience.
|
||||
content: |
|
||||
**How rows get created:** An administrator adds users and roles with New. Rows with source QA mode are created when a user switches their own QA mode in preferences (only when the host has no core preference store); they never add the user to the audience.
|
||||
|
||||
A User row matches the user id or email address. A Role row matches a role name from the host (by default a permission name the user holds). Matching ignores case. Deactivate a row to stop capture for that member without deleting it.
|
||||
|
||||
Requires `qa_capture.manage`.
|
||||
- key: table:qa_capture_audience;field:settingsId
|
||||
summary: The environment settings this member belongs to.
|
||||
- key: table:qa_capture_audience;field:audienceType
|
||||
summary: User for one person, Role for everyone with that role.
|
||||
- key: table:qa_capture_audience;field:value
|
||||
summary: The user id or email address for a user, or the role name for a role.
|
||||
- key: table:qa_capture_audience;field:source
|
||||
summary: Administrator for members added here, or the user's own QA mode switch.
|
||||
- key: table:qa_capture_audience;field:active
|
||||
summary: Turn off to stop capture for this member while keeping the row.
|
||||
- key: table:qa_capture_audience;field:notes
|
||||
summary: Notes for administrators, such as why this member was added. Optional.
|
||||
- key: table:qa_capture_sanitizer_rule;slot:help
|
||||
summary: What captures redact before anything leaves the browser. Active rules are sent to every capturing browser.
|
||||
content: |
|
||||
**How rows get created:** The built-in rules are added by the QBit's database changelog. Rules for fields the application marks secure are added at startup. Administrators add their own with New.
|
||||
|
||||
Rule types: Header (removed from requests and responses), Body field (JSON keys at any depth and form fields), Query parameter (redacted in URLs), Value pattern (a regular expression valid in Java and JavaScript), Dropped path prefix (same-origin paths whose bodies are dropped) and Blur selector (CSS selectors blurred in recordings).
|
||||
|
||||
Built-in and secured-data rules are never changed back after you edit or deactivate them. Requires `qa_capture.manage`.
|
||||
- key: table:qa_capture_sanitizer_rule;field:ruleType
|
||||
summary: What kind of value the rule removes. Header, body field, query parameter, pattern, path or blur selector.
|
||||
- key: table:qa_capture_sanitizer_rule;field:value
|
||||
summary: The header, field or parameter name, pattern, path prefix or CSS selector. Header names are stored lowercase.
|
||||
content: |
|
||||
A value pattern must compile in Java and use only syntax JavaScript shares, and must not match empty text. A dropped path prefix starts with a slash.
|
||||
- key: table:qa_capture_sanitizer_rule;field:source
|
||||
summary: Built in, secured data (a field the application marks secure) or administrator.
|
||||
- key: table:qa_capture_sanitizer_rule;field:sourceRef
|
||||
summary: For secured-data rules, the table and field that marked the value secure.
|
||||
- key: table:qa_capture_sanitizer_rule;field:active
|
||||
summary: Turn off to stop applying this rule. Inactive rules are not sent to browsers.
|
||||
- key: table:qa_capture_sanitizer_rule;field:description
|
||||
summary: Why the rule exists. Optional.
|
||||
- key: table:qa_capture_report;slot:help
|
||||
summary: Bugs testers reported, with where they happened, the recording link and the request ids.
|
||||
content: |
|
||||
**How rows get created:** A tester with QA capture on uses Report a bug; the report is created and the provider recorder opens. When the recording is saved, the provider's webhook attaches it (status Captured). A recording made without Report a bug arrives as a new report.
|
||||
|
||||
Use the request ids to find the matching server log lines. Set the GitHub issue and status as you triage.
|
||||
|
||||
Requires `qa_capture.view_reports` (or `qa_capture.manage`).
|
||||
- key: table:qa_capture_report;field:reference
|
||||
summary: The report reference (qa- and a unique id), sent to the provider and matched by its webhook.
|
||||
- key: table:qa_capture_report;field:title
|
||||
summary: A short description of the bug.
|
||||
- key: table:qa_capture_report;field:status
|
||||
summary: Open, Captured (recording attached), Filed (issue created) or Closed.
|
||||
- key: table:qa_capture_report;field:pageUrl
|
||||
summary: The page the tester was on, without secret query parameters.
|
||||
- key: table:qa_capture_report;field:appName
|
||||
summary: The app the tester was in.
|
||||
- key: table:qa_capture_report;field:tableName
|
||||
summary: The table the tester was looking at.
|
||||
- key: table:qa_capture_report;field:recordId
|
||||
summary: The record the tester was looking at.
|
||||
- key: table:qa_capture_report;field:processName
|
||||
summary: The process the tester was running.
|
||||
- key: table:qa_capture_report;field:requestIds
|
||||
summary: The most recent request ids from the tester's browser, one per line. Search the server logs for them.
|
||||
- key: table:qa_capture_report;field:userId
|
||||
summary: The user id of the tester.
|
||||
- key: table:qa_capture_report;field:userLabel
|
||||
summary: The tester's name.
|
||||
- key: table:qa_capture_report;field:environment
|
||||
summary: The environment the report came from.
|
||||
- key: table:qa_capture_report;field:provider
|
||||
summary: The capture provider.
|
||||
- key: table:qa_capture_report;field:frontendVersion
|
||||
summary: The version of the app the tester's browser was running.
|
||||
- key: table:qa_capture_report;field:backendVersion
|
||||
summary: The server build version.
|
||||
- key: table:qa_capture_report;field:node
|
||||
summary: The server node that took the report.
|
||||
- key: table:qa_capture_report;field:userAgent
|
||||
summary: The tester's browser.
|
||||
- key: table:qa_capture_report;field:viewport
|
||||
summary: The browser window size.
|
||||
- key: table:qa_capture_report;field:jamId
|
||||
summary: The Jam capture id, set by the Jam webhook.
|
||||
- key: table:qa_capture_report;field:jamUrl
|
||||
summary: Opens the Jam recording.
|
||||
- key: table:qa_capture_report;field:githubIssueUrl
|
||||
summary: The GitHub issue for this bug, once filed.
|
||||
- key: table:qa_capture_report;field:submitterComment
|
||||
summary: What the tester wrote when saving the recording.
|
||||
@@ -0,0 +1,328 @@
|
||||
components:
|
||||
schemas:
|
||||
QaCaptureCapabilityV1:
|
||||
description: "What QA capture means for the signed-in user. Clients that see\
|
||||
\ enabled=false do nothing else."
|
||||
properties:
|
||||
blurSelectors:
|
||||
description: "CSS selectors to blur in recordings; empty when not enabled."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
canReport:
|
||||
description: "Whether the user can file a report (Report a bug)."
|
||||
type: "boolean"
|
||||
context:
|
||||
$ref: "#/components/schemas/QaCaptureContextV1"
|
||||
cspOrigins:
|
||||
$ref: "#/components/schemas/QaCaptureCspOriginsV1"
|
||||
enabled:
|
||||
description: "Whether capture is on for this user."
|
||||
type: "boolean"
|
||||
provider:
|
||||
description: "The provider name (for example jam); null when not enabled."
|
||||
nullable: true
|
||||
type: "string"
|
||||
providerConfig:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/QaCaptureProviderConfigV1"
|
||||
description: "Provider settings; null when not enabled."
|
||||
nullable: true
|
||||
qaMode:
|
||||
$ref: "#/components/schemas/QaCaptureQaModeV1"
|
||||
reason:
|
||||
description: "Why capture is on or off."
|
||||
enum:
|
||||
- "AUDIENCE"
|
||||
- "QA_MODE"
|
||||
- "OFF"
|
||||
- "NOT_CONFIGURED"
|
||||
- "NO_ACCESS"
|
||||
- "QA_MODE_OFF"
|
||||
type: "string"
|
||||
reportsHref:
|
||||
description: "The QA reports screen, only when the user can view reports.\
|
||||
\ Clients follow it, never build it."
|
||||
nullable: true
|
||||
type: "string"
|
||||
requestIdHeader:
|
||||
description: "The response header carrying the request id (X-Request-Id)."
|
||||
type: "string"
|
||||
sanitizer:
|
||||
allOf:
|
||||
- $ref: "#/components/schemas/QaCaptureSanitizerV1"
|
||||
description: "Redaction rules; null when not enabled."
|
||||
nullable: true
|
||||
type: "object"
|
||||
QaCaptureContextV1:
|
||||
description: "Server context for capture metadata."
|
||||
properties:
|
||||
backendVersion:
|
||||
description: "The backend build version."
|
||||
nullable: true
|
||||
type: "string"
|
||||
environment:
|
||||
description: "The environment name."
|
||||
type: "string"
|
||||
node:
|
||||
description: "The serving node."
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
QaCaptureCspOriginsV1:
|
||||
description: "Content-Security-Policy origins the provider needs; empty when\
|
||||
\ not enabled."
|
||||
properties:
|
||||
connectSrc:
|
||||
description: "connect-src sources."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
frameSrc:
|
||||
description: "frame-src sources."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
scriptSrc:
|
||||
description: "script-src sources."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
type: "object"
|
||||
QaCaptureErrorV1:
|
||||
description: "An error."
|
||||
properties:
|
||||
error:
|
||||
description: "What went wrong."
|
||||
type: "string"
|
||||
type: "object"
|
||||
QaCaptureProviderConfigV1:
|
||||
description: "Provider settings for the browser. For Jam: teamId, recordingLinkId\
|
||||
\ and the script URLs."
|
||||
properties:
|
||||
recordingLinkId:
|
||||
description: "A reusable recording link id for in-app Report a bug; null\
|
||||
\ when none is set."
|
||||
nullable: true
|
||||
type: "string"
|
||||
scriptUrls:
|
||||
description: "The provider's scripts, in load order."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
teamId:
|
||||
description: "The provider team id."
|
||||
type: "string"
|
||||
type: "object"
|
||||
QaCaptureQaModeV1:
|
||||
description: "The user's self-serve QA mode switch."
|
||||
properties:
|
||||
active:
|
||||
description: "Whether the user's QA mode is on."
|
||||
type: "boolean"
|
||||
available:
|
||||
description: "Whether the user can switch QA mode (self-serve mode and the\
|
||||
\ qa_capture.use_qa_mode permission)."
|
||||
type: "boolean"
|
||||
preferenceSection:
|
||||
description: "The user preference section holding the switch; null when\
|
||||
\ unavailable."
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
QaCaptureReportRequestV1:
|
||||
description: "A new QA report. Only pageUrl is required."
|
||||
properties:
|
||||
appName:
|
||||
description: "The current app."
|
||||
nullable: true
|
||||
type: "string"
|
||||
frontendVersion:
|
||||
description: "The client build version."
|
||||
nullable: true
|
||||
type: "string"
|
||||
pageUrl:
|
||||
description: "The page the user is on; sanitizer query parameters are removed."
|
||||
type: "string"
|
||||
processName:
|
||||
description: "The current process."
|
||||
nullable: true
|
||||
type: "string"
|
||||
recordId:
|
||||
description: "The current record id."
|
||||
nullable: true
|
||||
type: "string"
|
||||
requestIds:
|
||||
description: "Recent X-Request-Id values (at most 50 are kept, each at most\
|
||||
\ 128 characters)."
|
||||
items:
|
||||
type: "string"
|
||||
nullable: true
|
||||
type: "array"
|
||||
tableName:
|
||||
description: "The current table."
|
||||
nullable: true
|
||||
type: "string"
|
||||
title:
|
||||
description: "A short title; defaults to the table and record, process,\
|
||||
\ app or page."
|
||||
nullable: true
|
||||
type: "string"
|
||||
userAgent:
|
||||
description: "The browser user agent."
|
||||
nullable: true
|
||||
type: "string"
|
||||
viewport:
|
||||
description: "The viewport size, for example 1440x900."
|
||||
nullable: true
|
||||
type: "string"
|
||||
type: "object"
|
||||
QaCaptureReportResultV1:
|
||||
description: "The created report and how to open the provider's recorder for\
|
||||
\ it."
|
||||
properties:
|
||||
id:
|
||||
description: "The report id."
|
||||
type: "integer"
|
||||
recordingParams:
|
||||
additionalProperties:
|
||||
type: "string"
|
||||
description: "Query parameters that open the recorder (Jam: jam-recording,\
|
||||
\ jam-title, jam-reference); null when no recording link is configured."
|
||||
nullable: true
|
||||
type: "object"
|
||||
reference:
|
||||
description: "The report reference (qa-<uuid>), matched by the provider\
|
||||
\ webhook."
|
||||
type: "string"
|
||||
title:
|
||||
description: "The report title."
|
||||
type: "string"
|
||||
type: "object"
|
||||
QaCaptureSanitizerV1:
|
||||
description: "Redaction rules applied in the browser before anything is sent\
|
||||
\ to the provider."
|
||||
properties:
|
||||
bodyFieldNames:
|
||||
description: "JSON keys (any depth, case-insensitive) and form fields replaced\
|
||||
\ by the redaction text."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
dropPathPrefixes:
|
||||
description: "Same-origin path prefixes whose request and response bodies\
|
||||
\ are dropped."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
headerNames:
|
||||
description: "Lowercase header names deleted from request and response headers."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
queryParamNames:
|
||||
description: "Query parameters redacted in request URLs and the page URL."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
redaction:
|
||||
description: "The replacement text."
|
||||
type: "string"
|
||||
valuePatterns:
|
||||
description: "JavaScript RegExp sources applied with flag g to bodies and\
|
||||
\ console arguments."
|
||||
items:
|
||||
type: "string"
|
||||
type: "array"
|
||||
type: "object"
|
||||
securitySchemes:
|
||||
sessionUuidCookie:
|
||||
in: "cookie"
|
||||
name: "sessionUUID"
|
||||
type: "apiKey"
|
||||
info:
|
||||
contact:
|
||||
email: "contact@kingsrook.com"
|
||||
description: "QA bug capture: the user's capture capability and QA report creation"
|
||||
title: "QQQ Middleware API extension: qa-capture"
|
||||
version: "1.0.0"
|
||||
openapi: "3.0.3"
|
||||
paths:
|
||||
/qqq/v1/qbit/qa-capture/capability:
|
||||
get:
|
||||
description: "Whether QA capture is on for the signed-in user and why, the provider\
|
||||
\ settings, sanitizer rules, blur selectors, Content-Security-Policy origins\
|
||||
\ and server context. Requires a session; a user without access gets enabled=false,\
|
||||
\ never 403."
|
||||
operationId: "qaCaptureCapability"
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QaCaptureErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QaCaptureErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QaCaptureErrorV1"
|
||||
description: "QA capture is not enabled for this user"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QaCaptureCapabilityV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Read the QA capture capability"
|
||||
/qqq/v1/qbit/qa-capture/report:
|
||||
post:
|
||||
description: "Stores the report (page, app, table, record, process, recent request\
|
||||
\ ids, client build) with the user, environment, backend version, node and\
|
||||
\ provider filled in by the server, and returns its reference and the recorder\
|
||||
\ parameters."
|
||||
operationId: "qaCaptureReportCreate"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QaCaptureReportRequestV1"
|
||||
required: true
|
||||
responses:
|
||||
400:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QaCaptureErrorV1"
|
||||
description: "Invalid input"
|
||||
401:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QaCaptureErrorV1"
|
||||
description: "Sign in required"
|
||||
403:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QaCaptureErrorV1"
|
||||
description: "QA capture is not enabled for this user"
|
||||
200:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/QaCaptureReportResultV1"
|
||||
description: "Successful Response"
|
||||
security:
|
||||
- sessionUuidCookie:
|
||||
- "N/A"
|
||||
summary: "Create a QA report"
|
||||
@@ -0,0 +1 @@
|
||||
version=${project.version}
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.Statement;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureDb;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.authentication.QAuthenticationMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import com.kingsrook.qqq.backend.core.modules.authentication.QAuthenticationModuleCustomizerInterface;
|
||||
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
|
||||
import liquibase.Liquibase;
|
||||
import liquibase.database.Database;
|
||||
import liquibase.database.DatabaseFactory;
|
||||
import liquibase.database.jvm.JdbcConnection;
|
||||
import liquibase.resource.ClassLoaderResourceAccessor;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test base: an H2 database (PostgreSQL mode) created by running the
|
||||
** shipped changelog through real Liquibase, an "admin" host app and a
|
||||
** "patient" table with secure fields, the QBit produced into a validated
|
||||
** QInstance, and sessions by user id prefix:
|
||||
** admin-* (every qa_capture permission), tester-* (use_qa_mode),
|
||||
** viewer-* (view_reports), anyone else (no permissions).
|
||||
*******************************************************************************/
|
||||
public class BaseQaCaptureTest
|
||||
{
|
||||
public static final String BACKEND_NAME = "rdbms";
|
||||
public static final String JDBC_URL = "jdbc:h2:mem:qa_capture_test;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;DATABASE_TO_LOWER=TRUE;NON_KEYWORDS=VALUE";
|
||||
public static final String TEAM_ID = "0815d765-0953-4f79-91a3-f5ed435abbdd";
|
||||
public static final String ADMIN = "admin-1";
|
||||
public static final String TESTER = "tester-1";
|
||||
public static final String VIEWER = "viewer-1";
|
||||
public static final String NOBODY = "nobody-1";
|
||||
|
||||
protected QInstance qInstance;
|
||||
protected QaCaptureQBitConfig config;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Fresh database and instance; signed in as the admin.
|
||||
*******************************************************************************/
|
||||
@BeforeEach
|
||||
void baseSetUp() throws Exception
|
||||
{
|
||||
resetDatabase();
|
||||
qInstance = newInstance();
|
||||
config = new QaCaptureQBitConfig().withBackendName(BACKEND_NAME).withEnvironment("prod")
|
||||
.withBackendVersion("0.1.110").withNode("node-a").withParentAppName("admin")
|
||||
.withJamWebhookSecret("whsec_" + java.util.Base64.getEncoder().encodeToString("test-secret-key-0123456789".getBytes()));
|
||||
customizeConfig(config);
|
||||
new QaCaptureQBitProducer().withQBitConfig(config).produce(qInstance);
|
||||
new QInstanceValidator().validate(qInstance);
|
||||
loginAs(ADMIN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void baseTearDown()
|
||||
{
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Hook: change the config before production.
|
||||
*******************************************************************************/
|
||||
protected void customizeConfig(QaCaptureQBitConfig config)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host instance: backend, mock authentication, an admin app and a
|
||||
** patient table with a sensitive and a PHI-classified field.
|
||||
*******************************************************************************/
|
||||
public static QInstance newInstance()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.setEnforcePermissionsInCoreActions(true);
|
||||
RDBMSBackendMetaData backend = new RDBMSBackendMetaData().withName(BACKEND_NAME).withVendor("h2").withHostName("mem").withDatabaseName("qa_capture_test").withUsername("sa");
|
||||
backend.setJdbcUrl(JDBC_URL);
|
||||
instance.addBackend(backend);
|
||||
instance.withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("mock").withType(QAuthenticationType.MOCK)
|
||||
.withCustomizer(new QCodeReference(TestSessionCustomizer.class)));
|
||||
instance.addTable(new QTableMetaData().withName("patient").withBackendName(BACKEND_NAME).withPrimaryKeyField("id")
|
||||
.withField(new QFieldMetaData("id", QFieldType.INTEGER))
|
||||
.withField(new QFieldMetaData("name", QFieldType.STRING))
|
||||
.withField(new QFieldMetaData("ssn", QFieldType.STRING).withIsSensitive(true))
|
||||
.withField(new QFieldMetaData("diagnosisCode", QFieldType.STRING).withSupplementalMetaData(
|
||||
new com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData().withClassification(com.kingsrook.qqq.backend.core.actions.ai.DataClassification.PHI))));
|
||||
instance.addApp(new QAppMetaData().withName("admin").withLabel("Admin"));
|
||||
return (instance);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Drop everything and run the shipped changelog.
|
||||
*******************************************************************************/
|
||||
public static void resetDatabase() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(JDBC_URL, "sa", ""); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("DROP ALL OBJECTS");
|
||||
}
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(DriverManager.getConnection(JDBC_URL, "sa", "")));
|
||||
try(Liquibase liquibase = new Liquibase(QaCaptureSchema.CHANGELOG, new ClassLoaderResourceAccessor(), database))
|
||||
{
|
||||
liquibase.update("");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Sign in as a test user.
|
||||
*******************************************************************************/
|
||||
protected QSession loginAs(String userId)
|
||||
{
|
||||
QSession session = TestSessionCustomizer.sessionFor(userId);
|
||||
QContext.init(qInstance, session);
|
||||
return (session);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert the prod settings row (as the system); returns it.
|
||||
*******************************************************************************/
|
||||
protected QRecord insertSettings(String mode, String teamId, String recordingLinkId) throws QException
|
||||
{
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.insert(QaCaptureSchema.SETTINGS, new QRecord()
|
||||
.withValue("environment", "prod").withValue("provider", "jam").withValue("mode", mode)
|
||||
.withValue("teamId", teamId).withValue("recordingLinkId", recordingLinkId))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Insert an audience row (as the system).
|
||||
*******************************************************************************/
|
||||
protected QRecord insertAudience(QRecord settings, String type, String value, boolean active) throws QException
|
||||
{
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.insert(QaCaptureSchema.AUDIENCE, new QRecord()
|
||||
.withValue("settingsId", settings.getValue("id")).withValue("audienceType", type).withValue("value", value)
|
||||
.withValue("source", "ADMIN").withValue("active", active))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** All rows of a table (as the system).
|
||||
*******************************************************************************/
|
||||
protected List<QRecord> all(String table) throws QException
|
||||
{
|
||||
return (QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.query(table, new QQueryFilter())));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Mock sessions by user id prefix.
|
||||
*******************************************************************************/
|
||||
public static class TestSessionCustomizer implements QAuthenticationModuleCustomizerInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void customizeSession(QInstance qInstance, QSession qSession, Map<String, Object> context)
|
||||
{
|
||||
QSession granted = sessionFor(qSession.getUuid());
|
||||
qSession.setUser(granted.getUser());
|
||||
qSession.setPermissions(granted.getPermissions());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A session for a test user.
|
||||
*******************************************************************************/
|
||||
public static QSession sessionFor(String userId)
|
||||
{
|
||||
QSession session = new QSession().withUser(new QUser().withIdReference(userId).withFullName("Name of " + userId));
|
||||
session.getUser().setEmail(userId + "@example.com");
|
||||
session.setPermissions(new HashSet<>());
|
||||
if(userId != null && userId.startsWith("admin"))
|
||||
{
|
||||
session.withPermissions(QaCaptureSchema.PERMISSIONS);
|
||||
}
|
||||
else if(userId != null && userId.startsWith("tester"))
|
||||
{
|
||||
session.withPermission(QaCaptureSchema.PERMISSION_USE_QA_MODE);
|
||||
}
|
||||
else if(userId != null && userId.startsWith("viewer"))
|
||||
{
|
||||
session.withPermission(QaCaptureSchema.PERMISSION_VIEW_REPORTS);
|
||||
}
|
||||
return (session);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureService;
|
||||
import com.kingsrook.qbits.qacapture.spi.JamProvider;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureWebhookRequest;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureWebhookResult;
|
||||
import com.kingsrook.qbits.qacapture.spi.SvixSignatureVerifier;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The jam.created webhook against the real schema: unmatched captures
|
||||
** become reports, other teams and events are ignored, malformed payloads
|
||||
** are rejected, and without a secret the webhook does not exist.
|
||||
*******************************************************************************/
|
||||
class JamWebhookTest extends BaseQaCaptureTest
|
||||
{
|
||||
private final JamProvider jam = new JamProvider();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Deliver a payload, signed now.
|
||||
*******************************************************************************/
|
||||
private QaCaptureWebhookResult deliver(String body) throws Exception
|
||||
{
|
||||
String timestamp = String.valueOf(Instant.now().getEpochSecond());
|
||||
String signature = "v1," + Base64.getEncoder().encodeToString(SvixSignatureVerifier.sign(config.getWebhookSecret("jam"), "msg_x", timestamp, body));
|
||||
QaCaptureWebhookRequest request = new QaCaptureWebhookRequest(Map.of("Svix-Id", "msg_x", "Svix-Timestamp", timestamp, "Svix-Signature", signature), body, Instant.now());
|
||||
return (jam.handleWebhook(request, new QaCaptureService(qInstance).webhookContext(jam)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String payload(Map<String, Object> overrides) throws Exception
|
||||
{
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("type", "jam.created");
|
||||
payload.put("jamId", "jam-9");
|
||||
payload.put("jamUrl", "https://jam.dev/c/jam-9");
|
||||
payload.put("teamId", TEAM_ID);
|
||||
payload.put("title", "Checkout button does nothing");
|
||||
payload.put("originalUrl", "https://admin.example.com/app/orders?code=secret&tab=1");
|
||||
payload.put("author", Map.of("email", "qa@example.com", "name", "QA Person"));
|
||||
payload.putAll(overrides);
|
||||
return (JsonUtils.toJson(payload));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An unmatched capture becomes a report; a repeat is a duplicate.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void unmatchedCaptureCreatesReport() throws Exception
|
||||
{
|
||||
insertSettings("AUDIENCE", TEAM_ID, null);
|
||||
QaCaptureWebhookResult result = deliver(payload(Map.of("recordingLink", Map.of("publicId", "abc", "reference", "qa-unknown", "submitterComment", "note"))));
|
||||
assertThat(result.status()).isEqualTo(200);
|
||||
assertThat(result.body()).containsEntry("outcome", "created");
|
||||
|
||||
QRecord report = all(QaCaptureSchema.REPORT).get(0);
|
||||
assertThat(report.getValueString("reference")).isEqualTo(result.body().get("reference")).startsWith("qa-");
|
||||
assertThat(report.getValueString("title")).isEqualTo("Checkout button does nothing");
|
||||
assertThat(report.getValueString("status")).isEqualTo("CAPTURED");
|
||||
assertThat(report.getValueString("pageUrl")).isEqualTo("https://admin.example.com/app/orders?tab=1");
|
||||
assertThat(report.getValueString("userId")).isEqualTo("qa@example.com");
|
||||
assertThat(report.getValueString("userLabel")).isEqualTo("QA Person");
|
||||
assertThat(report.getValueString("environment")).isEqualTo("prod");
|
||||
assertThat(report.getValueString("submitterComment")).isEqualTo("note");
|
||||
|
||||
assertThat(deliver(payload(Map.of())).body()).containsEntry("outcome", "duplicate");
|
||||
assertThat(all(QaCaptureSchema.REPORT)).hasSize(1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Ignored and rejected deliveries.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void ignoredAndRejected() throws Exception
|
||||
{
|
||||
assertThat(deliver(payload(Map.of())).body()).as("no settings row").containsEntry("outcome", "ignored");
|
||||
insertSettings("AUDIENCE", TEAM_ID, null);
|
||||
assertThat(deliver(payload(Map.of("teamId", "another-team"))).body()).containsEntry("outcome", "ignored");
|
||||
assertThat(deliver(payload(Map.of("type", "jam.deleted"))).body()).containsEntry("outcome", "ignored");
|
||||
assertThat(deliver(payload(Map.of("jamId", " "))).status()).isEqualTo(400);
|
||||
assertThat(deliver("[1, 2]").status()).isEqualTo(400);
|
||||
assertThat(deliver("not json").status()).isEqualTo(400);
|
||||
assertThat(all(QaCaptureSchema.REPORT)).isEmpty();
|
||||
|
||||
config.withJamWebhookSecret(null);
|
||||
QaCaptureWebhookResult noSecret = jam.handleWebhook(new QaCaptureWebhookRequest(Map.of(), payload(Map.of()), Instant.now()), new QaCaptureService(qInstance).webhookContext(jam));
|
||||
assertThat(noSecret.status()).isEqualTo(404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import com.kingsrook.qbits.qacapture.api.QaCaptureUiApi;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.extensions.MiddlewareSpecExtensionsMetaData;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import com.kingsrook.qqq.middleware.javalin.tools.UiApiContract;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The committed extension fragment (src/main/resources/openapi/qqq-v1/
|
||||
** qa-capture.yaml) is exactly what the code generates, and describes the
|
||||
** two contract operations.
|
||||
*******************************************************************************/
|
||||
class QaCaptureApiContractTest extends BaseQaCaptureTest
|
||||
{
|
||||
public static final Path FRAGMENT = Path.of("src/main/resources/openapi/qqq-v1/qa-capture.yaml");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Regenerate only by explicit build property, then check the fragment.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void contract() throws Exception
|
||||
{
|
||||
var extension = QaCaptureUiApi.extension();
|
||||
var version = new MiddlewareVersionV1();
|
||||
String generated = UiApiContract.generateExtensionYaml(version, extension);
|
||||
if(Boolean.getBoolean("qacapture.openapi.update"))
|
||||
{
|
||||
Files.createDirectories(FRAGMENT.getParent());
|
||||
Files.writeString(FRAGMENT, generated);
|
||||
}
|
||||
assertThat(UiApiContract.check(generated, Files.readString(FRAGMENT), null, 1,
|
||||
"./mvnw -Dtest=QaCaptureApiContractTest -Dqacapture.openapi.update=true test")).isEmpty();
|
||||
|
||||
var model = version.generateExtensionOpenAPIModel("qqq", extension);
|
||||
assertThat(model.getComponents().getSchemas().keySet()).allMatch(name -> name.startsWith("QaCapture"));
|
||||
assertThat(model.getPaths().keySet()).containsExactlyInAnyOrder("/qqq/v1/qbit/qa-capture/capability", "/qqq/v1/qbit/qa-capture/report");
|
||||
assertThat(extension.getSemanticVersion()).isEqualTo("1.0.0");
|
||||
assertThat(extension.getEffectivePathPrefix()).isEqualTo("/qbit/qa-capture");
|
||||
assertThat(generated).contains("qaCaptureCapability", "qaCaptureReportCreate", "providerConfig", "recordingLinkId", "scriptUrls",
|
||||
"preferenceSection", "reportsHref", "bodyFieldNames", "dropPathPrefixes", "blurSelectors", "cspOrigins", "requestIdHeader",
|
||||
"recordingParams", "frontendVersion", "QA_MODE_OFF", "NOT_CONFIGURED");
|
||||
assertThat(generated).doesNotContain("/hooks/", "/qa/recorder");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Production registers the extension and the route provider once; the
|
||||
** routes can be switched off.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void registration() throws Exception
|
||||
{
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).containsOnlyKeys("qa-capture");
|
||||
QaCaptureUiApi.register(qInstance);
|
||||
com.kingsrook.qbits.qacapture.web.QaCaptureRouteProvider.register(qInstance);
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(qInstance).getExtensions()).hasSize(1);
|
||||
assertThat(QJavalinMetaData.of(qInstance).getAdditionalRouteProviderReferences()).hasSize(1);
|
||||
|
||||
resetDatabase();
|
||||
QInstance disabled = newInstance();
|
||||
new QaCaptureQBitProducer().withQBitConfig(new QaCaptureQBitConfig().withBackendName(BACKEND_NAME).withEnvironment("prod").withRegisterRoutes(false)).produce(disabled);
|
||||
assertThat(MiddlewareSpecExtensionsMetaData.of(disabled)).isNull();
|
||||
assertThat(QJavalinMetaData.of(disabled)).isNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.regex.Pattern;
|
||||
import com.kingsrook.qbits.qacapture.service.SanitizerRules;
|
||||
import liquibase.Liquibase;
|
||||
import liquibase.database.Database;
|
||||
import liquibase.database.DatabaseFactory;
|
||||
import liquibase.database.jvm.JdbcConnection;
|
||||
import liquibase.resource.ClassLoaderResourceAccessor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The shipped changelog through real Liquibase on H2 (PostgreSQL mode): it
|
||||
** creates the four tables and seeds the 50 built-in rules (all valid, the
|
||||
** value patterns valid in Java), a second update runs nothing, and the seed
|
||||
** statement run again inserts nothing and keeps an administrator's edit.
|
||||
*******************************************************************************/
|
||||
class QaCaptureChangelogTest
|
||||
{
|
||||
private static final String URL = "jdbc:h2:mem:qa_capture_changelog;MODE=PostgreSQL;DB_CLOSE_DELAY=-1;NON_KEYWORDS=VALUE";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void seedsAreInsertOnlyAndIdempotent() throws Exception
|
||||
{
|
||||
try(Connection connection = DriverManager.getConnection(URL, "sa", ""))
|
||||
{
|
||||
update();
|
||||
assertThat(count(connection, "SELECT COUNT(*) FROM qa_capture_sanitizer_rule WHERE source = 'BUILT_IN' AND active = TRUE")).isEqualTo(50);
|
||||
for(String table : QaCaptureSchema.TABLES)
|
||||
{
|
||||
assertThat(count(connection, "SELECT COUNT(*) FROM " + table)).as(table + " is queryable").isGreaterThanOrEqualTo(0);
|
||||
}
|
||||
assertThat(count(connection, "SELECT COUNT(*) FROM qa_capture_settings")).as("no host data is seeded").isZero();
|
||||
try(Statement statement = connection.createStatement(); ResultSet rules = statement.executeQuery("SELECT rule_type, value FROM qa_capture_sanitizer_rule"))
|
||||
{
|
||||
while(rules.next())
|
||||
{
|
||||
assertThat(SanitizerRules.validate(rules.getString(1), rules.getString(2))).as(rules.getString(2)).isNull();
|
||||
if("VALUE_PATTERN".equals(rules.getString(1)))
|
||||
{
|
||||
assertThat(Pattern.compile(rules.getString(2)).matcher("Authorization: Bearer abc.def-123 eyJhbGciOi.eyJzdWIi.c2lnbmF0dXJl").find()).isTrue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int changesets = count(connection, "SELECT COUNT(*) FROM databasechangelog");
|
||||
update();
|
||||
assertThat(count(connection, "SELECT COUNT(*) FROM databasechangelog")).isEqualTo(changesets);
|
||||
|
||||
try(Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("UPDATE qa_capture_sanitizer_rule SET active = FALSE, description = 'admin edit' WHERE rule_type = 'QUERY_PARAM' AND value = 'state'");
|
||||
statement.execute(seedStatement());
|
||||
}
|
||||
assertThat(count(connection, "SELECT COUNT(*) FROM qa_capture_sanitizer_rule")).isEqualTo(50);
|
||||
assertThat(count(connection, "SELECT COUNT(*) FROM qa_capture_sanitizer_rule WHERE value = 'state' AND active = FALSE AND description = 'admin edit'")).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The INSERT of changeset 5.
|
||||
*******************************************************************************/
|
||||
private static String seedStatement() throws Exception
|
||||
{
|
||||
String script;
|
||||
try(InputStream in = QaCaptureChangelogTest.class.getClassLoader().getResourceAsStream(QaCaptureSchema.CHANGELOG))
|
||||
{
|
||||
script = new String(in.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
String seed = script.substring(script.indexOf("-- changeset qbit-qa-capture:5"), script.indexOf("-- changeset qbit-qa-capture:6"));
|
||||
return (seed.substring(seed.indexOf("INSERT INTO"), seed.indexOf("-- rollback")).trim().replaceAll(";$", ""));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One real Liquibase update on its own connection.
|
||||
*******************************************************************************/
|
||||
private static void update() throws Exception
|
||||
{
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(DriverManager.getConnection(URL, "sa", "")));
|
||||
try(Liquibase liquibase = new Liquibase(QaCaptureSchema.CHANGELOG, new ClassLoaderResourceAccessor(), database))
|
||||
{
|
||||
liquibase.update("");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static int count(Connection connection, String sql) throws Exception
|
||||
{
|
||||
try(Statement statement = connection.createStatement(); ResultSet resultSet = statement.executeQuery(sql))
|
||||
{
|
||||
resultSet.next();
|
||||
return (resultSet.getInt(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureEnablement;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureEnablement.Decision;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureEnablement.Reason;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QSession;
|
||||
import com.kingsrook.qqq.backend.core.model.session.QUser;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The enablement rules, without a database.
|
||||
*******************************************************************************/
|
||||
class QaCaptureEnablementTest
|
||||
{
|
||||
private final QaCaptureQBitConfig config = new QaCaptureQBitConfig();
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QRecord settings(String mode, String teamId)
|
||||
{
|
||||
return (new QRecord().withValue("id", 1).withValue("environment", "prod").withValue("provider", "jam").withValue("mode", mode).withValue("teamId", teamId));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QRecord member(String type, String value, boolean active, String source)
|
||||
{
|
||||
return (new QRecord().withValue("settingsId", 1).withValue("audienceType", type).withValue("value", value).withValue("active", active).withValue("source", source));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QSession session(String id, String email, String... permissions)
|
||||
{
|
||||
return (new QSession().withUser(new QUser().withIdReference(id).withEmail(email)).withPermissions(permissions));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** No row, OFF, no team id and an unknown provider.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void notConfiguredAndOff()
|
||||
{
|
||||
QSession user = session("u1", "u1@example.com");
|
||||
assertThat(QaCaptureEnablement.decide(config, user, null, List.of(), false).reason()).isEqualTo(Reason.NOT_CONFIGURED);
|
||||
assertThat(QaCaptureEnablement.decide(config, user, settings("OFF", "team"), List.of(member("USER", "u1", true, "ADMIN")), true).reason()).isEqualTo(Reason.OFF);
|
||||
assertThat(QaCaptureEnablement.decide(config, user, settings("AUDIENCE", " "), List.of(member("USER", "u1", true, "ADMIN")), false).reason()).isEqualTo(Reason.NOT_CONFIGURED);
|
||||
assertThat(QaCaptureEnablement.decide(config, user, settings("AUDIENCE", "team").withValue("provider", "other"), List.of(), false).reason()).isEqualTo(Reason.NOT_CONFIGURED);
|
||||
assertThat(QaCaptureEnablement.decide(config, user, settings("bogus", "team"), List.of(), false).reason()).as("unknown mode fails closed").isEqualTo(Reason.OFF);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** USER rows match the id or the email, case-insensitively; inactive rows
|
||||
** and QA mode rows do not count.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void audienceUsers()
|
||||
{
|
||||
QSession user = session("James@Example.com", "james@example.com");
|
||||
Decision byId = QaCaptureEnablement.decide(config, user, settings("AUDIENCE", "team"), List.of(member("USER", "james@example.COM", true, "ADMIN")), false);
|
||||
assertThat(byId.enabled()).isTrue();
|
||||
assertThat(byId.reason()).isEqualTo(Reason.AUDIENCE);
|
||||
assertThat(byId.provider().getName()).isEqualTo("jam");
|
||||
|
||||
QSession other = session("u2", "U2@example.com");
|
||||
assertThat(QaCaptureEnablement.decide(config, other, settings("AUDIENCE", "team"), List.of(member("USER", "u2@example.com", true, null)), false).enabled()).isTrue();
|
||||
assertThat(QaCaptureEnablement.decide(config, other, settings("AUDIENCE", "team"), List.of(member("USER", "u2", false, "ADMIN")), false).reason()).isEqualTo(Reason.NO_ACCESS);
|
||||
assertThat(QaCaptureEnablement.decide(config, other, settings("AUDIENCE", "team"), List.of(member("USER", "u2", true, "QA_MODE")), false).reason()).isEqualTo(Reason.NO_ACCESS);
|
||||
assertThat(QaCaptureEnablement.decide(config, other, settings("SELF_SERVE", "team"), List.of(member("USER", "u2", true, "ADMIN")), false).reason())
|
||||
.as("self-serve only ignores the audience").isEqualTo(Reason.NO_ACCESS);
|
||||
assertThat(QaCaptureEnablement.decide(config, new QSession(), settings("AUDIENCE", "team"), List.of(member("USER", "u2", true, "ADMIN")), false).enabled()).isFalse();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** ROLE rows match the session's permissions by default, or the host's
|
||||
** role resolver.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void audienceRoles()
|
||||
{
|
||||
QSession user = session("u1", null, "qa.testers");
|
||||
assertThat(QaCaptureEnablement.decide(config, user, settings("AUDIENCE", "team"), List.of(member("ROLE", "QA.Testers", true, "ADMIN")), false).enabled()).isTrue();
|
||||
|
||||
QaCaptureQBitConfig withResolver = new QaCaptureQBitConfig().withRoleResolver(session -> List.of("kof22-admins"));
|
||||
assertThat(QaCaptureEnablement.decide(withResolver, user, settings("AUDIENCE", "team"), List.of(member("ROLE", "kof22-admins", true, "ADMIN")), false).enabled()).isTrue();
|
||||
assertThat(QaCaptureEnablement.decide(withResolver, user, settings("AUDIENCE", "team"), List.of(member("ROLE", "qa.testers", true, "ADMIN")), false).enabled()).isFalse();
|
||||
assertThat(new QaCaptureQBitConfig().withRoleResolver(session -> null).resolveRoles(user)).isEmpty();
|
||||
assertThat(config.resolveRoles(null)).isEmpty();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Self-serve: the permission and the user's QA mode.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void selfServe()
|
||||
{
|
||||
QSession tester = session("t1", null, QaCaptureSchema.PERMISSION_USE_QA_MODE);
|
||||
Decision off = QaCaptureEnablement.decide(config, tester, settings("SELF_SERVE", "team"), List.of(), false);
|
||||
assertThat(off.reason()).isEqualTo(Reason.QA_MODE_OFF);
|
||||
assertThat(off.qaModeAvailable()).isTrue();
|
||||
assertThat(off.qaModeActive()).isFalse();
|
||||
|
||||
Decision on = QaCaptureEnablement.decide(config, tester, settings("AUDIENCE_AND_SELF_SERVE", "team"), List.of(), true);
|
||||
assertThat(on.enabled()).isTrue();
|
||||
assertThat(on.reason()).isEqualTo(Reason.QA_MODE);
|
||||
|
||||
assertThat(QaCaptureEnablement.decide(config, session("t2", null), settings("SELF_SERVE", "team"), List.of(), true).reason())
|
||||
.as("QA mode without the permission is ignored").isEqualTo(Reason.NO_ACCESS);
|
||||
assertThat(QaCaptureEnablement.decide(config, tester, settings("AUDIENCE", "team"), List.of(), true).qaModeAvailable()).isFalse();
|
||||
|
||||
Decision both = QaCaptureEnablement.decide(config, tester, settings("AUDIENCE_AND_SELF_SERVE", "team"), List.of(member("USER", "t1", true, "ADMIN")), false);
|
||||
assertThat(both.reason()).as("audience wins").isEqualTo(Reason.AUDIENCE);
|
||||
assertThat(both.qaModeAvailable()).isTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import liquibase.Liquibase;
|
||||
import liquibase.database.Database;
|
||||
import liquibase.database.DatabaseFactory;
|
||||
import liquibase.database.jvm.JdbcConnection;
|
||||
import liquibase.resource.ClassLoaderResourceAccessor;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.testcontainers.containers.PostgreSQLContainer;
|
||||
import org.testcontainers.junit.jupiter.Container;
|
||||
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The changelog on PostgreSQL (the production database), run by Liquibase
|
||||
** as an ordinary application user (no superuser, no extensions):
|
||||
** - every table possible-value field and join of the QBit has a database
|
||||
** foreign key (or an exemption with a reason);
|
||||
** - the built-in rules are seeded;
|
||||
** - the foreign key changeset is a no-op on a database that already has the
|
||||
** constraint.
|
||||
** Skipped when Docker is not available.
|
||||
*******************************************************************************/
|
||||
@Testcontainers(disabledWithoutDocker = true)
|
||||
class QaCaptureForeignKeyTest
|
||||
{
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// references that are deliberately not foreign keys: none //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
private static final Map<String, String> EXEMPT = Map.of();
|
||||
|
||||
@Container
|
||||
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:17-alpine");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyReferenceHasAForeignKey() throws Exception
|
||||
{
|
||||
try(Connection admin = DriverManager.getConnection(POSTGRES.getJdbcUrl(), POSTGRES.getUsername(), POSTGRES.getPassword());
|
||||
Statement statement = admin.createStatement())
|
||||
{
|
||||
statement.execute("DROP SCHEMA public CASCADE");
|
||||
statement.execute("CREATE SCHEMA public");
|
||||
statement.execute("CREATE ROLE qa_app LOGIN PASSWORD 'qa_app' NOSUPERUSER NOCREATEDB NOCREATEROLE");
|
||||
statement.execute("GRANT USAGE, CREATE ON SCHEMA public TO qa_app");
|
||||
}
|
||||
|
||||
try(Connection connection = DriverManager.getConnection(POSTGRES.getJdbcUrl(), "qa_app", "qa_app");
|
||||
Statement statement = connection.createStatement())
|
||||
{
|
||||
migrate(connection);
|
||||
|
||||
Map<String, String> foreignKeys = foreignKeys(statement);
|
||||
List<String> missing = new ArrayList<>();
|
||||
for(String reference : references())
|
||||
{
|
||||
if(!foreignKeys.containsKey(reference) && !EXEMPT.containsKey(reference))
|
||||
{
|
||||
missing.add(reference);
|
||||
}
|
||||
}
|
||||
assertThat(missing).as("references without a foreign key (add one to the changelog, or an exemption with a reason)").isEmpty();
|
||||
assertThat(foreignKeys).containsExactlyEntriesOf(Map.of("qa_capture_audience.settings_id", "qa_capture_settings CASCADE"));
|
||||
assertThat(rows(statement, "SELECT CAST(COUNT(*) AS TEXT) FROM qa_capture_sanitizer_rule WHERE source = 'BUILT_IN'")).containsExactly("50");
|
||||
|
||||
statement.execute("DELETE FROM databasechangelog WHERE id = 'qbit-qa-capture:6'");
|
||||
migrate(connection);
|
||||
assertThat(foreignKeys(statement)).isEqualTo(foreignKeys);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Run the whole changelog.
|
||||
*******************************************************************************/
|
||||
private static void migrate(Connection connection) throws Exception
|
||||
{
|
||||
Database database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(new JdbcConnection(connection));
|
||||
Liquibase liquibase = new Liquibase(QaCaptureSchema.CHANGELOG, new ClassLoaderResourceAccessor(), database);
|
||||
liquibase.update("");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "child.column" for every table possible-value field and join of the QBit.
|
||||
*******************************************************************************/
|
||||
private static List<String> references() throws Exception
|
||||
{
|
||||
QInstance qInstance = BaseQaCaptureTest.newInstance();
|
||||
new QaCaptureQBitProducer().withQBitConfig(new QaCaptureQBitConfig().withBackendName(BaseQaCaptureTest.BACKEND_NAME).withEnvironment("prod")).produce(qInstance);
|
||||
List<String> references = new ArrayList<>();
|
||||
for(String tableName : QaCaptureSchema.TABLES)
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(tableName);
|
||||
for(QFieldMetaData field : table.getFields().values())
|
||||
{
|
||||
if(field.getPossibleValueSourceName() != null && qInstance.getTable(field.getPossibleValueSourceName()) != null)
|
||||
{
|
||||
references.add(table.getName() + "." + field.getBackendName());
|
||||
}
|
||||
}
|
||||
}
|
||||
for(QJoinMetaData join : qInstance.getJoins().values())
|
||||
{
|
||||
if(join.getLeftTable().startsWith("qa_capture_"))
|
||||
{
|
||||
QTableMetaData right = qInstance.getTable(join.getRightTable());
|
||||
join.getJoinOns().forEach(on -> references.add(right.getName() + "." + right.getField(on.getRightField()).getBackendName()));
|
||||
}
|
||||
}
|
||||
assertThat(references).as("the QBit's references").isNotEmpty();
|
||||
return (references);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "child.column" to "parent RULE" for every foreign key.
|
||||
*******************************************************************************/
|
||||
private static Map<String, String> foreignKeys(Statement statement) throws Exception
|
||||
{
|
||||
Map<String, String> foreignKeys = new HashMap<>();
|
||||
try(ResultSet resultSet = statement.executeQuery("""
|
||||
SELECT tc.table_name || '.' || kcu.column_name, ccu.table_name || ' ' || rc.delete_rule
|
||||
FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name
|
||||
JOIN information_schema.referential_constraints rc ON rc.constraint_name = tc.constraint_name
|
||||
JOIN information_schema.constraint_column_usage ccu ON ccu.constraint_name = tc.constraint_name
|
||||
WHERE tc.constraint_type = 'FOREIGN KEY'"""))
|
||||
{
|
||||
while(resultSet.next())
|
||||
{
|
||||
foreignKeys.put(resultSet.getString(1), resultSet.getString(2));
|
||||
}
|
||||
}
|
||||
return (foreignKeys);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The first column of each row, as text.
|
||||
*******************************************************************************/
|
||||
private static List<String> rows(Statement statement, String sql) throws Exception
|
||||
{
|
||||
List<String> rows = new ArrayList<>();
|
||||
try(ResultSet resultSet = statement.executeQuery(sql))
|
||||
{
|
||||
while(resultSet.next())
|
||||
{
|
||||
rows.add(resultSet.getString(1));
|
||||
}
|
||||
}
|
||||
return (rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.qacapture.metadata.QaCaptureHelp;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.QHelpContent;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.help.bundles.HelpBundles;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.testing.HelpBundleAssertions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Owner guardrails for every qa_capture_ table:
|
||||
** - runtime code (outside the metadata package) reads or writes it;
|
||||
** - it is seeded by the changelog or listed in STARTS_EMPTY with where its
|
||||
** rows come from;
|
||||
** - its help says how rows get created; the bundle covers every visible
|
||||
** field, the tables and the app;
|
||||
** - names: lowercase snake_case with the qa_capture_ prefix, QQQ table name
|
||||
** equal to the backend name; permissions qa_capture.*.
|
||||
*******************************************************************************/
|
||||
class QaCaptureGuardrailsTest extends BaseQaCaptureTest
|
||||
{
|
||||
private static final Path MAIN = Path.of("src", "main", "java", "com", "kingsrook", "qbits", "qacapture");
|
||||
|
||||
static final Map<String, String> SEEDED = Map.of(
|
||||
QaCaptureSchema.SANITIZER_RULE, "changeset 5: built-in header, body field, query parameter, value pattern and blur selector rules");
|
||||
|
||||
static final Map<String, String> STARTS_EMPTY = Map.of(
|
||||
QaCaptureSchema.SETTINGS, "per installation: the host or an administrator adds its environment row (team ids are not ours to seed)",
|
||||
QaCaptureSchema.AUDIENCE, "administrators add members; QA mode switches add QA_MODE rows",
|
||||
QaCaptureSchema.REPORT, "runtime: Report a bug and provider webhooks");
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is used by runtime code, not only declared.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsUsedAtRuntime() throws Exception
|
||||
{
|
||||
List<String> runtimeSource;
|
||||
try(Stream<Path> files = Files.walk(MAIN))
|
||||
{
|
||||
runtimeSource = files.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> !path.startsWith(MAIN.resolve("metadata")) && !path.endsWith("QaCaptureSchema.java"))
|
||||
.map(QaCaptureGuardrailsTest::read).toList();
|
||||
}
|
||||
for(String table : QaCaptureSchema.TABLES)
|
||||
{
|
||||
String constant = table.substring("qa_capture_".length()).toUpperCase(Locale.ROOT);
|
||||
Pattern use = Pattern.compile("QaCaptureSchema\\." + constant + "\\b");
|
||||
assertThat(runtimeSource.stream().anyMatch(source -> use.matcher(source).find())).as(table + " must be read or written by runtime code").isTrue();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every table is seeded or explained; only seeded tables are inserted into
|
||||
** by the changelog.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void everyTableIsSeededOrExplained() throws Exception
|
||||
{
|
||||
String changelog = Files.readString(Path.of("src", "main", "resources", QaCaptureSchema.CHANGELOG), StandardCharsets.UTF_8);
|
||||
for(String table : QaCaptureSchema.TABLES)
|
||||
{
|
||||
boolean seeded = SEEDED.containsKey(table);
|
||||
assertThat(seeded ^ STARTS_EMPTY.containsKey(table)).as(table + " must be seeded or listed in STARTS_EMPTY (not both)").isTrue();
|
||||
assertThat(changelog.contains("INSERT INTO " + table + " ")).as(table + " seeded iff the changelog inserts it").isEqualTo(seeded);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Help for every table (with the empty state), field and the app.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void helpCoversEverything() throws Exception
|
||||
{
|
||||
for(String table : QaCaptureSchema.TABLES)
|
||||
{
|
||||
List<QHelpContent> help = qInstance.getTable(table).getHelpContent().get(QaCaptureHelp.SLOT);
|
||||
assertThat(help).as(table + " help").isNotEmpty();
|
||||
assertThat(help.get(0).getContent()).as(table + " says how rows get created").contains("How rows get created");
|
||||
}
|
||||
assertThat(qInstance.getApp(config.getAppName()).getHelpContent()).containsKey(QaCaptureHelp.SLOT);
|
||||
assertThat(QaCaptureHelp.entries()).hasSize(QaCaptureSchema.TABLES.size() + 1);
|
||||
|
||||
assertThat(HelpBundles.of(qInstance).getBundles()).singleElement().satisfies(bundle ->
|
||||
{
|
||||
assertThat(bundle.getId()).isEqualTo("qbit-qa-capture");
|
||||
assertThat(System.getProperty("qbit.artifactVersion")).isNotBlank();
|
||||
assertThat(bundle.getVersion()).isEqualTo(System.getProperty("qbit.artifactVersion")).isEqualTo(QaCaptureQBitProducer.VERSION);
|
||||
});
|
||||
HelpBundleAssertions.forInstance(qInstance)
|
||||
.bundles(QaCaptureHelp.RESOURCE)
|
||||
.owning(target -> !"process".equals(target.kind()) && (target.name().startsWith("qa_capture_") || target.name().equals(config.getAppName())))
|
||||
.assertComplete();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A host's own help is kept.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void hostHelpIsKept() throws Exception
|
||||
{
|
||||
QTableMetaData table = qInstance.getTable(QaCaptureSchema.REPORT);
|
||||
table.getHelpContent().remove(QaCaptureHelp.SLOT);
|
||||
table.withHelpContent(QaCaptureHelp.SLOT, new QHelpContent("host words"));
|
||||
QaCaptureHelp.apply(qInstance, config);
|
||||
assertThat(table.getHelpContent().get(QaCaptureHelp.SLOT).get(0).getContent()).isEqualTo("host words");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Naming rules and placement under the host's Admin app.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void namingAndPlacement()
|
||||
{
|
||||
for(String table : QaCaptureSchema.TABLES)
|
||||
{
|
||||
assertThat(table).matches("qa_capture_[a-z_]+");
|
||||
QTableMetaData metaData = qInstance.getTable(table);
|
||||
assertThat(metaData.getBackendDetails()).as(table + " has no backend table name override: QQQ name == SQL name").isNull();
|
||||
metaData.getFields().values().forEach(field -> assertThat(field.getBackendName() == null ? field.getName() : field.getBackendName()).matches("[a-z_]+"));
|
||||
assertThat(metaData.getIsHidden()).as(table + " is visible").isFalse();
|
||||
}
|
||||
assertThat(QaCaptureSchema.PERMISSIONS).allMatch(permission -> permission.matches("qa_capture\\.[a-z_]+"));
|
||||
assertThat(qInstance.getApp("admin").getChildren()).anyMatch(child -> config.getAppName().equals(child.getName()));
|
||||
assertThat(qInstance.getApp(config.getAppName()).getChildren()).extracting(child -> child.getName()).containsExactlyElementsOf(QaCaptureSchema.TABLES);
|
||||
assertThat(qInstance.getProcesses().keySet()).as("no QBit processes, only core's generated bulk table actions")
|
||||
.filteredOn(name -> name.startsWith("qa")).allMatch(name -> name.matches("qa_capture_[a-z_]+[.]bulk[A-Za-z]+"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String read(Path path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (Files.readString(path, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
throw (new IllegalStateException(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.qacapture.spi.SvixSignatureVerifier;
|
||||
import com.kingsrook.qbits.qacapture.web.QaCaptureRequestIds;
|
||||
import com.kingsrook.qqq.backend.core.instances.AbstractQQQApplication;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
|
||||
import com.kingsrook.qqq.middleware.javalin.QApplicationJavalinServer;
|
||||
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
|
||||
import com.kingsrook.qqq.middleware.javalin.routeproviders.NextDashboardRouteProvider;
|
||||
import com.kingsrook.qqq.middleware.javalin.specs.v1.MiddlewareVersionV1;
|
||||
import io.javalin.config.JavalinConfig;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Real HTTP through QApplicationJavalinServer with a Next dashboard export
|
||||
** at "/": request ids, the UI API extension, the recorder page winning over
|
||||
** the dashboard's fallback, the dashboard CSP helper and the Jam webhook.
|
||||
*******************************************************************************/
|
||||
class QaCaptureHttpTest extends BaseQaCaptureTest
|
||||
{
|
||||
private QApplicationJavalinServer server;
|
||||
private int port;
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Test routes reporting the request id seen by the handler.
|
||||
*******************************************************************************/
|
||||
public static class ProbeRoutes implements QJavalinRouteProviderInterface
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void setQInstance(QInstance qInstance)
|
||||
{
|
||||
// stateless
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void acceptJavalinConfig(JavalinConfig config)
|
||||
{
|
||||
config.routes.get("/qqq/probe/request-id", context -> context.result(String.valueOf(QaCaptureRequestIds.current())));
|
||||
config.routes.get("/other/request-id", context -> context.result(String.valueOf(QaCaptureRequestIds.current())));
|
||||
config.routes.get("/qqq/probe/boom", context ->
|
||||
{
|
||||
throw (new IllegalStateException("boom"));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@AfterEach
|
||||
void stopServer()
|
||||
{
|
||||
if(server != null)
|
||||
{
|
||||
server.stop();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Start the server (the dashboard CSP helper reads the settings now).
|
||||
*******************************************************************************/
|
||||
private void start() throws Exception
|
||||
{
|
||||
server = new QApplicationJavalinServer(new AbstractQQQApplication()
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QInstance defineQInstance()
|
||||
{
|
||||
return (qInstance);
|
||||
}
|
||||
}).withPort(0).withServeFrontendNext(false).withServeFrontendMaterialDashboard(false)
|
||||
.withServeLegacyUnversionedMiddlewareAPI(false).withStrictUiApiRoutes(true)
|
||||
.withMiddlewareVersionList(List.of(new MiddlewareVersionV1()))
|
||||
.withAdditionalRouteProviders(new ArrayList<>(List.of(new ProbeRoutes(),
|
||||
new NextDashboardRouteProvider("test-next-dashboard").withSecurityHeadersCustomizer(QaCaptureCsp::customize))));
|
||||
server.start();
|
||||
port = server.getBoundPort();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Send a request.
|
||||
*******************************************************************************/
|
||||
private HttpResponse<String> request(String method, String path, String body, String user, Map<String, String> headers) throws Exception
|
||||
{
|
||||
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create("http://127.0.0.1:" + port + path));
|
||||
if(user != null)
|
||||
{
|
||||
request.header("Cookie", "sessionUUID=" + user);
|
||||
}
|
||||
if(body != null)
|
||||
{
|
||||
request.header("Content-Type", "application/json");
|
||||
}
|
||||
if(headers != null)
|
||||
{
|
||||
headers.forEach(request::header);
|
||||
}
|
||||
request.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(body));
|
||||
try(HttpClient client = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NEVER).build())
|
||||
{
|
||||
return (client.send(request.build(), HttpResponse.BodyHandlers.ofString()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Map<String, Object> json(HttpResponse<String> response) throws Exception
|
||||
{
|
||||
assertThat(response.statusCode()).as(response.body()).isEqualTo(200);
|
||||
return (JsonUtils.toObject(response.body(), Map.class));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** X-Request-Id on every /qqq/* response, in the ThreadContext while the
|
||||
** request runs, cleared afterwards (also after an exception).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void requestIds() throws Exception
|
||||
{
|
||||
start();
|
||||
HttpResponse<String> reused = request("GET", "/qqq/probe/request-id", null, ADMIN, Map.of("X-Request-Id", "client:req-0001"));
|
||||
assertThat(reused.headers().firstValue("X-Request-Id")).hasValue("client:req-0001");
|
||||
assertThat(reused.body()).isEqualTo("client:req-0001");
|
||||
|
||||
HttpResponse<String> replaced = request("GET", "/qqq/probe/request-id", null, ADMIN, Map.of("X-Request-Id", "bad id"));
|
||||
String generated = replaced.headers().firstValue("X-Request-Id").orElseThrow();
|
||||
assertThat(generated).matches("[0-9a-f-]{36}");
|
||||
assertThat(replaced.body()).isEqualTo(generated);
|
||||
|
||||
HttpResponse<String> boom = request("GET", "/qqq/probe/boom", null, ADMIN, null);
|
||||
assertThat(boom.statusCode()).isEqualTo(500);
|
||||
assertThat(boom.headers().firstValue("X-Request-Id")).isPresent();
|
||||
|
||||
HttpResponse<String> api = request("GET", "/qqq/v1/qbit/qa-capture/capability", null, ADMIN, Map.of("X-Request-Id", "abcdefgh"));
|
||||
assertThat(api.headers().firstValue("X-Request-Id")).hasValue("abcdefgh");
|
||||
assertThat(request("GET", "/qqq/v1/metaData", null, null, null).headers().firstValue("X-Request-Id")).isPresent();
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// outside /qqq/* and /qa/* there is no id: nothing leaked between //
|
||||
// requests on the server's threads //
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
for(int i = 0; i < 10; i++)
|
||||
{
|
||||
HttpResponse<String> other = request("GET", "/other/request-id", null, ADMIN, null);
|
||||
assertThat(other.body()).isEqualTo("null");
|
||||
assertThat(other.headers().firstValue("X-Request-Id")).isEmpty();
|
||||
}
|
||||
assertThat(QaCaptureRequestIds.current()).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The extension is discoverable and serves the contract.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void uiApi() throws Exception
|
||||
{
|
||||
QRecord settings = insertSettings("AUDIENCE", TEAM_ID, "abc123");
|
||||
insertAudience(settings, "USER", NOBODY, true);
|
||||
start();
|
||||
|
||||
assertThat(request("GET", "/qqq/v1/version", null, ADMIN, null).body()).contains("qa-capture");
|
||||
assertThat(json(request("GET", "/qqq/v1/qbit/qa-capture/capability", null, NOBODY, null))).containsEntry("enabled", true).containsEntry("reason", "AUDIENCE");
|
||||
assertThat(json(request("GET", "/qqq/v1/qbit/qa-capture/capability", null, VIEWER, null))).containsEntry("enabled", false).containsEntry("reason", "NO_ACCESS");
|
||||
|
||||
Map<String, Object> created = json(request("POST", "/qqq/v1/qbit/qa-capture/report", JsonUtils.toJson(Map.of("pageUrl", "https://admin.example.com/app/x?code=1", "requestIds", List.of("abcdefgh"))), NOBODY, null));
|
||||
assertThat(created).containsKeys("id", "reference", "recordingParams");
|
||||
assertThat(request("POST", "/qqq/v1/qbit/qa-capture/report", JsonUtils.toJson(Map.of("pageUrl", "https://x")), VIEWER, null).statusCode()).isEqualTo(403);
|
||||
assertThat(request("POST", "/qqq/v1/qbit/qa-capture/report", "{}", NOBODY, null).statusCode()).isEqualTo(400);
|
||||
assertThat(request("POST", "/qqq/v1/qbit/qa-capture/report", "not json", NOBODY, null).statusCode()).isEqualTo(400);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The recorder page wins over the dashboard fallback: 404 when off (not
|
||||
** the dashboard), 200 with its own CSP when on; the dashboard CSP has
|
||||
** the Jam origins only while capture is active.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recorderAndDashboard() throws Exception
|
||||
{
|
||||
start();
|
||||
HttpResponse<String> off = request("GET", "/qa/recorder?jam-recording=abc", null, null, null);
|
||||
assertThat(off.statusCode()).isEqualTo(404);
|
||||
assertThat(off.body()).doesNotContain("spa-index");
|
||||
assertThat(off.headers().firstValue("Cache-Control")).hasValue("no-store");
|
||||
HttpResponse<String> dashboard = request("GET", "/", null, null, null);
|
||||
assertThat(dashboard.body()).contains("spa-index");
|
||||
assertThat(dashboard.headers().firstValue("Content-Security-Policy").orElse("")).doesNotContain("jam.dev");
|
||||
server.stop();
|
||||
|
||||
insertSettings("AUDIENCE", TEAM_ID, null);
|
||||
start();
|
||||
HttpResponse<String> on = request("GET", "/qa/recorder?jam-recording=abc&jam-title=x", null, null, null);
|
||||
assertThat(on.statusCode()).isEqualTo(200);
|
||||
assertThat(on.body()).contains("jam:team", TEAM_ID, "recorder.js", "capture.js", "setSanitizers").doesNotContain("spa-index");
|
||||
assertThat(on.headers().firstValue("Content-Security-Policy").orElse("")).contains("'sha256-", "https://js.jam.dev", "frame-src https://*.jam.dev");
|
||||
assertThat(on.headers().firstValue("Cache-Control")).hasValue("no-store");
|
||||
assertThat(on.headers().firstValue("Content-Type").orElse("")).startsWith("text/html");
|
||||
assertThat(on.headers().firstValue("X-Request-Id")).isPresent();
|
||||
assertThat(request("GET", "/", null, null, null).headers().firstValue("Content-Security-Policy").orElse(""))
|
||||
.contains("script-src 'self'", "https://js.jam.dev", "https://*.jam.dev");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The Jam webhook over HTTP: signed delivery matched to a report; bad
|
||||
** signature, unknown provider and oversized payloads rejected.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void jamWebhook() throws Exception
|
||||
{
|
||||
QRecord settings = insertSettings("AUDIENCE", TEAM_ID, "abc123");
|
||||
insertAudience(settings, "USER", NOBODY, true);
|
||||
start();
|
||||
Map<String, Object> created = json(request("POST", "/qqq/v1/qbit/qa-capture/report", JsonUtils.toJson(Map.of("pageUrl", "https://admin.example.com/")), NOBODY, null));
|
||||
String reference = (String) created.get("reference");
|
||||
|
||||
String body = JsonUtils.toJson(Map.of("type", "jam.created", "jamId", "jam-1", "jamUrl", "https://jam.dev/c/jam-1", "teamId", TEAM_ID,
|
||||
"recordingLink", Map.of("publicId", "abc123", "reference", reference, "submitterComment", "It broke")));
|
||||
HttpResponse<String> matched = request("POST", "/qqq/hooks/qa-capture/jam", body, null, signed("msg_1", body));
|
||||
assertThat(json(matched)).containsEntry("outcome", "matched").containsEntry("reference", reference);
|
||||
QRecord report = all(QaCaptureSchema.REPORT).get(0);
|
||||
assertThat(report.getValueString("status")).isEqualTo("CAPTURED");
|
||||
assertThat(report.getValueString("jamUrl")).isEqualTo("https://jam.dev/c/jam-1");
|
||||
assertThat(report.getValueString("submitterComment")).isEqualTo("It broke");
|
||||
|
||||
assertThat(json(request("POST", "/qqq/hooks/qa-capture/jam", body, null, signed("msg_1", body)))).containsEntry("outcome", "duplicate");
|
||||
Map<String, String> badSignature = signed("msg_2", body);
|
||||
badSignature.put("svix-signature", "v1," + Base64.getEncoder().encodeToString(new byte[32]));
|
||||
assertThat(request("POST", "/qqq/hooks/qa-capture/jam", body, null, badSignature).statusCode()).isEqualTo(401);
|
||||
assertThat(request("POST", "/qqq/hooks/qa-capture/other", body, null, signed("msg_3", body)).statusCode()).isEqualTo(404);
|
||||
String huge = "{\"x\":\"" + "a".repeat(1024 * 1024) + "\"}";
|
||||
assertThat(request("POST", "/qqq/hooks/qa-capture/jam", huge, null, signed("msg_4", huge)).statusCode()).isEqualTo(413);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Svix headers for a body, signed with the test secret.
|
||||
*******************************************************************************/
|
||||
private Map<String, String> signed(String id, String body)
|
||||
{
|
||||
String timestamp = String.valueOf(Instant.now().getEpochSecond());
|
||||
String secret = config.getWebhookSecret("jam");
|
||||
String signature = Base64.getEncoder().encodeToString(SvixSignatureVerifier.sign(secret, id, timestamp, body));
|
||||
return (new java.util.HashMap<>(Map.of("svix-id", id, "svix-timestamp", timestamp, "svix-signature", "v1," + signature)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.qacapture.service.QaModePreference;
|
||||
import com.kingsrook.qbits.qacapture.spi.JamProvider;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureCspOrigins;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureProvider;
|
||||
import com.kingsrook.qbits.qacapture.spi.QaCaptureWebhookRequest;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferencesMetaData;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Production, config validation and the provider registry.
|
||||
*******************************************************************************/
|
||||
class QaCaptureProducerTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** A provider that has no webhook.
|
||||
*******************************************************************************/
|
||||
static class OtherProvider implements QaCaptureProvider
|
||||
{
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getName()
|
||||
{
|
||||
return ("other");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String getLabel()
|
||||
{
|
||||
return ("Other");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<String> getScriptUrls()
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public QaCaptureCspOrigins getCspOrigins()
|
||||
{
|
||||
return (QaCaptureCspOrigins.none());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Map<String, Object> providerConfig(QRecord settings)
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Map<String, String> recordingParams(QRecord report, QRecord settings)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
**
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String recorderHead(QRecord settings, String sanitizersExpression)
|
||||
{
|
||||
return ("");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Invalid configs fail production with every problem listed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validation()
|
||||
{
|
||||
QInstance instance = BaseQaCaptureTest.newInstance();
|
||||
assertThatThrownBy(() -> new QaCaptureQBitProducer().produce(instance)).isInstanceOf(QException.class).hasMessageContaining("no config");
|
||||
assertThat(new QaCaptureQBitProducer().isEnabled()).isFalse();
|
||||
|
||||
assertThatThrownBy(() -> new QaCaptureQBitProducer().withQBitConfig(new QaCaptureQBitConfig().withBackendName("nope").withEnvironment("bad env!")
|
||||
.withAppName("1bad").withParentAppName("missing").withProviders(List.of())).produce(instance))
|
||||
.isInstanceOf(QException.class)
|
||||
.hasMessageContaining("backend nope is not defined").hasMessageContaining("environment is required")
|
||||
.hasMessageContaining("appName").hasMessageContaining("parent app missing").hasMessageContaining("at least one provider");
|
||||
assertThatThrownBy(() -> new QaCaptureQBitProducer().withQBitConfig(new QaCaptureQBitConfig().withEnvironment("prod")).produce(instance))
|
||||
.hasMessageContaining("backendName is required");
|
||||
|
||||
QaCaptureQBitConfig config = new QaCaptureQBitConfig().withBackendName(BaseQaCaptureTest.BACKEND_NAME).withEnvironment("prod");
|
||||
config.withProvider(new OtherProvider());
|
||||
assertThat(config.getProviders()).containsKeys("jam", "other");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Producing twice is refused; metadata and identity are complete.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void produce() throws Exception
|
||||
{
|
||||
QInstance instance = BaseQaCaptureTest.newInstance();
|
||||
QaCaptureQBitConfig config = new QaCaptureQBitConfig().withBackendName(BaseQaCaptureTest.BACKEND_NAME).withEnvironment("prod").withAppLabel("QA")
|
||||
.withJamWebhookSecret("whsec_c2VjcmV0").withNode(null);
|
||||
QaCaptureQBitProducer producer = new QaCaptureQBitProducer().withQBitConfig(config).withNamespace("qa");
|
||||
producer.produce(instance);
|
||||
assertThat(producer.getNamespace()).isEqualTo("qa");
|
||||
assertThat(producer.getQBitMetaData().getArtifactId()).isEqualTo("qbit-qa-capture");
|
||||
assertThat(instance.getApp("qaCapture").getLabel()).isEqualTo("QA");
|
||||
assertThat(QaModePreference.isRegistered(instance)).isTrue();
|
||||
assertThat(UserPreferencesMetaData.of(instance).getSections()).singleElement().satisfies(section ->
|
||||
assertThat(section.getPermissionName()).isEqualTo(QaCaptureSchema.PERMISSION_USE_QA_MODE));
|
||||
QaModePreference.register(instance);
|
||||
assertThat(UserPreferencesMetaData.of(instance).getSections()).hasSize(1);
|
||||
assertThat(QaCaptureRuntime.getCurrentInstance()).isSameAs(instance);
|
||||
assertThat(config.toString()).contains("jam").contains("<redacted>").doesNotContain("c2VjcmV0");
|
||||
assertThat(config.getEffectiveNode()).isEqualTo(System.getenv("HOSTNAME"));
|
||||
assertThat(config.getDefaultBackendNameForTables()).isEqualTo(BaseQaCaptureTest.BACKEND_NAME);
|
||||
|
||||
assertThatThrownBy(() -> new QaCaptureQBitProducer().withQBitConfig(new QaCaptureQBitConfig().withBackendName(BaseQaCaptureTest.BACKEND_NAME).withEnvironment("prod")).produce(instance))
|
||||
.hasMessageContaining("already exists");
|
||||
assertThat(QaCaptureRuntime.find(BaseQaCaptureTest.newInstance())).isNull();
|
||||
assertThatThrownBy(() -> QaCaptureRuntime.of(new QInstance())).isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The default webhook hook answers 404.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void defaultWebhookIsNotFound() throws Exception
|
||||
{
|
||||
assertThat(new OtherProvider().handleWebhook(new QaCaptureWebhookRequest(null, null, null), null).status()).isEqualTo(404);
|
||||
assertThat(new JamProvider().getLabel()).isEqualTo("Jam");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureDb;
|
||||
import com.kingsrook.qbits.qacapture.service.QaCaptureService;
|
||||
import com.kingsrook.qbits.qacapture.service.QaModePreference;
|
||||
import com.kingsrook.qbits.qacapture.service.SanitizerRules;
|
||||
import com.kingsrook.qbits.qacapture.service.SecuredDataRuleSync;
|
||||
import com.kingsrook.qbits.qacapture.web.RecorderPage;
|
||||
import com.kingsrook.qqq.backend.core.actions.preferences.UserPreferencesAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
|
||||
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.preferences.UserPreferenceSectionResult;
|
||||
import com.kingsrook.qqq.middleware.javalin.routeproviders.NextDashboardSecurityHeaders;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The service against the real changelog schema: capability shapes, report
|
||||
** creation, QA mode storage, secured-data rules, the CSP helper, the
|
||||
** recorder page and table permissions.
|
||||
*******************************************************************************/
|
||||
class QaCaptureServiceTest extends BaseQaCaptureTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
** No settings row: not configured, nothing provider-specific.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void capabilityNotConfigured() throws Exception
|
||||
{
|
||||
Map<String, Object> capability = new QaCaptureService(qInstance).capability(loginAs(NOBODY));
|
||||
assertThat(capability).containsEntry("enabled", false).containsEntry("reason", "NOT_CONFIGURED")
|
||||
.containsEntry("provider", null).containsEntry("providerConfig", null).containsEntry("sanitizer", null)
|
||||
.containsEntry("canReport", false).containsEntry("reportsHref", null).containsEntry("requestIdHeader", "X-Request-Id")
|
||||
.containsEntry("blurSelectors", List.of());
|
||||
assertThat(capability.get("context")).isEqualTo(Map.of("environment", "prod", "backendVersion", "0.1.110", "node", "node-a"));
|
||||
assertThat(capability.get("cspOrigins")).isEqualTo(Map.of("scriptSrc", List.of(), "frameSrc", List.of(), "connectSrc", List.of()));
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> qaMode = (Map<String, Object>) capability.get("qaMode");
|
||||
assertThat(qaMode).containsEntry("available", false).containsEntry("active", false).containsEntry("preferenceSection", null);
|
||||
assertThat(new ArrayList<>(capability.keySet())).containsExactly("enabled", "reason", "provider", "providerConfig", "qaMode", "canReport",
|
||||
"reportsHref", "sanitizer", "blurSelectors", "cspOrigins", "context", "requestIdHeader");
|
||||
assertThat(new QaCaptureService(qInstance).capability(loginAs(VIEWER))).containsEntry("reportsHref", "/app/qa_capture_report");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An audience member gets the full capability.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void capabilityForAudience() throws Exception
|
||||
{
|
||||
QRecord settings = insertSettings("AUDIENCE", TEAM_ID, "abc123");
|
||||
QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.update(QaCaptureSchema.SETTINGS, new QRecord().withValue("id", settings.getValue("id"))
|
||||
.withValue("extraBlurSelectors", ".secret-panel\n\n [data-private] ")));
|
||||
insertAudience(settings, "USER", NOBODY + "@EXAMPLE.com", true);
|
||||
SecuredDataRuleSync.sync(qInstance);
|
||||
|
||||
Map<String, Object> capability = new QaCaptureService(qInstance).capability(loginAs(NOBODY));
|
||||
assertThat(capability).containsEntry("enabled", true).containsEntry("reason", "AUDIENCE").containsEntry("provider", "jam")
|
||||
.containsEntry("canReport", true).containsEntry("reportsHref", null);
|
||||
assertThat((Map<String, Object>) capability.get("providerConfig")).containsEntry("teamId", TEAM_ID).containsEntry("recordingLinkId", "abc123")
|
||||
.containsEntry("scriptUrls", List.of("https://js.jam.dev/recorder.js", "https://js.jam.dev/capture.js"));
|
||||
Map<String, Object> sanitizer = (Map<String, Object>) capability.get("sanitizer");
|
||||
assertThat(sanitizer).containsEntry("redaction", "[redacted]").containsKeys("headerNames", "bodyFieldNames", "queryParamNames", "valuePatterns", "dropPathPrefixes");
|
||||
assertThat((List<String>) sanitizer.get("headerNames")).contains("authorization", "cookie", "set-cookie").hasSize(8);
|
||||
assertThat((List<String>) sanitizer.get("bodyFieldNames")).contains("password", "accessToken", "ssn", "diagnosisCode");
|
||||
assertThat((List<String>) sanitizer.get("queryParamNames")).contains("code", "state", "token");
|
||||
assertThat((List<String>) sanitizer.get("valuePatterns")).hasSize(2);
|
||||
assertThat((List<String>) capability.get("blurSelectors")).containsExactly("[data-qqq-secure]", "input[type=password]", ".secret-panel", "[data-private]");
|
||||
assertThat(capability.get("cspOrigins")).isEqualTo(Map.of("scriptSrc", List.of("https://js.jam.dev"), "frameSrc", List.of("https://*.jam.dev"),
|
||||
"connectSrc", List.of("https://*.jam.dev")));
|
||||
|
||||
assertThat(new QaCaptureService(qInstance).capability(loginAs(VIEWER))).containsEntry("enabled", false).containsEntry("reason", "NO_ACCESS");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Reports: stripped URL, capped request ids, server-filled fields,
|
||||
** recording parameters; 403 and 400 cases.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void createReport() throws Exception
|
||||
{
|
||||
QRecord settings = insertSettings("AUDIENCE", TEAM_ID, "abc123");
|
||||
insertAudience(settings, "USER", NOBODY, true);
|
||||
QaCaptureService service = new QaCaptureService(qInstance);
|
||||
|
||||
List<Object> requestIds = new ArrayList<>();
|
||||
for(int i = 0; i < 60; i++)
|
||||
{
|
||||
requestIds.add("req-00000-" + i);
|
||||
}
|
||||
requestIds.add("bad id with spaces");
|
||||
requestIds.add(42);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("pageUrl", "https://admin.kof22.com/app/blog_post/42?code=xyz&tab=main&STATE=s#top");
|
||||
body.put("tableName", "blog_post");
|
||||
body.put("recordId", "42");
|
||||
body.put("requestIds", requestIds);
|
||||
body.put("frontendVersion", "1.0.0-RC.30");
|
||||
body.put("userAgent", "Mozilla/5.0");
|
||||
body.put("viewport", "1440x900");
|
||||
|
||||
Map<String, Object> result = service.createReport(loginAs(NOBODY), body);
|
||||
assertThat(result).containsKeys("id", "reference", "title", "recordingParams").containsEntry("title", "Bug on blog_post 42");
|
||||
String reference = (String) result.get("reference");
|
||||
assertThat(reference).matches("qa-[0-9a-f-]{36}");
|
||||
assertThat(result.get("recordingParams")).isEqualTo(Map.of("jam-recording", "abc123", "jam-title", "Bug on blog_post 42", "jam-reference", reference));
|
||||
|
||||
QRecord stored = all(QaCaptureSchema.REPORT).get(0);
|
||||
assertThat(stored.getValueString("pageUrl")).isEqualTo("https://admin.kof22.com/app/blog_post/42?tab=main#top");
|
||||
assertThat(stored.getValueString("requestIds").split("\n")).hasSize(50).startsWith("req-00000-0");
|
||||
assertThat(stored.getValueString("status")).isEqualTo("OPEN");
|
||||
assertThat(stored.getValueString("userId")).isEqualTo(NOBODY);
|
||||
assertThat(stored.getValueString("userLabel")).isEqualTo("Name of " + NOBODY);
|
||||
assertThat(stored.getValueString("environment")).isEqualTo("prod");
|
||||
assertThat(stored.getValueString("provider")).isEqualTo("jam");
|
||||
assertThat(stored.getValueString("backendVersion")).isEqualTo("0.1.110");
|
||||
assertThat(stored.getValueString("node")).isEqualTo("node-a");
|
||||
assertThat(stored.getValueString("frontendVersion")).isEqualTo("1.0.0-RC.30");
|
||||
assertThat(stored.getValueString("viewport")).isEqualTo("1440x900");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
// default titles; no recording link means no recording parameters //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.update(QaCaptureSchema.SETTINGS, new QRecord().withValue("id", settings.getValue("id")).withValue("recordingLinkId", null)));
|
||||
Map<String, Object> second = service.createReport(loginAs(NOBODY), Map.of("pageUrl", "https://admin.kof22.com/app/orders?token=1", "processName", "orders.ship"));
|
||||
assertThat(second).containsEntry("title", "Bug in orders.ship").containsEntry("recordingParams", null);
|
||||
assertThat(service.createReport(loginAs(NOBODY), Map.of("pageUrl", "https://x.example.com/a/b?c=1", "appName", "crm"))).containsEntry("title", "Bug in crm");
|
||||
assertThat(service.createReport(loginAs(NOBODY), Map.of("pageUrl", "https://x.example.com/a/b?c=1"))).containsEntry("title", "Bug on /a/b");
|
||||
assertThat(service.createReport(loginAs(NOBODY), Map.of("pageUrl", "https://x.example.com/", "title", " Custom "))).containsEntry("title", "Custom");
|
||||
|
||||
assertThatThrownBy(() -> service.createReport(loginAs(NOBODY), Map.of())).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> service.createReport(loginAs(NOBODY), Map.of("pageUrl", 5))).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> service.createReport(loginAs(NOBODY), Map.of("pageUrl", "https://x", "requestIds", "nope"))).isInstanceOf(QBadRequestException.class);
|
||||
assertThatThrownBy(() -> service.createReport(loginAs(VIEWER), Map.of("pageUrl", "https://x"))).isInstanceOf(QPermissionDeniedException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** QA mode through core's preference action, stored in the audience table
|
||||
** (no key-value store in this host); the section is hidden without the
|
||||
** permission.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void qaModeFallbackStore() throws Exception
|
||||
{
|
||||
QaCaptureService service = new QaCaptureService(qInstance);
|
||||
loginAs(TESTER);
|
||||
assertThatThrownBy(() -> new UserPreferencesAction().patchSection(QaModePreference.SECTION_NAME, Map.of("values", Map.of("qaMode", true))))
|
||||
.as("no settings row yet").isInstanceOf(QException.class);
|
||||
|
||||
insertSettings("SELF_SERVE", TEAM_ID, null);
|
||||
assertThat(service.capability(loginAs(TESTER))).containsEntry("reason", "QA_MODE_OFF")
|
||||
.extractingByKey("qaMode").isEqualTo(Map.of("available", true, "active", false, "preferenceSection", "qa-capture"));
|
||||
|
||||
UserPreferenceSectionResult patched = new UserPreferencesAction().patchSection(QaModePreference.SECTION_NAME, Map.of("values", Map.of("qaMode", true)));
|
||||
assertThat(patched.getValues().getValues()).containsEntry("qaMode", true);
|
||||
assertThat(service.capability(loginAs(TESTER))).containsEntry("enabled", true).containsEntry("reason", "QA_MODE");
|
||||
|
||||
QRecord row = all(QaCaptureSchema.AUDIENCE).get(0);
|
||||
assertThat(row.getValueString("source")).isEqualTo("QA_MODE");
|
||||
assertThat(row.getValueString("value")).isEqualTo(TESTER);
|
||||
|
||||
new UserPreferencesAction().patchSection(QaModePreference.SECTION_NAME, Map.of("values", Map.of("qaMode", false)));
|
||||
assertThat(service.capability(loginAs(TESTER))).containsEntry("reason", "QA_MODE_OFF");
|
||||
assertThat(all(QaCaptureSchema.AUDIENCE)).hasSize(1);
|
||||
|
||||
loginAs(VIEWER);
|
||||
assertThatThrownBy(() -> new UserPreferencesAction().getSection(QaModePreference.SECTION_NAME)).isInstanceOf(QNotFoundException.class);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Secured-data rules: added once, insert-only, never touching admin edits.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void securedDataRules() throws Exception
|
||||
{
|
||||
assertThat(SecuredDataRuleSync.secureFields(qInstance)).containsEntry("ssn", "patient.ssn").containsEntry("diagnosisCode", "patient.diagnosisCode")
|
||||
.doesNotContainKey("name");
|
||||
assertThat(SecuredDataRuleSync.sync(qInstance)).as("ssn is already built in").isEqualTo(1);
|
||||
assertThat(SecuredDataRuleSync.sync(qInstance)).isZero();
|
||||
|
||||
QRecord rule = all(QaCaptureSchema.SANITIZER_RULE).stream().filter(r -> "diagnosisCode".equals(r.getValueString("value"))).findFirst().orElseThrow();
|
||||
assertThat(rule.getValueString("source")).isEqualTo("SECURED_DATA");
|
||||
assertThat(rule.getValueString("sourceRef")).isEqualTo("patient.diagnosisCode");
|
||||
|
||||
QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.update(QaCaptureSchema.SANITIZER_RULE, new QRecord().withValue("id", rule.getValue("id")).withValue("active", false)));
|
||||
assertThat(SecuredDataRuleSync.sync(qInstance)).isZero();
|
||||
assertThat(QaCaptureRuntime.asSystem(qInstance, SanitizerRules::load).getBodyFieldNames()).doesNotContain("diagnosisCode").contains("ssn");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The dashboard CSP gets the provider origins only while capture is active.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void cspHelper() throws Exception
|
||||
{
|
||||
NextDashboardSecurityHeaders headers = new NextDashboardSecurityHeaders();
|
||||
assertThat(QaCaptureCsp.customize(headers, qInstance)).isFalse();
|
||||
assertThat(QaCaptureCsp.customize(headers, null)).isFalse();
|
||||
assertThat(QaCaptureCsp.customize(null, qInstance)).isFalse();
|
||||
|
||||
QRecord settings = insertSettings("OFF", TEAM_ID, null);
|
||||
assertThat(QaCaptureCsp.customize(headers, qInstance)).isFalse();
|
||||
assertThat(headers.getSources("script-src")).containsExactly("'self'");
|
||||
|
||||
QaCaptureRuntime.asSystem(qInstance, () -> QaCaptureDb.update(QaCaptureSchema.SETTINGS, new QRecord().withValue("id", settings.getValue("id")).withValue("mode", "SELF_SERVE")));
|
||||
QaCaptureCsp.customize(headers);
|
||||
assertThat(headers.getSources("script-src")).containsExactly("'self'", "https://js.jam.dev");
|
||||
assertThat(headers.getSources("frame-src")).containsExactly("'self'", "https://*.jam.dev");
|
||||
assertThat(headers.getSources("connect-src")).containsExactly("'self'", "https://*.jam.dev");
|
||||
|
||||
///////////////////////////////////////////////////////////////////
|
||||
// a broken database is logged and leaves the headers unchanged //
|
||||
///////////////////////////////////////////////////////////////////
|
||||
try(java.sql.Connection connection = java.sql.DriverManager.getConnection(JDBC_URL, "sa", ""); java.sql.Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("DROP ALL OBJECTS");
|
||||
}
|
||||
NextDashboardSecurityHeaders fresh = new NextDashboardSecurityHeaders();
|
||||
assertThat(QaCaptureCsp.customize(fresh, qInstance)).isFalse();
|
||||
assertThat(fresh.getSources("script-src")).containsExactly("'self'");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The recorder page: provider markup, sanitizers, hash-based CSP.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void recorderPage() throws Exception
|
||||
{
|
||||
assertThat(RecorderPage.build(qInstance)).isNull();
|
||||
insertSettings("AUDIENCE", TEAM_ID, null);
|
||||
RecorderPage.Page page = RecorderPage.build(qInstance);
|
||||
assertThat(page.html()).contains("<meta name=\"jam:team\" content=\"" + TEAM_ID + "\">",
|
||||
"<script type=\"module\" src=\"https://js.jam.dev/recorder.js\"></script>",
|
||||
"<script type=\"module\" src=\"https://js.jam.dev/capture.js\"></script>",
|
||||
"window.jam.capture.setSanitizers(sanitizers)");
|
||||
assertThat(page.html().indexOf("capture.js")).isLessThan(page.html().indexOf("setSanitizers"));
|
||||
assertThat(page.contentSecurityPolicy()).contains("script-src 'self' https://js.jam.dev 'sha256-", "frame-src https://*.jam.dev",
|
||||
"connect-src 'self' https://*.jam.dev", "frame-ancestors 'none'");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Table access follows the QBit's permissions; writes are validated.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void tablePermissionsAndValidation() throws Exception
|
||||
{
|
||||
loginAs(VIEWER);
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(QaCaptureSchema.SETTINGS))).isInstanceOf(QPermissionDeniedException.class);
|
||||
assertThat(new QueryAction().execute(new QueryInput(QaCaptureSchema.REPORT)).getRecords()).isEmpty();
|
||||
loginAs(TESTER);
|
||||
assertThatThrownBy(() -> new QueryAction().execute(new QueryInput(QaCaptureSchema.REPORT))).isInstanceOf(QPermissionDeniedException.class);
|
||||
|
||||
loginAs(ADMIN);
|
||||
assertThat(new QueryAction().execute(new QueryInput(QaCaptureSchema.SANITIZER_RULE)).getRecords()).hasSize(50);
|
||||
QRecord bad = new InsertAction().execute(new InsertInput(QaCaptureSchema.SANITIZER_RULE).withRecord(new QRecord()
|
||||
.withValue("ruleType", "VALUE_PATTERN").withValue("value", "a++").withValue("source", "ADMIN").withValue("active", true))).getRecords().get(0);
|
||||
assertThat(bad.getErrors()).isNotEmpty();
|
||||
QRecord header = new InsertAction().execute(new InsertInput(QaCaptureSchema.SANITIZER_RULE).withRecord(new QRecord()
|
||||
.withValue("ruleType", "HEADER").withValue("value", " X-Tenant-Secret ").withValue("source", "ADMIN").withValue("active", true))).getRecords().get(0);
|
||||
assertThat(header.getErrors()).isEmpty();
|
||||
assertThat(header.getValueString("value")).isEqualTo("x-tenant-secret");
|
||||
QRecord badUpdate = new com.kingsrook.qqq.backend.core.actions.tables.UpdateAction().execute(new com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput(QaCaptureSchema.SANITIZER_RULE)
|
||||
.withRecord(new QRecord().withValue("id", header.getValue("id")).withValue("ruleType", "DROP_PATH"))).getRecords().get(0);
|
||||
assertThat(badUpdate.getErrors()).as("x-tenant-secret is not a path").isNotEmpty();
|
||||
assertThat(QContext.getQSession().getUser().getIdReference()).isEqualTo(ADMIN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.kingsrook.qbits.qacapture.service.SanitizerRules;
|
||||
import com.kingsrook.qbits.qacapture.web.SanitizerScript;
|
||||
import com.kingsrook.qqq.backend.core.model.data.QRecord;
|
||||
import org.junit.jupiter.api.Assumptions;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Rule validation, grouping, URL stripping, and the generated JavaScript
|
||||
** sanitizers executed in Node (skipped when node is not installed).
|
||||
*******************************************************************************/
|
||||
class SanitizerRulesTest
|
||||
{
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static QRecord rule(String type, String value, boolean active)
|
||||
{
|
||||
return (new QRecord().withValue("id", 1).withValue("ruleType", type).withValue("value", value).withValue("active", active));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What is valid.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void validate()
|
||||
{
|
||||
assertThat(SanitizerRules.validate("VALUE_PATTERN", "eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+")).isNull();
|
||||
assertThat(SanitizerRules.validate("VALUE_PATTERN", "(?<word>abc)")).isNull();
|
||||
assertThat(SanitizerRules.validate("VALUE_PATTERN", "abc(?=def)")).isNull();
|
||||
assertThat(SanitizerRules.validate("VALUE_PATTERN", "a++")).contains("JavaScript");
|
||||
assertThat(SanitizerRules.validate("VALUE_PATTERN", "(?i)secret")).contains("JavaScript");
|
||||
assertThat(SanitizerRules.validate("VALUE_PATTERN", "\\Asecret")).contains("JavaScript");
|
||||
assertThat(SanitizerRules.validate("VALUE_PATTERN", "[a-z&&[^b]]")).contains("JavaScript");
|
||||
assertThat(SanitizerRules.validate("VALUE_PATTERN", "(unclosed")).contains("not a valid");
|
||||
assertThat(SanitizerRules.validate("VALUE_PATTERN", "x*")).contains("empty");
|
||||
assertThat(SanitizerRules.validate("DROP_PATH", "qqq/v1")).contains("starts with /");
|
||||
assertThat(SanitizerRules.validate("DROP_PATH", "/qqq/v1/secret")).isNull();
|
||||
assertThat(SanitizerRules.validate("HEADER", "x api key")).contains("header");
|
||||
assertThat(SanitizerRules.validate("BODY_FIELD", "anything goes")).isNull();
|
||||
assertThat(SanitizerRules.validate("BODY_FIELD", " ")).contains("required");
|
||||
assertThat(SanitizerRules.validate("NOPE", "x")).contains("Unknown");
|
||||
assertThat(SanitizerRules.validate(null, "x")).contains("Unknown");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Grouping skips inactive, invalid and duplicate rows; headers lowercase.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void groupingAndStripping()
|
||||
{
|
||||
SanitizerRules rules = SanitizerRules.fromRecords(List.of(
|
||||
rule("HEADER", "X-Api-Key", true), rule("HEADER", "x-api-key", true), rule("HEADER", "cookie", false),
|
||||
rule("BODY_FIELD", "password", true), rule("QUERY_PARAM", "code", true), rule("QUERY_PARAM", "Token", true),
|
||||
rule("VALUE_PATTERN", "a++", true), rule("VALUE_PATTERN", "secret-[0-9]+", true),
|
||||
rule("DROP_PATH", "/qqq/v1/vault", true), rule("BLUR_SELECTOR", ".ssn", true), rule("BODY_FIELD", null, true),
|
||||
new QRecord().withValue("ruleType", null).withValue("value", "x").withValue("active", true)));
|
||||
assertThat(rules.getHeaderNames()).containsExactly("x-api-key");
|
||||
assertThat(rules.getBodyFieldNames()).containsExactly("password");
|
||||
assertThat(rules.getQueryParamNames()).containsExactly("code", "Token");
|
||||
assertThat(rules.getValuePatterns()).containsExactly("secret-[0-9]+");
|
||||
assertThat(rules.getDropPathPrefixes()).containsExactly("/qqq/v1/vault");
|
||||
assertThat(rules.withExtraBlurSelectors(".ssn\n.card\r\n").getBlurSelectors()).containsExactly(".ssn", ".card");
|
||||
assertThat(rules.withExtraBlurSelectors(null).getBlurSelectors()).hasSize(2);
|
||||
|
||||
assertThat(rules.stripQueryParams("https://a.example.com/p?code=1&keep=2&TOKEN=3&token%3D=4&&x#frag", 2000))
|
||||
.isEqualTo("https://a.example.com/p?keep=2&token%3D=4&x#frag");
|
||||
assertThat(rules.stripQueryParams("https://a.example.com/p?code=1", 2000)).isEqualTo("https://a.example.com/p");
|
||||
assertThat(rules.stripQueryParams("https://a.example.com/very/long", 10)).isEqualTo("https://a.");
|
||||
assertThat(rules.stripQueryParams(" ", 10)).isNull();
|
||||
assertThat(rules.stripQueryParams(null, 10)).isNull();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The generated sanitizers, run in Node against Jam-shaped events.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void javascriptSanitizers(@TempDir Path directory) throws Exception
|
||||
{
|
||||
String node = findNode();
|
||||
Assumptions.assumeTrue(node != null, "node is not installed");
|
||||
|
||||
SanitizerRules rules = SanitizerRules.fromRecords(List.of(
|
||||
rule("HEADER", "authorization", true), rule("HEADER", "Set-Cookie", true), rule("BODY_FIELD", "password", true), rule("BODY_FIELD", "accessToken", true),
|
||||
rule("QUERY_PARAM", "code", true), rule("VALUE_PATTERN", "eyJ[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+", true),
|
||||
rule("VALUE_PATTERN", "[Bb]earer\\s+[A-Za-z0-9._~+/=-]+", true), rule("DROP_PATH", "/qqq/v1/vault", true)));
|
||||
String script = "globalThis.location = { href: 'https://admin.example.com/app/x', origin: 'https://admin.example.com' };\n"
|
||||
+ "const s = " + SanitizerScript.expression(rules) + ";\n"
|
||||
+ "const out = [];\n"
|
||||
+ "const req = s.network.requestSanitizer({ type: 'fetch', url: '/qqq/v1/table?code=abc&x=1', method: 'POST',"
|
||||
+ " headers: { authorization: 'Bearer abc', 'content-type': 'application/json', 'x-note': 'token eyJa.eyJb.sig' },"
|
||||
+ " body: JSON.stringify({ user: { Password: 'p', name: 'n' }, list: [{ accessToken: 't' }], note: 'Bearer zzz' }) });\n"
|
||||
+ "out.push(JSON.stringify(req));\n"
|
||||
+ "out.push(JSON.stringify(s.network.requestSanitizer({ type: 'xhr', url: '/login', headers: {}, body: 'password=secret&user=u' })));\n"
|
||||
+ "out.push(JSON.stringify(s.network.requestSanitizer({ type: 'fetch', url: '/qqq/v1/vault/1', headers: {}, body: '{\"a\":1}' })));\n"
|
||||
+ "out.push(JSON.stringify(s.network.responseSanitizer({ url: 'https://admin.example.com/qqq/v1/vault/1', headers: { 'set-cookie': 'x' }, body: 'secret' })));\n"
|
||||
+ "out.push(JSON.stringify(s.console.logSanitizer({ level: 'log', args: ['jwt eyJh.eyJi.sig', { password: 'p', ok: 1 }] })));\n"
|
||||
+ "out.push(JSON.stringify(s.network.webSocketSanitizer({ action: 'send', url: 'wss://x', data: '{\"accessToken\":\"t\"}' })));\n"
|
||||
+ "console.log(out.join('\\n'));\n";
|
||||
Path file = directory.resolve("sanitizers.js");
|
||||
Files.writeString(file, script, StandardCharsets.UTF_8);
|
||||
|
||||
Process process = new ProcessBuilder(node, file.toString()).redirectErrorStream(true).start();
|
||||
assertThat(process.waitFor(30, TimeUnit.SECONDS)).isTrue();
|
||||
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
assertThat(process.exitValue()).as(output).isZero();
|
||||
List<String> lines = output.lines().toList();
|
||||
|
||||
assertThat(lines.get(0)).contains("code=%5Bredacted%5D", "x=1").doesNotContain("authorization", "Bearer abc", "eyJa.eyJb.sig")
|
||||
.contains("\\\"Password\\\":\\\"[redacted]\\\"", "\\\"name\\\":\\\"n\\\"", "\\\"accessToken\\\":\\\"[redacted]\\\"", "\\\"note\\\":\\\"[redacted]\\\"");
|
||||
assertThat(lines.get(1)).contains("password=%5Bredacted%5D", "user=u");
|
||||
assertThat(lines.get(2)).doesNotContain("body");
|
||||
assertThat(lines.get(3)).doesNotContain("set-cookie", "secret");
|
||||
assertThat(lines.get(4)).contains("jwt [redacted]", "\"password\":\"[redacted]\"", "\"ok\":1");
|
||||
assertThat(lines.get(5)).contains("[redacted]").doesNotContain(":\\\"t\\\"");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The node executable on the PATH, or null.
|
||||
*******************************************************************************/
|
||||
private static String findNode()
|
||||
{
|
||||
String path = System.getenv("PATH");
|
||||
if(path == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
for(String directory : path.split(File.pathSeparator))
|
||||
{
|
||||
for(String name : List.of("node", "node.exe"))
|
||||
{
|
||||
File candidate = new File(directory, name);
|
||||
if(candidate.isFile() && candidate.canExecute())
|
||||
{
|
||||
return (candidate.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2026 Kingsrook, LLC
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.qacapture;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import com.kingsrook.qbits.qacapture.spi.SvixSignatureVerifier;
|
||||
import com.kingsrook.qbits.qacapture.spi.SvixSignatureVerifier.Outcome;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Svix signature verification.
|
||||
*******************************************************************************/
|
||||
class SvixSignatureVerifierTest
|
||||
{
|
||||
private static final String SECRET = "whsec_" + Base64.getEncoder().encodeToString("an-example-secret-key".getBytes(StandardCharsets.UTF_8));
|
||||
private static final Instant NOW = Instant.ofEpochSecond(1_760_000_000L);
|
||||
private static final String BODY = "{\"type\":\"jam.created\"}";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
private static String signature(String secret, String id, long timestamp, String body)
|
||||
{
|
||||
return ("v1," + Base64.getEncoder().encodeToString(SvixSignatureVerifier.sign(secret, id, String.valueOf(timestamp), body)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A known vector: the reference Svix example (secret, id, timestamp, body).
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void svixReferenceVector()
|
||||
{
|
||||
String secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
|
||||
String id = "msg_p5jXN8AQM9LWM0D4loKWxJek";
|
||||
String timestamp = "1614265330";
|
||||
String body = "{\"test\": 2432232314}";
|
||||
String expected = "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=";
|
||||
assertThat(SvixSignatureVerifier.verify(secret, id, timestamp, expected, body, Instant.ofEpochSecond(1614265330L))).isEqualTo(Outcome.VALID);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Valid, rotated (several signatures), tampered, stale and malformed.
|
||||
*******************************************************************************/
|
||||
@Test
|
||||
void outcomes()
|
||||
{
|
||||
long ts = NOW.getEpochSecond();
|
||||
String sig = signature(SECRET, "msg_1", ts, BODY);
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_1", String.valueOf(ts), sig, BODY, NOW)).isEqualTo(Outcome.VALID);
|
||||
|
||||
String other = "whsec_" + Base64.getEncoder().encodeToString("old-secret".getBytes(StandardCharsets.UTF_8));
|
||||
String rotated = signature(other, "msg_1", ts, BODY) + " v2,abc " + sig;
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_1", String.valueOf(ts), rotated, BODY, NOW)).isEqualTo(Outcome.VALID);
|
||||
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_1", String.valueOf(ts), sig, BODY + " ", NOW)).isEqualTo(Outcome.BAD_SIGNATURE);
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_2", String.valueOf(ts), sig, BODY, NOW)).isEqualTo(Outcome.BAD_SIGNATURE);
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_1", String.valueOf(ts), "v1,!!!notbase64", BODY, NOW)).isEqualTo(Outcome.BAD_SIGNATURE);
|
||||
assertThat(SvixSignatureVerifier.verify("whsec_%%%", "msg_1", String.valueOf(ts), sig, BODY, NOW)).isEqualTo(Outcome.BAD_SIGNATURE);
|
||||
|
||||
long stale = ts - 301;
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_1", String.valueOf(stale), signature(SECRET, "msg_1", stale, BODY), BODY, NOW)).isEqualTo(Outcome.BAD_TIMESTAMP);
|
||||
long future = ts + 301;
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_1", String.valueOf(future), signature(SECRET, "msg_1", future, BODY), BODY, NOW)).isEqualTo(Outcome.BAD_TIMESTAMP);
|
||||
long edge = ts - 300;
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_1", String.valueOf(edge), signature(SECRET, "msg_1", edge, BODY), BODY, NOW)).isEqualTo(Outcome.VALID);
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_1", "yesterday", sig, BODY, NOW)).isEqualTo(Outcome.BAD_TIMESTAMP);
|
||||
|
||||
assertThat(SvixSignatureVerifier.verify(null, "msg_1", String.valueOf(ts), sig, BODY, NOW)).isEqualTo(Outcome.MISSING_HEADERS);
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, " ", String.valueOf(ts), sig, BODY, NOW)).isEqualTo(Outcome.MISSING_HEADERS);
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET, "msg_1", String.valueOf(ts), null, BODY, NOW)).isEqualTo(Outcome.MISSING_HEADERS);
|
||||
assertThat(SvixSignatureVerifier.verify(SECRET.substring("whsec_".length()), "msg_1", String.valueOf(ts), sig, BODY, NOW))
|
||||
.as("a secret without the prefix works").isEqualTo(Outcome.VALID);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Configuration status="WARN">
|
||||
<Appenders>
|
||||
<Console name="Console" target="SYSTEM_OUT">
|
||||
<!-- Core can emit diagnostic events at OFF; exclude those as well. -->
|
||||
<LevelRangeFilter minLevel="FATAL" maxLevel="WARN" onMatch="NEUTRAL" onMismatch="DENY"/>
|
||||
<PatternLayout pattern="%d{HH:mm:ss} %-5level [%X{requestId}] %logger{36} - %msg%n"/>
|
||||
</Console>
|
||||
</Appenders>
|
||||
<Loggers>
|
||||
<Logger name="org.testcontainers" level="WARN"/>
|
||||
<Logger name="com.github.dockerjava" level="WARN"/>
|
||||
<Logger name="org.apache.hc" level="WARN"/>
|
||||
<Logger name="tc" level="WARN"/>
|
||||
<Root level="WARN"><AppenderRef ref="Console"/></Root>
|
||||
</Loggers>
|
||||
</Configuration>
|
||||
@@ -0,0 +1,2 @@
|
||||
<!doctype html>
|
||||
<html><head><title>Test dashboard</title></head><body><div id="root">spa-index</div></body></html>
|
||||
Reference in new issue
Block a user