From 1bf13be26eea8e882576f78e5c194353fe882e71 Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 13 Nov 2026 08:15:30 -0600 Subject: [PATCH 1/2] feat: operators read print jobs; retire the Remove actions; plain refusal messages Operators read print jobs, read only. PrintingReadPermission accepted printing_operator for print stations and printers and required printing_admin for print jobs, so a person who prints could not see what was printed. A holder of printing_operator now has every read shape on printing_job that an administrator has (query, get, count, aggregate, export) and sees every job of the tenant, with the same fields; the table has no field for the document, its storage reference or the retained binding. Control history and deletion history stay administrator-only and nobody gains a write. Retire the Remove actions. Delete (RC.3) keeps job history and records who deleted what, so the older archive actions duplicated it on the screens. Removed: the processes printingRemoveDevices and printingRemoveSelectedPrinters and the control action Action.REMOVE. No HTTP or agent route called them; the REVOKE control still resolves to printingRevokeDevices. PrintingService.removePrinter and removeDevice stay as service methods, and records archived earlier behave as before. Refusals say what happened and what to do, and name no framework. The permission checks, the 403 text of the native and legacy routes, the control-action sign-in text and the OpenAPI descriptions are reworded; statuses, error codes and JSON field names are unchanged. A host may give its application's display name with PrintingQBitConfig.withProductName; the default is "this application". Co-Authored-By: Claude Fable 5.2 --- CHANGELOG.md | 111 ++++++- README.md | 8 +- SECURITY.md | 2 +- docs/OPERATOR-CATALOG.md | 6 +- docs/RETAINED-PRINT-FACADE.md | 4 +- docs/STORAGE-AND-RETENTION.md | 2 +- docs/UI-WALKTHROUGH.md | 8 +- .../printing/PrintingAdminPermission.java | 2 +- .../PrintingControlAuthorization.java | 2 +- .../printing/PrintingDeletePermission.java | 2 +- .../printing/PrintingDeviceProcesses.java | 19 +- .../qbits/printing/PrintingMessages.java | 114 +++++++ .../printing/PrintingOperatorPermission.java | 7 +- .../qbits/printing/PrintingProcesses.java | 5 +- .../qbits/printing/PrintingQBitConfig.java | 24 ++ .../printing/PrintingReadPermission.java | 19 +- .../printing/PrintingRealtimeRoutes.java | 2 +- .../qbits/printing/PrintingRoutes.java | 4 +- .../qbits/printing/PrintingService.java | 13 +- .../qbits/printing/PrintingUiApiSpec.java | 8 +- .../resources/openapi/qqq-v1/printing.yaml | 148 ++++----- .../qbits/printing/PrintingDeleteTest.java | 18 +- .../printing/PrintingDeviceProcessesTest.java | 33 +- .../qbits/printing/PrintingMessagesTest.java | 127 +++++++ .../printing/PrintingOperatorReadTest.java | 310 ++++++++++++++++++ .../qbits/printing/PrintingOperatorTest.java | 17 +- .../qbits/printing/PrintingProcessesTest.java | 29 +- .../qbits/printing/PrintingUiApiTest.java | 12 +- .../printing/RetainedPrintFacadeTest.java | 15 +- 29 files changed, 895 insertions(+), 176 deletions(-) create mode 100644 src/main/java/com/kingsrook/qbits/printing/PrintingMessages.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingMessagesTest.java create mode 100644 src/test/java/com/kingsrook/qbits/printing/PrintingOperatorReadTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a47a4f..376851d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -97,15 +97,16 @@ before deploying this version.** - New permission **`printing_delete`** (`PrintingDeletePermission`). Both actions require `printing_delete` **and** `printing_admin`; `printing_admin` alone does not delete and is not offered the actions. A host grants it where it grants the others. - - **Remove printers**, **Remove print station** and **Revoke print station access** are - unchanged (same process names, `printing_admin`). Remove still archives (`removed_at`, - record kept); a removed record can be deleted. + - **Revoke print station access** is unchanged (same process name, `printing_admin`). + **Remove printers** and **Remove print station** were unchanged in RC.3 and RC.4 and are + retired in RC.5 (below); a record they archived (`removed_at`) can be deleted. - **A grouped read is a read.** `PrintingReadPermission` let a query, a get and a count through and refused everything else as a write, so an aggregate ("print jobs by format") was refused for everybody, administrators included, wherever permissions are enforced. It now accepts `AggregateInput` and `ExportInput` with exactly the permission a query needs (`printing_operator` or `printing_admin` on stations and printers, `printing_admin` on the - other tables). Inserts, updates and deletes through table actions stay refused. + other tables; RC.5, below, adds print jobs to what an operator reads). Inserts, updates and + deletes through table actions stay refused. - Schema step for a host that owns the schema (PostgreSQL, one transaction): ```sql @@ -183,6 +184,108 @@ No wire, contract or schema change; the schema step above is the same. the in-memory database of the test suite; the build has no PostgreSQL driver or container support, so the delete and the schema step are not exercised on PostgreSQL here. +### 1.2.0-RC.5: operators read print jobs; Remove actions retired; plain refusal messages + +No wire, contract or schema change (extension 2.4.0, wire v1, subprotocol unchanged; the +published OpenAPI document changes in description text only). Print clients need no update. +**A host reviews the upgrade notes at the end of this section.** + +- **A holder of `printing_operator` reads print jobs, read only.** `PrintingReadPermission` + let an operator read print stations and printers and required `printing_admin` for print + jobs, so a person who prints could not see what was printed. An operator now has every + read shape on `printing_job` that an administrator has: query, get, count, aggregate and + export. + - What an operator sees: **every print job of their tenant**, whoever submitted it, the + same as print stations and printers. The tenant lock on the table (`printing_tenant`) is + the boundary; a host that wants a narrower view adds its own record lock, as it can for + stations and printers. + - The fields are the ones an administrator reads: identifiers, title, format, status and + status message, the names of the station and printer, the spooler job id, the document's + size and SHA-256 (`content_hash`), the print options (`options_json`) and the request's + idempotency key. The table has never had a field for the document, its storage + reference, the retained binding, the retained actor or the accepted event, and has none + now. An idempotency key is not a credential: an operator request is keyed by a hash of + the submitting person, so another person cannot reconcile or replay it. + - Unchanged: `printing_device_command` (control history) and `printing_deletion` (deletion + history) require `printing_admin`. No table write for anybody. Every action on a job, a + printer or a station (retry, cancel, approve, revoke, delete, enroll, ...) requires + `printing_admin` as before, and the HTTP routes are unchanged: the operator routes still + reconcile only the caller's own request. The Printing app entry in the navigation stays + an administrator's; an operator reaches the jobs through table reads. + - This reverses a property two tests asserted ("an operator cannot enumerate jobs"). +- **The Remove actions are retired; Delete is the one way to take a record away.** RC.3 + added Delete, which keeps the job history with its names and records who deleted what. + Removed from the screens and the metadata: + - process **Remove print station** (`printingRemoveDevices`) and its control action + `PrintingDeviceProcesses.Action.REMOVE`; + - process **Remove printers** (`printingRemoveSelectedPrinters`). + What stays: + - every HTTP and agent route. No route ever called a Remove process, and no print client + calls a remove, unshare or revoke route. `POST .../admin/devices/{deviceId}/actions` with + `REVOKE` still requires and runs `printingRevokeDevices`, as it always resolved. + - **Revoke print station access**, **Delete print stations**, **Delete printers**. + - `PrintingService.removePrinter` and `removeDevice`, as service methods for a host that + calls them; nothing in this library does. + - records archived earlier: `removed_at` keeps them out of the operator catalog, claims, + the `PRINTERS` frame and the selectors; a queue shared again comes back awaiting + approval; retention cleanup still purges archived records that no job references; an + archived record can be deleted. +- **Refusals say what happened and what to do, and name no framework.** Statuses, error + codes and JSON field names are unchanged; only sentences changed. + - Permission checks (shown on screens and by the table and process API): + "Printing administrator permission is required" is now "You are signed in to this + application, but your account is not allowed to manage print stations. Ask an + administrator to give you the printing administrator role."; the operator and delete + checks read the same way ("... not allowed to use printing ... the printing operator + role", "... not allowed to delete print stations and printers ..."). A caller with no + session is told to sign in. "The current session cannot manage this printing tenant" is + now "... your account is not set up for printing in this organization. Ask an + administrator to give your account printing access for your organization." + - Native routes (`/qqq/v1/qbit/printing/...`), every `403`: the `error` and `message` + text was "User or device is not authorized". It is now "You are signed in, but your + account or this print station is not allowed to do this. Ask an administrator to check + that you have the printing administrator role and that this print station still has + access." on the station and administration routes, and "You are signed in, but your + account is not allowed to print here. Ask an administrator to check that you have the + printing operator role and that the printer is approved." on the three operator routes. + One sentence per route family still covers every reason, so the answer does not say + which check failed. Status `403` and code `PERMISSION_DENIED` are unchanged. + - Legacy routes (`/printing/v1/...`, code `PRINTING_403` unchanged): "Enrollment requires + native QQQ authentication" is now "This server enrolls a print station only for a + signed-in person. Sign in from the print client, then enroll again."; "Printing + administrator permission required" is now "Your account is not allowed to manage print + stations. Ask an administrator to give you the printing administrator role." + - A control action started without a signed-in person: "Sign in with an authorized QQQ + user before controlling a print station" is now "Sign in to this application to control + a print station." + - OpenAPI descriptions: "Requires the current QQQ user session, ..." is "Requires a + signed-in user session, ..."; the `401` response is described as "Sign-in required". +- **`PrintingQBitConfig.withProductName(name)`**, optional. The name the host's users know + the application by replaces "this application" in the sentences above that say "signed in + to ...". It is used nowhere else; null or blank keeps the neutral words. + +Upgrade notes for a host: + +- **Permissions.** Nothing to grant. Everyone who already holds `printing_operator` can read + the tenant's print jobs after the upgrade; a host for which that is too wide adds a record + lock to `printing_job` or withholds the permission. Role documents that say an operator + cannot read job history need the new sentence. +- **Removed processes.** `printingRemoveDevices` and `printingRemoveSelectedPrinters` no + longer exist. Delete them from help entries, reviewed or allow-listed process lists, role + and permission documents, saved links, and tests that enumerate the Printing processes. + A request to run either fails: the process does not exist. +- **Message texts.** A host test or help page that quotes one of the old sentences needs the + new one. Code that reads the status or the code needs no change. +- **Optional.** Call `withProductName("...")` with the name on your sign-in page. + +Tests added: an operator queries, gets, counts and groups the jobs of its tenant and never +another tenant's; the job row has no document, binding or credential field (a new field +fails the test until it is reviewed); an operator is refused control and deletion history, +every table write and every administrator action; a person with neither permission is +refused every read and write on every printing table; neither Remove process is registered +and each control wire value belongs to one action; each new sentence, the host's name in +it, and the unchanged status, code and field names of a refused HTTP request. + ## 1.1.2 Runtime and publication move to the QRun-IO Gitea Maven registry; no printing contract diff --git a/README.md b/README.md index d8f70f4..d324e8d 100644 --- a/README.md +++ b/README.md @@ -125,11 +125,13 @@ Typical setup: run **Enroll a print station** from Printing home or Print statio For label printers, configure the machine's **Windows Printing Defaults** for the actual stock size and print method before testing PDFs through the service. A test profile selects document size and ZPL resolution; it does not change the Windows driver or printer's media configuration. Our 300 dpi Zebra required 4 × 6-inch stock and Thermal Direct instead of its previous 4.09 × 2-inch Thermal Transfer defaults. After correcting those settings and resuming the printer, the user confirmed the built-in PDF label, alongside the already confirmed ZPL label and laser PDF. Barcode scanning and measured alignment remain separate checks; `SPOOLED` alone is not proof of physical output. -On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Remove printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the printer and print station names they were submitted under (stored on the job, so they read the same after a rename or a delete); approval, availability, format and status fields have named choices. +On Printers, select one or more rows or open a printer record to **Approve printers**, **Disable printers**, **Delete printers**, **Print a test page**, or **Print a test label**. Standard QQQ review/result screens show the selected scope; preview does not print. Each selected printer is read again under the current user's permissions before execution. Jobs display the printer and print station names they were submitted under (stored on the job, so they read the same after a rename or a delete); approval, availability, format and status fields have named choices. -Deleting is separate from removing. **Delete printers** and **Delete print stations** (on a selection or a record; they need `printing_delete` as well as `printing_admin`) take the record out of every list for good. A station is revoked first, so its computer must be enrolled again and then is a new station; its printers go with it. Queued jobs are canceled, jobs already sent without a reported result are closed as outcome unknown, and finished jobs stay in Print jobs with the names they were printed under. A record is refused, by name, while its station is connected and printing. A printer that its station still shares comes back from the next inventory as a new printer awaiting approval. Every deletion is recorded in **Deletion history** (who, what, when, what went with it). See the [walkthrough](docs/UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer). +**Delete printers** and **Delete print stations** (on a selection or a record; they need `printing_delete` as well as `printing_admin`) take the record out of every list for good. A station is revoked first, so its computer must be enrolled again and then is a new station; its printers go with it. Queued jobs are canceled, jobs already sent without a reported result are closed as outcome unknown, and finished jobs stay in Print jobs with the names they were printed under. A record is refused, by name, while its station is connected and printing. A printer that its station still shares comes back from the next inventory as a new printer awaiting approval. Every deletion is recorded in **Deletion history** (who, what, when, what went with it). See the [walkthrough](docs/UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer). -Removing is archiving, not deleting. **Remove printers** sets **Removed at** on the selected printers, clears their approval and exposure, and takes them out of the operator catalog, claims, the `PRINTERS` frame and every device/printer selector; their rows and job history stay. The Printers and Print stations tables have no server-side default filter, so add the filter **Removed at is empty** to the view you use day to day. If the device shares a removed queue again, the next inventory brings it back exposed but **not approved**, exactly like a newly discovered queue. **Remove print station** (an action that requires confirmation) revokes the station's access through the existing REVOKE control, then archives the station and all of its printers; an already revoked station is archived without a new command. A removed station cannot be restored; connect the computer again with a fresh enrollment, which creates a new station. A removed record can be deleted. Retention cleanup later hard-deletes removed printers and devices that no job references (see [storage and retention](docs/STORAGE-AND-RETENTION.md)). +The older **Remove printers** and **Remove print station** actions are retired (1.2.0); Delete is the one way to take a printer or a print station away. Records they archived before the upgrade keep their **Removed at** time and stay out of the operator catalog, claims, the `PRINTERS` frame and every station/printer selector. The Printers and Print stations tables have no server-side default filter, so keep the filter **Removed at is empty** on the view you use day to day, or delete the archived records. If a station shares an archived queue again, the next inventory brings it back exposed but **not approved**, exactly like a newly discovered queue. Retention cleanup hard-deletes archived printers and stations that no job references (see [storage and retention](docs/STORAGE-AND-RETENTION.md)). `PrintingService.removePrinter` and `removeDevice` remain for a host that calls them; no screen action or route does. + +Who reads what: a holder of `printing_operator` (or `printing_admin`) reads the print stations, printers and print jobs of the tenant, read only; an operator sees every job of the tenant, not only its own. Control history, deletion history and every action need `printing_admin` (deleting needs `printing_delete` too). A refusal tells the person what happened and what to ask an administrator for; set `PrintingQBitConfig.withProductName("...")` so it names your application ("You are signed in to ..."), otherwise it says "this application". Metadata names follow QBit conventions: tables and data sources use `printing_*`, while processes/widgets use `printing...`. The initial namespace is fixed to `printing`. The QBit preserves the host's `QBrandingMetaData`; customer identity belongs in the host branding, not a custom QBit build. diff --git a/SECURITY.md b/SECURITY.md index 32746a6..1a4e7a0 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,6 +4,6 @@ Report vulnerabilities privately through [GitHub vulnerability reporting](https: The supported release line is 1.0.x for QQQ 4.1+, currently built against QQQ 4.1.0-RC.21. Report the exact QBit, QQQ and Windows connector versions. Older QQQ versions are outside scope. -The host must provide authenticated management authorization and the correct tenant/facility context. Native QQQ operations require `printing_admin` and a matching `printing_tenant` session security value. Native device operations require both the current QQQ user session and bound-device proof; a device token alone cannot authorize them. Operator catalog/submission uses `printing_operator` or administration plus native destination/process permissions; it grants no general device administration. See [authorization and controls](docs/OPERATIONS.md#device-controls-and-durable-action-receipts). HTTPS is required for remote deployment; the supplied HTTP demos deliberately bind to loopback. +The host must provide authenticated management authorization and the correct tenant/facility context. Native QQQ operations require `printing_admin` and a matching `printing_tenant` session security value. Native device operations require both the current QQQ user session and bound-device proof; a device token alone cannot authorize them. Operator catalog/submission uses `printing_operator` or administration plus native destination/process permissions; it grants no general device administration. `printing_operator` also reads the tenant's print stations, printers and print jobs (read only, every job of the tenant); control history and deletion history require `printing_admin`. See [authorization and controls](docs/OPERATIONS.md#device-controls-and-durable-action-receipts). HTTPS is required for remote deployment; the supplied HTTP demos deliberately bind to loopback. The UI demonstration uses a fixed mock identity and synthetic data and must remain local. It registers no connector transport routes. RAW ZPL is printer-control input: submit trusted generated labels, not arbitrary untrusted printer commands. Stored documents can contain personal information. See [storage and retention](docs/STORAGE-AND-RETENTION.md) for dedicated bucket boundaries, tenant isolation and the default 365-day policy. diff --git a/docs/OPERATOR-CATALOG.md b/docs/OPERATOR-CATALOG.md index 03c9e0f..1842439 100644 --- a/docs/OPERATOR-CATALOG.md +++ b/docs/OPERATOR-CATALOG.md @@ -7,8 +7,10 @@ their permissions; this is an additive operator API, not a device bearer API. Hosts grant `printing_operator` and the `printing_tenant` security key. The native `printingSubmit` process remains the domain write permission. Current device and printer table/record security, credential scopes and client-channel ceilings apply -on every operation. Generic job/command history, table writes, enrollment and -device management remain administrator-only. Hosts may add destination locks. +on every operation. Since 1.2.0 an operator also reads the tenant's print jobs +through the native table (read only, every job of the tenant; see the changelog). +Command and deletion history, table writes, enrollment and device management +remain administrator-only. Hosts may add destination locks. Native callbacks cannot replace the initiating actor, session, instance, service or row-security context. Submission rechecks current process, credential, channel and destination authority after loading/storage callbacks and before commit. diff --git a/docs/RETAINED-PRINT-FACADE.md b/docs/RETAINED-PRINT-FACADE.md index 61ff17e..2adcc0b 100644 --- a/docs/RETAINED-PRINT-FACADE.md +++ b/docs/RETAINED-PRINT-FACADE.md @@ -6,7 +6,9 @@ routes. It is disabled until the host supplies `PrintingQBitConfig.withRetainedSourceAuthority`. Current `printing_operator` (or administrator) authority and native tenant/device/printer record checks remain mandatory. Submission also requires the shared `printingSubmit` process -permission. This grants no device management or generic job-history access. +permission. The facade grants no device management. Reading print jobs is the +table's own read rule: since 1.2.0 a holder of `printing_operator` reads the +tenant's job rows, which carry no retained binding, actor or accepted event. ## Public contract diff --git a/docs/STORAGE-AND-RETENTION.md b/docs/STORAGE-AND-RETENTION.md index b34bbd0..b37d1f6 100644 --- a/docs/STORAGE-AND-RETENTION.md +++ b/docs/STORAGE-AND-RETENTION.md @@ -36,7 +36,7 @@ Printing administration exposes retention settings and a reviewed **Run cleanup* When the host supplies a scheduler, maintenance runs every five minutes in bounded batches of at most 200. That permits a nominal 57,600 eligible documents per day before storage latency or failures; it is not measured production throughput. Changing a retention setting does not itself purge documents. Disabling cleanup preserves content. Without a configured scheduler there is no automatic job; the administration screen must show that condition. -**Delete printers** and **Delete print stations** delete the record at once and keep every job (see the [walkthrough](UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer)); a deleted station's retained-byte counter stays until cleanup has removed its last stored document, then cleanup deletes the counter. The same run purges archived inventory. **Remove printers** and **Remove print station** never delete rows; they set `removed_at` (shown as **Removed at**) so the rows leave the default views, selectors, catalog, claims and the realtime `PRINTERS` frame while job history keeps resolving to them. After the document step, each cleanup run hard-deletes, within the remaining batch allowance, removed printers that no `printing_job` references (neither by `printer_row_id` nor by device and queue identity) and then removed devices that have no jobs and no printer rows left; a removed device's control receipts and resource accounting go with it. Anything a job references is never deleted, so a removed printer or device stays, hidden, for as long as its jobs exist. The purge counts toward the run's reported total and is logged with the printer and device counts; disabling cleanup pauses it together with document removal. Cleanup never deletes an active (non-removed) printer or device, and never deletes a job. +**Delete printers** and **Delete print stations** delete the record at once and keep every job (see the [walkthrough](UI-WALKTHROUGH.md#delete-a-print-station-or-a-printer)); a deleted station's retained-byte counter stays until cleanup has removed its last stored document, then cleanup deletes the counter. The same run purges archived inventory. The **Remove printers** and **Remove print station** actions (retired in 1.2.0) and the service methods `removePrinter` / `removeDevice` never delete rows; they set `removed_at` (shown as **Removed at**) so the rows leave the default views, selectors, catalog, claims and the realtime `PRINTERS` frame while job history keeps resolving to them. After the document step, each cleanup run hard-deletes, within the remaining batch allowance, removed printers that no `printing_job` references (neither by `printer_row_id` nor by device and queue identity) and then removed devices that have no jobs and no printer rows left; a removed device's control receipts and resource accounting go with it. Anything a job references is never deleted, so a removed printer or device stays, hidden, for as long as its jobs exist. The purge counts toward the run's reported total and is logged with the printer and device counts; disabling cleanup pauses it together with document removal. Cleanup never deletes an active (non-removed) printer or device, and never deletes a job. Only eligible successful terminal jobs lose their document content. Active, failed or uncertain work remains protected. Job metadata, document hashes, receipts and idempotency records remain available for auditing and duplicate prevention. Legacy completion times are recovered only from durable successful events; unknown completion dates remain protected. Existing inline database documents remain readable; the initial storage integration does not silently migrate old bytes to S3. Reprinting a job whose document has been removed returns an explicit unavailable-document result. diff --git a/docs/UI-WALKTHROUGH.md b/docs/UI-WALKTHROUGH.md index b573552..b1eb720 100644 --- a/docs/UI-WALKTHROUGH.md +++ b/docs/UI-WALKTHROUGH.md @@ -18,13 +18,11 @@ Open **Printers** in QQQ. The operational columns show the printer name, named p ## Act on one printer or a selection -Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Remove printers**, **Delete printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer. +Select one or more printer rows and open **Actions**, or open an individual printer record and use its actions. Available operations include **Approve printers**, **Disable printers**, **Delete printers**, **Print a test page**, and **Print a test label**. The standard QQQ review screen describes the selection; reviewing does not print. Submitting a test action queues one job per selected eligible printer. -**Remove printers** archives the selection: each printer gets a **Removed at** time, loses approval and exposure, and disappears from the operator catalog, the connector's approval frame and the device/printer choices in the setup and test-print forms. Its record and job history stay. The Printers table has no server-side default filter, so add **Removed at is empty** to the Printers view you keep (the screenshots above predate this field) to hide archived rows; clear it to audit them. If the Windows companion shares the same queue again, the next inventory brings the printer back as exposed but not approved, so approve it again before it can print. +## Records archived by the retired Remove actions -## Remove a print station - -Open a Print stations record or select rows and choose **Remove print station**. The action requires the confirmation checkbox. It revokes the station's access permanently through the same control as **Revoke print station access**, then marks the station and all of its printers removed. The station drops out of the Print stations view once you add **Removed at is empty** there, the setup and test-print choices and every enrollment, claim and control path; its jobs remain in Print jobs and its control history keeps the REVOKE receipt. A removed station cannot be un-removed; enroll the computer again to create a new station. Retention cleanup eventually hard-deletes removed stations and printers that no job references. To take the record out of the list now, delete it. +Before 1.2.0 the actions **Remove printers** and **Remove print station** archived a record instead of deleting it. They are retired; use **Delete printers** and **Delete print stations** (below). A record archived earlier still has a **Removed at** time, has no approval or exposure, and is absent from the operator catalog, the connector's approval frame and the station/printer choices in the setup and test-print forms; its job history stays. The Printers and Print stations tables have no server-side default filter, so add **Removed at is empty** to the views you keep (the screenshots above predate this field) to hide archived rows; clear it to audit them, and delete them when you no longer want them listed. If the Windows companion shares an archived queue again, the next inventory brings the printer back as exposed but not approved, so approve it again before it can print. An archived station cannot be brought back; enroll the computer again to create a new station. Retention cleanup eventually hard-deletes archived stations and printers that no job references. ## Delete a print station or a printer diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingAdminPermission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingAdminPermission.java index 46ba400..773bae2 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingAdminPermission.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingAdminPermission.java @@ -52,7 +52,7 @@ public class PrintingAdminPermission extends UseOtherPermissionNameCustomPermiss { if(QContext.getQSession() == null || !QContext.getQSession().hasPermission("printing_admin")) { - throw new QPermissionDeniedException("Printing administrator permission is required"); + throw new QPermissionDeniedException(PrintingMessages.notAllowed(QContext.getQSession() != null, PrintingMessages.MANAGE_STATIONS, PrintingMessages.ADMIN_GRANT)); } } } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingControlAuthorization.java b/src/main/java/com/kingsrook/qbits/printing/PrintingControlAuthorization.java index b3ebbc5..3bf54c2 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingControlAuthorization.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingControlAuthorization.java @@ -133,7 +133,7 @@ public final class PrintingControlAuthorization PrintingDeviceProcesses.Action selected = null; for(PrintingDeviceProcesses.Action action : PrintingDeviceProcesses.Action.values()) { - // The first action owning a wire value is its control process; Remove device reuses REVOKE's wire without owning it. + // Each wire value belongs to exactly one action, and that action's process is the permission the request needs. if(selected == null && action.wire.equals(request.optString("action"))) { selected = action; diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeletePermission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeletePermission.java index 45b1e83..249841b 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDeletePermission.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeletePermission.java @@ -58,7 +58,7 @@ public class PrintingDeletePermission extends UseOtherPermissionNameCustomPermis { if(QContext.getQSession() == null || !QContext.getQSession().hasPermission(NAME) || !QContext.getQSession().hasPermission("printing_admin")) { - throw new QPermissionDeniedException("Permission to delete print stations and printers is required"); + throw new QPermissionDeniedException(PrintingMessages.notAllowed(QContext.getQSession() != null, PrintingMessages.DELETE, PrintingMessages.DELETE_GRANT)); } } } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java index 4178aaf..e02c054 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceProcesses.java @@ -56,7 +56,7 @@ import org.json.JSONObject; /******************************************************************************* - ** Native administration controls use the caller's QQQ session and selected rows. + ** Native administration controls use the caller's session and selected rows. ** Review is read-only; durable service requests execute only in the load step. *******************************************************************************/ public final class PrintingDeviceProcesses @@ -82,9 +82,6 @@ public final class PrintingDeviceProcesses "Connected agents will be asked to resume normal traffic. An offline computer cannot be reached immediately."), REVOKE("printingRevokeDevices", "Revoke " + PrintingNames.STATION_LOWER + " access", "REVOKE", true, false, "Access will be revoked permanently. The old credential cannot be restored; local re-enrollment is required."), - REMOVE("printingRemoveDevices", "Remove " + PrintingNames.STATION_LOWER, "REVOKE", true, false, - "Access will be revoked permanently and the " + PrintingNames.STATION_LOWER + " and its " + PrintingNames.PRINTERS_LOWER + " will be hidden from the printing views; the records stay, marked removed. Job history is kept. A removed " - + PrintingNames.STATION_LOWER + " cannot be restored; a new enrollment creates a new " + PrintingNames.STATION_LOWER + ". To take the record out of the list as well, use " + PrintingDeleteProcesses.DEVICES_LABEL + "."), REFRESH("printingRefreshDevices", "Refresh printer inventory", "REFRESH_INVENTORY", false, false, "A bounded refresh request will wait for a compatible agent. Acceptance does not mean it has executed."), CLEAR("printingClearDeviceQueues", "Cancel queued jobs", "CLEAR_QUEUE", true, false, @@ -183,7 +180,7 @@ public final class PrintingDeviceProcesses if(QContext.getQSession() == null || QContext.getQSession().getUser() == null || QContext.getQSession().getUser().getIdReference() == null || QContext.getQSession().getUser().getIdReference().isBlank()) { - throw new QException("Sign in with an authorized QQQ user before controlling a " + PrintingNames.STATION_LOWER); + throw new QException(PrintingMessages.notAllowed(false, "control a " + PrintingNames.STATION_LOWER, PrintingMessages.ADMIN_GRANT)); } return QContext.getQSession().getUser().getIdReference(); } @@ -329,17 +326,7 @@ public final class PrintingDeviceProcesses } try { - String status; - if(action == Action.REMOVE) - { - // Removal reuses the REVOKE control for the credential; an already revoked device only needs archiving. - status = current.getValueString("revoked_at") == null ? PrintingControlAuthorization.deviceAction(deviceId, request).getString("status") : "SUCCEEDED"; - PrintingProcesses.currentService().removeDevice(deviceId); - } - else - { - status = PrintingControlAuthorization.deviceAction(deviceId, request).getString("status"); - } + String status = PrintingControlAuthorization.deviceAction(deviceId, request).getString("status"); ProcessSummaryLine summary = "PENDING".equals(status) ? pending : ("SUCCEEDED".equals(status) ? applied : unresolved); summary.incrementCountAndAddPrimaryKey(current.getValue("id")); output.getRecords().add(current); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingMessages.java b/src/main/java/com/kingsrook/qbits/printing/PrintingMessages.java new file mode 100644 index 0000000..81129e5 --- /dev/null +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingMessages.java @@ -0,0 +1,114 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.model.metadata.QInstance; +import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData; + + +/******************************************************************************* + ** The sentences a person reads when printing refuses them. Each one says what + ** happened and what to do next, in the words of the application the person is + ** using: the host supplies its display name (PrintingQBitConfig.withProductName) + ** and no product or framework name is written here. + ** + ** Only wording lives here. The exception type, the HTTP status and the JSON + ** field that carry a sentence are chosen by the caller and do not change with it. + *******************************************************************************/ +public final class PrintingMessages +{ + public static final String DEFAULT_PRODUCT_NAME = "this application"; + + static final String MANAGE_STATIONS = "manage " + PrintingNames.STATIONS_LOWER; + static final String ADMIN_GRANT = "printing administrator role"; + static final String USE_PRINTING = "use printing"; + static final String OPERATOR_GRANT = "printing operator role"; + static final String DELETE = "delete " + PrintingNames.STATIONS_LOWER + " and " + PrintingNames.PRINTERS_LOWER; + static final String DELETE_GRANT = "permission to delete " + PrintingNames.STATIONS_LOWER + " and " + PrintingNames.PRINTERS_LOWER + ", in addition to the " + ADMIN_GRANT; + + //////////////////////////////////////////////////////////////////////////////////////////// + // Returned by HTTP routes, where one sentence must cover every reason for a refusal so // + // that the answer does not tell a caller which check failed. No product name: the reader // + // may be a program, and the print client shows its own sentence for the same status. // + //////////////////////////////////////////////////////////////////////////////////////////// + static final String HTTP_STATION_REFUSED = "You are signed in, but your account or this " + PrintingNames.STATION_LOWER + " is not allowed to do this. Ask an administrator to check that you have the " + + ADMIN_GRANT + " and that this " + PrintingNames.STATION_LOWER + " still has access."; + static final String HTTP_OPERATOR_REFUSED = "You are signed in, but your account is not allowed to print here. Ask an administrator to check that you have the " + + OPERATOR_GRANT + " and that the " + PrintingNames.PRINTER_LOWER + " is approved."; + static final String HTTP_ADMIN_REFUSED = "Your account is not allowed to " + MANAGE_STATIONS + ". Ask an administrator to give you the " + ADMIN_GRANT + "."; + static final String HTTP_ENROLL_NEEDS_SIGN_IN = "This server enrolls a " + PrintingNames.STATION_LOWER + " only for a signed-in person. Sign in from the print client, then enroll again."; + + + + /*************************************************************************** + ** Constants and wording only. + ***************************************************************************/ + private PrintingMessages() + { + } + + + + /*************************************************************************** + ** What the host calls its application, or neutral words when the host did + ** not say or no application is serving this thread. + ***************************************************************************/ + public static String productName() + { + QInstance instance = QContext.getQInstance(); + if(instance != null && instance.getQBits() != null) + { + for(QBitMetaData metadata : instance.getQBits().values()) + { + if(metadata.getConfig() instanceof PrintingQBitConfig config) + { + return config.getProductName(); + } + } + } + return DEFAULT_PRODUCT_NAME; + } + + + + /*************************************************************************** + ** The account is signed in and holds no printing access for the tenant this + ** application prints for (the host did not give it the printing_tenant key). + ***************************************************************************/ + static String notSetUpForPrinting() + { + return "You are signed in to " + productName() + ", but your account is not set up for printing in this organization. Ask an administrator to give your account printing access for your organization."; + } + + + + /*************************************************************************** + ** A person who is not signed in is told to sign in; a person who is signed + ** in and lacks the grant is told so, and whom to ask for what. + ***************************************************************************/ + static String notAllowed(boolean signedIn, String activity, String grant) + { + return signedIn ? "You are signed in to " + productName() + ", but your account is not allowed to " + activity + ". Ask an administrator to give you the " + grant + "." + : "Sign in to " + productName() + " to " + activity + "."; + } +} diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java index 96c1643..9c3eb9a 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingOperatorPermission.java @@ -30,7 +30,10 @@ import org.jspecify.annotations.Nullable; /******************************************************************************* - ** Submit permission grants no device management or general job history access. + ** The operator grant: submit print jobs and read, without changing anything, + ** the print stations, printers and print jobs of the tenant. It grants no + ** management of a print station or printer and no read of the control or + ** deletion history. *******************************************************************************/ public class PrintingOperatorPermission extends UseOtherPermissionNameCustomPermissionChecker { @@ -51,7 +54,7 @@ public class PrintingOperatorPermission extends UseOtherPermissionNameCustomPerm { if(QContext.getQSession() == null || (!QContext.getQSession().hasPermission("printing_operator") && !QContext.getQSession().hasPermission("printing_admin"))) { - throw new QPermissionDeniedException("Printing operator permission is required"); + throw new QPermissionDeniedException(PrintingMessages.notAllowed(QContext.getQSession() != null, PrintingMessages.USE_PRINTING, PrintingMessages.OPERATOR_GRANT)); } } } \ No newline at end of file diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java index 04defd7..3d1aa5c 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingProcesses.java @@ -89,8 +89,6 @@ public class PrintingProcesses implements BackendStep selected(instance, "printingTestSelectedZpl", "Print a test label", "Each selected ZPL printer will receive one test label after you continue."); selected(instance, "printingEnableSelectedPrinters", "Approve " + PrintingNames.PRINTERS_LOWER, "The selected " + PrintingNames.PRINTERS_LOWER + " will be approved for printing after you continue."); selected(instance, "printingDisableSelectedPrinters", "Disable " + PrintingNames.PRINTERS_LOWER, "The selected " + PrintingNames.PRINTERS_LOWER + " will stop receiving new print jobs after you continue."); - selected(instance, "printingRemoveSelectedPrinters", "Remove " + PrintingNames.PRINTERS_LOWER, "The selected " + PrintingNames.PRINTERS_LOWER + " will be hidden from the operator catalog, the selectors and their " + PrintingNames.STATIONS_LOWER + " after you continue; the records stay, marked removed. Job history is kept; sharing a queue again from its " - + PrintingNames.STATION_LOWER + " brings it back awaiting approval. To take the record out of the list as well, use " + PrintingDeleteProcesses.PRINTERS_LABEL + "."); } @@ -251,7 +249,7 @@ public class PrintingProcesses implements BackendStep List tenants = keys == null ? null : keys.get("printing_tenant"); if(tenants == null || !tenants.contains(config.getService().getTenantId())) { - throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException("The current session cannot manage this printing tenant"); + throw new com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException(PrintingMessages.notSetUpForPrinting()); } return config.getService(); } @@ -536,7 +534,6 @@ public class PrintingProcesses implements BackendStep case "printingTestSelectedPdf" -> submitTest(printer, true, input.getValueString("testProfile"), testRequestKey(input, printer)); case "printingTestSelectedZpl" -> submitTest(printer, false, input.getValueString("testProfile"), testRequestKey(input, printer)); case "printingEnableSelectedPrinters", "printingDisableSelectedPrinters" -> currentService().enablePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id"), "printingEnableSelectedPrinters".equals(input.getProcessName())); - case "printingRemoveSelectedPrinters" -> currentService().removePrinter(printer.getValueString("device_id"), printer.getValueString("printer_id")); default -> throw new QException("Unknown selected printing action"); } done.incrementCountAndAddPrimaryKey(printer.getValue("id")); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java index 99b9512..e707148 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingQBitConfig.java @@ -38,6 +38,30 @@ public class PrintingQBitConfig implements QBitConfig private @Nullable String schedulerName; private @Nullable String facilityPossibleValueSourceName; private transient @Nullable RetainedPrintSourceAuthority retainedSourceAuthority; + private String productName = PrintingMessages.DEFAULT_PRODUCT_NAME; + + + + /*************************************************************************** + ** Optional: what the host's users call the application they are signed in + ** to. It is used only in sentences a person reads (a refusal says "You are + ** signed in to , but ..."); null or blank keeps the neutral default. + ***************************************************************************/ + public PrintingQBitConfig withProductName(@Nullable String value) + { + productName = value == null || value.isBlank() ? PrintingMessages.DEFAULT_PRODUCT_NAME : value.strip(); + return this; + } + + + + /*************************************************************************** + ** The host's display name, or "this application" when it supplied none. + ***************************************************************************/ + public String getProductName() + { + return productName; + } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java b/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java index 0291f84..b8086d7 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingReadPermission.java @@ -21,7 +21,7 @@ package com.kingsrook.qbits.printing; -import com.kingsrook.qqq.backend.core.context.QContext; +import java.util.Set; import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput; import com.kingsrook.qqq.backend.core.model.actions.metadata.MetaDataInput; @@ -37,17 +37,28 @@ import org.jspecify.annotations.Nullable; /******************************************************************************* ** Operational rows are read-only; mutations must pass PrintingService validation. + ** + ** Who may read which table: + ** - print stations, printers and print jobs: a holder of printing_operator or + ** printing_admin. An operator reads every job of the tenant (the tenant lock + ** on the table is the boundary, as it is for stations and printers), with the + ** same fields an administrator reads. The table has no field for a job's + ** document, its storage reference or its retained binding. + ** - control history, deletion history and anything else: printing_admin only. *******************************************************************************/ public class PrintingReadPermission extends PrintingAdminPermission { + static final Set OPERATOR_READABLE = Set.of(PrintingTables.DEVICE, PrintingTables.PRINTER, PrintingTables.JOB); + + + /*************************************************************************** - ** Require explicit printing administrator access and reject generic writes. + ** Require the read grant of the table and reject generic writes from everybody. ***************************************************************************/ @Override public void checkPermissionsThrowing(AbstractActionInput input, @Nullable MetaDataWithPermissionRules metadata) throws QPermissionDeniedException { - if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table - && (PrintingTables.DEVICE.equals(table.getName()) || PrintingTables.PRINTER.equals(table.getName()))) + if(metadata instanceof com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData table && OPERATOR_READABLE.contains(table.getName())) { new PrintingOperatorPermission().checkPermissionsThrowing(input, metadata); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java index 60ad0d5..0b65128 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRealtimeRoutes.java @@ -197,7 +197,7 @@ public final class PrintingRealtimeRoutes implements QJavalinRouteProviderInterf || ((context.cookie("sessionUUID") == null || context.cookie("sessionUUID").isBlank()) && (context.cookie("sessionId") == null || context.cookie("sessionId").isBlank())) || context.header("Authorization") != null || context.header("x-api-key") != null || context.header("X-QQQ-In-Process-Session") != null) { - throw new PrintingException(403, "A standard QQQ session and the printing subprotocol are required"); + throw new PrintingException(403, "A signed-in session cookie and the printing subprotocol are required"); } URI request = URI.create(context.url()); URI origin = URI.create(java.util.Objects.toString(context.header("Origin"), "")); diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java index 662dde1..bca5439 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingRoutes.java @@ -108,7 +108,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface { if(legacyEnrollment != LegacyEnrollment.ENABLED) { - throw new PrintingException(403, "Enrollment requires native QQQ authentication"); + throw new PrintingException(403, PrintingMessages.HTTP_ENROLL_NEEDS_SIGN_IN); } return service.enroll(body(context)); })); @@ -205,7 +205,7 @@ public class PrintingRoutes implements QJavalinRouteProviderInterface { if(!administrator.test(context)) { - throw new PrintingException(403, "Printing administrator permission required"); + throw new PrintingException(403, PrintingMessages.HTTP_ADMIN_REFUSED); } } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index a282eab..4f9aa86 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -727,7 +727,7 @@ public class PrintingService { if(deviceId == null || token == null || token.length() > 128 || principalId == null || principalId.isBlank() || principalId.length() > 200) { - throw new PrintingException(403, "User or device is not authorized"); + throw new PrintingException(403, PrintingMessages.HTTP_STATION_REFUSED); } try(Connection connection = dataSource.getConnection()) { @@ -737,7 +737,7 @@ public class PrintingService List rows = query(connection, "SELECT token_hash,bound_user_id FROM printing_device WHERE tenant_id=? AND id=? AND enabled=1 AND revoked_at IS NULL FOR UPDATE", tenantId, deviceId); if(rows.isEmpty() || !MessageDigest.isEqual(rows.getFirst().getString("token_hash").getBytes(StandardCharsets.UTF_8), hash(token).getBytes(StandardCharsets.UTF_8))) { - throw new PrintingException(403, "User or device is not authorized"); + throw new PrintingException(403, PrintingMessages.HTTP_STATION_REFUSED); } String owner = rows.getFirst().optString("bound_user_id", null); if(owner == null && bind) @@ -746,7 +746,7 @@ public class PrintingService } else if(!principalId.equals(owner)) { - throw new PrintingException(403, "User or device is not authorized"); + throw new PrintingException(403, PrintingMessages.HTTP_STATION_REFUSED); } connection.commit(); } @@ -938,6 +938,9 @@ public class PrintingService ** and the PRINTERS frame, but its row and job history stay. A later inventory ** that shares the same queue again brings it back exposed and not approved. ** Removing an already removed printer changes nothing. + ** + ** Service API only since 1.2.0: no screen action and no HTTP or agent route + ** calls it (the Remove printers action is retired in favor of Delete printers). ***************************************************************************/ public void removePrinter(String deviceId, String printerId) throws SQLException { @@ -962,6 +965,10 @@ public class PrintingService ** (credential rotation, disconnect, pending-command outcome); this method ** never performs it and refuses a device that still holds a live credential. ** A removed device cannot be restored; a new enrollment creates a new device. + ** + ** Service API only since 1.2.0: no screen action and no HTTP or agent route + ** calls it (the Remove print station action is retired in favor of Delete + ** print stations). ***************************************************************************/ public void removeDevice(String deviceId) throws SQLException { diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java index b09cb46..24e70d6 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingUiApiSpec.java @@ -73,8 +73,8 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec { return new BasicOperation().withHttpMethod(method).withPath(PrintingUiApi.PREFIX + path) .withOperationId("printing" + operation).withShortSummary("Printing " + operation) - .withLongDescription(operation.startsWith("Operator") ? "Requires the current QQQ user session, printing_operator or printing_admin, current tenant and native destination record access. Submissions additionally require printingSubmit process and credential/channel write authority." - : "Requires the current QQQ user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication."); + .withLongDescription(operation.startsWith("Operator") ? "Requires a signed-in user session, printing_operator or printing_admin, current tenant and native destination record access. Submissions additionally require printingSubmit process and credential/channel write authority." + : "Requires a signed-in user session, printing_admin permission and native record access. Device operations additionally require secondary device headers bound to this principal. No primary device-bearer authentication."); } @@ -350,7 +350,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec : cause instanceof QAuthenticationException ? 401 : cause instanceof QPermissionDeniedException ? 403 : cause instanceof QBadRequestException || cause instanceof org.json.JSONException ? 400 : 500; String message = status == 500 ? "The printing operation could not be completed" - : status == 401 ? "Sign in to continue" : status == 403 ? "User or device is not authorized" : cause.getMessage(); + : status == 401 ? "Sign in to continue" : status == 403 ? (operation.startsWith("Operator") ? PrintingMessages.HTTP_OPERATOR_REFUSED : PrintingMessages.HTTP_STATION_REFUSED) : cause.getMessage(); context.header("Cache-Control", "no-store").contentType("application/json"); QJavalinUtils.respondWithError(context, org.eclipse.jetty.http.HttpStatus.getCode(status), message); } @@ -361,7 +361,7 @@ public abstract class PrintingUiApiSpec extends AbstractJsonEndpointSpec public List defineAdditionalBasicResponses() { return List.of(new BasicResponse(HttpStatus.BAD_REQUEST, "Invalid request", BasicErrorResponseV1.class.getSimpleName()), - new BasicResponse(HttpStatus.UNAUTHORIZED, "Native QQQ sign-in required", BasicErrorResponseV1.class.getSimpleName()), + new BasicResponse(HttpStatus.UNAUTHORIZED, "Sign-in required", BasicErrorResponseV1.class.getSimpleName()), new BasicResponse(HttpStatus.FORBIDDEN, "Permission, device proof or user binding denied", BasicErrorResponseV1.class.getSimpleName()), new BasicResponse(HttpStatus.NOT_FOUND, "Record not found", BasicErrorResponseV1.class.getSimpleName()), new BasicResponse(HttpStatus.CONFLICT, "State or request conflict", BasicErrorResponseV1.class.getSimpleName()), diff --git a/src/main/resources/openapi/qqq-v1/printing.yaml b/src/main/resources/openapi/qqq-v1/printing.yaml index 34b487f..b77a2c4 100644 --- a/src/main/resources/openapi/qqq-v1/printing.yaml +++ b/src/main/resources/openapi/qqq-v1/printing.yaml @@ -761,9 +761,9 @@ openapi: "3.0.3" paths: /qqq/v1/qbit/printing/session: get: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingSession" parameters: - in: "header" @@ -791,7 +791,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -842,9 +842,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/bind: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingBind" parameters: - in: "header" @@ -878,7 +878,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -929,9 +929,9 @@ paths: - "printing" /qqq/v1/qbit/printing/devices/enroll: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingEnroll" requestBody: content: @@ -951,7 +951,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1002,9 +1002,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/heartbeat: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingHeartbeat" parameters: - in: "header" @@ -1038,7 +1038,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1089,9 +1089,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/printers: put: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingInventory" parameters: - in: "header" @@ -1125,7 +1125,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1176,9 +1176,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/jobs/claim: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingClaim" parameters: - in: "header" @@ -1212,7 +1212,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1263,9 +1263,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/jobs/queued: get: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingQueued" parameters: - in: "header" @@ -1293,7 +1293,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1344,9 +1344,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/jobs/{jobId}/content: get: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingContent" parameters: - in: "header" @@ -1380,7 +1380,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1432,9 +1432,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/jobs/{jobId}/events: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingEvent" parameters: - in: "header" @@ -1474,7 +1474,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1525,9 +1525,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/test-print: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingTest" parameters: - in: "header" @@ -1561,7 +1561,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1612,9 +1612,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/jobs/{jobId}/retry: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingRetry" parameters: - in: "header" @@ -1654,7 +1654,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1705,9 +1705,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/jobs/{jobId}/cancel: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingCancel" parameters: - in: "header" @@ -1747,7 +1747,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1798,9 +1798,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/control/poll: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingPoll" parameters: - in: "header" @@ -1834,7 +1834,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1885,9 +1885,9 @@ paths: - "printing" /qqq/v1/qbit/printing/device/control/commands/{commandId}/receipt: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingReceipt" parameters: - in: "header" @@ -1927,7 +1927,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -1978,9 +1978,9 @@ paths: - "printing" /qqq/v1/qbit/printing/admin/devices/{deviceId}/actions: post: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingAction" parameters: - in: "path" @@ -2007,7 +2007,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -2058,9 +2058,9 @@ paths: - "printing" /qqq/v1/qbit/printing/admin/devices/{deviceId}/commands: get: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingCommands" parameters: - in: "path" @@ -2091,7 +2091,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -2142,9 +2142,9 @@ paths: - "printing" /qqq/v1/qbit/printing/admin/devices/{deviceId}/commands/{commandId}: get: - description: "Requires the current QQQ user session, printing_admin permission\ - \ and native record access. Device operations additionally require secondary\ - \ device headers bound to this principal. No primary device-bearer authentication." + description: "Requires a signed-in user session, printing_admin permission and\ + \ native record access. Device operations additionally require secondary device\ + \ headers bound to this principal. No primary device-bearer authentication." operationId: "printingCommand" parameters: - in: "path" @@ -2171,7 +2171,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -2222,7 +2222,7 @@ paths: - "printing" /qqq/v1/qbit/printing/operator/printers: get: - description: "Requires the current QQQ user session, printing_operator or printing_admin,\ + description: "Requires a signed-in user session, printing_operator or printing_admin,\ \ current tenant and native destination record access. Submissions additionally\ \ require printingSubmit process and credential/channel write authority." operationId: "printingOperatorCatalog" @@ -2249,7 +2249,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -2300,7 +2300,7 @@ paths: - "printing" /qqq/v1/qbit/printing/operator/jobs: post: - description: "Requires the current QQQ user session, printing_operator or printing_admin,\ + description: "Requires a signed-in user session, printing_operator or printing_admin,\ \ current tenant and native destination record access. Submissions additionally\ \ require printingSubmit process and credential/channel write authority." operationId: "printingOperatorSubmit" @@ -2322,7 +2322,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: @@ -2373,7 +2373,7 @@ paths: - "printing" /qqq/v1/qbit/printing/operator/requests/{requestId}: get: - description: "Requires the current QQQ user session, printing_operator or printing_admin,\ + description: "Requires a signed-in user session, printing_operator or printing_admin,\ \ current tenant and native destination record access. Submissions additionally\ \ require printingSubmit process and credential/channel write authority." operationId: "printingOperatorRequest" @@ -2396,7 +2396,7 @@ paths: application/json: schema: $ref: "#/components/schemas/BasicErrorResponseV1" - description: "Native QQQ sign-in required" + description: "Sign-in required" 403: content: application/json: diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingDeleteTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingDeleteTest.java index d718653..314c01d 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingDeleteTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingDeleteTest.java @@ -459,8 +459,9 @@ class PrintingDeleteTest /*************************************************************************** ** A grouped read is a read: it is allowed to whoever may query the table and - ** refused to whoever may not, and an export is treated the same way. Writes - ** stay refused for everybody. + ** refused to whoever may not, and an export is treated the same way. An + ** operator may query print jobs, so may group and export them, and may not + ** read the control history. Writes stay refused for everybody. ***************************************************************************/ @Test void aggregateIsAllowedToAReaderAndRefusedWithoutTheReadPermission() throws Exception @@ -486,13 +487,20 @@ class PrintingDeleteTest QContext.getQSession().setPermissions(new java.util.HashSet<>(Set.of("printing_operator"))); assertEquals(1, new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.PRINTER).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "status")))).getResults().size()); - assertThrows(QPermissionDeniedException.class, () -> new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "format"))))); - assertEquals("Printing administrator permission is required", assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(new AggregateInput(PrintingTables.JOB), jobs)).getMessage()); + List operatorsByFormat = new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "format")))).getResults(); + assertEquals("2", String.valueOf(operatorsByFormat.getFirst().getAggregateValue(count))); + checker.checkPermissionsThrowing(new ExportInput(), jobs); + var commands = fixture.instance().getTable(PrintingTables.COMMAND); + assertThrows(QPermissionDeniedException.class, () -> new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.COMMAND).withAggregate(count)))); + assertEquals("You are signed in to this application, but your account is not allowed to manage print stations. Ask an administrator to give you the printing administrator role.", + assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(new AggregateInput(PrintingTables.COMMAND), commands)).getMessage()); + assertEquals("Use the printing management service to change operational state", assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(new UpdateInput(PrintingTables.JOB), jobs)).getMessage()); QContext.getQSession().setPermissions(new java.util.HashSet<>()); assertThrows(QPermissionDeniedException.class, () -> new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "format"))))); assertThrows(QPermissionDeniedException.class, () -> new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.PRINTER).withAggregate(count).withGroupBy(new GroupBy(QFieldType.STRING, "status"))))); - assertEquals("Printing operator permission is required", assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(new AggregateInput(PrintingTables.PRINTER), printers)).getMessage()); + assertEquals("You are signed in to this application, but your account is not allowed to use printing. Ask an administrator to give you the printing operator role.", + assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(new AggregateInput(PrintingTables.PRINTER), printers)).getMessage()); assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(new ExportInput(), jobs)); } diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceProcessesTest.java index d32ea7a..c0bf611 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingDeviceProcessesTest.java @@ -48,6 +48,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -133,23 +134,31 @@ class PrintingDeviceProcessesTest /*************************************************************************** - ** Remove device revokes through the existing control, archives the device and - ** its printers, keeps job history readable, and replays without a new command. + ** The older Remove actions are retired: no process, label or control action + ** of that name is registered, Delete is, and the REVOKE control belongs to + ** Revoke access alone. The archive the service still performs for a host + ** that calls it (a revoked station and its printers marked removed, job + ** history readable, a second call changing nothing) is unchanged. ***************************************************************************/ @Test - void removeDeviceRevokesArchivesAndKeepsHistory() throws Exception + void removeActionsAreRetiredAndTheServiceArchiveKeepsHistory() throws Exception { var fixture = PrintingProcessesTest.fixture(); - var process = fixture.instance().getProcess(PrintingDeviceProcesses.Action.REMOVE.process); - assertEquals("Remove print station", process.getLabel()); - assertTrue(process.getStep("confirmControl") instanceof com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData); - assertTrue(PrintingDeviceProcesses.Action.REMOVE.preview.contains("history is kept")); + assertNull(fixture.instance().getProcess("printingRemoveDevices")); + assertNull(fixture.instance().getProcess("printingRemoveSelectedPrinters")); + assertEquals(List.of(), fixture.instance().getProcesses().values().stream() + .filter(process -> process.getName().toLowerCase(java.util.Locale.ROOT).contains("remove") || String.valueOf(process.getLabel()).startsWith("Remove")).map(process -> process.getName()).toList()); + assertEquals(List.of(PrintingDeviceProcesses.Action.REVOKE), java.util.Arrays.stream(PrintingDeviceProcesses.Action.values()).filter(action -> "REVOKE".equals(action.wire)).toList()); + assertEquals(PrintingDeviceProcesses.Action.values().length, java.util.Arrays.stream(PrintingDeviceProcesses.Action.values()).map(action -> action.wire).distinct().count()); + assertEquals("Delete print stations", fixture.instance().getProcess(PrintingDeleteProcesses.DEVICES).getLabel()); + assertEquals("Delete printers", fixture.instance().getProcess(PrintingDeleteProcesses.PRINTERS).getLabel()); String job = submit(fixture.service(), fixture.device()); - RunBackendStepInput input = prepared(PrintingDeviceProcesses.Action.REMOVE, fixture.device()); - assertThrows(QException.class, () -> new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput())); + assertEquals(409, assertThrows(PrintingException.class, () -> fixture.service().removeDevice(fixture.device())).getStatus()); assertEquals(2, fixture.service().devices().length()); + RunBackendStepInput input = prepared(PrintingDeviceProcesses.Action.REVOKE, fixture.device()); input.addValue(PrintingDeviceProcesses.CONFIRM, true); new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()); + fixture.service().removeDevice(fixture.device()); QRecord device = get(PrintingTables.DEVICE, fixture.device()); assertFalse(device.getValueString("revoked_at").isBlank()); assertFalse(device.getValueString("removed_at").isBlank()); @@ -166,7 +175,7 @@ class PrintingDeviceProcessesTest var commands = fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands"); assertEquals(1, commands.length()); assertEquals("REVOKE", commands.getJSONObject(0).getString("action")); - new PrintingDeviceProcesses.Load().runOnePage(input, new RunBackendStepOutput()); + fixture.service().removeDevice(fixture.device()); assertEquals(1, fixture.service().deviceCommandsPage(fixture.device(), 50, null).getJSONArray("commands").length()); assertEquals(device.getValueString("removed_at"), get(PrintingTables.DEVICE, fixture.device()).getValueString("removed_at")); QueryInput visible = new QueryInput().withTableName(PrintingTables.DEVICE).withFilter(new QQueryFilter().withCriteria("removed_at", QCriteriaOperator.IS_BLANK)); @@ -175,9 +184,7 @@ class PrintingDeviceProcessesTest revokedOnly.addValue(PrintingDeviceProcesses.CONFIRM, true); new PrintingDeviceProcesses.Load().runOnePage(revokedOnly, new RunBackendStepOutput()); assertEquals(1, fixture.service().devices().length()); - RunBackendStepInput removeRevoked = prepared(PrintingDeviceProcesses.Action.REMOVE, fixture.otherDevice()); - removeRevoked.addValue(PrintingDeviceProcesses.CONFIRM, true); - new PrintingDeviceProcesses.Load().runOnePage(removeRevoked, new RunBackendStepOutput()); + fixture.service().removeDevice(fixture.otherDevice()); assertEquals(0, fixture.service().devices().length()); assertEquals(1, fixture.service().deviceCommandsPage(fixture.otherDevice(), 50, null).getJSONArray("commands").length()); } diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingMessagesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingMessagesTest.java new file mode 100644 index 0000000..1a38397 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingMessagesTest.java @@ -0,0 +1,127 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + + +/******************************************************************************* + ** The sentences a refused person reads: what happened, what to do next, the + ** host's own name for its application, and never a framework's name. + *******************************************************************************/ +class PrintingMessagesTest +{ + /*************************************************************************** + ** Each fixture owns its native context. + ***************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + + + /*************************************************************************** + ** Without a host setting the sentences use neutral words; the three grants + ** each name what the person may not do and what to ask an administrator for. + ***************************************************************************/ + @Test + void refusalsSayWhatHappenedAndWhatToAskFor() throws Exception + { + PrintingProcessesTest.fixture(); + QContext.getQSession().setPermissions(Set.of()); + assertEquals("You are signed in to this application, but your account is not allowed to manage print stations. Ask an administrator to give you the printing administrator role.", + assertThrows(QPermissionDeniedException.class, () -> new PrintingAdminPermission().checkPermissionsThrowing(new RunBackendStepInput(), null)).getMessage()); + assertEquals("You are signed in to this application, but your account is not allowed to use printing. Ask an administrator to give you the printing operator role.", + assertThrows(QPermissionDeniedException.class, () -> new PrintingOperatorPermission().checkPermissionsThrowing(new RunBackendStepInput(), null)).getMessage()); + assertEquals("You are signed in to this application, but your account is not allowed to delete print stations and printers. Ask an administrator to give you the permission to delete print stations and printers, in addition to the printing administrator role.", + assertThrows(QPermissionDeniedException.class, () -> new PrintingDeletePermission().checkPermissionsThrowing(new RunBackendStepInput(), null)).getMessage()); + QContext.getQSession().setPermissions(Set.of("printing_admin")); + QContext.getQSession().setSecurityKeyValues(Map.of()); + assertEquals("You are signed in to this application, but your account is not set up for printing in this organization. Ask an administrator to give your account printing access for your organization.", + assertThrows(QPermissionDeniedException.class, PrintingProcesses::currentService).getMessage()); + } + + + + /*************************************************************************** + ** A host that supplies its display name has its users read that name; a + ** blank setting keeps the neutral default. Nobody signed in is told to sign in. + ***************************************************************************/ + @Test + void hostDisplayNameIsUsedOnlyWhenTheHostProvidesOne() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + PrintingQBitConfig config = (PrintingQBitConfig) fixture.instance().getQBits().values().iterator().next().getConfig(); + assertEquals("this application", config.getProductName()); + assertEquals("this application", PrintingMessages.productName()); + config.withProductName(" Example Warehouse "); + assertEquals("Example Warehouse", PrintingMessages.productName()); + QContext.getQSession().setPermissions(Set.of()); + assertEquals("You are signed in to Example Warehouse, but your account is not allowed to manage print stations. Ask an administrator to give you the printing administrator role.", + assertThrows(QPermissionDeniedException.class, () -> new PrintingAdminPermission().checkPermissionsThrowing(new RunBackendStepInput(), null)).getMessage()); + QContext.getQSession().setPermissions(Set.of("printing_admin")); + QContext.getQSession().setUser(new QUser()); + assertEquals("Sign in to Example Warehouse to control a print station.", + assertThrows(QException.class, () -> PrintingDeviceProcesses.actor(new RunBackendStepInput())).getMessage()); + config.withProductName(" "); + assertEquals("this application", PrintingMessages.productName()); + config.withProductName(null); + assertEquals("this application", PrintingMessages.productName()); + QContext.clear(); + assertEquals("this application", PrintingMessages.productName()); + assertEquals("Sign in to this application to use printing.", + assertThrows(QPermissionDeniedException.class, () -> new PrintingOperatorPermission().checkPermissionsThrowing(new RunBackendStepInput(), null)).getMessage()); + } + + + + /*************************************************************************** + ** No sentence a route or a screen can return names a framework. + ***************************************************************************/ + @Test + void noSentenceNamesAFramework() + { + for(String sentence : List.of(PrintingMessages.HTTP_STATION_REFUSED, PrintingMessages.HTTP_OPERATOR_REFUSED, PrintingMessages.HTTP_ADMIN_REFUSED, PrintingMessages.HTTP_ENROLL_NEEDS_SIGN_IN, + PrintingMessages.notSetUpForPrinting(), PrintingMessages.notAllowed(true, PrintingMessages.MANAGE_STATIONS, PrintingMessages.ADMIN_GRANT), PrintingMessages.notAllowed(false, PrintingMessages.USE_PRINTING, PrintingMessages.OPERATOR_GRANT))) + { + assertFalse(sentence.contains("QQQ"), sentence); + } + assertEquals("You are signed in, but your account or this print station is not allowed to do this. Ask an administrator to check that you have the printing administrator role and that this print station still has access.", + PrintingMessages.HTTP_STATION_REFUSED); + assertEquals("You are signed in, but your account is not allowed to print here. Ask an administrator to check that you have the printing operator role and that the printer is approved.", + PrintingMessages.HTTP_OPERATOR_REFUSED); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorReadTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorReadTest.java new file mode 100644 index 0000000..abcda59 --- /dev/null +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorReadTest.java @@ -0,0 +1,310 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.printing; + + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement; +import com.kingsrook.qqq.backend.core.actions.permissions.PermissionsHelper; +import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction; +import com.kingsrook.qqq.backend.core.actions.tables.CountAction; +import com.kingsrook.qqq.backend.core.actions.tables.GetAction; +import com.kingsrook.qqq.backend.core.actions.tables.InsertAction; +import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; +import com.kingsrook.qqq.backend.core.context.QContext; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException; +import com.kingsrook.qqq.backend.core.model.actions.AbstractTableActionInput; +import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput; +import com.kingsrook.qqq.backend.core.model.actions.reporting.ExportInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.Aggregate; +import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.AggregateResult; +import com.kingsrook.qqq.backend.core.model.actions.tables.aggregate.GroupBy; +import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter; +import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput; +import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput; +import com.kingsrook.qqq.backend.core.model.data.QRecord; +import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; +import org.json.JSONObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** What a holder of printing_operator, and a person with no printing permission, + ** may read. Every read here asks for permission enforcement, as a request from + ** a user does, against one database that holds the jobs of two tenants. + *******************************************************************************/ +class PrintingOperatorReadTest +{ + private static final Aggregate COUNT = new Aggregate("id", AggregateOperator.COUNT); + private static final List EVERY_TABLE = List.of(PrintingTables.DEVICE, PrintingTables.PRINTER, PrintingTables.JOB, PrintingTables.COMMAND, PrintingTables.DELETION); + + + + /*************************************************************************** + ** Each fixture owns its native context. + ***************************************************************************/ + @AfterEach + void clear() + { + QContext.clear(); + } + + + + /*************************************************************************** + ** An operator queries, gets, counts and groups the print jobs of their own + ** tenant, whoever submitted them, and "what was printed today" is an + ** ordinary date filter. Another tenant's jobs are in the same table and are + ** never returned, counted or fetched. + ***************************************************************************/ + @Test + void operatorReadsCountsAndGroupsTheJobsOfTheirTenantOnly() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + String mine = submit(fixture.service(), fixture.device()); + String second = submit(fixture.service(), fixture.device()); + String third = submit(fixture.service(), fixture.otherDevice()); + PrintingService otherTenant = new PrintingService(source(fixture), "tenant-b"); + String foreign = submit(otherTenant, fixture.foreignDevice()); + submit(otherTenant, fixture.foreignDevice()); + signIn(fixture, "tenant-a", "printing_operator"); + + List rows = new QueryAction().execute(enforced(new QueryInput(PrintingTables.JOB))).getRecords(); + assertEquals(Set.of(mine, second, third), new HashSet<>(rows.stream().map(row -> row.getValueString("id")).toList())); + assertEquals(3, rows.size()); + assertEquals(mine, get(PrintingTables.JOB, mine).getValueString("id")); + assertEquals(3, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB))).getCount()); + GroupBy station = new GroupBy(QFieldType.STRING, "device_id"); + List byStation = new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(COUNT).withGroupBy(station))).getResults(); + assertEquals(Set.of(fixture.device() + "=2", fixture.otherDevice() + "=1"), new HashSet<>(byStation.stream().map(row -> row.getGroupByValue(station) + "=" + row.getAggregateValue(COUNT)).toList())); + QQueryFilter today = new QQueryFilter().withCriteria("created_time", QCriteriaOperator.GREATER_THAN_OR_EQUALS, Instant.now().minus(1, ChronoUnit.DAYS)); + assertEquals(3, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB).withFilter(today))).getCount()); + new PrintingReadPermission().checkPermissionsThrowing(new ExportInput(), fixture.instance().getTable(PrintingTables.JOB)); + + assertNull(get(PrintingTables.JOB, foreign)); + assertEquals(0, new QueryAction().execute(enforced(new QueryInput(PrintingTables.JOB).withFilter(new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, foreign)))).getRecords().size()); + assertEquals(0, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB).withFilter(new QQueryFilter().withCriteria("tenant_id", QCriteriaOperator.EQUALS, "tenant-b")))).getCount()); + + signIn(fixture, "tenant-b", "printing_operator"); + assertEquals(2, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB))).getCount()); + assertNull(get(PrintingTables.JOB, mine)); + } + + + + /*************************************************************************** + ** The job row an operator reads is the row an administrator reads, and it + ** has no field for the document, where the document is stored, the retained + ** binding or the accepted event. A new field on the table fails this test + ** until somebody decides an operator may read it. + ***************************************************************************/ + @Test + void jobRowCarriesNoDocumentBindingOrCredential() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + String job = submit(fixture.service(), fixture.device()); + Set reviewed = Set.of("id", "tenant_id", "device_id", "printer_row_id", "title", "format", "content_hash", "content_length", "options_json", "idempotency_key", "created_at", "created_time", + "status", "message", "spooler_job_id", "device_name", "computer_name", "printer_name", "queue_name"); + assertEquals(reviewed, fixture.instance().getTable(PrintingTables.JOB).getFields().keySet()); + QRecord administrators = get(PrintingTables.JOB, job); + signIn(fixture, "tenant-a", "printing_operator"); + QRecord operators = get(PrintingTables.JOB, job); + assertEquals(administrators.getValues(), operators.getValues()); + assertTrue(reviewed.containsAll(operators.getValues().keySet()), operators.getValues().keySet().toString()); + for(String withheld : List.of("content_base64", "content_reference", "content_storage_table", "content_binding", "retained_binding_json", "retained_actor_id", "accepted_event_id")) + { + assertFalse(operators.getValues().containsKey(withheld), withheld); + } + } + + + + /*************************************************************************** + ** Control history and deletion history stay with the administrator: an + ** operator is refused every read shape on both. + ***************************************************************************/ + @Test + void operatorCannotReadControlOrDeletionHistory() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + signIn(fixture, "tenant-a", "printing_operator"); + for(String table : List.of(PrintingTables.COMMAND, PrintingTables.DELETION)) + { + assertRefusedEveryRead(fixture, table); + } + for(String table : List.of(PrintingTables.DEVICE, PrintingTables.PRINTER, PrintingTables.JOB)) + { + new QueryAction().execute(enforced(new QueryInput(table))); + } + } + + + + /*************************************************************************** + ** Reading is all the operator gained: no table write on any printing table, + ** and none of the administrator's actions on a job, a printer or a station. + ***************************************************************************/ + @Test + void operatorCannotWriteOrRunAnAdministratorAction() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + String job = submit(fixture.service(), fixture.device()); + signIn(fixture, "tenant-a", "printing_operator"); + PrintingReadPermission checker = new PrintingReadPermission(); + for(String table : EVERY_TABLE) + { + var metadata = fixture.instance().getTable(table); + for(var write : List.of(new InsertInput(table), new UpdateInput(table), new DeleteInput(table), new RunBackendStepInput())) + { + assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(write, metadata), table); + } + } + InsertInput insert = new InsertInput(PrintingTables.JOB).withRecord(new QRecord().withValue("id", UUID.randomUUID().toString()).withValue("tenant_id", "tenant-a").withValue("title", "forged")); + insert.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + assertThrows(QException.class, () -> new InsertAction().execute(insert)); + for(String process : List.of(PrintingJobProcesses.RETRY, PrintingJobProcesses.CANCEL, PrintingDeviceProcesses.Action.CANCEL_WINDOWS.process, PrintingDeviceProcesses.Action.CLEAR.process, + PrintingDeviceProcesses.Action.REVOKE.process, "printingEnrollDevice", "printingEnableSelectedPrinters", PrintingDeleteProcesses.DEVICES, PrintingDeleteProcesses.PRINTERS)) + { + assertThrows(QPermissionDeniedException.class, () -> PermissionsHelper.checkProcessPermissionThrowing(new RunBackendStepInput().withProcessName(process), process), process); + } + assertThrows(QException.class, () -> PrintingControlAuthorization.job(fixture.device(), job)); + assertThrows(QException.class, () -> PrintingControlAuthorization.principal()); + signIn(fixture, "tenant-a", "printing_admin"); + assertEquals("QUEUED", get(PrintingTables.JOB, job).getValueString("status")); + assertEquals(1, new CountAction().execute(enforced(new CountInput(PrintingTables.JOB))).getCount()); + } + + + + /*************************************************************************** + ** A signed-in person with neither printing permission is refused every read + ** and every write on every printing table. + ***************************************************************************/ + @Test + void personWithNeitherPermissionIsRefusedEverything() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + submit(fixture.service(), fixture.device()); + signIn(fixture, "tenant-a"); + PrintingReadPermission checker = new PrintingReadPermission(); + for(String table : EVERY_TABLE) + { + assertRefusedEveryRead(fixture, table); + var metadata = fixture.instance().getTable(table); + for(var write : List.of(new InsertInput(table), new UpdateInput(table), new DeleteInput(table))) + { + assertThrows(QPermissionDeniedException.class, () -> checker.checkPermissionsThrowing(write, metadata), table); + } + } + assertThrows(QException.class, () -> PrintingOperatorAuthorization.catalog(50, null)); + } + + + + /*************************************************************************** + ** Query, get, count, aggregate and export are all refused on the table. + ***************************************************************************/ + private static void assertRefusedEveryRead(PrintingProcessesTest.Fixture fixture, String table) + { + assertThrows(QPermissionDeniedException.class, () -> new QueryAction().execute(enforced(new QueryInput(table))), table); + assertThrows(QPermissionDeniedException.class, () -> new GetAction().execute(enforced(new GetInput(table).withPrimaryKey("any"))), table); + assertThrows(QPermissionDeniedException.class, () -> new CountAction().execute(enforced(new CountInput(table))), table); + assertThrows(QPermissionDeniedException.class, () -> new AggregateAction().execute(enforced(new AggregateInput(table).withAggregate(COUNT))), table); + assertThrows(QPermissionDeniedException.class, () -> new PrintingReadPermission().checkPermissionsThrowing(new ExportInput(), fixture.instance().getTable(table)), table); + } + + + + /*************************************************************************** + ** A new session for one person of one tenant holding exactly these permissions. + ***************************************************************************/ + private static void signIn(PrintingProcessesTest.Fixture fixture, String tenant, String... permissions) + { + QContext.init(fixture.instance(), new QSession().withUser(new QUser().withIdReference("warehouse-person")).withPermissions(permissions).withSecurityKeyValue("printing_tenant", tenant)); + } + + + + /*************************************************************************** + ** The read asks for permission enforcement as a user's request does. + ***************************************************************************/ + private static T enforced(T input) + { + input.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + return input; + } + + + + /*************************************************************************** + ** Read one row through QQQ with the session's permissions and tenant. + ***************************************************************************/ + private static QRecord get(String table, String id) throws QException + { + return new GetAction().execute(enforced(new GetInput(table).withPrimaryKey(id))).getRecord(); + } + + + + /*************************************************************************** + ** The fixture's database as a data source, for another tenant's service on it. + ***************************************************************************/ + private static DriverDataSource source(PrintingProcessesTest.Fixture fixture) + { + var backend = (com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData) fixture.instance().getBackends().values().iterator().next(); + return new DriverDataSource(backend.getJdbcUrl(), "sa", ""); + } + + + + /*************************************************************************** + ** One synthetic PDF job for the station's "laser" queue; nothing is printed. + ***************************************************************************/ + private static String submit(PrintingService service, String station) throws Exception + { + return service.submit(new JSONObject().put("deviceId", station).put("printerId", "laser").put("title", "Operator read fixture") + .put("format", "PDF").put("contentBase64", Base64.getEncoder().encodeToString("%PDF-1.4 fixture".getBytes(StandardCharsets.UTF_8))) + .put("idempotencyKey", UUID.randomUUID().toString())).getString("jobId"); + } +} diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java index 0fca3bd..f8511d6 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingOperatorTest.java @@ -82,20 +82,27 @@ class PrintingOperatorTest /*************************************************************************** - ** Operators can submit and reconcile their own request, never manage devices. + ** Operators can submit and reconcile their own request, never manage devices + ** or reconcile another person's request. They read the tenant's print jobs + ** (read only; PrintingOperatorReadTest), which does not let them read the + ** control history. ***************************************************************************/ @Test - void operatorSubmitsWithoutAdminAndCannotEnumerateJobsOrOtherActors() throws Exception + void operatorSubmitsWithoutAdminAndCannotManageOrReconcileOtherActors() throws Exception { var f = PrintingProcessesTest.fixture(); QContext.getQSession().setPermissions(Set.of("printing_operator")); assertEquals(2, PrintingOperatorAuthorization.catalog(50, null).getJSONArray("printers").length()); assertThrows(QException.class, () -> PrintingControlAuthorization.principal()); - QueryInput denied = new QueryInput().withTableName(PrintingTables.JOB); - denied.setPermissionEnforcement(PermissionEnforcement.ENFORCE); - assertThrows(QException.class, () -> new QueryAction().execute(denied)); + QueryInput history = new QueryInput().withTableName(PrintingTables.COMMAND); + history.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + assertThrows(QException.class, () -> new QueryAction().execute(history)); + QueryInput jobs = new QueryInput().withTableName(PrintingTables.JOB); + jobs.setPermissionEnforcement(PermissionEnforcement.ENFORCE); + assertEquals(0, new QueryAction().execute(jobs).getRecords().size()); JSONObject request = request(f.printerId(), "echo:scan-1"); JSONObject accepted = PrintingOperatorAuthorization.submit(request); + assertEquals(List.of(accepted.getString("jobId")), new QueryAction().execute(jobs).getRecords().stream().map(row -> row.getValueString("id")).toList()); assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.submit(request).getString("jobId")); assertEquals(accepted.getString("jobId"), PrintingOperatorAuthorization.observe("echo:scan-1").getString("jobId")); assertTrue(PrintingOperatorAuthorization.submit(request(f.printerId(), "x".repeat(128))).has("jobId")); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java index b048432..13385ae 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingProcessesTest.java @@ -507,30 +507,25 @@ class PrintingProcessesTest /*************************************************************************** - ** Remove printers archives the selection: hidden from the list, the approval - ** frame, claims and the selectors, history kept; a re-shared queue returns - ** exposed but awaiting approval again. + ** The Remove printers action is retired; a printer the service archives for + ** a host that calls it is still hidden from the list, the approval frame, + ** claims and the selectors, with its history kept, and a re-shared queue + ** returns exposed but awaiting approval again. ***************************************************************************/ @Test void removedPrintersAreArchivedUntilTheDeviceSharesThemAgain() throws Exception { Fixture fixture = fixture(); - var process = fixture.instance().getProcess("printingRemoveSelectedPrinters"); - assertEquals("Remove printers", process.getLabel()); - assertEquals(PrintingTables.PRINTER, process.getTableName()); - assertEquals(1, process.getMinInputRecords()); - assertEquals(PrintingAdminPermission.class.getName(), process.getPermissionRules().getCustomPermissionChecker().getName()); + assertEquals(null, fixture.instance().getProcess("printingRemoveSelectedPrinters")); + assertEquals("Delete printers", fixture.instance().getProcess(PrintingDeleteProcesses.PRINTERS).getLabel()); + assertThrows(QException.class, () -> new PrintingProcesses.SelectedLoad().runOnePage(new RunBackendStepInput().withProcessName("printingRemoveSelectedPrinters") + .withRecords(List.of(new QRecord().withValue("id", fixture.printerId()))), new RunBackendStepOutput())); + assertEquals(1, fixture.service().printers(fixture.device()).length()); assertEquals("Removed at", fixture.instance().getTable(PrintingTables.PRINTER).getField("removed_at").getLabel()); assertEquals("Removed at", fixture.instance().getTable(PrintingTables.DEVICE).getField("removed_at").getLabel()); String queued = PrintingProcesses.submitTest(PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device()), true); - RunBackendStepOutput preview = new RunBackendStepOutput(); - new PrintingProcesses.SelectedTransform().runOnePage(new RunBackendStepInput().withProcessName(process.getName()) - .withRecords(List.of(new QRecord().withValue("id", fixture.printerId()))), preview); - assertEquals(1, preview.getRecords().size()); assertEquals(1, fixture.service().printers(fixture.device()).length()); - var result = new RunProcessAction().execute(new RunProcessInput().withProcessName(process.getName()) - .withValue("recordIds", fixture.printerId()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)); - assertFalse(result.getException().isPresent(), () -> result.getException().toString()); + fixture.service().removePrinter(fixture.device(), "laser"); assertEquals(0, fixture.service().printers(fixture.device()).length()); assertEquals(0, fixture.service().printerApprovals(fixture.device()).length()); assertTrue(fixture.service().claim(fixture.device()).isNull("job")); @@ -549,9 +544,7 @@ class PrintingProcessesTest var filter = selector.getPossibleValueSourceFilter().clone(); filter.interpretValues(Map.of("input", Map.of("deviceId", fixture.device())), PossibleValueSearchFilterUseCase.FORM); assertTrue(new SearchPossibleValueSourceAction().execute(new SearchPossibleValueSourceInput().withPossibleValueSourceName(PrintingTables.PRINTER).withDefaultQueryFilter(filter)).getResults().isEmpty()); - var replay = new RunProcessAction().execute(new RunProcessInput().withProcessName(process.getName()) - .withValue("recordIds", fixture.printerId()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP)); - assertFalse(replay.getException().isPresent(), () -> replay.getException().toString()); + fixture.service().removePrinter(fixture.device(), "laser"); fixture.service().inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser").put("name", "Packing laser").put("formats", new JSONArray(List.of("PDF"))))); JSONObject shared = fixture.service().printers(fixture.device()).getJSONObject(0); assertTrue(shared.getBoolean("available")); diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java index 3648b9d..d337fde 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingUiApiTest.java @@ -76,7 +76,7 @@ class PrintingUiApiTest } - /** Device proof cannot authorize native routes without a current authenticated QQQ user. */ + /** Device proof cannot authorize native routes without a current authenticated user; a refusal keeps its status and JSON field and reads as a plain sentence. */ @Test void actualTableAuthHttpRequiresExplicitBindingAndPreservesBinaryContent() throws Exception { @@ -118,7 +118,11 @@ class PrintingUiApiTest assertEquals("operator", session.getString("principalId")); assertEquals("demo", session.getString("tenantId")); assertFalse(session.has("deviceId")); - assertEquals(403, send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), cookie, device, proof).statusCode()); + JSONObject refused = json(send(client, base, prefix + "/device/jobs/claim", "POST", new JSONObject(), cookie, device, proof), 403); + assertEquals(Set.of("traceId", "code", "error", "message"), refused.keySet()); + assertEquals("PERMISSION_DENIED", refused.getString("code")); + assertEquals(PrintingMessages.HTTP_STATION_REFUSED, refused.getString("error")); + assertEquals(PrintingMessages.HTTP_STATION_REFUSED, refused.getString("message")); assertEquals(403, send(client, base, prefix + "/device/bind", "POST", new JSONObject(), cookie, device, "wrong").statusCode()); json(send(client, base, prefix + "/device/bind", "POST", new JSONObject(), cookie, device, proof), 200); JSONObject codeForNative = service.createEnrollment("default", "Native-only code").put("deviceName", "Native-only code").put("agentVersion", "test"); @@ -128,6 +132,10 @@ class PrintingUiApiTest () -> assertEquals(401, legacy(client, base, "/device/jobs/queued", "GET", null, proof).statusCode()), () -> assertEquals(401, legacy(client, base, "/device/control/poll", "POST", new JSONObject(), proof).statusCode()), () -> assertEquals(403, legacy(client, base, "/enroll", "POST", codeForNative, null).statusCode())); + JSONObject legacyRefused = json(legacy(client, base, "/enroll", "POST", codeForNative, null), 403); + assertEquals(Set.of("code", "message"), legacyRefused.keySet()); + assertEquals("PRINTING_403", legacyRefused.getString("code")); + assertEquals(PrintingMessages.HTTP_ENROLL_NEEDS_SIGN_IN, legacyRefused.getString("message")); assertEquals("QUEUED", service.getJob(job).getString("status")); assertEquals("default", service.enrollmentFacility(codeForNative.getString("enrollmentCode"))); JSONObject nativeOnly = json(send(client, base, prefix + "/devices/enroll", "POST", codeForNative, cookie, null, null), 200); diff --git a/src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java b/src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java index 611c3dd..3712d86 100644 --- a/src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/RetainedPrintFacadeTest.java @@ -42,6 +42,7 @@ import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -458,9 +459,9 @@ class RetainedPrintFacadeTest assertThrows(QException.class, () -> facade.observation("intent-1", eventId)); } - /** A least-privilege operator can accept its exact source without generic job or management access. */ + /** A least-privilege operator can accept its exact source without management access; its read of the job row shows no retained binding. */ @Test - void operatorAcceptanceDoesNotGrantQueueEnumeration() throws Exception + void operatorAcceptanceGrantsNoManagementAndTheJobRowShowsNoBinding() throws Exception { var fixture = fixture(); QContext.init(fixture.instance(), session("operator")); @@ -472,7 +473,15 @@ class RetainedPrintFacadeTest new com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput(), null)); var query = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput().withTableName(PrintingTables.JOB); query.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE); - assertThrows(QException.class, () -> new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query)); + var rows = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query).getRecords(); + assertEquals(1, rows.size()); + for(String withheld : List.of("retained_binding_json", "retained_actor_id", "accepted_event_id", "content_base64", "content_reference", "content_storage_table", "content_binding")) + { + assertFalse(rows.getFirst().getValues().containsKey(withheld), withheld); + } + var history = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput().withTableName(PrintingTables.COMMAND); + history.setPermissionEnforcement(com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement.ENFORCE); + assertThrows(QException.class, () -> new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(history)); assertEquals(1, count(fixture, "printing_job")); } -- 2.54.0 From 1d23c985fd8c6c631535d57d295f67bb5ed73f5c Mon Sep 17 00:00:00 2001 From: James Maes Date: Fri, 13 Nov 2026 08:18:10 -0600 Subject: [PATCH 2/2] test: the selection-process count follows the two retired Remove processes PrintingExtractTest counts the streamed-selection processes that use the enforced extract step: 18 before, 16 without printingRemoveDevices and printingRemoveSelectedPrinters. Co-Authored-By: Claude Fable 5.2 --- .../java/com/kingsrook/qbits/printing/PrintingExtractTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingExtractTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingExtractTest.java index 7e96d28..6b7d14a 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingExtractTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingExtractTest.java @@ -120,7 +120,7 @@ class PrintingExtractTest checked++; } } - assertEquals(18, checked); + assertEquals(16, checked); } -- 2.54.0