fix: preserve stored file metadata and verify restore authorization
Shared Java Maven CI / secrets (push) Successful in 1m14s
Shared Java Maven CI / verify (push) Successful in 3m18s
Shared Java Maven CI / gate (push) Successful in 29s
qrun-release / release (push) Successful in 4m1s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 3d75c629c2706be133505268b6696fe9b1ae420c] (pull_request) Successful in 7s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 26s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m14s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 2m29s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
Shared Java Maven CI / secrets (push) Successful in 1m14s
Shared Java Maven CI / verify (push) Successful in 3m18s
Shared Java Maven CI / gate (push) Successful in 29s
qrun-release / release (push) Successful in 4m1s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 3d75c629c2706be133505268b6696fe9b1ae420c] (pull_request) Successful in 7s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 26s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 2m14s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Successful in 2m29s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9e8685f6e0
commit
ae3fd7be06
8 files changed
+702
-28
No files matched your search
+19
-3
@@ -1,6 +1,6 @@
|
||||
# Single-instance deployment and recovery
|
||||
|
||||
Written 2026-11-05 from the static final review of source base `a9eae04` (see [review reconciliation](REVIEW-RECONCILIATION-2026-11-05.md)). This is a **procedure**, not evidence. **No backup, restore or failover drill has been executed**, and no production storage or high-availability deployment is claimed. Nothing here adds a distributed product; the supported model is one server process per database.
|
||||
Written 2026-11-05 from the static final review of source base `a9eae04` (see [review reconciliation](REVIEW-RECONCILIATION-2026-11-05.md)). The procedure below is for the host/operator in a real incident. The only executed evidence is the automated [isolated local-demo recovery acceptance](#isolated-local-demo-recovery-acceptance-automated-passed), which covers the demo's stopped H2 database plus object snapshot and restore. No production storage, enterprise object-store or high-availability deployment is supported or qualified, and those are not tasks of this scanner release. The supported model is one server process per database.
|
||||
|
||||
## Topology
|
||||
|
||||
@@ -21,6 +21,22 @@ Written 2026-11-05 from the static final review of source base `a9eae04` (see [r
|
||||
6. **Echo:** jobs accepted after the snapshot may be absent from the database although a print was already queued. Manually reconcile the physical or Printing queue against the restored jobs **before authorizing any retry**, and retry only what that comparison shows was not printed. This is a manual check; there is **no exactly-once physical print guarantee**, and a retry can still produce a duplicate or a missing print if the queue or printer state is unknown.
|
||||
7. Reopen to users only after the above are done.
|
||||
|
||||
### Isolated local-demo recovery acceptance (automated, passed)
|
||||
|
||||
`EchoDemoAcceptanceTest.restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited` (Echo composed demo) exercises this procedure on private temporary directories only.
|
||||
|
||||
**Verification (2026-11-05):** the combined five-module `verify` on the merged source (`develop` `9e8685f` plus the storage fixes) passed with qualified private owner dependencies: 196 tests (root 53, demo 8, Record Files 22, Echo 74, Echo demo 39), 0 failures, 0 errors, 0 skips, and zero Checkstyle violations in every module. The qualified owner inputs are private, so this is not public-dependency qualification.
|
||||
|
||||
The case restores H2 scan rows, the exact leading-zero value and symbology, and the Record Files owner's own `RecordFilesTables.FILE` row. It asserts that row's exact table, record and attachment relationship and the SHA-256 of the object bytes. It shows that a revocation and session end made after the snapshot are reverted by the restore, then reapplies the normal Revoke and End Session processes and shows that a valid in-flight run and a new initialization are denied. No physical printer, service or demo data was changed.
|
||||
|
||||
Flow: it enrolls and approves a synthetic device, scans over real HTTP, and lets the real Record Files service retain the generated original in the demo local-file adapter. With the servers stopped and H2 shut down, it copies the database and object directory together, makes later changes (a second scan and file, a revocation and an ended session), restores only those two temporary directories and reads them without the application. It then restarts on a new ephemeral loopback port and applies the existing Revoke and End Session processes.
|
||||
|
||||
What this is **not**: it covers the local demo's H2 database and local files only, using private coherent fixture inputs. It is not published-cohort qualification (that is repeated when the final public dependencies are adopted), and enterprise object-store and high-availability recovery are unsupported and unqualified. In a real incident the full procedure above still requires an operator-owned audit of approvals, sessions and the print queue before a restored service returns to users.
|
||||
|
||||
Storage regressions in `PrivateObjectFilesTest` (part of the Echo demo's 39 tests): `concurrentSameKeyWritersLeaveOneWinnersBytesAndMetadata` (concurrent same-key writers leave one winner's bytes and metadata), `dotBucketsAreRefusedAndValidBucketsStillWork` (`.` and `..` buckets refused), plus `existingObjectIsNeverReplaced` and `keysCannotTraverseOrAlias`.
|
||||
|
||||
Limits: the file store is the single-host demo adapter and the database is the demo's H2 file, so this does not qualify production storage, an object store, encryption or high availability. Other database backends are not supported or tested here. The renderer is controlled and the printer is a database fixture; no job is physically printed and no normal demo data is changed. The file check reads the Record Files rows and object bytes directly, not through a product service.
|
||||
|
||||
## Session cleanup status
|
||||
|
||||
Ending a session (`MobileSessionService`) first persists the ended state with `authCleanupStatus = PENDING`. Cleanup then sets `REQUESTED` (with `authCleanupAt`) **before** calling the logout executor and removing the stored core session. If that call throws a `QException`, the status becomes `RETRY_NEEDED`. So `REQUESTED` means cleanup was attempted and no failure was reported; it is not independent proof that the provider session is gone. Cleanup is skipped when a binding does not exist, and for a single end inside an open transaction; a status left at `PENDING` also needs a retry. The bulk End Session process commits all tombstones first and runs cleanup afterwards.
|
||||
@@ -29,11 +45,11 @@ To retry: find sessions with `authCleanupStatus` of `RETRY_NEEDED` or `PENDING`
|
||||
|
||||
## Retention
|
||||
|
||||
Scans, sessions, bindings and Echo scans are **kept indefinitely** by default. Table deletes are blocked by permissions and foreign keys tie history to devices. Barcode payloads and last-known location are retained, so retention is a deliberate host decision: any purge is a host DBA procedure, taken with a snapshot first and respecting the foreign keys. Audit is current-state only; revoking overwrites the previous approver and time.
|
||||
Scans, sessions, bindings and Echo scans are **kept indefinitely** by default. Table deletes are blocked by permissions and foreign keys tie history to devices. Barcode payloads and last-known location are retained, so a different retention period is a host-owned policy, not a feature of this release: any purge is a host DBA procedure, taken with a snapshot first and respecting the foreign keys. Audit is current-state only; revoking overwrites the previous approver and time.
|
||||
|
||||
## Database migration beyond H2
|
||||
|
||||
The supplied, tested scripts are H2: `src/main/resources/db/mobile-scanners-h2.sql` and the Echo `echo-h2.sql`. For any other backend the **host owns the equivalent migration**; do not assume `ADD COLUMN IF NOT EXISTS` or the column types. Additive columns that older databases need before running current servers: on `mobileScan`, nullable `aimIdentifier` (`VARCHAR(3)`) and `rawDataBase64`; on Echo, nullable `echo_scan.aimIdentifier`, `rendererValue` and `humanReadable`, and **`echo_run.copies`, which is `INT NOT NULL DEFAULT 1`** (existing runs keep one copy), so it is not nullable and the equivalent migration must supply the default. Echo also has nullable template columns (`templateId`, `templateRevisionId`) in `echo-h2.sql`. Test the migration on a copy, and snapshot first.
|
||||
The supplied, tested scripts are H2: `src/main/resources/db/mobile-scanners-h2.sql` and the Echo `echo-h2.sql`. Other backends are unsupported and untested here; a host that uses one **owns the equivalent migration**; do not assume `ADD COLUMN IF NOT EXISTS` or the column types. Additive columns that older databases need before running current servers: on `mobileScan`, nullable `aimIdentifier` (`VARCHAR(3)`) and `rawDataBase64`; on Echo, nullable `echo_scan.aimIdentifier`, `rendererValue` and `humanReadable`, and **`echo_run.copies`, which is `INT NOT NULL DEFAULT 1`** (existing runs keep one copy), so it is not nullable and the equivalent migration must supply the default. Echo also has nullable template columns (`templateId`, `templateRevisionId`) in `echo-h2.sql`. Test the migration on a copy, and snapshot first.
|
||||
|
||||
## Demo-only file adapters
|
||||
|
||||
|
||||
@@ -6,27 +6,28 @@
|
||||
|
||||
| Area | Evidence (see session state for revisions and counts) |
|
||||
| --- | --- |
|
||||
| Server source and development publication | `develop` at `a9eae04` (PR 17 merge; includes PR 13 and the committed GS1 and photo-capture source); PR 12 snapshot `0.1.0-20261103.233448-3645` read back anonymously and matches reviewed source. |
|
||||
| Android host libraries | `main` at `0ae2101`; SDK run 3634 passed and artifacts verified. Development libraries only. |
|
||||
| Current cohort tests | Final merged source: 193 server tests and Checkstyle pass; Android 232 unit tests and lint pass. Inherited report-only guardrail findings remain visible; qualified owner dependencies are still required. |
|
||||
| Server source and development publication | PR 19 merged as `9e8685f`, including record/photo UX support and earlier PR 17/18 changes; the storage/recovery patch is based on that source. Earlier PR 12 snapshot `0.1.0-20261103.233448-3645` was read back anonymously and matched its reviewed source. |
|
||||
| Android host libraries | Published development libraries from source `0ae2101`: SDK run 3634 passed and artifacts verified. The scanner application's newer source and signed candidate are recorded below. |
|
||||
| Current cohort tests | Combined five-module `verify` (2026-11-05): 196 server tests (53 / 8 / 22 / 74 / 39), zero failures, errors and skips, zero Checkstyle violations, with qualified private owner dependencies. Android 232 unit tests and lint pass. Inherited report-only guardrail findings remain visible. |
|
||||
| Storage integrity and recovery | `PrivateObjectFilesTest` concurrent-writer and dot-bucket regressions; `EchoDemoAcceptanceTest.restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited` is the isolated local-demo recovery acceptance (stopped H2 plus object snapshot/restore, `RecordFilesTables.FILE` relationship and byte hashes). See [operations](OPERATIONS.md). |
|
||||
| Local runtime | One server on public QQQ RC.21 and Printing 1.0.0; API 1.10, mobile 1.5, printing 2.3, mobile-record-files 1.0; existing data preserved. |
|
||||
| Device and admin processes | Enrollment, approval and revocation work from a single record and from bulk selection (checked rows, select-all, first N), with confirmation and visible results, covered by HTTP and browser E2E tests. Earlier browser run: 15 cases. |
|
||||
| Hardware | TC22 manual tests: operator-reported pass on November 4 (no per-case artifacts). MC9400 configured and approved; trigger check pending. |
|
||||
|
||||
## Open gates
|
||||
|
||||
The maintained server and existing TC22 app now run the GS1, rich test-data and record UX changes. Actions → Add photo, grouped/related records, search/filter/Back state and the saved-photo gallery are implemented and verified by isolated native acceptance plus an actual TC22 visual pass. Existing data and enrollment were preserved. See [current evidence](history/MOBILE-UX-2026-11-05.md). This supersedes the earlier undeployed status; physical GS1 print/rescan and signed-build production deployment remain open. The user authorized normal source commit/push/merge, not a new artifact release.
|
||||
The maintained server and existing TC22 app now run the GS1, rich test-data and record UX changes. Actions → Add photo, grouped/related records, search/filter/Back state and the saved-photo gallery are implemented and verified by isolated native acceptance plus an actual TC22 visual pass. Existing data and enrollment were preserved. See [current evidence](history/MOBILE-UX-2026-11-05.md). This supersedes the earlier undeployed status; physical GS1 print/rescan and signed-build production deployment remain open. The user authorized the final release, signing and publication for this bounded goal; Android PR 15 (`c782aaaaba3f62422232436a9000af9d4b38fc82`) merged after all CI.
|
||||
|
||||
| Gate | What is still required |
|
||||
| --- | --- |
|
||||
| Public dependency cohort | Public, coherent Generation/bridges/Record Files/Scripting artifacts from their owner; adoption of the Core/Collaboration/Next UI foundations when released. Today these are qualified owner inputs. |
|
||||
| Release | Final combined CI on the final revisions (a fresh full gate and browser E2E re-run), a reviewed release manifest, and release/RC publication, which stays disabled until then. Redesign of the shared CI pipeline is outside this scope. |
|
||||
| Signing and distribution | Done: QRun key with backup, signed 1.0 APK, direct/MDM channel. Open: qualification of the signed build on a device against an HTTPS server, then distribution and update path. A signed preview is published; it is not a qualified production rollout. No signing CI. Deferred by the user to the very end. |
|
||||
| Deployment | HTTPS and the host's identity provider, supported database migration beyond the supplied H2 schema, and an executed restore drill. Single-instance procedure, recovery and retention guidance is written in [operations](OPERATIONS.md); it is a procedure, not an executed drill, and claims no production storage. The HTTP loopback demo is not a production deployment. |
|
||||
| Provider authentication | Native OAuth2 requires UI API 1.10 native-session and nonce-validation support on the server (verified in the Android source). Still open: acceptance against a configured provider (such as Authentik) on a native handheld, including logout and account switching. No such handheld test is claimed. |
|
||||
| Further device qualification | MC9400 physical trigger check and MDM-applied managed configuration remain unverified. The TC22 camera, GPS and screen-viewing checks are covered by the accepted operator group report; no separate per-case evidence is claimed or required again for that demo. |
|
||||
| Release | Retest the full gate when the final public dependency cohort is adopted, with a reviewed release manifest; Maven publication stays disabled until then. Redesign of the shared CI pipeline is outside this scope. |
|
||||
| Signing and distribution | Done: QRun key with backup, signed Android 1.0.1 (code 3) built from the merged PR 15 source tree and installed in place on an MC9400 (installed bytes match; login/enrollment preserved; Scan Capture reopened; HTTPS connected). Open: MC9400 physical trigger scan, public dependencies and final distribution. No signing CI. |
|
||||
| Deployment | HTTPS and the host's identity provider. Single-instance procedure, recovery and default-indefinite, host-owned retention guidance is in [operations](OPERATIONS.md); isolated local-demo recovery acceptance has passed. Enterprise object-store/HA and non-H2 databases are unsupported and unqualified options, not release tasks. The HTTP loopback demo is not a production deployment. |
|
||||
| Provider authentication | Native OAuth2 requires UI API 1.10 native-session and nonce-validation support on the server (verified in the Android source). Still open: native Authentik login, logout and account switching on a handheld. No such handheld test is claimed. |
|
||||
| Further device qualification | MC9400 physical trigger check and MDM-applied managed configuration remain unverified. Company profile/wallpaper is separate tracked scope, not a blocker for this release goal. The TC22 camera, GPS and screen-viewing checks are covered by the accepted operator group report; no separate per-case evidence is claimed or required again for that demo. |
|
||||
| Barcode fidelity | Echo's supported profiles are documented ([limits](../examples/echo/README.md#fidelity-and-limits)); The committed source supports bounded GS1-128; physical print/rescan remains open. Other GS1, ECI, structured and binary profiles remain refused. No universal barcode-reproduction claim. |
|
||||
| Final reviews | Reconcile the dated security, architecture and standards reports to the final revision; they are scoped historical reviews, not a fresh exhaustive scan. A dated static review of base `a9eae04` with its scope and limits is in [review reconciliation](REVIEW-RECONCILIATION-2026-11-05.md); it is not an audit rating. |
|
||||
| Final reviews | Reconcile the dated security, architecture and standards reports to the final revision; they are scoped historical reviews, not a fresh exhaustive scan. A dated static review of base `a9eae04`, its supplement and its scope and limits are in [review reconciliation](REVIEW-RECONCILIATION-2026-11-05.md); it is not an audit rating. |
|
||||
|
||||
## Rules that stay in force
|
||||
|
||||
|
||||
@@ -21,6 +21,17 @@ This records what one static review covered. It is not an audit rating and not a
|
||||
- `UI-API.md` operation table now lists all seven operations; root `pom.xml` url, scm and issue tracker point at Gitea.
|
||||
- Deferred: the screen-upload 409 and every other operation's 409 share the generic text "Installation already enrolled" in `MobileJsonSpec`. Correcting it is a contract text change needing a patch version bump and regenerated YAML, so it is not done here.
|
||||
|
||||
## Supplement: findings checked after the review
|
||||
|
||||
Scope: the four findings raised after the static review. This is not an audit rating or a score.
|
||||
|
||||
- **F1, concurrent metadata overwrite:** fixed in `PrivateObjectFiles` and regression-tested by `PrivateObjectFilesTest.lateSecondWriterIsRefusedAndFirstWritersBytesAndMetadataRemain` (also `existingObjectIsNeverReplaced`). The controlled interleaving reproduced mismatched metadata on the old implementation; all eight storage tests then passed with the fix.
|
||||
- **F2, dot bucket traversal:** fixed and regression-tested by `PrivateObjectFilesTest.dotBucketsAreRefusedAndValidBucketsStillWork` (also `keysCannotTraverseOrAlias`).
|
||||
- **F3, ZPL command injection:** disproven at source. In the owner Generation renderer, `ZplRenderer.java` lines 326-331 encode every UTF-8 byte under `^FH`. `RendererTest.typedZplEscapesCommandsAndEmitsRasterBarcodes` covers `^XA`, `~JA` and underscore input. That source is owner-held; the exact published artifacts are still pending qualification.
|
||||
- **F4, label clipping:** the assumption is guarded by renderer placement bounds, which refuse overwide text. No universal media-fidelity claim is made.
|
||||
|
||||
The fixes were verified by the 2026-11-05 combined five-module `verify` (196 tests, zero failures, errors and skips, zero Checkstyle violations) on qualified private owner dependencies, not on published artifacts. Pending: owner artifact qualification, physical output and any enterprise object-store or high-availability recovery (unsupported, not tasks of this release).
|
||||
|
||||
## Inherited guardrail heuristics (visible, not disabled)
|
||||
|
||||
Two generic guardrail reports are heuristic mismatches, not missing behavior. They stay visible as existing signals; no check was disabled and no test marker was added to satisfy them.
|
||||
|
||||
@@ -6,7 +6,7 @@ Current as of **2026-11-05**. Open work is in [TODO](TODO.md), release gates in
|
||||
|
||||
GS1-128 duplication, including barcode and printed HRI, is merged through [PR 15](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/pulls/15), commit `f9b0eb2`, and included in the [Forge source prerelease](https://git.kof22.com/QRun-IO/qbit-mobile-scanners/releases/tag/echo-gs1-source-20261104). This is source publication, not a published Echo Maven dependency. PRs 17 and 18 add publication metadata, the frozen contract baseline and recovery guidance; those changes are retained in this feature branch.
|
||||
|
||||
The [rich demo fixture](MOBILE-TEST-DATA.md) and [record UX improvements](history/MOBILE-UX-2026-11-05.md) are implemented. Native browsing, record Actions and photo presentation belong to the companion Android repository. The user authorized committing, pushing and merging both repositories; source review follows ordinary Gitea checks. No new artifact release is included.
|
||||
The [rich demo fixture](MOBILE-TEST-DATA.md) and [record UX improvements](history/MOBILE-UX-2026-11-05.md) are implemented. Native browsing, record Actions and photo presentation belong to the companion Android repository. The user authorized the final release, signing and publication for this bounded goal. Android [PR 15](https://git.kof22.com/QRun-IO/qqq-android/pulls/15) merged as `c782aaaaba3f62422232436a9000af9d4b38fc82` after all CI. Maven publication switches stay disabled until the public dependency cohort is adopted.
|
||||
|
||||
| Item | State |
|
||||
| --- | --- |
|
||||
@@ -26,7 +26,8 @@ The [local demo](LOCAL-DEMO.md) uses public QQQ `4.1.0-RC.21`, released Printing
|
||||
|
||||
## Verification
|
||||
|
||||
- The final merged-source cohort passed **193 server tests**: scanner 53, base demo 8, mobile Record Files 22, Echo 74, Echo demo 36, with Checkstyle. The new build parent contributes four guardrail tests; its report-only schema-location/contract-test findings remain visible. Source/resource copies were hash-checked; private owner coordinates were overridden only in the isolated verification build.
|
||||
- The combined five-module `verify` (2026-11-05, base `9e8685f` plus the storage fixes) passed **196 server tests** with zero failures, errors and skips and zero Checkstyle violations: scanner 53, base demo 8, mobile Record Files 22, Echo 74, Echo demo 39. It used qualified private owner dependencies; there is no public-dependency or physical-output qualification, and the gate must be repeated when the public cohort is adopted. The new build parent's report-only schema-location/contract-test findings remain visible.
|
||||
- Storage evidence: `PrivateObjectFilesTest.concurrentSameKeyWritersLeaveOneWinnersBytesAndMetadata` and `dotBucketsAreRefusedAndValidBucketsStillWork`; recovery acceptance `EchoDemoAcceptanceTest.restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited` (details in [OPERATIONS](OPERATIONS.md#isolated-local-demo-recovery-acceptance-automated-passed)).
|
||||
- Android passed **232 unit tests**, lint with **0 errors/0 warnings**, and the debug APK build. E2E APK/instrumentation builds and eight runner guard tests also passed.
|
||||
- Isolated MC9400 native acceptance passed search/filter/paging/Back, grouped/related records, camera/gallery upload, parent-scoped saved-photo list, approved preview, rotation, pending-upload navigation protection and forced-logout cleanup. All requests matched the semantic API. Process-photo regression also passed.
|
||||
- The final date-only correction received unit/lint/build checks and actual TC22 verification. Actual-device screenshots confirm Actions → Add photo, dates/timezones, currency, related tabs, photo empty state and return to page two. Synthetic camera/gallery inputs in automated acceptance are not a new operator camera report.
|
||||
@@ -34,8 +35,10 @@ The [local demo](LOCAL-DEMO.md) uses public QQQ `4.1.0-RC.21`, released Printing
|
||||
|
||||
## Handhelds and remaining limits
|
||||
|
||||
Current TC22 APK SHA-256: `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`. Matching signer and installed bytes were verified; the same app reopened connected and signed in. Prior preview hashes are in [history](history/RICH-RECORDS-2026-11-05.md).
|
||||
Previous TC22 APK SHA-256: `F71033C835AC3EB361A04D78C842947C5A04C85CE1AB1B999AA8CEFBFC9D7A95`; prior preview hashes are in [history](history/RICH-RECORDS-2026-11-05.md).
|
||||
|
||||
The November 4 TC22 operator group pass remains accepted and is not requested again. MC9400 remains configured and approved; its physical trigger/backend check is outstanding. Physical GS1 print/rescan, signed-build HTTPS qualification, provider callback acceptance and fleet rollout remain open. Echo supports only its documented [barcode profiles](../examples/echo/README.md#fidelity-and-limits).
|
||||
Signed Android `1.0.1` (code 3) was built from the identical merged source tree (PR 15) and installed in place on the MC9400. The installed bytes match the built APK, login and enrollment were preserved, the authorized Scan Capture screen reopened, and the HTTPS connection was established. Pending: the MC9400 physical trigger scan, native Authentik login/logout/account switching, the public dependency cohort and final distribution.
|
||||
|
||||
Operations and restoration guidance is in [OPERATIONS](OPERATIONS.md); no restore drill is claimed. The [dated review](REVIEW-RECONCILIATION-2026-11-05.md) remains scoped to its recorded base. Public owner dependencies require a separate reviewed adoption and new gate.
|
||||
The November 4 TC22 operator group pass remains accepted and is not requested again. Physical GS1 print/rescan, provider callback acceptance and fleet rollout remain open. Echo supports only its documented [barcode profiles](../examples/echo/README.md#fidelity-and-limits). Company profile/wallpaper is separate tracked scope, not a blocker for this release goal.
|
||||
|
||||
Operations and recovery guidance is in [OPERATIONS](OPERATIONS.md). The automated stopped-H2 plus object snapshot/restore case passed as isolated local-demo recovery acceptance (no physical printing, no normal demo data changed). Enterprise object-store/HA and non-H2 databases are unsupported and unqualified, not release tasks. The [dated review](REVIEW-RECONCILIATION-2026-11-05.md) records its scope and supplement. Public owner dependencies require a separate reviewed adoption and a new gate.
|
||||
+6
-4
@@ -12,7 +12,7 @@ Note: redesign of the shared CI pipeline is outside this scanner/local Echo scop
|
||||
## This repository
|
||||
|
||||
- [ ] Qualify GS1 template/media setup and **physical print/rescan** of the requested label profile. Software duplication, native acceptance and maintained-demo adoption are complete.
|
||||
- [ ] Exercise the [operations](OPERATIONS.md) backup/restore procedure on the selected deployment (not yet executed), and decide retention.
|
||||
Retention stays indefinite by default and any change is a host-owned policy ([operations](OPERATIONS.md#retention)). Enterprise object-store and high-availability recovery, and database backends other than H2, are unsupported/unqualified deployment options, not tasks of this release. The 2026-11-05 gate (196 tests, zero failures/errors/skips, zero Checkstyle violations) used qualified private owner dependencies; the required public-cohort rerun remains listed below.
|
||||
- [ ] Optionally correct the generic 409 text in `MobileJsonSpec` (screen upload is a stale-frame conflict) with a patch version bump and regenerated contract; deferred.
|
||||
- [ ] Complete the authorized readiness work in [the active plan](GS1-RECORD-PHOTOS-PLAN.md), retaining explicit external/hardware gates.
|
||||
|
||||
@@ -21,13 +21,15 @@ Note: redesign of the shared CI pipeline is outside this scanner/local Echo scop
|
||||
|
||||
## Hardware and device follow-up
|
||||
|
||||
- [ ] **MC9400:** one physical trigger scan, then confirm the backend record. The device is installed, signed in and approved ([session state](SESSION-STATE.md#handhelds)).
|
||||
- [ ] **MC9400:** one physical trigger scan, then confirm the backend record. The signed 1.0.1 (code 3) build is installed in place and approved ([session state](SESSION-STATE.md#handhelds)).
|
||||
- [ ] Native Authentik login, logout and account switching on a handheld (not yet tested).
|
||||
- [ ] Optional: capture per-case evidence (photos, logs, a scanned-back label) on a future run. The November 4 operator pass is accepted as is and is not to be requested again.
|
||||
|
||||
## Release (deliberately last)
|
||||
|
||||
- [ ] Signed-build qualification on a device against an HTTPS server (enrollment, scanner path, navigation, update), then distribution. The QRun key, signed 1.0 APK and direct/MDM channel are done; a signed preview is published, but no production rollout or automatic APK-signing CI is qualified.
|
||||
- [ ] HTTPS deployment, host-owned database migration beyond H2, and the remaining gates in [production readiness](PRODUCTION-READINESS.md).
|
||||
- [ ] Final distribution of the signed 1.0.1 build and the public dependencies it waits on. Signing is done: the build was installed in place on an MC9400 through ADB, preserved enrollment, and reopened Scan Capture against the HTTPS server. Remaining device checks are listed above; no automatic APK-signing CI is qualified.
|
||||
- [ ] Company profile/wallpaper is a separate tracked scope and not a blocker for this bounded release goal.
|
||||
- [ ] HTTPS deployment by the host (any non-H2 migration is host-owned and unsupported here) and the remaining gates in [production readiness](PRODUCTION-READINESS.md).
|
||||
|
||||
## Not in version one
|
||||
|
||||
|
||||
+48
-6
@@ -30,12 +30,17 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
** file name, so no key can address another path, alias another object or climb
|
||||
** out of the root. Objects are written to a temporary file in their own folder
|
||||
** and linked into place, so a reader never sees a partial object and an existing
|
||||
** object is never replaced.
|
||||
** object is never replaced. Publication is serialized within one JVM only; the
|
||||
** demo does not support several processes sharing one private root.
|
||||
*******************************************************************************/
|
||||
final class PrivateObjectFiles
|
||||
{
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final int MAXIMUM_KEY = 2048;
|
||||
|
||||
// Guards final existence check, metadata publication and linking (and delete) across
|
||||
// all instances in this JVM. The demo is a single process; this is not a cross-process lock.
|
||||
private static final Object PUBLISH_LOCK = new Object();
|
||||
private final Path root;
|
||||
|
||||
|
||||
@@ -68,7 +73,7 @@ final class PrivateObjectFiles
|
||||
***************************************************************************/
|
||||
Path file(String bucket, String key)
|
||||
{
|
||||
if(bucket == null || !bucket.matches("[A-Za-z0-9._-]{1,64}") || key == null || key.isEmpty() || key.length() > MAXIMUM_KEY)
|
||||
if(bucket == null || !bucket.matches("[A-Za-z0-9._-]{1,64}") || bucket.equals(".") || bucket.equals("..") || key == null || key.isEmpty() || key.length() > MAXIMUM_KEY)
|
||||
{
|
||||
throw new IllegalArgumentException("Object bucket or key is outside policy");
|
||||
}
|
||||
@@ -117,8 +122,7 @@ final class PrivateObjectFiles
|
||||
}
|
||||
}
|
||||
JSON.writeValue(sidecar.toFile(), metadata == null ? Map.of() : metadata);
|
||||
Files.move(sidecar, metadataFile(target), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
Files.createLink(target, temporary);
|
||||
publish(target, temporary, sidecar);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -129,6 +133,41 @@ final class PrivateObjectFiles
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Publish the metadata and then the object, as one step with respect to
|
||||
** other writers and deleters in this JVM. The lock is shared by every
|
||||
** instance, so two instances over the same root cannot interleave. It covers
|
||||
** only the final existence check and two cheap file operations, never the
|
||||
** copy of the content. A winner's metadata is therefore never replaced by a
|
||||
** loser's, and the metadata exists whenever the object does. This does not
|
||||
** coordinate separate processes.
|
||||
***************************************************************************/
|
||||
private static void publish(Path target, Path temporary, Path sidecar) throws IOException
|
||||
{
|
||||
Path metadataFile = metadataFile(target);
|
||||
synchronized(PUBLISH_LOCK)
|
||||
{
|
||||
if(Files.exists(target, java.nio.file.LinkOption.NOFOLLOW_LINKS))
|
||||
{
|
||||
throw new FileAlreadyExistsException("Object already exists");
|
||||
}
|
||||
|
||||
// no object exists here, so any metadata is a stale leftover and may be replaced
|
||||
Files.move(sidecar, metadataFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
try
|
||||
{
|
||||
Files.createLink(target, temporary);
|
||||
}
|
||||
catch(IOException | RuntimeException failure)
|
||||
{
|
||||
Files.deleteIfExists(metadataFile);
|
||||
throw failure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read an object's identity; absent means empty. Links are refused.
|
||||
***************************************************************************/
|
||||
@@ -162,8 +201,11 @@ final class PrivateObjectFiles
|
||||
void delete(String bucket, String key) throws IOException
|
||||
{
|
||||
Path target = file(bucket, key);
|
||||
Files.deleteIfExists(target);
|
||||
Files.deleteIfExists(metadataFile(target));
|
||||
synchronized(PUBLISH_LOCK)
|
||||
{
|
||||
Files.deleteIfExists(target);
|
||||
Files.deleteIfExists(metadataFile(target));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
+450
@@ -16,14 +16,21 @@ import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.Statement;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -34,6 +41,7 @@ import com.kingsrook.examples.echo.EchoTemplatePublisher;
|
||||
import com.kingsrook.qbits.documentgeneration.DocumentDefinitionService;
|
||||
import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitConfig;
|
||||
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
|
||||
import com.kingsrook.qbits.recordfiles.RecordFilesTables;
|
||||
import com.kingsrook.qqq.backend.core.context.QContext;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -71,11 +79,17 @@ class EchoDemoAcceptanceTest
|
||||
private static final int RENDERER_CAP = 262144;
|
||||
private static final String CODE_VALUE = "001234567890";
|
||||
private static final String QR_VALUE = "QR-VALUE-0099";
|
||||
/** EchoPermissions.checkPermissionsThrowing: the refusal for any actor without a currently approved scanner. */
|
||||
private static final String ECHO_DEVICE_REFUSAL = "Echo requires an approved scanner and current Echo access.";
|
||||
private static final Set<Integer> PROTECTED = Set.of(57832, 51631, 18081, 18086, 18443, 19443, 18447, 18448, 18547, 18549, 18593);
|
||||
|
||||
@TempDir
|
||||
Path root;
|
||||
|
||||
/** A second, separate temporary directory: the only place the snapshot is written to or read from. */
|
||||
@TempDir
|
||||
Path snapshotDirectory;
|
||||
|
||||
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
private String origin;
|
||||
private String deviceId;
|
||||
@@ -823,6 +837,442 @@ class EchoDemoAcceptanceTest
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Everything a restore must give back exactly, read straight from the private
|
||||
** database and object directory rather than through the application.
|
||||
** retainedFiles are the Record Files owner's own rows (RecordFilesTables.FILE):
|
||||
** id, source table, source record, attachment, size.
|
||||
***************************************************************************/
|
||||
private record Capture(List<List<String>> echoScans, List<List<String>> mobileScans, List<List<String>> retainedFiles, Map<String, String> objects)
|
||||
{
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** The status and error text of a refused request; see refusal().
|
||||
***************************************************************************/
|
||||
private record Refusal(int status, String message)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Bounded backup/restore qualification of the composed host, on private
|
||||
** temporary paths only. A synthetic approved device scans an exact
|
||||
** leading-zero value through real HTTP and the real Record Files service
|
||||
** retains the generated original (the owner's retained-file row names the scan
|
||||
** as its source table and record). With every server stopped and the H2 files
|
||||
** closed, the database and object directory are copied together. A second scan
|
||||
** and file, a revocation and an ended session are then made, and only the two
|
||||
** temporary directories are restored. The restored state must equal the
|
||||
** snapshot and the later changes must be gone. The reverted approval is
|
||||
** trusted until the existing Revoke and End Session processes re-audit it,
|
||||
** after which a run opened before the revocation, and a new sign-in, are
|
||||
** refused by the host's own authorization. The renderer is controlled and the
|
||||
** printer is a database fixture: no job is printed, and no normal demo
|
||||
** database, service or external provider is touched. Retained files are read
|
||||
** from the owner's table, not through its service, and this does not qualify
|
||||
** production storage.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void restoredSnapshotKeepsScanAndRetainedFileAndRevertedApprovalStateIsReaudited() throws Exception
|
||||
{
|
||||
String scannerPassword = UUID.randomUUID().toString();
|
||||
String adminPassword = UUID.randomUUID().toString();
|
||||
String firstZpl = "^XA^FO48,48^FDcode128|" + CODE_VALUE + "^FS^XZ";
|
||||
String secondZpl = "^XA^FO48,48^FDqr|" + QR_VALUE + "^FS^XZ";
|
||||
String printerRowId;
|
||||
Integer template;
|
||||
String staleCookie;
|
||||
String sessionId;
|
||||
String firstEventId;
|
||||
Capture atSnapshot;
|
||||
|
||||
// 1. Enroll, approve and scan through the real host; the original is retained by the real service.
|
||||
int firstPort = freePort();
|
||||
EchoDemoSettings first = new EchoDemoSettings(root, firstPort, TENANT, TOKEN, true, null, null);
|
||||
origin = "http://127.0.0.1:" + firstPort;
|
||||
try(EchoDemo demo = new EchoDemo(first, new ControlledRenderer(RENDERER_CAP), RENDERER_CAP))
|
||||
{
|
||||
demo.initializeDatabase(scannerPassword, adminPassword);
|
||||
printerRowId = enrollPrinter(demo, first);
|
||||
demo.start(firstPort);
|
||||
String admin = login("admin", adminPassword);
|
||||
staleCookie = login("scanner", scannerPassword);
|
||||
template = publishTemplate(demo, printerRowId, admin, "Echo recovery label", MEDIA, REVISION, 101600, 152400, 300);
|
||||
enrollAndApproveDevice(staleCookie, admin);
|
||||
HttpResponse<String> heartbeat = request("POST", "/qqq/v1/qbit/mobile-scanners/sessions/heartbeat", staleCookie, "{\"foreground\":true,\"screenAvailable\":false}", true);
|
||||
assertEquals(200, heartbeat.statusCode(), "The approved device must open a session.");
|
||||
sessionId = new JSONObject(heartbeat.body()).getString("sessionId");
|
||||
JSONObject firstEvent = event("LABEL-TYPE-CODE128", CODE_VALUE);
|
||||
firstEventId = firstEvent.getString("eventId");
|
||||
assertEquals("QUEUED", scanInNewRun(staleCookie, printerRowId, template, firstEvent).getString("printStatus"));
|
||||
atSnapshot = capture(first);
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopDatabase(first);
|
||||
}
|
||||
assertEquals(1, atSnapshot.echoScans().size());
|
||||
List<String> scan = atSnapshot.echoScans().getFirst();
|
||||
assertEquals(firstEventId, scan.get(1));
|
||||
assertEquals(CODE_VALUE, scan.get(3), "The exact leading-zero text must be stored.");
|
||||
assertEquals("LABEL-TYPE-CODE128", scan.get(4));
|
||||
assertEquals("code128", scan.get(5));
|
||||
assertEquals(1, atSnapshot.mobileScans().size());
|
||||
assertEquals(scan.get(2), atSnapshot.mobileScans().getFirst().get(0), "The Echo scan must name its mobile scan.");
|
||||
assertEquals(firstEventId, atSnapshot.mobileScans().getFirst().get(1));
|
||||
assertEquals(CODE_VALUE, atSnapshot.mobileScans().getFirst().get(2));
|
||||
assertEquals("scanner", atSnapshot.mobileScans().getFirst().get(3), "Attribution is derived by the server.");
|
||||
assertEquals(deviceId, atSnapshot.mobileScans().getFirst().get(4));
|
||||
assertEquals(Set.of(firstZpl), retainedOriginals(), "Record Files retained exactly the generated original.");
|
||||
// the owner's retained-file row: source table and record are this exact scan, and the size is that of the original
|
||||
assertEquals(1, atSnapshot.retainedFiles().size(), "Exactly one retained-file row exists for the one scan.");
|
||||
List<String> file = atSnapshot.retainedFiles().getFirst();
|
||||
assertEquals(EchoQBitProducer.SCAN_TABLE, file.get(1), "The retained file's source table is the Echo scan table.");
|
||||
assertEquals(scan.get(0), file.get(2), "The retained file's source record is this exact scan.");
|
||||
assertNotNull(file.get(3), "The retained file references its standard attachment.");
|
||||
assertEquals(String.valueOf(firstZpl.length()), file.get(4), "The retained size is that of the generated original.");
|
||||
|
||||
// 2. Snapshot the closed database and the object directory together, into the other temporary directory.
|
||||
takeSnapshot();
|
||||
|
||||
// 3. Change state after the snapshot, then stop.
|
||||
int secondPort = freePort();
|
||||
EchoDemoSettings second = new EchoDemoSettings(root, secondPort, TENANT, TOKEN, true, null, null);
|
||||
origin = "http://127.0.0.1:" + secondPort;
|
||||
try(EchoDemo demo = new EchoDemo(second, new ControlledRenderer(RENDERER_CAP), RENDERER_CAP))
|
||||
{
|
||||
demo.start(secondPort);
|
||||
String admin = login("admin", adminPassword);
|
||||
scanInNewRun(staleCookie, printerRowId, template, event("QRCODE", QR_VALUE));
|
||||
runSelected("mobileDeviceRevoke", "mobileDevice", deviceId, new JSONObject(), admin);
|
||||
runSelected("mobileSessionEnd", "mobileSession", sessionId, new JSONObject().put("reason", "post-snapshot change"), admin);
|
||||
awaitSessionEnded(second, sessionId, staleCookie);
|
||||
assertEquals(List.of(List.of("REVOKED")), sql(second, "SELECT status FROM mobileDevice WHERE id = '" + deviceId + "'", 1));
|
||||
assertEquals(2, sql(second, "SELECT id FROM echo_scan", 1).size());
|
||||
assertEquals(2, sql(second, "SELECT id FROM record_files_file", 1).size());
|
||||
assertEquals(Set.of(firstZpl, secondZpl), retainedOriginals());
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopDatabase(second);
|
||||
}
|
||||
|
||||
// 4. Restore only the two temporary directories, then read them with no application running.
|
||||
restoreSnapshot();
|
||||
int thirdPort = freePort();
|
||||
EchoDemoSettings third = new EchoDemoSettings(root, thirdPort, TENANT, TOKEN, true, null, null);
|
||||
try
|
||||
{
|
||||
assertEquals(atSnapshot, capture(third), "The restored scan rows, retained-file row and object bytes must equal the snapshot.");
|
||||
assertEquals(List.of(List.of("APPROVED")), sql(third, "SELECT status FROM mobileDevice WHERE id = '" + deviceId + "'", 1),
|
||||
"The restore reverts the later revocation, so the device is approved again.");
|
||||
assertEquals(List.of(List.of("ACTIVE")), sql(third, "SELECT status FROM mobileSession WHERE id = '" + sessionId + "'", 1),
|
||||
"The restore reverts the later session end.");
|
||||
assertEquals(1, sql(third, "SELECT id FROM demoSession WHERE id = '" + staleCookie + "'", 1).size(), "The earlier sign-in session is restored too.");
|
||||
assertEquals(1, sql(third, "SELECT id FROM printing_job", 1).size());
|
||||
assertEquals(Set.of(firstZpl), retainedOriginals(), "No object from after the snapshot may remain.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopDatabase(third);
|
||||
}
|
||||
|
||||
// 5. Restart on a new ephemeral port: the restored scan is served, stale authority works until re-audit, and then is refused.
|
||||
ControlledRenderer renderer = new ControlledRenderer(RENDERER_CAP);
|
||||
origin = "http://127.0.0.1:" + thirdPort;
|
||||
try(EchoDemo demo = new EchoDemo(third, renderer, RENDERER_CAP))
|
||||
{
|
||||
demo.start(thirdPort);
|
||||
JSONArray scans = query(EchoQBitProducer.SCAN_TABLE, staleCookie);
|
||||
assertEquals(1, scans.length());
|
||||
assertEquals(CODE_VALUE, scans.getJSONObject(0).getJSONObject("values").getString("barcode"));
|
||||
HttpResponse<String> current = request("GET", "/qqq/v1/qbit/mobile-scanners/devices/current", staleCookie, null, true);
|
||||
assertEquals(200, current.statusCode());
|
||||
assertEquals("APPROVED", new JSONObject(current.body()).getString("status"), "Reopening without a re-audit would trust the reverted approval.");
|
||||
|
||||
// a real run, opened while the restored approval is still trusted and parked at its scan step
|
||||
String run = openRun(staleCookie, printerRowId, template);
|
||||
JSONObject lateScan = event("QRCODE", "STALE-SUBMIT-0001");
|
||||
|
||||
String admin = login("admin", adminPassword);
|
||||
runSelected("mobileDeviceRevoke", "mobileDevice", deviceId, new JSONObject(), admin);
|
||||
assertEquals(List.of(List.of("REVOKED")), sql(third, "SELECT status FROM mobileDevice WHERE id = '" + deviceId + "'", 1));
|
||||
// the sign-in session is still valid, so this refusal is the host's device authorization, not authentication
|
||||
assertEquals(200, request("GET", "/qqq/v1/qbit/mobile-scanners/devices/current", staleCookie, null, true).statusCode());
|
||||
assertAuthorizationRefused(refusal(form("/qqq/v1/processes/echoPrint/" + run + "/step/scan", staleCookie, lateScan, true), staleCookie));
|
||||
assertAuthorizationRefused(refusal(form("/qqq/v1/processes/echoPrint/init", staleCookie, new JSONObject(), true), staleCookie));
|
||||
|
||||
runSelected("mobileSessionEnd", "mobileSession", sessionId, new JSONObject().put("reason", "restore re-audit"), admin);
|
||||
awaitSessionEnded(third, sessionId, staleCookie);
|
||||
assertEquals(401, request("GET", "/qqq/v1/metaData", staleCookie, null, true).statusCode(), "The ended session must no longer authenticate.");
|
||||
assertEquals(401, form("/qqq/v1/processes/echoPrint/" + run + "/step/scan", staleCookie, lateScan, true).statusCode(),
|
||||
"The same run cannot be continued with the ended session.");
|
||||
|
||||
String freshCookie = login("scanner", scannerPassword);
|
||||
assertAuthorizationRefused(refusal(form("/qqq/v1/processes/echoPrint/init", freshCookie, new JSONObject(), true), freshCookie));
|
||||
assertEquals(0, renderer.renders.size(), "Nothing may be generated after the restore.");
|
||||
assertEquals(1, sql(third, "SELECT id FROM echo_scan", 1).size());
|
||||
assertEquals(1, sql(third, "SELECT id FROM mobileScan", 1).size());
|
||||
assertEquals(1, sql(third, "SELECT id FROM printing_job", 1).size());
|
||||
assertEquals(atSnapshot.objects(), objectHashes(), "Refused submissions must not change retained files.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
stopDatabase(third);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A new Echo run, then one scan in it, as the approved scanner device.
|
||||
***************************************************************************/
|
||||
private JSONObject scanInNewRun(String scanner, String printerRowId, Integer template, JSONObject event) throws Exception
|
||||
{
|
||||
String process = openRun(scanner, printerRowId, template);
|
||||
return echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/scan", scanner, event, true), scanner).getJSONObject("values");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A new Echo run chosen through setup, ready for its scan step.
|
||||
***************************************************************************/
|
||||
private String openRun(String scanner, String printerRowId, Integer template) throws Exception
|
||||
{
|
||||
String process = echo(form("/qqq/v1/processes/echoPrint/init", scanner, new JSONObject(), true), scanner).getString("processUUID");
|
||||
assertEquals("scan", echo(form("/qqq/v1/processes/echoPrint/" + process + "/step/setup", scanner,
|
||||
new JSONObject().put("printerId", printerRowId).put("mediaId", MEDIA).put("templateId", template), true), scanner).getString("nextStep"));
|
||||
return process;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Run an existing administrator process on one selected record, from its
|
||||
** Actions menu, exactly as the browser and native client do.
|
||||
***************************************************************************/
|
||||
private void runSelected(String process, String table, String recordId, JSONObject input, String admin) throws Exception
|
||||
{
|
||||
JSONObject selection = new JSONObject().put("recordsParam", "recordIds").put("recordIds", recordId).put("tableName", table);
|
||||
JSONObject launched = complete(form("/qqq/v1/processes/" + process + "/init", admin, selection, false), process, admin, false);
|
||||
assertEquals("input", launched.getString("nextStep"));
|
||||
complete(form("/qqq/v1/processes/" + process + "/" + launched.getString("processUUID") + "/step/input", admin, input, false), process, admin, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The End Session process commits the tombstone, then removes the stored
|
||||
** authentication session asynchronously; wait for both, within a bound.
|
||||
***************************************************************************/
|
||||
private static void awaitSessionEnded(EchoDemoSettings settings, String sessionId, String cookie) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(15).toNanos();
|
||||
List<List<String>> state;
|
||||
List<List<String>> remaining;
|
||||
boolean done;
|
||||
do
|
||||
{
|
||||
state = sql(settings, "SELECT status, authCleanupStatus FROM mobileSession WHERE id = '" + sessionId + "'", 2);
|
||||
remaining = sql(settings, "SELECT id FROM demoSession WHERE id = '" + cookie + "'", 1);
|
||||
done = List.of(List.of("ENDED", "REQUESTED")).equals(state) && remaining.isEmpty();
|
||||
if(!done)
|
||||
{
|
||||
Thread.sleep(50);
|
||||
}
|
||||
}
|
||||
while(!done && System.nanoTime() < deadline);
|
||||
assertEquals(List.of(List.of("ENDED", "REQUESTED")), state, "The session must be ended with cleanup requested.");
|
||||
assertTrue(remaining.isEmpty(), "The stored authentication session must be removed.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Follow an Echo process request to its outcome: the immediate HTTP status,
|
||||
** or, when the step went asynchronous, the final status body. Returns the
|
||||
** HTTP status and the "error" text, with no assertion about either.
|
||||
***************************************************************************/
|
||||
private Refusal refusal(HttpResponse<String> response, String cookie) throws Exception
|
||||
{
|
||||
long deadline = System.nanoTime() + Duration.ofSeconds(30).toNanos();
|
||||
JSONObject body = new JSONObject(response.body());
|
||||
while(response.statusCode() == 200 && ("JOB_STARTED".equals(body.optString("type")) || "RUNNING".equals(body.optString("type"))))
|
||||
{
|
||||
assertTrue(System.nanoTime() < deadline, "Native process timed out.");
|
||||
Thread.sleep(25);
|
||||
response = request("GET", "/qqq/v1/processes/" + EchoQBitProducer.PROCESS + "/" + body.getString("processUUID") + "/status/" + body.getString("jobUUID"), cookie, null, true);
|
||||
body = new JSONObject(response.body());
|
||||
}
|
||||
return new Refusal(response.statusCode(), body.optString("error"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The exact refusal of a revoked device under a still-valid sign-in: the
|
||||
** Echo process permission checker's own message (EchoPermissions), as a 403
|
||||
** or as the process error, and never a success or any other failure.
|
||||
***************************************************************************/
|
||||
private static void assertAuthorizationRefused(Refusal refusal)
|
||||
{
|
||||
assertTrue(refusal.status() == 403 || refusal.status() == 200, "Unexpected status " + refusal.status() + ": " + refusal.message());
|
||||
assertEquals(ECHO_DEVICE_REFUSAL, refusal.message(), "The refusal must be Echo's own device authorization message.");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read the durable state straight from the closed-to-the-app database and
|
||||
** the object directory.
|
||||
***************************************************************************/
|
||||
private Capture capture(EchoDemoSettings settings) throws Exception
|
||||
{
|
||||
List<List<String>> echoScans = sql(settings, "SELECT id, eventId, mobileScanId, barcode, symbology, rendererSymbology, printJobId, state FROM echo_scan ORDER BY id", 8);
|
||||
List<List<String>> mobileScans = sql(settings, "SELECT id, eventId, barcode, userId, deviceId FROM mobileScan ORDER BY id", 5);
|
||||
List<List<String>> retainedFiles = sql(settings, "SELECT id, table_name, record_id, attachment_id, size_bytes FROM " + RecordFilesTables.FILE + " ORDER BY id", 5);
|
||||
return new Capture(echoScans, mobileScans, retainedFiles, objectHashes());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The SHA-256 of every file under the object directory, by relative path,
|
||||
** including each object's metadata file.
|
||||
***************************************************************************/
|
||||
private Map<String, String> objectHashes() throws Exception
|
||||
{
|
||||
Path objects = root.resolve("objects");
|
||||
Map<String, String> hashes = new TreeMap<>();
|
||||
try(Stream<Path> files = Files.walk(objects))
|
||||
{
|
||||
for(Path file : files.filter(Files::isRegularFile).toList())
|
||||
{
|
||||
hashes.put(objects.relativize(file).toString().replace('\\', '/'), HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(file))));
|
||||
}
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The content of every retained original (an object that is not metadata).
|
||||
***************************************************************************/
|
||||
private Set<String> retainedOriginals() throws Exception
|
||||
{
|
||||
Set<String> retained = new HashSet<>();
|
||||
try(Stream<Path> files = Files.walk(root.resolve("objects")))
|
||||
{
|
||||
for(Path file : files.filter(Files::isRegularFile).filter(path -> !path.getFileName().toString().endsWith(".json")).toList())
|
||||
{
|
||||
retained.add(Files.readString(file, StandardCharsets.US_ASCII));
|
||||
}
|
||||
}
|
||||
return retained;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Close the private file database so its files are complete and unlocked.
|
||||
** The server is already stopped when this runs.
|
||||
***************************************************************************/
|
||||
private static void stopDatabase(EchoDemoSettings settings) throws Exception
|
||||
{
|
||||
QContext.clear();
|
||||
try(Connection connection = DriverManager.getConnection(EchoDemo.jdbcUrl(settings), "sa", ""); Statement statement = connection.createStatement())
|
||||
{
|
||||
statement.execute("SHUTDOWN");
|
||||
}
|
||||
com.kingsrook.qqq.backend.module.rdbms.jdbc.ConnectionManager.resetConnectionProviders();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Copy the closed database and object directory, together, to the snapshot
|
||||
** directory.
|
||||
***************************************************************************/
|
||||
private void takeSnapshot() throws Exception
|
||||
{
|
||||
for(String name : List.of("db", "objects"))
|
||||
{
|
||||
copyTree(root.resolve(name), snapshotDirectory.resolve(name));
|
||||
}
|
||||
try(Stream<Path> files = Files.list(snapshotDirectory.resolve("db")))
|
||||
{
|
||||
List<String> names = files.map(path -> path.getFileName().toString()).toList();
|
||||
assertTrue(names.stream().anyMatch(name -> name.endsWith(".mv.db")), "The snapshot must contain the database file: " + names);
|
||||
assertFalse(names.stream().anyMatch(name -> name.endsWith(".lock.db")), "The database must be closed before the snapshot.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Replace only this test's database and object directories from the snapshot.
|
||||
***************************************************************************/
|
||||
private void restoreSnapshot() throws Exception
|
||||
{
|
||||
Path liveRoot = root.toAbsolutePath().normalize();
|
||||
Path snapshotRoot = snapshotDirectory.toAbsolutePath().normalize();
|
||||
assertFalse(snapshotRoot.startsWith(liveRoot) || liveRoot.startsWith(snapshotRoot), "The two temporary directories must be separate.");
|
||||
for(String name : List.of("db", "objects"))
|
||||
{
|
||||
Path live = liveRoot.resolve(name).normalize();
|
||||
assertTrue(live.startsWith(liveRoot), "Only paths inside this test's temporary directory may be replaced.");
|
||||
assertTrue(Files.isDirectory(snapshotRoot.resolve(name)));
|
||||
deleteTree(live);
|
||||
copyTree(snapshotRoot.resolve(name), live);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Copy a directory tree, creating directories and preserving file times.
|
||||
***************************************************************************/
|
||||
private static void copyTree(Path from, Path to) throws Exception
|
||||
{
|
||||
try(Stream<Path> paths = Files.walk(from))
|
||||
{
|
||||
for(Path source : paths.toList())
|
||||
{
|
||||
Path target = to.resolve(from.relativize(source).toString());
|
||||
if(Files.isDirectory(source))
|
||||
{
|
||||
Files.createDirectories(target);
|
||||
}
|
||||
else
|
||||
{
|
||||
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Delete a directory tree, children first.
|
||||
***************************************************************************/
|
||||
private static void deleteTree(Path tree) throws Exception
|
||||
{
|
||||
try(Stream<Path> paths = Files.walk(tree))
|
||||
{
|
||||
for(Path path : paths.sorted(Comparator.reverseOrder()).toList())
|
||||
{
|
||||
Files.delete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One scan event with a unique id and the exact scanned text.
|
||||
***************************************************************************/
|
||||
|
||||
+149
@@ -8,16 +8,24 @@ package com.kingsrook.examples.echo.demo;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.FileAlreadyExistsException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
@@ -109,6 +117,147 @@ class PrivateObjectFilesTest
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Dot path segments are not buckets; ordinary names containing dots are.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void dotBucketsAreRefusedAndValidBucketsStillWork() throws IOException
|
||||
{
|
||||
PrivateObjectFiles files = new PrivateObjectFiles(root);
|
||||
assertThrows(IllegalArgumentException.class, () -> files.file(".", "k"));
|
||||
assertThrows(IllegalArgumentException.class, () -> files.file("..", "k"));
|
||||
assertThrows(IllegalArgumentException.class, () -> files.create("..", "k", bytes("x"), 1024, Map.of()));
|
||||
|
||||
assertTrue(files.file("canonical", "k").normalize().startsWith(root.resolve("canonical")));
|
||||
assertTrue(files.file("a.b-c_d", "k").normalize().startsWith(root.resolve("a.b-c_d")));
|
||||
assertTrue(files.file("..x", "k").normalize().startsWith(root.resolve("..x")));
|
||||
files.create("canonical", "k", bytes("ok"), 1024, Map.of("a", "b"));
|
||||
assertEquals(Map.of("a", "b"), files.find("canonical", "k").orElseThrow().metadata());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A stream that, on its first read, reports that its writer is inside the
|
||||
** write path and then waits until the test releases that writer.
|
||||
***************************************************************************/
|
||||
private static InputStream gated(String value, CountDownLatch entered, CountDownLatch release)
|
||||
{
|
||||
ByteArrayInputStream delegate = bytes(value);
|
||||
return new InputStream()
|
||||
{
|
||||
private boolean first = true;
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public int read() throws IOException
|
||||
{
|
||||
gate();
|
||||
return delegate.read();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws IOException
|
||||
{
|
||||
gate();
|
||||
return delegate.read(buffer, offset, length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
private void gate() throws IOException
|
||||
{
|
||||
if(first)
|
||||
{
|
||||
first = false;
|
||||
entered.countDown();
|
||||
try
|
||||
{
|
||||
if(!release.await(30, TimeUnit.SECONDS))
|
||||
{
|
||||
throw new IOException("Test writer was never released");
|
||||
}
|
||||
}
|
||||
catch(InterruptedException failure)
|
||||
{
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException(failure);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Two adapters over one root write the same key with different bytes and
|
||||
** metadata. Both pass the initial existence check and block in their streams;
|
||||
** the first is released and completes, then the second is released and must
|
||||
** be refused at the final existence check without touching the first's
|
||||
** bytes or metadata (the old code replaced the metadata before failing).
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void lateSecondWriterIsRefusedAndFirstWritersBytesAndMetadataRemain() throws Exception
|
||||
{
|
||||
PrivateObjectFiles first = new PrivateObjectFiles(root);
|
||||
PrivateObjectFiles second = new PrivateObjectFiles(root);
|
||||
CountDownLatch enteredOne = new CountDownLatch(1);
|
||||
CountDownLatch enteredTwo = new CountDownLatch(1);
|
||||
CountDownLatch releaseOne = new CountDownLatch(1);
|
||||
CountDownLatch releaseTwo = new CountDownLatch(1);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try
|
||||
{
|
||||
Future<?> one = executor.submit(() ->
|
||||
{
|
||||
first.create("canonical", "same", gated("content-from-one", enteredOne, releaseOne), 1024, Map.of("writer", "one"));
|
||||
return null;
|
||||
});
|
||||
Future<?> two = executor.submit(() ->
|
||||
{
|
||||
second.create("canonical", "same", gated("content-from-two-longer", enteredTwo, releaseTwo), 1024, Map.of("writer", "two"));
|
||||
return null;
|
||||
});
|
||||
|
||||
// both writers are past their initial existence check and blocked in their streams
|
||||
assertTrue(enteredOne.await(30, TimeUnit.SECONDS), "first writer should enter the write path");
|
||||
assertTrue(enteredTwo.await(30, TimeUnit.SECONDS), "second writer should enter the write path");
|
||||
|
||||
// first finishes completely while second stays blocked
|
||||
releaseOne.countDown();
|
||||
one.get(30, TimeUnit.SECONDS);
|
||||
assertFalse(two.isDone(), "second writer must still be blocked in its stream");
|
||||
|
||||
// second now reaches publication with an object already in place and must refuse
|
||||
releaseTwo.countDown();
|
||||
ExecutionException failure = assertThrows(ExecutionException.class, () -> two.get(30, TimeUnit.SECONDS));
|
||||
assertInstanceOf(FileAlreadyExistsException.class, failure.getCause());
|
||||
|
||||
var stored = first.find("canonical", "same").orElseThrow();
|
||||
assertEquals("content-from-one", Files.readString(stored.file()));
|
||||
assertEquals(Map.of("writer", "one"), stored.metadata());
|
||||
assertEquals(stored.metadata(), second.find("canonical", "same").orElseThrow().metadata());
|
||||
}
|
||||
finally
|
||||
{
|
||||
releaseOne.countDown();
|
||||
releaseTwo.countDown();
|
||||
executor.shutdownNow();
|
||||
assertTrue(executor.awaitTermination(30, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
try(Stream<Path> all = Files.walk(root))
|
||||
{
|
||||
assertFalse(all.anyMatch(path -> path.getFileName().toString().startsWith(".incoming-")));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Delete removes the object and its metadata; absent is not an error.
|
||||
***************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user