Compare commits

...
Author SHA1 Message Date
james.maesandCodex 571f14ff82 ci: use the shared branch-aware CodeQL worker
Co-Authored-By: Codex <noreply@openai.com>
2026-10-21 10:24:57 -05:00
james.maesandCodex 0ce28313f9 ci: adopt central orb 0.7.12 and stable gates (#24)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 08:52:48 -05:00
james.maesandCodex a8980eec0f ci: centralize rc release completion in qqq orb (#23)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 02:24:23 -05:00
james.maesandCodex 082f3b95f0 ci: adopt shared orb 0.7.11 (#22)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 00:56:21 -05:00
james.maes c681b2c0e7 Merge pull request #21 from QRun-IO/feature/central-ci
ci: adopt shared QQQ orb standard
2026-10-20 19:21:02 -05:00
james.maesandCodex 558effe85f ci: adopt verified central QQQ orb 0.7.3
Co-Authored-By: Codex <noreply@openai.com>
2026-10-20 19:07:42 -05:00
james.maes 93f6f86f2d Merge pull request #18 from QRun-IO/feature/clearer-alert-titles
fix(alerts): stable chain break alert title
2026-10-19 10:05:18 -05:00
james.maes 7e15fb92e0 Merge pull request #20 from QRun-IO/feature/embedded-audit-app
feat: allow host-owned Audit app composition
2026-10-19 10:05:14 -05:00
james.maesandCodex a5d7192b95 feat: allow host-owned Audit app composition
Keep standalone defaults while permitting embedded WORM metadata and authorized widgets without replacing host navigation or help.

Co-Authored-By: Codex <noreply@openai.com>
Signed-off-by: James Maes <james@kof22.com>
2026-10-19 10:01:20 -05:00
james.maesandClaude Opus 5.5 05fcbdd8b8 fix(alerts): stable chain break alert title
The wormaudit.chain.break title was the first break reason, so the bell text
changed on every run. The title is now "Audit chain verification found N
problems"; the first reasons move to the summary. Dedupe key and severity are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:33:59 -05:00
james.maes d78ecc1bc4 Merge pull request #17 from QRun-IO/ci/release-rc-secrets-inherit
ci(release-rc): pass secrets so the shared workflow can sign the RC tag
2026-10-16 08:04:06 -05:00
james.maesandClaude Opus 5.5 e81c8ba37e ci(release-rc): pass secrets to the shared workflow so it can sign the RC tag
QRun-IO/.github#2 adds a tag-and-release job that signs vX with the
QRUN_TAG_SIGNING_KEY secret after Maven Central confirms the artifact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:20:12 -05:00
james.maes a9e985a86c Merge pull request #16 from QRun-IO/feature/purge-replace-as-owner
fix(ddl): replace worm_audit_purge when the owner revoked its own EXECUTE
2026-10-15 09:16:36 -05:00
james.maesandClaude Opus 5.5 bde8ff416f fix(ddl): replace worm_audit_purge when the owner revoked its own EXECUTE
PostgreSQL refuses CREATE OR REPLACE FUNCTION when the owning role has no
EXECUTE on it, which is the CNPG shape (the runtime role owns the schema
and worm-audit:6 revokes its EXECUTE). worm-audit:13 now grants EXECUTE
back to the migrating role only when missing, replaces the function, and
revokes it again inside the same changeset transaction.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:13:59 -05:00
james.maes 92c5fc9ca4 Merge pull request #15 from QRun-IO/feature/ddl-comment-not-changeset
fix(ddl): no comment line may start with -- changeset
2026-10-15 07:58:54 -05:00
james.maesandClaude Opus 5.5 86f9929cd2 fix(ddl): no comment line may start with -- changeset
Liquibase formatted SQL parses any line beginning with '-- changeset' as a
directive, so the worm-audit:13 comment broke the changelog. Reword it and
guard the rendered DDL in CanonicalAndCryptoTest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:56:47 -05:00
james.maes 4842cb174d Merge pull request #14 from QRun-IO/feature/chain-coverage-perf
perf(chain): normalized detail coverage table and sweep floors
2026-10-15 07:42:38 -05:00
16 changed files with 265 additions and 83 deletions

No files matched your search

+10 -61
View File
@@ -1,65 +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.12
workflows:
test_only:
setup:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [ qqq-maven-registry-credentials ]
filters:
branches:
ignore: /(develop|release/.*|integration.*)/
tags:
only: []
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+$/ ]
- qqq-orb/generate_pipeline:
orb_version: 0.7.12
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
only: /^v\d+\.\d+\.\d+$/
+23
View File
@@ -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
+62 -13
View File
@@ -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
View File
@@ -0,0 +1,16 @@
profile: qbit
orb_version: 0.7.12
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-no-integration
publish:
snapshot: true
release_candidate: true
release: true
asciidoc: false
release_completion: true
+2
View File
@@ -6,6 +6,8 @@ All notable changes are documented here, following [Keep a Changelog](https://ke
### Fixed
- `worm-audit:13` failed with `permission denied for function worm_audit_purge` when the migrating role owns the function but had revoked its own EXECUTE (a runtime role that owns the schema, as on CNPG; PostgreSQL refuses `CREATE OR REPLACE` then). The changeset now grants EXECUTE back to the migrating role only if it lacks it, replaces the function, and revokes it again in the same transaction. Found by Website-Backend's CNPG-shaped changelog test.
- `worm-audit:13` had a comment line starting with `-- changeset`, which Liquibase formatted-SQL parsing rejects as a malformed directive. The comment is reworded, and `CanonicalAndCryptoTest` now requires every `-- changeset` line to be a real directive.
- Chain writer database load. Each 30-second tick re-read the whole late-detail lookback (24 h of `audit_detail`) and probed the GIN array index once per detail (18.4 s and 1.78M buffer hits per tick on a 120k-detail production database), and re-scanned every settled audit header for the unchained anti-join. Coverage now lives in `worm_audit_chain_detail` (`worm-audit:13`): one insert-only row per covered detail, written by the chain trigger in the same statement as its link and backfilled from the existing chain, so the late-detail check is a primary-key probe. Each tick also reads only ids above per-JVM sweep floors (rows settled since the last complete sweep); after a restart and once an hour a tick sweeps everything, as before. Link hash inputs, `detail_ids`, the verifier and exports are unchanged.
- The chain trigger's overlap check (`EXISTS ... && ...`) ran as a cached generic plan that sequentially scanned all of `worm_audit_chain` for every inserted link (55k sequential scans, 1.2B tuples read in production, about 80 ms per link and growing). The trigger now inserts the coverage rows and lets their primary key reject a detail covered twice.
- Verification counts detail coverage by aggregating the chain's own detail lists once instead of one index probe per detail, and breaks when the coverage table disagrees with the chain.
+6
View File
@@ -137,6 +137,12 @@ Open `/app/worm_audit` (or `/app/admin/worm_audit` with `parentAppName="admin"`)
The full-width `wormAuditHome` HTML widget uses the standard `/qqq/v1/widget` API.
Hosts can add `wormAuditHealthCard` to their own dashboard. Both widgets and the app
require read permission on at least one WORM table, using the host's table permission rules.
To embed WORM in an existing host app, set `withIncludeApp(false)` and configure
`appName`, `appLabel` and optional `parentAppName` for that host app. The host owns
its navigation, home widget placement and app help; the qbit does not create or replace
the app or fill its help. Both widgets and all table help, permissions, guards,
processes and schedules remain registered. The health card links to the configured
host app. Standalone app creation remains enabled by default.
The aggregate home shows only identifiers, counts, times, shortened hashes and reasons;
it never renders audit messages or field values. Record links retain the target table's permissions.
+20 -2
View File
@@ -469,8 +469,8 @@ BEGIN
END;
$fn$;
-- The array index worm_audit_chain_detail_coverage is no longer used by this release. It stays until a later
-- changeset drops it, so a previous-release writer still running during a rolling deploy keeps its indexed probe.
-- The array index worm_audit_chain_detail_coverage is no longer used by this release. It stays until
-- a later migration drops it, so a previous-release writer still running during a rolling deploy keeps its indexed probe.
DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_chain_detail;
CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_chain_detail
@@ -482,6 +482,16 @@ REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_chain_detail FROM "app";
GRANT SELECT, INSERT ON public.worm_audit_chain_detail TO "app";
-- Same function as worm-audit:5, except that coverage rows are locked and purged before their links.
-- The migrating role may have revoked its own EXECUTE (a runtime role that owns the schema, as on CNPG);
-- PostgreSQL then refuses CREATE OR REPLACE. Grant it back for this statement only, then restore.
DO $$
BEGIN
IF NOT has_function_privilege(current_user, 'public.worm_audit_purge(timestamptz)', 'EXECUTE') THEN
EXECUTE format('GRANT EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) TO %I', current_user);
PERFORM set_config('worm_audit.purge_regrant', 'on', true);
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION public.worm_audit_purge(p_cutoff timestamptz) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp SET timezone = 'UTC' AS $fn$
DECLARE
@@ -536,3 +546,11 @@ BEGIN
VALUES(now(), session_user, p_cutoff, '6 years', 'PURGED', audits_count, details_count, links_count, boundary, anchor);
END;
$fn$;
DO $$
BEGIN
IF current_setting('worm_audit.purge_regrant', true) = 'on' THEN
EXECUTE format('REVOKE EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) FROM %I', current_user);
PERFORM set_config('worm_audit.purge_regrant', 'off', true);
END IF;
END;
$$;
@@ -48,6 +48,7 @@ public class WormAuditQBitConfig implements QBitConfig
private String exportLockMode = "GOVERNANCE";
private int exportRetentionDays = 30;
private final WormAuditSettings settings = new WormAuditSettings(this);
private boolean includeApp = true;
private String appName = "worm_audit";
private String appLabel = "WORM Audit";
private String parentAppName;
@@ -131,7 +132,26 @@ public class WormAuditQBitConfig implements QBitConfig
/*******************************************************************************
** Read the app name.
** Whether to create the standalone app. Widgets remain available when false.
*******************************************************************************/
public boolean getIncludeApp()
{
return (includeApp);
}
/*******************************************************************************
** Disable standalone app creation when the host owns the Audit app and help.
*******************************************************************************/
public WormAuditQBitConfig withIncludeApp(boolean includeApp)
{
this.includeApp = includeApp;
return (this);
}
/*******************************************************************************
** Read the app name (the host-owned destination when includeApp is false).
*******************************************************************************/
public String getAppName()
{
@@ -214,7 +214,10 @@ public class WormAuditQBitProducer implements QBitMetaDataProducer<WormAuditQBit
{
qInstance.addScheduler(new QuartzSchedulerMetaData().withName(qBitConfig.getSchedulerName()));
}
output.add(WormAuditApp.app(qBitConfig, producedTables));
if(qBitConfig.getIncludeApp())
{
output.add(WormAuditApp.app(qBitConfig, producedTables));
}
for(QWidgetMetaData widget : WormAuditApp.widgets())
{
output.add(widget);
@@ -115,7 +115,7 @@ public final class WormAuditHelp
table.withHelpContent(SLOT, content(entry.getValue()));
}
}
else if(key.startsWith("app:"))
else if(config.getIncludeApp() && key.startsWith("app:"))
{
QAppMetaData app = qInstance.getApp(config.getAppName());
if(app != null && (app.getHelpContent() == null || !app.getHelpContent().containsKey(SLOT)))
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.wormaudit.processes;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import com.kingsrook.qbits.wormaudit.WormAuditQBitConfig;
import com.kingsrook.qbits.wormaudit.chain.AuditStore;
import com.kingsrook.qbits.wormaudit.model.WormAuditTables;
@@ -43,6 +44,11 @@ import com.kingsrook.qqq.backend.core.model.data.QRecord;
*******************************************************************************/
public final class VerificationRuns
{
/** How many break reasons the alert summary spells out; the title carries the total. */
private static final int ALERT_SUMMARY_REASONS = 3;
/*******************************************************************************
** Utility class.
*******************************************************************************/
@@ -79,8 +85,15 @@ public final class VerificationRuns
}
else
{
long problems = result.getBreakCount();
String summary = result.getBreaks().stream().limit(ALERT_SUMMARY_REASONS).map(b -> b.reason()).collect(Collectors.joining("; "));
if(problems > ALERT_SUMMARY_REASONS)
{
summary += "; and " + (problems - ALERT_SUMMARY_REASONS) + " more";
}
QAlerts.raise(new AlertSignal("wormaudit.chain.break").withDedupKey("chain")
.withTitle(result.getBreaks().getFirst().reason()).withRecordRef(new AlertRecordRef(WormAuditTables.VERIFICATION, row.getValue("id"))));
.withTitle("Audit chain verification found " + problems + (problems == 1 ? " problem" : " problems")).withSummary(summary)
.withRecordRef(new AlertRecordRef(WormAuditTables.VERIFICATION, row.getValue("id"))));
}
}
return (result);
@@ -469,8 +469,8 @@ BEGIN
END;
$fn$;
-- The array index worm_audit_chain_detail_coverage is no longer used by this release. It stays until a later
-- changeset drops it, so a previous-release writer still running during a rolling deploy keeps its indexed probe.
-- The array index worm_audit_chain_detail_coverage is no longer used by this release. It stays until
-- a later migration drops it, so a previous-release writer still running during a rolling deploy keeps its indexed probe.
DROP TRIGGER IF EXISTS worm_audit_row_guard ON public.worm_audit_chain_detail;
CREATE TRIGGER worm_audit_row_guard BEFORE UPDATE OR DELETE ON public.worm_audit_chain_detail
@@ -482,6 +482,16 @@ REVOKE UPDATE, DELETE, TRUNCATE ON public.worm_audit_chain_detail FROM ${app_rol
GRANT SELECT, INSERT ON public.worm_audit_chain_detail TO ${app_role};
-- Same function as worm-audit:5, except that coverage rows are locked and purged before their links.
-- The migrating role may have revoked its own EXECUTE (a runtime role that owns the schema, as on CNPG);
-- PostgreSQL then refuses CREATE OR REPLACE. Grant it back for this statement only, then restore.
DO $$
BEGIN
IF NOT has_function_privilege(current_user, 'public.worm_audit_purge(timestamptz)', 'EXECUTE') THEN
EXECUTE format('GRANT EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) TO %I', current_user);
PERFORM set_config('worm_audit.purge_regrant', 'on', true);
END IF;
END;
$$;
CREATE OR REPLACE FUNCTION public.worm_audit_purge(p_cutoff timestamptz) RETURNS void
LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog, public, pg_temp SET timezone = 'UTC' AS $fn$
DECLARE
@@ -536,3 +546,11 @@ BEGIN
VALUES(now(), session_user, p_cutoff, '${retention}', 'PURGED', audits_count, details_count, links_count, boundary, anchor);
END;
$fn$;
DO $$
BEGIN
IF current_setting('worm_audit.purge_regrant', true) = 'on' THEN
EXECUTE format('REVOKE EXECUTE ON FUNCTION public.worm_audit_purge(timestamptz) FROM %I', current_user);
PERFORM set_config('worm_audit.purge_regrant', 'off', true);
END IF;
END;
$$;
@@ -167,6 +167,9 @@ class CanonicalAndCryptoTest
java.nio.file.Files.readString(
java.nio.file.Path.of("docs/ddl/worm-audit-postgres.sql")));
assertThat(sql).doesNotContain("${");
// Liquibase parses every line starting with "-- changeset" as a directive, comments included.
assertThat(sql.lines().filter(line -> line.startsWith("-- changeset")))
.allMatch(line -> line.matches("-- changeset worm-audit:[0-9]+( splitStatements:false)?"));
assertThat(sql).contains("GET DIAGNOSTICS stack = PG_CONTEXT", "function worm_audit_purge(timestamp with time zone)", "SET timezone = 'UTC'");
// The documented migration contract deliberately uses physical snake_case columns.
assertThat(sql).contains("OLD.audit_id", "OLD.timestamp", "NEW.name", "GRANT UPDATE (label, create_date, modify_date)");
@@ -142,7 +142,8 @@ class ProcessesAndAlertsTest
assertThat(Sink.raised.getFirst().getDedupKey()).isEqualTo("chain");
assertThat(Sink.raised.getFirst().getRecordRefs().getFirst().getTableName())
.isEqualTo(WormAuditTables.VERIFICATION);
assertThat(Sink.raised.getFirst().getTitle()).contains("Break at link 1");
assertThat(Sink.raised.getFirst().getTitle()).isEqualTo("Audit chain verification found 1 problem");
assertThat(Sink.raised.getFirst().getSummary()).contains("Break at link 1");
var first = STORE.all(WormAuditTables.VERIFICATION).getFirst();
assertThat(first.getValueLong("first_break_link_id")).isEqualTo(1);
assertThat(first.getValueLong("first_break_audit_id")).isEqualTo(1);
@@ -278,6 +278,29 @@ class WormAuditHomeTest
}
/*******************************************************************************
** Embedded widgets retain direct authorization without a standalone app.
*******************************************************************************/
@Test
void embeddedWidgetsRequireReadPermission() throws Exception
{
var config = ChainVerificationTest.signed().withIncludeApp(false).withAppName("audit").withAppLabel("Audit");
var instance = init(config);
assertThat(instance.getApp("worm_audit")).isNull();
assertThat(instance.getApp("audit")).isNull();
var input = new RenderWidgetInput().withWidgetMetaData(instance.getWidget(WormAuditApp.HEALTH_CARD_WIDGET));
QContext.getQSession().withPermissions("admin.hasAccess");
assertThatThrownBy(() -> new WormAuditApp.HealthCardRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
assertThatThrownBy(() -> new WormAuditApp.HomeRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
QContext.getQSession().withPermissions(WormAuditTables.CHAIN + ".read");
String card = ((RawHTML) new WormAuditApp.HealthCardRenderer().render(input).getWidgetData()).getHtml();
assertThat(card).contains("href=\"/app/audit\"");
assertThat(new WormAuditApp.HomeRenderer().render(input).getWidgetData()).isInstanceOf(RawHTML.class);
QContext.setQSession(new QSession());
assertThatThrownBy(() -> new WormAuditApp.HealthCardRenderer().render(input)).isInstanceOf(QPermissionDeniedException.class);
}
/*******************************************************************************
** Exercise the real SQL anti-join without Docker, including its exact boundary.
*******************************************************************************/
@@ -21,10 +21,15 @@
package com.kingsrook.qbits.wormaudit;
import java.util.List;
import com.kingsrook.qbits.wormaudit.app.WormAuditApp;
import com.kingsrook.qbits.wormaudit.model.WormAuditHelp;
import com.kingsrook.qbits.wormaudit.ddl.WormAuditDdl;
import com.kingsrook.qbits.wormaudit.model.WormAuditTables;
import com.kingsrook.qbits.wormaudit.processes.WormAuditProcesses;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
@@ -43,6 +48,7 @@ class WormAuditQBitProducerTest
{
var config = new WormAuditQBitConfig();
QInstance instance = WormAuditTestSupport.init(config);
assertThat(config.getIncludeApp()).isTrue();
assertThat(config.getRetentionYears()).isEqualTo(6);
assertThat(config.getTableMetaDataCustomizer()).isNull();
assertThat(config.getSigningKey()).isNull();
@@ -160,4 +166,36 @@ class WormAuditQBitProducerTest
() -> WormAuditDdl.resolveNames(new WormAuditQBitConfig(), new QInstance()))
.isInstanceOf(IllegalArgumentException.class);
}
/*******************************************************************************
** Embedded production keeps the host app intact and registers all evidence metadata.
*******************************************************************************/
@Test
void embeddedMetadataPreservesHostApp() throws Exception
{
var config = new WormAuditQBitConfig().withBackendName("memory").withIncludeApp(false).withAppName("audit");
var instance = WormAuditTestSupport.core(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
var host = new QAppMetaData().withName("audit").withLabel("Combined Audit").withWidgets(List.of("hostSummary"));
instance.addApp(host);
new WormAuditQBitProducer().withQBitConfig(config).produce(instance);
assertThat(config.getIncludeApp()).isFalse();
assertThat(instance.getApp("worm_audit")).isNull();
assertThat(instance.getApp("audit")).isSameAs(host);
assertThat(host.getLabel()).isEqualTo("Combined Audit");
assertThat(host.getWidgets()).containsExactly("hostSummary");
assertThat(host.getHelpContent()).isNullOrEmpty();
assertThat(instance.getWidget(WormAuditApp.HEALTH_CARD_WIDGET)).isNotNull();
assertThat(instance.getWidget(WormAuditApp.HOME_WIDGET)).isNotNull();
for(String name : WormAuditTables.ALL)
{
assertThat(instance.getTable(name).getHelpContent()).containsKey(WormAuditHelp.SLOT);
assertThat(instance.getTable(name).getPermissionRules().getPermissionBaseName()).isEqualTo(name);
}
assertThat(instance.getTable(WormAuditTables.CHAIN).getDisabledCapabilities()).contains(Capability.TABLE_UPDATE, Capability.TABLE_DELETE, Capability.TABLE_INSERT);
assertThat(instance.getTable("audit").getDisabledCapabilities()).contains(Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
assertThat(instance.getProcess(WormAuditProcesses.VERIFY)).isNotNull();
assertThat(instance.getProcess(WormAuditProcesses.CHAIN).getSchedule().getRepeatSeconds()).isEqualTo(config.getChainIntervalSeconds());
assertThat(instance.getProcess(WormAuditProcesses.DAILY).getSchedule().getCronExpression()).isEqualTo(config.getVerifyCron());
}
}