Skip to content

Testing ​

What exists ​

Every tests/*.sh below is wired into CI, with two exceptions, and the build says so rather than this page. Wired is not run: GitHub-hosted runners ran no job from 2026-09-15 to 2026-10-01, because the account's billing failed (DLP-1215). In that window the suites ran only where a session ran them by hand, which each change's ticket records. Hosted runners have run again since about 00:10 UTC on 2026-10-01; what the Windows jobs have shown since is under Windows CI status.

Since 2026-09-28 a self-hosted runner brings eight of them back (DLP-1225). mydlp-gh-runner.service (deploy/github-runner/) runs on this host as ghrunner, an unprivileged user that cannot read production's configuration, database or keys. Only the jobs that touch no service port are routed to it, through the repository variable LINUX_RUNNER, and only for a push to main: a pull request, a Dependabot bump above all, brings code nobody has merged and does not run on this host. Those jobs are: lint, licenses, test, supply-chain, secrets, extension, dashboard and windows-script. Production owns 127.0.0.1:5432 and :8080 here, which e2e, migrations and browser hard-code, so those stay on GitHub-hosted runners and do not run there until billing does.

e2e and migrations run through tools/local_ci.py instead (DLP-1233, 2026-09-28). Each job gets a throwaway postgres:16-alpine on a free loopback port. The workflow's 5432 and 8080 are rewritten to scratch ports, and a step that still names 5432, 8080 or 9090 is refused rather than run. sudo apt-get install and pip install are answered by checks that the package is already here, and install nothing on the production host. The first run, on b0f90b08, passed 130 of 139 steps. Every failure had a cause in the repository, fixed with that change:

  • a job-wide server whose workers took three suites' work;
  • two suites tied to the agent release, or reading another tenant's audit row (DLP-1238, DLP-1239);
  • two suites that ignored API_BIN;
  • an append-only check that could not pass on an empty table.

It runs as root, which the hosted runners do not (DLP-1221). browser still runs nowhere.

  • A job that builds the workspace refuses to start with less than 32 GB free on /, and says so in its log; every job's build output is removed when it ends.
  • The wall-clock budgets are skipped there, because they would measure the neighbours.
  • Each job starts with the runner's private /tmp emptied, as a hosted VM's would be, and the extension test uses Playwright's Chromium from the runner user's home (SELF_HOSTED_CHROME_BIN).
  • No Windows job runs on it: that needs a Windows runner (DLP-449).
  • The sandbox isolation suite and the elevated half of install_cli need root, which the runner deliberately does not have, so they skip there with the reason printed. They run as root on GitHub-hosted runners, under sudo with DLP_ROOT_TESTS=required so a skip fails (DLP-1228), and locally when a session runs the tests as root. tools/ci_covers_tests.py fails when a shell test is named by no step and is not excluded on purpose (DLP-553). The exceptions are load_test.sh and fuzz_campaign.sh - both measure for as long as they are given, which is a schedule's job.

Until 2026-09-23 twenty-three of these rows were counts of assertions that ran nowhere. Running them the first time found a 500 in production code that had been there three days (DLP-564) and a test that sent its requests to the production server on the machine it was run from.

SuiteCountCommand
Rust unit and integration tests499cargo test --workspace
Governed recurring report execution36 assertions against PostgreSQL and a real worker: validation, RBAC, indirect edit/delete refusal, tenant isolation, DST-aware schedule representation, one occurrence, disable, token revocation, interrupted-worker recovery, audit privacy and atomic deletiontests/report_schedules.sh
Governed scheduled-report deliveryA real private-CA HTTPS receiver verifies HMAC, CSV and idempotency; the suite covers destination approval/RBAC, tenant isolation, ACK-only success, retry identity, changed-payload refusal, destination disablement, runtime export authority, append-only history, privacy-safe aggregate telemetry and secret/evidence canariestests/report_delivery.sh
Property-based tests (generated input)49 properties, 4 failing and ignoredcargo test --workspace properties::
End-to-end workflow63 assertionstests/e2e_vertical_slice.sh
Retention enforcement15 assertionstests/retention.sh
Per-device rate limiting and ingestion quotas48 assertionstests/rate_limit.sh
Dashboard type checkingstrictnpm --prefix apps/dashboard run typecheck
Dashboard lintzero warningsnpm --prefix apps/dashboard run lint
Dashboard unit and component tests609npm --prefix apps/dashboard run test
First-class classification workspace11 component/API-contract assertions covering tree semantics, typed elements, validation, revision conflicts, bundle impact, protected fork, RBAC and narrow layoutnpm --prefix apps/dashboard run test -- --run src/pages/classification-workspace.test.tsx
Tooling unit tests (SBOM, licence policy, action pins, doc paths)63python3 -m unittest discover -s tools -p 'test_*.py'
Licence gates fire11 assertionstests/licence_gate.sh
Archive bomb handling16 assertions, plus 4 timing ceilings under DLP_TIMING_TESTS=1tests/archive_bombs.sh
Fuzz smoke (8 targets)no crashtests/fuzz_smoke.sh
Fuzz campaign (8 targets, scheduled weekly)4h each, 5h for regex_compile, with coveragetests/fuzz_campaign.sh <target> <seconds>
Docker Compose stack21 assertionstests/compose_stack.sh
Kubernetes manifests44 assertionstests/kubernetes_manifests.sh
Hardware-id migration13 assertionstests/hardware_id_migration.sh
Tamper visibility30 assertionstests/tamper_visibility.sh
Overview aggregates29 assertionstests/overview_aggregates.sh
Sequence gaps and silence alerting32 assertionstests/sequence_gaps.sh
Metrics endpoint29 assertionstests/metrics_endpoint.sh
Device filters30 assertionstests/device_filters.sh
Policy order and enablement42 assertionstests/policy_ordering.sh
Data classification list and signed hierarchy16 assertionstests/classification_list.sh
First-class classification migration12 assertionstests/classification_hierarchy.sh
Transactional classification authoring and policy binding39 assertions against a real PostgreSQL/API process, including typed persistence/bundle compilation, exact signed policy coordinates, stale/disabled/cross-tenant refusal, mutation guards, revisions, tenancy, RBAC, protected forks and audit privacytests/classification_crud.sh
Windows typed-classification evaluation2 native-agent assertions covering every typed element and bounded fail-closed reasonscargo test -p dlp-agent-core --test classification_elements
Windows classification-policy binding3 assertions covering exact signed-bundle lineage, bounded fail-safe evidence and a context-only file-open decision with no detector hitcargo test -p dlp-agent-core --test classification_policy_binding
Activity categorisation42 assertionstests/activity_rules.sh
Behaviour analysis48 assertionstests/behaviour_analysis.sh
Insight priority22 assertionstests/insight_priority.sh
Per-person risk41 assertionstests/user_risk.sh
Per-person baselines27 assertionstests/user_baseline.sh
Working hours per tenant28 assertionstests/business_hours.sh
Ingestion commits5 assertions, a measurementtests/ingest_commits.sh · see Capacity
An updated rule matches only after activation18 assertions: a bound classification cannot be changed at all, an improved rule in the catalogue matches nothing by itself, and a rollback goes forward (DLP-489)tests/predefined_rule_activation.sh
The enrolment credential's whole life12 assertions, including that a revoked device cannot check intests/enrollment_lifecycle.sh
The systemd units3 assertions, including that the check itself can failtests/systemd_units.sh
A harmless copy leaves no trace10 assertions: exactly one clipboard event for the sensitive copy, none for the harmless one, and neither value anywhere on disktests/clipboard_privacy.sh
Audit checkpoints15 assertionstests/audit_checkpoints.sh
Audit archival35 assertions, including the storage-failure pathtests/audit_archive.sh
Periodic jobs record each pass, and the scheduler judges the records (DLP-2222, DLP-2371)Against a running server: housekeeping, audit anchoring and the sixteen other judged loops each write their own start and finish, a stamp that fails against an unreachable authority still records its finish, token expiry records nothing without a mail relay, and the scheduler part reads a fresh finish as fine, one two hours old as housekeeping_stopped, a pass five minutes in as running, one two hours in as housekeeping_hung, no row once the first pass is due as housekeeping_not_recorded, and a renewal four hours stale beside a working housekeeping as bundle_renewal_stoppedtests/job_heartbeats.sh
Authoring a classification rule29 assertionstests/detector_authoring.sh
Building, deploying and withdrawing a protected-document corpus85 assertions, including the build limits the console is told, build, recognition-test and withdrawal telemetry that names nothing, lifecycle and dependency state, duplicate refusal, fleet acknowledgement, tenant isolation, withdrawal impact, and that not a word of the source documents reaches the database, bundle or audit logtests/fingerprint_corpus.sh
A protected-document corpus, tested for what it must never do35 assertions. A unique canary, and each document as base64 exactly as sent, absent from a pg_dump of the whole database, the server log and every response, after builds that succeed, fail, are refused as duplicates or exceed the size limit, and a probe. A failed, still-building or never-ready version is kept out of the signed bundle even when a published policy references it. A direct-call matrix covers auditor, viewer, analyst and a custom role without policies:read, and another organisation's reads and withdrawals get the answer for an id that does not exist (DLP-590, API half)tests/fingerprint_corpus_security.sh
Match previews only through the justified evidence path33 assertions. No preview in the incident detail for Owner, Admin, Analyst or Auditor, with a count and a reason instead. /evidence refuses a missing or short reason, a role without evidence:read and another organisation, recording none of them. One audit entry and one access-log row per justified look, carrying the reason and naming the person (DLP-651)tests/evidence_access.sh
Refusing an EDM import an endpoint cannot hold22 assertionstests/edm_budget.sh
A discovery scan that repeats, and working its findings45 assertions, including that a run which is not due does not start and that a file marked remediated comes backtests/discovery_schedule.sh
Discovery reaches only the storage assigned to each organisationTwo organisations with their own roots and credential prefixes: each names its own and not the other's share, bucket credential or one-off path; a refusal reads the same for an existing and a missing path; a symbolic link out of a root is refused; the credential selector lists only the organisation's variables; a queued scan outside the assignment is paused once at start-up and audited without its location; and with no assignment and two organisations everything is refused (DLP-675)tests/discovery_tenant_isolation.sh
An EDM dataset reaching an endpoint24 assertions against a real agent, including that no source value appears in the bundle, the database, the audit log or the eventtests/edm_delivery.sh
An uninstall that does not destroy the case18 assertions against a real agent, including that an unreachable server does not block a removaltests/uninstall_flush.sh
A policy the fleet cannot act on25 assertions against a real agent's own capability matrix, including a department or group scope no endpoint can evaluate: warned by name, audited, shown on the list, published all the same (DLP-666)tests/policy_coverage.sh
A draft's coverage preview answers for the draft it sends8 assertions: refused without a token; a draft with a directory-group scope is warned by name beside the no-endpoint notice, and the same draft without that group is not; nothing is saved (DLP-2353)tests/coverage_preview.sh
A device certificate that renews itself19 assertions, including that an already-expired certificate still renews and a failed renewal keeps the old onetests/certificate_rotation.sh
The agent's own log25 assertions against a real scan of a real card number, including that no fragment of the match is anywhere in the log, that the log is bounded and forgets, and that turning it off turns it offtests/agent_logging.sh
Scanning object storage34 assertions against a real MinIO in a container, including that a scan names only a DLP_DISCOVERY_ variable (DLP-634), that every page is read, that nothing in the bucket is written or modified, and that the credential never comes back out of the APItests/s3_discovery.sh
Reusable discovery targets46 assertions: an inline: credential refused and kept out of every row; ten connection tests a minute per organisation, the eleventh a 429 with Retry-After and an audit entry, another organisation unaffected, and fifteen at once answering exactly ten (DLP-637), a moved target carrying its scheduled runs, retiring refused while anything is scheduled, another tenant's target a 404tests/discovery_targets.sh
Discovery targets and their connection test52 assertions against a real MinIO and a recording stub: every failure class named separately, only GETs asking for one key, a redirect neither followed by the test nor by a scan (DLP-633), a 15 s bound, a Viewer refused, and six planted secrets in no response, log, audit entry or target rowtests/discovery_target_validation.sh
A discovery target's whole lifeFor a share and a MinIO bucket: created, tested, scheduled, scanned, a finding acknowledged, the audit trail in hash-chain order; a share unmounted under a scheduled run and remounted; another organisation given the same answer as for an id that does not exist at every step, including a remediation with no ids; the classification inventory over real findings (DLP-635); and the MinIO secret searched for in a dump of the whole database, the server log and every responsetests/discovery_lifecycle.sh
How much of what the detectors say is trueA labelled corpus of 58 synthetic documents past every shipped pack, with at least one positive and one negative case for all 46 built-in detectors, reporting precision, recall and the confusion matrix per detector against a floor recorded in the corpus file. Every document is a negative case for every detector it is not labelled for, except detector-specific ambiguous outcomes, which are named but excluded from metrics and floorspackages/detector-engine/tests/detector_quality.rs
Each detector in each language, on its ownThe multilingual corpus (42 synthetic documents in Azerbaijani, Turkish, Russian, English and mixed) carries floors per detector and language (DLP-1692): az.fin and az.voen in Azerbaijani, ru.inn and ru.snils in Russian, tr.tckn and tr.vkn in Turkish, precision 0.80 and recall 1.00. A language's total sums every detector and hid az.voen missing a real supplier export, which is how DLP-1699 was found. A slice needs three labelled positives before its floor is judged; with fewer it is reported as insufficient evidence, never as a pass, and the shipped corpus may not carry a floor its documents cannot support. Ambiguous labels are named but excluded from precision, recall and floors, and a detector labelled both positive and ambiguous on one document fails validation (DLP-1677)packages/detector-engine/tests/multilingual_quality.rs::each_detector_is_held_to_its_own_floor_in_each_language, quality::tests
What the detectors say about a file, from its bytesEleven fixture files built from synthetic text by recipes in the test (plain text, CSV, a Word document with a card split inside a digit group, a workbook with a card stored as a number, Chrome's Save-as-PDF with glyph-mapped fonts, a PDF with the card only in its document information, a scanned PDF, an unreadable blob) go through extract and the engine as an endpoint reads them (DLP-1693). Counted by the format the bytes really are. A file not read, or read only in part, is listed with the reason and is never a clean negative or a miss. A PDF labelled clean from its page text alone is reported as a false positive, because extraction finds the card in its metadatapackages/detector-engine/tests/file_quality.rs, quality::measure_files
Quality figures, stored per detector revisionAssertions against a real database, including automatic bootstrap measurement, all built-ins having labelled positive cases, tenant-authored rules being named when the shipped corpus cannot measure them, older revision results never carrying forward, figures coming from the tenant's enabled detectors, no column holding a document, write permission for measuring, and observed precision using explicit detector verdicts onlytests/detector_quality.sh
A dropped, pasted or picked file reaching a verdict30 assertions driving the extension's content script in real Chrome, including that a blocked file never reaches the application's own handler and that text pasted as text is neither read nor interceptedagent/browser-extension/test/run.mjs
Signing inside a PKCS#11 token26 assertions against a real SoftHSM tokentests/pkcs11_signing.sh
Subject access (Article 15)22 assertionstests/subject_access.sh
The user directory, and a leaver losing access44 assertionstests/user_directory.sh
Read-only AD objects for policy targeting (DLP-1435)A synthetic, recording LDAPS peer with a generated private CA and real API/PostgreSQL: stable binary SID/GUID, bounded encrypted pagination, rename-by-SID, disabled users, unsupported user targeting, missing GUID, referral/diagnostic refusal, ten-second cancellation, connector health, privacy at trace logging, source persistence and an exact read-only LDAP attribute/base allowlist. This is not proof of customer AD provisioning or Windows enforcement.tests/ad_directory.sh and tests/ad_directory_fixture.py, dedicated scratch DB in CI
Read-only Entra objects (DLP-1436)Recording token/Graph peer and real API/PostgreSQL: pinned tenant/read-only consent, exact metadata filters, bounded encrypted pagination, stable rename lookup, disabled/unknown/dynamic unselectable state, expiry/throttle/consent/partial-response failures, refused redirects, tenant/RBAC boundaries, source health and trace-log privacy. Not customer consent or endpoint enforcement proof.tests/entra_directory.sh and tests/entra_directory_fixture.py, dedicated scratch DB in CI
Two analysts dividing one incident queue25 assertionstests/incident_queue.sh
A report re-evaluated for whoever runs it28 assertionstests/report_library.sh
Webhook delivery, and where a redirect cannot take it51 assertions, against a real HTTPS receivertests/integrations.sh
Syslog to a SIEM41 assertions, against a real TLS collector that parses RFC 5424tests/syslog.sh
Escalation to Jira and ServiceNow43 assertions, against real HTTPS trackerstests/tracker.sh
Federated sign-in48 assertions, against a real HTTPS identity provider, run in CItests/oidc.sh
ID token verification31 vectors from an independent implementationcargo test -p dlp-crypto --test jwt_vectors
Ingestion capacitya measurement, not a pass/failtests/load_test.sh · see Capacity
Windows workflow80 assertions, wired into CI on windows-latest and not yet passed: no hosted job ran from 2026-09-15 to 2026-10-01 (DLP-1215), and since then windows-e2e stops before enrolment (Windows CI status)tests/windows_verification.ps1
Windows release-signing policy17 assertions without access to a release key; the protected release job supplies the real SignTool evidencetests/windows_release_signing.ps1
Benchmarks4cargo bench -p dlp-detector-engine
Prose in user-facing strings7 self-tests, 118 filestools/check_prose.py
Documented limitations still true5 assertions, designed to fail when a gap is closedpackages/detector-engine/tests/documented_limitations.rs

Everything below is reproducible on a checkout with PostgreSQL available.


Running the suite ​

bash
# Unit and integration tests. No database required - the engines are pure.
cargo test --workspace

# End-to-end, against a running server and a real compiled agent.
export DATABASE_URL="postgres://mydlp:<password>@127.0.0.1/mydlp"
cargo build                                  # debug binaries are enough
./target/debug/dlp-management-api &           # or systemctl start mydlp-api
bash tests/e2e_vertical_slice.sh

# Every one of these starts its own API on a spare port, so none of them
# disturbs - or talks to - a server that is already running. Each reads
# API_BIN, defaulting to the release build; CI passes the debug one. Each
# refuses a binary whose version or commit is not this tree's (DLP-554), so
# rebuild after pulling, or after a deploy has rebuilt target/. To give
# each a database of its own, as CI does:
#   DATABASE_URL=postgres:///postgres tools/with_scratch_db.sh retention bash tests/retention.sh
cargo build --release
bash tests/retention.sh
bash tests/rate_limit.sh
bash tests/retention_metrics.sh
bash tests/tamper_visibility.sh
bash tests/overview_aggregates.sh
bash tests/sequence_gaps.sh
bash tests/metrics_endpoint.sh
bash tests/device_filters.sh
bash tests/policy_ordering.sh
bash tests/classification_list.sh
bash tests/classification_crud.sh
bash tests/classification_overview.sh
bash tests/activity_rules.sh
bash tests/behaviour_analysis.sh
bash tests/insight_priority.sh
bash tests/user_risk.sh
bash tests/user_baseline.sh
bash tests/business_hours.sh
bash tests/ingest_commits.sh
bash tests/audit_checkpoints.sh
bash tests/audit_archive.sh
bash tests/detector_authoring.sh
bash tests/fingerprint_corpus.sh
bash tests/edm_budget.sh
bash tests/pkcs11_signing.sh
bash tests/subject_access.sh
bash tests/user_directory.sh
bash tests/incident_queue.sh
bash tests/report_library.sh
bash tests/integrations.sh
bash tests/syslog.sh
bash tests/tracker.sh
bash tests/oidc.sh

# Capacity. Not a pass/fail: it prints a figure and what the database was
# waiting on. Read docs/CAPACITY.md before quoting the number - it is only
# meaningful beside the load average it was taken under.
cargo build --release -p dlp-loadgen
bash tests/load_test.sh 10000 20
# Two API replicas, or a soak of hours: DLP_LOAD_REPLICAS and
# DLP_LOAD_SOAK_MINUTES, described in docs/CAPACITY.md §4 (DLP-1180).

# SBOMs. Needs syft on PATH; writes sbom/*.json and fails if either document
# is incomplete.
python3 tools/generate_sbom.py

# Licence gates. Deliberately breaks the policy, asserts the build fails, and
# restores the tree. Refuses to run if deny.toml or the licence documents have
# uncommitted changes, so it cannot restore over your work.
bash tests/licence_gate.sh

# Archive bombs. Builds hostile archives and measures what they cost.
cargo build --release -p dlp-detector-engine --example archive_probe
bash tests/archive_bombs.sh

# Dashboard
npm --prefix apps/dashboard ci
npm --prefix apps/dashboard run typecheck
npm --prefix apps/dashboard run lint
npm --prefix apps/dashboard run build

Without the database password ​

Every database-backed script above wants a DATABASE_URL with a password in it. On a machine where PostgreSQL accepts peer authentication over its unix socket - the Debian and Ubuntu default, local all all peer in pg_hba.conf - there is no password to find. Run as the postgres system user and connect over the socket:

bash
sudo -u postgres createdb dlp_scratch
sudo -u postgres env \
  DATABASE_URL='postgres:///dlp_scratch?host=/var/run/postgresql' \
  DLP_TEST_PORT=18099 \
  bash tests/retention_metrics.sh
bash tests/tamper_visibility.sh
bash tests/overview_aggregates.sh
bash tests/sequence_gaps.sh
bash tests/metrics_endpoint.sh
bash tests/device_filters.sh
bash tests/policy_ordering.sh
bash tests/classification_list.sh
bash tests/classification_crud.sh
bash tests/activity_rules.sh
bash tests/behaviour_analysis.sh
bash tests/insight_priority.sh
bash tests/user_risk.sh
bash tests/user_baseline.sh
bash tests/business_hours.sh
bash tests/ingest_commits.sh
bash tests/audit_checkpoints.sh
bash tests/audit_archive.sh
bash tests/detector_authoring.sh
bash tests/fingerprint_corpus.sh
bash tests/edm_budget.sh
bash tests/pkcs11_signing.sh
bash tests/user_directory.sh
bash tests/incident_queue.sh
bash tests/report_library.sh
bash tests/subject_access.sh
bash tests/integrations.sh
bash tests/syslog.sh
bash tests/tracker.sh
bash tests/oidc.sh
sudo -u postgres dropdb dlp_scratch

The binary and the script must be somewhere postgres can read - /root is not - so copy them to a scratch directory first if the checkout lives there.

This is how #371 was verified, and it is worth preferring to a password even when one is available: it runs against a database created and dropped for the test, so a script that does something unexpected cannot touch a real one.


The rate-limiting scenario ​

tests/rate_limit.sh starts its own API process on port 18080 with a deliberately slow refill rate, then drives it with two devices that sign their requests with real Ed25519 keys. The slow rate is what makes the assertions exact numbers rather than ranges: at the shipped default of one token per second, a few hundred milliseconds of burst earns a token or two back and "exactly 20 allowed" becomes "20, 21 or 22".

It proves that a device exceeding its bucket is refused with a usable Retry-After, that the refusals begin only once the bucket is empty, that a second device on the same tenant is untouched, that the counters and the single per-episode alert land where they should, that another tenant sees none of it, and that the throttled device is served again on its own once tokens refill. It also asserts that a rate of 0 is refused at start-up rather than silently locking every endpoint out.

The same script covers the ingestion quotas. Two details in it are load-bearing rather than incidental. An oversized batch is asserted to be a 400 that costs the device nothing, because charging for it would let a buggy agent starve a healthy one of its budget. And the byte-quota device sends fat events - two per batch with a long file path - rather than more of them: with fixed-size events the event and byte buckets fill in proportion, only the tighter one is ever observable, and a test that padded nothing would pass while proving nothing.


Windows ​

Windows CI status ​

Measured from the runs, not assumed (DLP-1467), as of 2026-10-02:

  • 2026-09-15 15:15 UTC to 2026-10-01 about 00:10 UTC: no GitHub-hosted job ran at all, because the account's billing failed (DLP-1215, deleted from the tracker on 2026-09-30). The Windows jobs were written in that window, so none of them ran.
  • Since 2026-10-01: they run on windows-latest. In run 36794832660 and the two after it, every native build failed on rc.exe (DLP-1471).
  • Run 36967693772 (2026-10-02):
    • windows-agent built the agent natively, the first time that has passed. It ran dlp-agent-core's tests on Windows for the first time: 1144 passed, 5 failed. Four session-pipe tests fail from one cause (DLP-1489); one USN journal test fails on an 8.3 short path (DLP-1490). The steps after the tests were skipped.
    • windows-e2e stopped before enrolment, because its API did not outlive the step that started it (DLP-1492).
    • The release build and the native ARM64 MSI jobs were skipped.
  • So no Windows job has yet passed on windows-latest. Every page that says a Windows CI step has not passed means this list.

The rest of this section describes what each job is for. A sentence saying what a job "covers" or "asserts" means what it will check, not what has been seen. Until they pass, the evidence for Windows is:

  • the Linux tests of platform decisions declared outside cfg;
  • the x86_64-pc-windows-gnu cross-compile;
  • the static half of the MSI script under pwsh;
  • a few binaries under Wine 9 (DLP-1126, DLP-1207).

Windows is the primary production target, and until #317 nothing in CI touched it. Six jobs are written for a Windows runner, across three workflows, and they answer different questions. The list is here in full because it is read as the answer to "can this be tested on Windows?" - and when it was three jobs out of date, tickets were declined on the reasoning that no lane existed to run them (DLP-345).

JobWorkflowRunnerQuestion
windows-agentci.ymlwindows-latestdo the Windows code paths work?
windows-e2eci.ymlwindows-latestdoes the product work on Windows?
windows-arm64-msici.ymlwindows-11-armdoes the native ARM64 package install?
watcher-burstwindows-stress.ymlwindows-latestdoes the watcher survive a burst? (DLP-212)
at-scalewindows-stress.ymlwindows-latest2,000 detectors and 500 policies (DLP-211)
build-unsignedwindows-release-signing.ymlwindows-latestdoes the release package build before it is signed?

A seventh is defined and cannot run: sign-and-verify, in the same workflow, targets a self-hosted Windows runner in a protected environment whose CNG provider delegates to a cloud HSM. That runner and the certificate behind it do not exist - DLP-1175 - so the job is a written intention rather than evidence, and it is listed here as one so that finding it in the workflow does not read as coverage.

Two more jobs are about Windows without running on it: the lint job's cross-compile, and windows-script, which parses all seven PowerShell scripts so a 470-line harness nobody executes cannot rot into a missing brace. It names them explicitly rather than globbing, so an eighth script is not covered until somebody adds it to the list.

Does it compile? A cross-compile check on the lint job, --all-targets, every workspace crate but spike-fanotify-block. This exists because the Windows agent had not built for two releases: #474 added a field to RemovableMount, updated the Linux arm of scan() and left the Windows arm one field short. Every gate ran on Linux, so no gate could see it. This one catches the class in seconds.

Do the Windows code paths work? windows-agent, on windows-latest: builds with the native toolchain and linker, runs the agent crates' tests - including dlp-sandbox, which is there for the opposite reason to the others (DLP-343): it has no Windows implementation, and the job asserts that it says so rather than claiming an isolation it does not have - and asserts the reported capability matrix - it says windows, it is not empty, nothing claims enforcement this build does not implement, and every channel that cannot enforce gives a reason. Those branches - the state-directory ACL, the key-protection check, elevation through OpenProcessToken - have still not yet passed anywhere: this job's run stops at the failing tests listed in Windows CI status.

Does the product work on Windows? windows-e2e, also on windows-latest, runs tests/windows_verification.ps1 against a real server: PostgreSQL from the runner image, the management API built natively, a tenant bootstrapped, a policy published, and the agent enrolled and scanning. It covers enrollment, the private-key and state-directory ACLs, drive letters, spaces, Unicode names, deep paths, ReadDirectoryChangesW monitoring, offline buffering and revocation.

It also runs the service lifecycle and then a second, independent MSI sequence. tests/windows_msi_installer.ps1 builds a lower and current package, checks failed-enrollment rollback, installs silently, upgrades a running service without replacing identity, refuses downgrade, verifies the inventory row and first Event Log version, and uninstalls silently. The older standalone service test remains useful for service recovery behaviour; it is no longer the enterprise installer claim.

Three limits bound all six, and they are why several test tickets stay open rather than being closed by a lane that looks close enough. A hosted runner has no interactive desktop - no window station, so nothing that needs somebody copying or typing can run there (windows-stress.yml records this; GetClipboardOwner is DLP-1176's remainder). It cannot reboot and return to the same job, so a service proved running after a restart is not a service proved running after a boot (DLP-449). And it is one machine with no domain, which is the next limit in full:

One honest limit: a CI runner is not a fleet. One image, no domain, no Group Policy, no endpoint protection alongside the agent, a loopback server over plain http - because the agent's release build rightly refuses that and CI builds debug, which the script reports as a skip with the reason rather than as a pass - and no reboot. "Survives a reboot" is asserted as its two halves, automatic start and recovery after a kill, rather than by rebooting a build agent.

Read the table row as written: the assertions now run, and what they run against is a build server.


Fuzzing ​

Eight targets in packages/detector-engine/fuzz, covering every boundary that parses input the agent has no say over. Named for the detector engine because that is where most of them are, and not limited to it - the last two are elsewhere in the tree:

TargetBoundary
extract_anyWhole-file extraction, arbitrary bytes and an arbitrary name
detect_typeType detection from magic bytes - runs before any parser, on every file
extract_pdfPDF streams: index arithmetic over attacker-chosen offsets
archive_inspectThe decompressor, and the limits enforced on attacker-chosen sizes
regex_compileThe safety gate that decides whether a pattern may run
bundle_deserialiseBundle parsing, which happens before the signature over it can be checked
extract_rtfRTF control words and groups, where the nesting depth is attacker-chosen
sandbox_helper_outputWhat the agent reads back from the isolated helper (DLP-230). Not a hypothetical boundary: the helper exists because the parser inside it may be taken over, and everything it writes afterwards is attacker-controlled input arriving on a pipe the agent opened itself

The property is the weak one that matters: it returns. A panic on any of these paths is a denial of service on a customer's machine.

tests/fuzz_smoke.sh runs each target briefly and is what CI executes. It is sized for a pull request and its job is to catch a regression - the committed seeds in fuzz/seeds/ are replayed first, so anything fuzzing has already found is re-checked every time instead of waiting to be rediscovered. Finding something new takes hours and belongs in a longer campaign, not in a pull request.

The working corpus is not committed: it is tens of thousands of generated inputs, all reproducible by running the fuzzer again. fuzz/seeds/ is the handful worth keeping.

What it has found so far. regex_compile asserts two directions - that the safety gate never panics, and that a pattern the gate accepts actually compiles. The second failed on "\n)\n": an unbalanced group passed the safety checks and was refused by the compiler a moment later. It was never a live defect, because every caller compiles immediately and that error is handled - but validate_pattern is public and its answer was "safe" about an unusable pattern. The gate now confirms compilability, and the input is a committed seed.

The long campaign ​

.github/workflows/fuzz-campaign.yml runs its targets in parallel every Sunday, so the campaign costs one target's wall clock rather than the sum of them.

Four hours each, and five for regex_compile - not the six this page claimed until DLP-326. regex_compile asks for twice the slice, because each of its runs compiles the pattern now and so executes far fewer iterations in the same time; equal time is not equal exploration. Doubling 240 gives 480, which the workflow then clamps to 300, and it says so in the log rather than silently shortening a campaign that still reports a figure. The ceiling is not ours: a GitHub-hosted job is killed at 360 minutes, and the workflow sets its own 350-minute timeout so that our message arrives before that guillotine does.

All eight targets, since DLP-344. extract_rtf and sandbox_helper_output had seed corpora and built, and the smoke run exercised them for seconds, but neither had ever been in the weekly matrix - so a clean campaign said nothing about either. sandbox_helper_output is the one that mattered: it parses what the sandboxed helper sends back, which the sandbox calls hostile input to the agent in as many words, and it is therefore the trust boundary the sandbox exists to create.

The two new targets have no coverage figures below yet. They will have them after the first campaign that includes them, and until then their absence from that table is the honest state rather than an oversight.

Two things about it matter more than the running:

  • The corpus persists. A campaign that restarts from the committed seeds every week re-explores the same shallow branches forever. The workflow caches corpus/<target> and carries it forward, including from a run that crashed - the inputs reached before the crash are the expensive part.
  • A crash reaches a person. The crashing input is uploaded with 90-day retention and an issue is opened naming the target, with the reproduction command. A red square on a scheduled workflow nobody subscribed to is not a notification. One open issue per target, because a weekly campaign that reopens the same finding every Sunday trains everyone to close it unread.

tests/fuzz_campaign.sh <target> <seconds> is the same thing, runnable by a person - a campaign nobody can reproduce locally is one nobody can investigate.

Coverage ​

The campaign measures it, because "no crash in four hours" means nothing if the fuzzer never got past the first branch.

The figure reported is the coverage of the module the target actually drives, not the workspace total. The workspace number for detect_type is 1.45%, which is arithmetic over crypto, contracts and every engine module it never calls; the module under test is at 20%. A misleading coverage figure is worse than none, because it is the one number that says whether a clean run means anything.

Measured per target, and one of the six is a finding rather than a figure:

TargetExec/secCoverage of the module it drives
regex_compile238safe_regex.rs - 93.5% of regions
extract_any15 438extract.rs - 86.4%
extract_pdf6 971extract.rs - 61.9%
archive_inspect3 773archive.rs - 52.9%
detect_type46 848extract.rs - 20.4%
bundle_deserialise24 164engine.rs - 0.0%

bundle_deserialise guards everything it tests behind if let Ok(..) = serde_json::from_str(..), and its only committed seed does not parse. Its millions of executions have been exercising serde's rejection path and nothing else, which makes it the one target that has never actually fuzzed what it exists to fuzz. Tracked as #439, and it is the reason coverage was measured rather than assumed.

Requires nightly, cargo-fuzz and llvm-tools for the coverage pass:

bash
rustup toolchain install nightly --profile minimal
rustup component add llvm-tools --toolchain nightly
cargo install cargo-fuzz --locked
bash tests/fuzz_smoke.sh 30                  # the pull-request run
bash tests/fuzz_campaign.sh archive_inspect 3600   # one hour, with coverage

Coverage is measured after the campaign and never fails it: a coverage toolchain that is missing or has drifted must not turn a clean campaign into a red one.


Archive bombs ​

tests/archive_bombs.sh builds seven hostile archives and measures what each one costs to inspect: peak resident memory, wall time, bytes actually decompressed. The assertions are about survival, not detection - a hostile archive must be refused within a bounded amount of memory and time and reported with a reason, whatever it declares about itself.

Two choices in it are deliberate.

The archives are built with Python's zipfile, not with the Rust library that reads them. A defence tested only against its own writer proves that the writer and the reader agree, which is not the question being asked.

One archive is padded with incompressible noise so its declared expansion ratio sits under the threshold - built specifically to defeat the cheap header check, so that what the test measures is the read budget rather than the header test. That is the assertion that matters: the header is written by whoever built the archive.

The memory figures come from archive_probe, which reads its own VmHWM from /proc. Keeping the measurement inside the thing measured means the numbers in archive.rs can be re-checked rather than taken on trust.


Proving the licence gates ​

tests/licence_gate.sh exists because a gate that has never refused anything is a green tick rather than a gate. It introduces each violation deliberately, asserts the build fails and that the message names the offender, then restores the tree - including on an interrupt, because a test that left a forbidden licence in deny.toml would be worse than no test.

One detail in it is load-bearing. The obvious way to test notices drift - append a line, regenerate, git diff - quietly passes: the generator overwrites the appended line, the file returns to exactly what is committed, and nothing is detected. It was written that way first. The working version puts a genuinely stale file in place and checks that regeneration produces something different, which is what CI actually compares.


The end-to-end scenario ​

tests/e2e_vertical_slice.sh runs the full workflow against a real server, a real PostgreSQL database and a real compiled Rust agent. Nothing is mocked.

Each numbered step is the one from the specification:

  1. Administrator logs in - and a wrong password is rejected
  2. Administrator creates an enrollment token - and the token value proves unretrievable afterwards
  3. The Rust agent enrols - the private key is 0600, and the token is refused on a second use
  4. The agent checks in, an administrator submits a signed whole-company policy and a second owner releases it - the same policy asking to block, which no Linux endpoint can carry out, is refused with the publication guard's reason, and a policy referencing an unknown detector is refused with 422
  5. The agent downloads and verifies the bundle - and a tampered cache is rejected, asserted by actually editing the cached file 6–11. Synthetic sensitive data is placed, scanned, evaluated, and a redacted event is buffered
  6. Network interruption - nothing is delivered, events accumulate locally 13–14. Connectivity returns - the queue drains to zero
  7. A replay of the same events creates no duplicate rows 16–17. Incidents are generated, risk-scored with explainable factors, and visible through the API the dashboard uses. Asserted: no raw sensitive value appears anywhere in the incident record, redacted previews are present, and a benign file of the same shape produced nothing
  8. An analyst investigates and resolves - an illegal transition returns 409, closing without a resolution returns 422, evidence access is recorded
  9. The audit log contains every lifecycle action and the hash chain verifies intact
  10. Tenant isolation - another tenant gets 404 on the same incident id, sees zero incidents, and an unauthenticated request gets 401

Last run: 63 passed, 0 failed (2026-10-15, DLP-2372). On the build host the agent ran as nobody, through an AGENT_BIN wrapper using setpriv --reuid=nobody, because other processes there held 126 of root's 128 inotify instances and a root agent failed to open its watch with EMFILE (DLP-2305). The API and psql ran as root. Nothing in the suite needs the agent to run as root.


Security tests ​

ThreatTest
Cross-tenant reade2e step 20; policy-engine::cross_tenant_policy_never_applies
IDORe2e step 20 - another tenant's incident id returns 404, identical to a missing row
Forged policy signaturecrypto::bundle::tampered_payload_is_rejected, unknown_signing_key_is_rejected
Policy downgradecrypto::bundle::downgrade_is_refused; agent-side a_downgraded_bundle_is_refused_and_the_current_one_survives
Signing-key rotation abusetrust_set::overlap_accepts_bundle_signed_by_new_key, removal_rejects_bundle_signed_by_old_key, empty_trust_set_is_rejected, trust_set_signed_by_retired_key_is_rejected, trust_set_rollback_is_rejected
Tampered policy cachepolicy_runtime::a_tampered_cache_file_is_not_loaded; e2e step 5 edits the real file
Expired / future-dated bundleexpired_bundle_is_refused, future_dated_bundle_beyond_skew_is_refused
Wrong tenant / device / group bundlethree separate bundle tests
Replay of a signed requestrequest_auth::stale_requests_are_rejected, changing_the_body_breaks_the_signature, plus the nonce table's primary key
Enrollment token reusee2e step 3
Regex denial of servicesafe_regex::classic_redos_pattern_is_linear_here asserts wall-clock; compilation_blowup_is_capped_not_fatal
Malformed documentsextract::malformed_pdf_does_not_hang_or_panic across four hostile inputs
UTF-8 boundary handlingengine::malformed_utf8_boundaries_do_not_panic
Queue corruptionsecure_buffer::a_corrupted_row_is_quarantined_not_delivered
Agent restartsecure_buffer::queue_survives_reopening, an_expired_lease_is_retried_not_lost
Offline operatione2e steps 12–14
Unauthorised evidence accessauth::viewer_cannot_see_evidence_or_audit; route strips previews with a visible flag
CSV formula injectionincidents::csv_formula_injection_is_neutralised
Audit tamperinge2e step 0 attempts a real DELETE and UPDATE against audit_log; both refused by trigger
An audit entry removed without a copyaudit_archive.sh §1, §8, §9 - no archive directory, storage that will not take the copy, and an unverified segment each prune nothing; the last is refused by the database, not the application
An archived copy silently alteredaudit_archive::a_modified_entry_in_the_archive_is_caught, an_entry_removed_from_the_middle_of_the_archive_is_caught - the segment is read back off the storage and its chain recomputed before anything is pruned
Password brute forcepassword::wrong_password_does_not_verify, hashes_are_salted_and_therefore_unique; lockout and timing equalisation in identity
A signing key extractable from the serverpkcs11_signing.sh §4 - the token reports the key as never extractable and always sensitive, which is the hardware's statement rather than ours
A silent fallback to a software keypkcs11_signing.sh §5 - with the token gone, the publish fails and no bundle is written
A PIN written where it can be readpkcs11_signing.sh §0 and §2 - a literal PIN is refused at configuration time, and the PIN appears nowhere in the audit log
Insecure TLS configurationtransport::insecure_tls_is_refused_outside_debug_builds, a_ca_bundle_with_no_certificate_is_rejected - the release agent refused an http:// URL in production during bring-up
JWT algorithm confusionjwt_vectors::a_symmetric_algorithm_over_the_providers_public_key_is_refused - a token HMAC-signed with the provider's own RSA modulus as the key
Unsigned ID tokenjwt_vectors::an_unsigned_token_is_refused (alg: none)
ID token signed by the wrong keyjwt_vectors::a_token_signed_by_the_wrong_key_is_refused - the kid names a key the provider does publish
An access token used as an identityjwt_vectors::an_access_token_presented_as_an_id_token_is_refused - same provider, same signing key, different purpose
A token minted for another clientjwt_vectors::several_audiences_authorised_to_someone_else_is_refused; oidc.sh §5
Login CSRFoidc.sh §10 - a callback carrying a state this server never issued
Sign-in replayoidc.sh §6 - the callback and the handoff are each consumed in the statement that reads them
Nonce replay across sign-insoidc.sh §7 - the provider returns a token answering a different sign-in
Open redirect after authenticatingoidc.sh §11; oidc::a_return_to_may_only_be_a_path_on_this_console; sso.test.ts
A mapping carrying evidence into a trackertracker::a_mapping_cannot_reach_a_field_the_console_redacts; tracker.sh §0 - a Jira project has more members than the security team
A duplicate ticket in a service desktracker.sh §8 - a second escalation returns the existing key and the tracker is not asked again
Authentication mistaken for authorisationoidc.sh §8 - somebody the provider authenticates who is in no mapped group gets no session and no account
A custom role resolving differently from a built-in onepermission_matrix.sh §1 - every read route in the router (read by tools/route_inventory.py, so a new route is asked the day it is added) is asked by each built-in role and by a custom role holding exactly its permissions; the two must agree on all of them
A role quietly gaining or losing a routepermission_matrix.sh §2 - the five built-in roles against tests/fixtures/route_access.json; a route with no recorded answer fails until somebody decides who may read it
A probe that never reached the permission checkpermission_matrix.sh §4 - the owner must get past every route's extractors, or a 400 would read as "allowed"; found on its first run, where evidence read as open to a Viewer
Delegating more than you holdcustom_role_access.sh §4, custom_roles.sh §4 - defining, editing, assigning, changing and disabling are each bounded by the actor's own permissions
A stale grant after a role is narrowedcustom_role_access.sh §2 (the next request), §3 (a token), §8 (a scheduled report run)
A custom role that fails opencustom_role_access.sh §7 - retired, hand-edited with tenant:write or an unknown identifier: nothing granted
Hidden menus as the only controle2e/custom-roles.spec.ts - a holder's rail offers only what the role grants, and a typed address outside it is refused by name; the server refusal underneath is permission_matrix.sh

The console suite's slow tests ​

A full console run (npx vitest run) is expected to be clean. Until DLP-1353 a few tests timed out in every full run and passed alone, which teaches people to rerun until green. Two were slow for a reason, and each now has the limit its reason calls for, not a larger timeout for everything:

  • src/copy.test.ts, uses no retired word in the copy a person reads: parses every page and component with the TypeScript compiler, about 6 s alone, growing with the console. It waits on nothing, so it has its own 60 s limit.
  • src/unsaved-work.test.tsx: its first wait covers the whole console's first render, cold. Only that wait is 20 s; the rest keep 5 s.

The third, corpus-staged.test.tsx, was slow for no reason: it base64-encoded 12 MiB to prove a split that 96 KiB proves as well, and now serves small upload limits instead (6.5 s to 1.2 s).

A test that is slow because it waits on a timer or a retry is a fault to fix, not a limit to raise.

The console's failure states ​

npm --prefix apps/dashboard run test. 52 tests, 2 skipped.

Strict typing and lint gate the build and say nothing about what an analyst sees when a request fails. A blank screen is the worst outcome available, because it cannot be told apart from an empty queue - and in a security product "nothing happened" and "I could not find out what happened" lead to opposite actions.

The tests stub fetch, not the API module, so every one exercises the real client: the real Zod validation, the real ApiError mapping, the real retry. Mocking api.get would test the components against a fiction and would not notice the client and the server drifting apart.

FileCovers
src/pages/states.test.tsxA failing request on Incidents, Endpoints and Policies shows the server's own message, announces it with role="alert" and offers a retry that actually re-requests. A response whose shape the client does not recognise, and one that is not JSON at all, are errors rather than half-rendered pages. Each page's empty state says what to do next and is not announced as an error. A request in flight marks its region aria-busy and does not claim there is nothing. Evidence withheld from a role is stated, with the match counts still shown, and never rendered as an empty element
src/pages/session.test.tsxThe sign-in form appears only once the session is known to be absent; the login copy never reveals which half of the credentials was wrong; an analyst without audit:read does not get an Audit log tab and one with it does
src/components/states.test.tsxErrorState announces itself, prefers the server's message, survives a non-Error rejection and offers a retry only when there is one. EmptyState is never announced as an alert. Skeleton says "Loading" in text, not only in animation. A missing timestamp reads as "never" and an unparseable one as "unknown", rather than "NaN minutes ago"
src/components/version.test.tsxThe build string reaches the DOM and a version mismatch is announced, not only coloured
src/App.test.tsxThe rail and the page heading are both generated from one NAV table and cannot disagree; exactly one item is current and / is not current everywhere; every rail link goes to a route that exists; collapsing hides labels without dropping destinations or accessible names; the top bar carries the account, not the title
src/components/icons.test.tsxEvery icon emits explicit width/height - without them the browser uses 300×150, which is what broke the sign-out button - is aria-hidden and focusable="false", and takes currentColor. Plus a guard that no selector in styles.css sizes an svg, since CSS beats a presentation attribute and would silently win over the size the component asked for
src/styles.contrast.test.tsEvery foreground/background pairing the interface renders, in both themes, against the ratio it needs. Read out of the real stylesheet rather than a copied table, so the test cannot pass while the palette drifts

Two tests are skipped, pointing at #429: a non-401 failure of /auth/me signs the analyst out, so an outage presents itself as a credential problem. They assert the intended behaviour and are skipped rather than deleted - see KNOWN_LIMITATIONS.md §6.2.

CI runs npm test without --passWithNoTests, so a test file that stops matching fails the build instead of passing in silence.

In real browsers (DLP-618) ​

DATABASE_URL=<scratch database> npm --prefix apps/dashboard run test:browser, after npm run build and cargo build -p dlp-management-api.

jsdom applies no stylesheet, runs no layout and has no network, so everything above can say what the markup is and nothing about what a person sees. This suite runs the production build against an API it starts itself, on the database it is given and a port of its own - never the API's default address, which on the machine this is built on is production. It refuses to start without a database.

It signs in as each of the five roles: the owner through the login form, the other four invited by the owner on the Users page and accepting by opening the link it shows and choosing a password on /accept-invitation, so every session is one the product issued and the path a new person takes is walked four times a run (DLP-686). invitations.spec.ts covers the other side: an expired invitation is refused on the page with the sentence the invitee reads, and no session results. Then, in Chromium and in Firefox, in the light theme and the dark:

  • every destination the role's rail offers is opened, and fails on an uncaught error, a console error, an API call that fails or is refused - a 403 on a page the rail offered is the console asking for something the role may not have - or a serious or critical axe violation, including colour contrast against the real stylesheet;
  • every destination the owner's rail offers and the role's does not is typed in, and must say which permission it needs.

The rail is read from the page rather than listed in the test, so a new destination is covered the day it appears.

The theme is the console's own stored choice, written by apps/dashboard/e2e/fixtures.ts before any page script runs, in every context a spec opens. The console does not read the operating system's colour scheme (DLP-1429), so between that change and DLP-1630 the two light projects emulated a light scheme and drew the dark theme: the light palette went ungraded, and a spec hard-coding the light hover colour is what showed it. A spec imports test from ./fixtures, not from @playwright/test, or its light run is a second dark one.

Problems that existed when the suite landed are in apps/dashboard/e2e/known-problems.json, each naming the ticket that owns it (DLP-621 contrast, DLP-622 ARIA, DLP-623 a 404 on Classification). The suite fails on anything not listed, and e2e/check-baseline.mjs fails on an entry no run reproduced, so the list only shrinks.

Traces and screenshots are kept only on failure, only ever of the synthetic tenant the run seeded, and CI keeps the report for fourteen days.

e2e/keyboard.spec.ts (DLP-619) drives the console with a keyboard: the first Tab reaches Skip to content and it moves focus into the page; every page sets the title a screen reader announces; Tab walks the page end to end without being held anywhere; the collapsed rail still names every link; / opens search and the arrow keys move through results without leaving the box; the arrow keys move between tabs; leaving unsaved work asks first, puts focus on Stay, and Escape returns focus to where it was. What a screen reader says no browser reports, so that half is a manual protocol, , not yet performed by a person.

e2e/layout.spec.ts (DLP-620) opens every destination at 640 and 320 CSS pixels - a 1280-pixel screen at 200% and 400% zoom - and fails if anything but a data table scrolls sideways (WCAG 1.4.10), naming the element that overflows and the one pushing it; under forced colours it fails a status chip with no words; under reduced motion it fails anything still animating. The setup project seeds three hundred endpoints and sixty policies with long Azerbaijani, Russian and English names, so every spec runs against a tenant that looks like a real one, and the endpoint table must page them and keep its headers. How long each page takes to settle is written to e2e-results/settle-times.json and the report - recorded, not asserted.

e2e/corpora.spec.ts (DLP-590, the console half of tests/fingerprint_corpus_security.sh) builds a document-fingerprint corpus from two uploaded files as the owner, in all four projects. It sees the corpus ready, opens its detail, checks a passage against it, and withdraws it through the dialog that will not confirm without a reason. Each step holds the suite's bar: no console error, no failed call, no serious axe finding. At the end a canary that was inside both files is searched for in every API response, every URL visited, local and session storage, and the page itself. With a leak into session storage planted in the builder, the spec fails naming it. A Viewer sees the list and is refused the builder, naming edm:write. Its first run found DLP-639: a link styled as a button inherited the link colour and failed contrast when hovered.

e2e/discovery.spec.ts (DLP-597) does one workflow as a person would, in all four projects. It names a share on the host under Discovery → Targets, tests its connection, and queues a scan from it under Scans. When the run completes, it opens the run from its own link, checks that the finding shows the file and not the matched value, and acknowledges it. Back then returns to the run list after a filter was changed. Every step is held to the same bar as the rail walk: no console error, no failed or refused call, and no serious axe finding. A Viewer is shown three sections, no write control, and the refusal on a typed Remediation address. Its first run found DLP-635: the classification inventory answered 500 once a finding existed.

e2e/classification-retire.spec.ts (DLP-709) runs in all four projects, as a Security analyst. It seeds an empty draft classification and an unused custom detector through the API, then retires the classification through its dialog. The dialog reads the server's impact ("Nothing depends on this classification") and will not retire without a reason. It then deletes the detector through its question. Both dialogs are graded by axe, and the run is held to the bar of no console error and no refused call. Refusals (a published policy blocking a retire, a detector still in use) are asserted in vitest instead, because a refused call is what the watcher reports. With the retire's reason dropped, the server answers 422 and the spec fails. Its first run found DLP-774: a disabled detector's row fails colour contrast.


Property and boundary coverage ​

Generated-input tests cover the four boundaries where an example is worth least (#353): redaction, normalisation, the structural validators and the risk model. These are the places where the specification is arithmetic or an invariant, so a generator can assert the rule itself rather than one instance of it - that every number with a correctly computed Luhn digit passes, rather than that one published test PAN does.

ModulePropertiesWhat they assert
contracts::redaction11Full reveals nothing but the length; a partial preview keeps no more than keep characters and keeps the right end; a value short enough that keep would expose half of it is masked entirely; previews and silhouettes are length-capped; nothing panics on arbitrary Unicode
detector-engine::normalize9Every offset maps back to a character boundary inside the original and any mapped range can be sliced; the map never goes backwards; no invisible character survives; transliteration never invents an at-sign
detector-engine::validators17Every correctly constructed Luhn / TCKN / IBAN value validates and every single-character change to one does not; separators never change a verdict; an unknown IBAN country is inconclusive, never validated; entropy stays in [0,8]; no validator panics
policy-engine::risk12The score is always 0..=100 and its band always matches it; the factor breakdown reconstructs the score; a mitigation never raises a score and a non-preventing action is not a mitigation; the model is monotonic in prior incidents and bypass attempts, and deterministic

Four of the 49 fail and are marked #[ignore] with a ticket number. They were left asserting what the code claims rather than relaxed to match what it does - a property softened to pass deletes the only record that the two disagree. See KNOWN_LIMITATIONS.md §6.3; the tickets are #425 (e-mail previews are not length-capped), #426 (NFKC-produced whitespace escapes collapsing, so normalisation is not idempotent) and #427 (line_of panics on a non-boundary offset). CI runs cargo test --workspace --all-targets without --ignored, so the suite is green and the four stay visible.

Run them with cargo test --workspace -- --ignored to watch them fail, which is how a fix is verified.

Two of the four are now fixed and running (v0.9.1). #426 landed, so normalize::whitespace_never_survives_in_runs and normalize::normalisation_is_idempotent no longer carry #[ignore]: all 9 of the detector-engine::normalize properties now run in the ordinary suite. They are joined by an exhaustive hand-written test that enumerates, over the whole of Unicode, every character whose NFKC form crosses the whitespace boundary in either direction and asserts both properties on each - a generator can only sample that space, and the case that caused #426 was found by weighting it. Two of the four remain ignored: #425 and #427.

Alongside the generated tests, every engine boundary still has an explicit one:

  • Empty input, single character, oversized input, truncation on a multi-byte character
  • Redaction: short values, long values, values with no separator, Unicode
  • Backoff: never overflows across 1000 attempts, always within the ceiling, jitter spreads reconnections
  • Queue: empty operations, capacity eviction ordering, lease expiry, integrity failure
  • Risk model: weights sum to 1.0, score always in [0,100], factor contributions reconstruct the score, determinism

Benchmarks ​

cargo bench -p dlp-detector-engine. Host: 16-core x86-64, Linux 6.8, --release, Criterion with 100 samples.

BenchmarkBeforeAfterChange
scan_text/16KiB_core_pack13.55 ms (1.17 MiB/s)1.14 ms (13.9 MiB/s)−91.6%

What the benchmark found. The first run measured 1.17 MiB/s, far below what a 21-detector regex pack should cost. The cause was structural: the engine normalised the entire document once per detector, so a 21-detector pack walked a 1 MiB file 21 times. Normalised text is now cached per distinct option set - nearly every detector shares the same options - which removed 20 of the 21 passes.

This is why the benchmark exists. The correctness tests all passed at 1.17 MiB/s.

What OCR reads, and what it costs (DLP-231) ​

cargo test -p dlp-sandbox --test ocr -- --nocapture, after building the helper with --features ocr. Character accuracy is the longest common subsequence over the normalised stream - letters and digits, lower-cased, no spacing - because word accuracy scores a perfect read of 4111 1111 1111 1111 as 67% when Tesseract groups the spaces differently, and the extractor normalises whitespace away before any detector sees it.

PageCharacters recovered
Rendered, English100%
Rendered, Turkish100%
Rendered, Azerbaijani100%
Rendered, Russian100%
Worst case, English67%

The last row is the one to quote. It is fifteen-pixel text, rotated two degrees, blurred, five per cent speckle and JPEG at quality 35 - about the bottom of what a scanner or a phone camera produces. At that quality a card number does not survive: the prose is mostly readable and the digits are not, 4111 1111 1111 1111 comes back as 4144 aieeiiaaar, and the right answer is no finding rather than a finding about a number nobody wrote. A test asserts that too, because it is the validator's job and OCR does not get to bypass it.

An operator deciding whether to enable OCR on a fleet of scanned invoices is entitled to that figure before they find out. The floors in the test - 95% clean, 55% worst case - are set below the measured values so ordinary variation does not fail a build and a pack or Tesseract upgrade that makes a fleet worse does.

cargo run --release -p dlp-sandbox --example ocr_cost measures what it costs. Every figure includes starting the sandboxed process, loading the language pack and tearing it down, because that is what an endpoint pays:

PageSizeMedian
Rendered, English2.9 KiB475 ms
Rendered, Russian4.7 KiB227 ms
Worst case, English10.8 KiB451 ms

Per page, not per megabyte, and the difference is the point: the cost is almost all fixed - a process, a pack, a teardown - so a page costs roughly the same whether it holds two lines or twenty, and sizing OCR by file size would be sizing it by the wrong number. Against the no-OCR path, which reads a megabyte of text in about 140 ms, one scanned page costs a few hundred milliseconds. That is why OCR is off the real-time path, and why the enforcement runtime - which adjudicates an open() while somebody waits on it - deliberately does not have it.

What an EDM set costs a scan (DLP-242) ​

SPIKE-005 measured what a dataset costs to hold and deliberately did not measure what it costs to scan, because that cost does not depend on the dataset size at all - the fingerprint is computed before the lookup, so ten thousand records and a million cost the same per token. It depends on the document and on the number of fields.

cargo run --release -p dlp-detector-engine --example edm_latency. Same host and same corpus as the figure above, median of three runs, 10,000 records per field, and budgets raised so nothing is cut short - since DLP-257 the EDM matcher stops at its per-detector budget, which is right on an endpoint and would have this harness measure the budget instead of the work.

CorpusEDM setMedianMiB/svs baseline
16 KiBnone1.75 ms9.121.00x
16 KiBpresent, 0 fields26.24 ms0.6115.0x
16 KiB1 field33.92 ms0.4719.4x
16 KiB3 fields49.36 ms0.3228.3x
16 KiB8 fields90.17 ms0.1851.7x
1 MiBnone116.06 ms8.621.00x
1 MiBpresent, 0 fields1836.61 ms0.5415.8x
1 MiB1 field2349.60 ms0.4320.3x
1 MiB3 fields3193.24 ms0.3127.5x
1 MiB8 fields5920.70 ms0.1751.0x

An EDM set is by a wide margin the most expensive thing in the detector pack. With three fields - an ordinary customer record - a scan runs at about 3 per cent of the no-EDM throughput. The answer to "what does a tenant pay for adding a fourth field" is about 510 ms per megabyte scanned, measured as the slope between the one-field and eight-field points rather than as added / fields, which would charge the fixed cost to the fields and understate every addition after the first.

The cost is two costs, and the larger one is not the fields. The zero-field row is the reason that row exists: a set with no fields in it still tokenises the document and normalises every run of up to eight adjacent tokens, and simply hashes nothing. That walk is 1.7 s per megabyte - three quarters of the one-field cost and a third of the eight-field cost, and nothing in DLP-256 touches it.

What DLP-256 took off the per-field cost ​

Measured the same way, on one host in one session, so the two rows are comparable to each other rather than to the table above:

1 MiB, 8 fieldsWalkPer fieldTotal
Before2048 ms528.66 ms6466 ms
After1790 ms392.67 ms5302 ms

About a quarter off the per-field slope, and a fifth off the eight-field total. The walk rows differ by run-to-run noise: the zero-field case hashes nothing, so nothing in this change could have moved it, and the size of that difference is the honest measure of this host's variance.

It is not the figure DLP-256 predicted, and the reason is a decision rather than a shortfall. That ticket costed a redesign in which the normalised value is hashed once per token run and the field is separated in the lookup table instead of inside the MAC - which would have taken the eight-field case to roughly 2.4 s. It was refused. The field name inside the HMAC is what makes a tag for surname useless against the account column, and without it every field's set is keyed the same way: a bundle holder can test any tag against any column, and required_fields - the thing that makes EDM precise rather than noisy, "an isolated date of birth is noise; a date of birth beside the matching IBAN is a customer record" - stops meaning what it says.

What was taken instead is free: HMAC is defined over a stream, so the key schedule and the field name are primed once per field per scan and the state is cloned per value (dlp_crypto::edm::FieldHasher). The tags are byte-identical, so no dataset is re-imported, no bundle format changes, and the separation property is untouched - asserted by a test, because if it ever stopped holding every existing EDM set would silently stop matching.

The per-field slope is therefore reduced and not removed. One keyed hash per field per token run is what per-field matching costs, and the remaining way to make EDM fast is the walk, which nobody has costed.

Run-to-run variance on a shared host is a few per cent; the two runs recorded during DLP-242 and DLP-257 differed by up to 10 per cent on the absolute figures and agreed on every ratio. The ratios are the thing to read.

At the time of measurement nothing stopped it.ScanLimits::endpoint_realtime sets a 300 ms per-detector budget, and the Edm arm of run_detector never looked at the clock - the check existed in the regex and keyword arms only, and Entropy and DocumentFingerprint were missing it too. A 1 MiB file on the endpoint's live file-event path therefore spent six seconds in one detector and reported no timeout, with max_bytes there set to 2 MiB. Fixed in DLP-257: all three arms now read the clock, a scan cut short is marked timed_out, and a detector that ran out of time is reported even when it found nothing - otherwise the one place that knows the scan was partial has nowhere to say so. packages/detector-engine/tests/detector_budget.rs holds it.

Policy evaluation is asserted in-test rather than benchmarked: evaluation_is_fast_enough_for_the_endpoint_hot_path fails the build if 50 policies against 20 detector results exceed 50 ms, because that path runs on every file event.


What is not tested ​

Stated plainly.

GapWhyTicket
Windows beyond one hosted runnerThe enrollment-to-incident flow runs on a GitHub-hosted Windows VM in the windows-e2e job (tests/windows_verification.ps1), but no run's result has been read from here (see What has been observed below). It has run on one physical, non-domain Windows 11 machine with a real USB stick (2026-09-28, recorded on DLP-1176), and never on a domain-joined host or a fleet of more than oneDLP-448, DLP-449, DLP-1176
Console with a screen readerThe browser suite (below) loads every page, runs axe, drives the keyboard, reflows at 400% and forces colours; no browser reports what a screen reader says, and the manual protocol that covers it has not yet been run by a personDLP-964
Load beyond one host and one sweeptests/load_test.sh measures sustained ingestion on one host, and docs/CAPACITY.md records the figures and what they leave out. Not measured: two API replicas against one database, a soak of hours, batches that pay correlation and risk scoring, and 10,000 endpoints on a quiet hostDLP-1180
OIDC against a commercial providertests/oidc.sh runs in CI against a real HTTPS provider the test starts, with a real RSA key, a JWKS and a PKCE check. It has never been run against Entra ID or Okta, which needs a tenantDLP-1181

CI ​

.github/workflows/ci.yml runs formatting, Clippy with -D warnings, the full test suite, the dashboard type check, lint and build, the licence policy (cargo-deny), vulnerability scanning (cargo-audit), SBOM generation and a migration check. The end-to-end script runs against a PostgreSQL service container.

Sixteen jobs, not the twelve an older note recorded: lint, licenses, test, e2e, container-scan, supply-chain, fuzz, compose, kubernetes, windows-agent, windows-e2e, windows-script, secrets, dashboard, build and migrations. Only build declares needs, on lint and test; the other fifteen start together.

What has been observed, and what has not ​

"It passes on my machine" and "CI is green" are different claims, and this section keeps them apart. One run has been read, with gh: run 38664217661 on main. Seven of its jobs passed: Format and lint, Tests, Dashboard, Licence policy, Browser extension, Secret scanning and the Windows verification script. Advisories and SBOM ran and failed at the cargo-deny RUSTSEC advisory check. Ten jobs failed without starting a step: each carries the GitHub annotation that the job was not started because account payments have failed, so billing stopped them (DLP-1215). Three Windows jobs were skipped. No single run has passed every job.

What was checked, because several of the failure modes that stop a first run can be settled without watching one:

CheckResult
Both workflow files parse as YAML and declare well-formed jobsYes - 16 jobs in ci.yml, 1 in fuzz-campaign.yml
A push to main triggers a run at allYes - on: push: branches: [main], plus pull_request and workflow_dispatch
Every pinned action SHA still resolves in its upstream repositoryYes - all 12 distinct pins resolve, oldest committed 2024-11-20
Repository secrets that must be configured before a run can passNone. The only secrets. reference in either file is GITHUB_TOKEN, which Actions provides automatically
Paths that exist only on a developer machineNone - no /root or /home/<user> in either file
Tools assumed present on the runnerEach is installed by an explicit step: cargo-deny, cargo-audit, cargo-fuzz, psql, python3-cryptography, softhsm2, opensc, the cross-compilation toolchains
The e2e PostgreSQL service container declares a health checkYes - pg_isready, 5s interval, 10 retries

One thing worth knowing before reading a red secrets job: gitleaks/gitleaks-action@v2 is free for repositories owned by an individual account and requires a GITLEAKS_LICENSE for organisation-owned ones. This repository is under a personal account, so no licence is needed - but that changes the day it moves to an organisation, and the failure message names a licence rather than a leak.

What remains is the observation itself: open the Actions tab, read the result of the most recent main run, and record it here. That needs an account with access to the repository. A job that fails gets its own ticket with the log excerpt, rather than a note here.

Statements here describe what is built and tested; planned work is marked as planned.