diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c7d0a..2a47a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,8 +60,9 @@ before deploying this version.** record it will not delete, with the reason. Service: `PrintingService.deleteDevice(deviceId, actor)`, `deletePrinter(printerRowId, actor)`, and the read-only `deviceDeletionPlan` / `printerDeletionPlan`. - - A station is first revoked through the existing REVOKE control (credential rotated, - disconnected, pending commands closed), then, in one transaction: its queued jobs become + - A station is revoked through the existing REVOKE control (credential rotated, + disconnected, pending commands closed) and, in the same transaction (RC.4, below; it was + a separate step before): its queued jobs become `FAILED` ("Canceled before dispatch: its print station was deleted"); jobs it was sent without a reported result (`CLAIMED`, `RECEIVED`, `SUBMITTING`) become `OUTCOME_UNKNOWN`; each of those gets a `printing_event` row (`event_origin` `SERVER_DELETION`) naming the @@ -135,6 +136,53 @@ before deploying this version.** the step) before anything is revoked. The previous library keeps working on the new schema; jobs it writes get their names when their station or printer is deleted. +### 1.2.0-RC.4: a station delete decides its refusal under the lock + +No wire, contract or schema change; the schema step above is the same. + +- **Fixed: deleting a station could close a job as `OUTCOME_UNKNOWN` that the station had + just been sent and was about to print.** `deleteDevice` decided "connected and printing" + from a read without a lock, revoked the station, and only then locked and read again; a + revoked station never counts as connected, so the second read could not refuse. A station + that was idle at the first read and claimed a job before the revocation was deleted with + that job in its hands. +- The delete is now one transaction. It takes the tenant admission lock and then the + station's row lock (the order the other paths use; a claim, a job event and a heartbeat + take the station's row lock before they change anything), decides the refusal there, + applies the REVOKE control on the same connection, closes the jobs, deletes the rows and + commits once. A claim either commits before the delete locks, and the delete is refused + (409, the station and its job untouched), or waits for the delete and is then answered + `404 Device not found`: the job was canceled by the delete and was never sent. +- A delete that is refused or fails changes nothing. Before, a delete whose transaction + failed left the station revoked; now the revocation rolls back with the rest and the + delete can simply be run again. A station that an earlier version left revoked, or that + was revoked with **Revoke print station access**, is deleted without a second revocation. +- The station's connection is woken once, after the commit. The revocation of a delete no + longer goes through the public `PrintingService.deviceAction`; a host that wraps that + method to observe revocations sees a delete in `printing_deletion` instead. +- `deletePrinter` did not have the window (it decides under the station's row lock and + revokes nothing) and is unchanged. +- **HTTP API text restored.** RC.3 changed one text an HTTP route returns, contrary to the + note above: approving or unapproving a removed printer (`PUT + /printing/v1/admin/devices/{deviceId}/printers/{printerId}`, 409) answers "Printer was + removed; share it from the device again before changing its approval" again. The other + text RC.3 changed ("Revoke the print station's access before removing it") is raised only + by `removeDevice`, which no HTTP or agent route calls, and stays. +- `initialize()` fills `device_name` / `printer_name` only on jobs whose station or printer + still exists. Before, jobs of a deleted station or printer that have no name were rewritten on + every start, and where such a job had a queue name but no printer name the queue name was + set to null. They are still read on every start; there is no index on the name columns. +- Deleting a station or a printer writes the names only onto its jobs that lack one, so a + long job history is not rewritten while the tenant's admission lock is held. +- Tests added for: the claim that lands just before the delete locks (station and printer) + and the claim in flight that the delete waits for; a printer refused while it prints on a + connected station; an already revoked station; a failed transaction rolled back and + retried; the foreign-key violation (`23503`) named as the missing schema step; the final + step refusing a record that passed the review; the review naming a record that went away + or conflicts; a grouped read of print jobs counting the caller's tenant only. They run on + 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.1.2 Runtime and publication move to the QRun-IO Gitea Maven registry; no printing contract diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java index d004c9d..996b2dd 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingDeviceControls.java @@ -102,6 +102,41 @@ final class PrintingDeviceControls /** A native authorized queue snapshot must still match after taking the device lock. */ JSONObject action(String deviceId, JSONObject request, String actor, @Nullable Set authorizedQueue) throws SQLException + { + Validated valid = validated(request, actor); + try(Connection connection = source.getConnection()) + { + connection.setAutoCommit(false); + try + { + JSONObject response = apply(connection, deviceId, request, valid, actor, authorizedQueue); + connection.commit(); + return response; + } + catch(Exception exception) + { + connection.rollback(); + throw exception; + } + } + } + + + /** The same action inside a transaction its caller began and will commit or roll back; nothing is committed here. */ + JSONObject action(Connection connection, String deviceId, JSONObject request, String actor) throws SQLException + { + return apply(connection, deviceId, request, validated(request, actor), actor, null); + } + + + /** The checked parts of one action request. */ + private record Validated(String requestId, String action, @Nullable String jobId) + { + } + + + /** Strict input is refused before a connection or a lock is taken. */ + private static Validated validated(JSONObject request, String actor) { fields(request, Set.of("requestId", "issuedAt", "action", "jobId")); String requestId = text(request, "requestId", 128); @@ -119,141 +154,136 @@ final class PrintingDeviceControls { throw new PrintingException(400, "A bounded authenticated actor is required"); } - try(Connection connection = source.getConnection()) + return new Validated(requestId, action, jobId); + } + + + /** Lock the device, replay a retained receipt or apply and record the action; the caller owns the transaction. */ + private JSONObject apply(Connection connection, String deviceId, JSONObject request, Validated valid, String actor, @Nullable Set authorizedQueue) throws SQLException + { + String requestId = valid.requestId(); + String action = valid.action(); + String jobId = valid.jobId(); + JSONObject device = lock(connection, deviceId, false); + List old = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenant, deviceId, requestId); + if(!old.isEmpty()) { - connection.setAutoCommit(false); - try + if(!new JSONObject(old.getFirst().getString("request_json")).similar(request)) { - JSONObject device = lock(connection, deviceId, false); - List old = query(connection, "SELECT * FROM printing_device_command WHERE tenant_id=? AND device_id=? AND request_id=?", tenant, deviceId, requestId); - if(!old.isEmpty()) - { - if(!new JSONObject(old.getFirst().getString("request_json")).similar(request)) - { - throw new PrintingException(409, "Request identity was already used for a different action"); - } - connection.commit(); - return record(old.getFirst()); - } - Instant issued; - try - { - issued = Instant.parse(utc(request, "issuedAt")); - } - catch(java.time.format.DateTimeParseException exception) - { - throw new PrintingException(400, "Invalid action time"); - } - Instant current = Instant.now(); - if(issued.isBefore(current.minusSeconds(900))) - { - throw new PrintingException(410, "Action request expired; review a new action explicitly"); - } - if(issued.isAfter(current.plusSeconds(60))) - { - throw new PrintingException(400, "Action time is too far in the future"); - } - if(device.getInt("enabled") != 1 || device.has("revoked_at")) - { - throw new PrintingException(409, "Revoked devices require a new local enrollment"); - } - if(!REMOTE.contains(action) && !Set.of("CLEAR_QUEUE", "REVOKE").contains(action) && device.getLong("control_revision") >= 9007199254740991L) - { - throw new PrintingException(409, "Control revision capacity reached"); - } - expire(connection, deviceId); - update(connection, "DELETE FROM printing_device_command WHERE id IN (SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status<>'PENDING' AND created_at= 1000 && !"REVOKE".equals(action)) - { - throw new PrintingException(429, "Device control receipt capacity reached; receipts are retained and revocation remains available"); - } - String id = UUID.randomUUID().toString(); - String now = current.toString(); - String expires = current.plusSeconds(900).toString(); - String result = null; - Integer cancelled = null; - if(REMOTE.contains(action)) - { - if(device.getInt("control_version") != 1 || !new JSONArray(device.getString("control_capabilities")).toList().contains(action)) - { - throw new PrintingException(409, "CONTROL_UNSUPPORTED"); - } - if(count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId) >= 100) - { - throw new PrintingException(429, "Too many pending device commands"); - } - if(jobId != null) - { - List jobs = query(connection, "SELECT id,status,spooler_job_id FROM printing_job WHERE tenant_id=? AND device_id=? AND id=?", tenant, deviceId, jobId); - if(jobs.isEmpty()) - { - throw new PrintingException(404, "Job not found"); - } - if(!Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(jobs.getFirst().getString("status")) || jobs.getFirst().optString("spooler_job_id").isBlank()) - { - throw new PrintingException(409, "Only an owned job with a recorded spool identity can be canceled"); - } - } - } - else if("CLEAR_QUEUE".equals(action)) - { - if(authorizedQueue != null) - { - Set currentQueue = new java.util.HashSet<>(); - for(JSONObject row : query(connection, "SELECT id FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' LIMIT 10001", tenant, deviceId)) - { - currentQueue.add(row.getString("id")); - } - if(!currentQueue.equals(authorizedQueue)) - { - throw new PrintingException(409, "The queue changed; review the current authorized jobs before retrying"); - } - } - cancelled = update(connection, "UPDATE printing_job SET status='FAILED',message=? WHERE tenant_id=? AND device_id=? AND status='QUEUED'", PrintingService.CANCELED_BEFORE_DISPATCH, tenant, deviceId); - result = "QUEUED_JOBS_CANCELLED"; - } - else if("REVOKE".equals(action)) - { - update(connection, "UPDATE printing_device SET enabled=0,token_hash=?,revoked_at=?,dispatch_paused=1,connection_state='DISCONNECTED',control_revision=? WHERE tenant_id=? AND id=?", UUID.randomUUID().toString(), now, Math.min(9007199254740991L, device.getLong("control_revision") + 1), tenant, deviceId); - update(connection, "UPDATE printing_device_command SET status='OUTCOME_UNKNOWN' WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId); - result = "CREDENTIAL_REVOKED"; - } - else - { - int paused = device.getInt("dispatch_paused"); - String connected = device.getString("connection_state"); - if("PAUSE_DISPATCH".equals(action)) - { - paused = 1; - } - if("RESUME_DISPATCH".equals(action)) - { - paused = 0; - } - if("DISCONNECT".equals(action)) - { - connected = "DISCONNECTED"; - } - if("RECONNECT".equals(action)) - { - connected = "CONNECTED"; - } - update(connection, "UPDATE printing_device SET dispatch_paused=?,connection_state=?,control_revision=control_revision+1 WHERE tenant_id=? AND id=?", paused, connected, tenant, deviceId); - result = "DESIRED_STATE_UPDATED"; - } - update(connection, "INSERT INTO printing_device_command (id,tenant_id,device_id,request_id,action,job_id,actor,request_json,status,created_at,expires_at,result_code,cancelled_job_count) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", - id, tenant, deviceId, requestId, action, jobId, actor, request.toString(), REMOTE.contains(action) ? "PENDING" : "SUCCEEDED", now, expires, result, cancelled); - JSONObject response = record(command(connection, deviceId, id)); - connection.commit(); - return response; + throw new PrintingException(409, "Request identity was already used for a different action"); } - catch(Exception exception) + return record(old.getFirst()); + } + Instant issued; + try + { + issued = Instant.parse(utc(request, "issuedAt")); + } + catch(java.time.format.DateTimeParseException exception) + { + throw new PrintingException(400, "Invalid action time"); + } + Instant current = Instant.now(); + if(issued.isBefore(current.minusSeconds(900))) + { + throw new PrintingException(410, "Action request expired; review a new action explicitly"); + } + if(issued.isAfter(current.plusSeconds(60))) + { + throw new PrintingException(400, "Action time is too far in the future"); + } + if(device.getInt("enabled") != 1 || device.has("revoked_at")) + { + throw new PrintingException(409, "Revoked devices require a new local enrollment"); + } + if(!REMOTE.contains(action) && !Set.of("CLEAR_QUEUE", "REVOKE").contains(action) && device.getLong("control_revision") >= 9007199254740991L) + { + throw new PrintingException(409, "Control revision capacity reached"); + } + expire(connection, deviceId); + update(connection, "DELETE FROM printing_device_command WHERE id IN (SELECT id FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status<>'PENDING' AND created_at= 1000 && !"REVOKE".equals(action)) + { + throw new PrintingException(429, "Device control receipt capacity reached; receipts are retained and revocation remains available"); + } + String id = UUID.randomUUID().toString(); + String now = current.toString(); + String expires = current.plusSeconds(900).toString(); + String result = null; + Integer cancelled = null; + if(REMOTE.contains(action)) + { + if(device.getInt("control_version") != 1 || !new JSONArray(device.getString("control_capabilities")).toList().contains(action)) { - connection.rollback(); - throw exception; + throw new PrintingException(409, "CONTROL_UNSUPPORTED"); + } + if(count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId) >= 100) + { + throw new PrintingException(429, "Too many pending device commands"); + } + if(jobId != null) + { + List jobs = query(connection, "SELECT id,status,spooler_job_id FROM printing_job WHERE tenant_id=? AND device_id=? AND id=?", tenant, deviceId, jobId); + if(jobs.isEmpty()) + { + throw new PrintingException(404, "Job not found"); + } + if(!Set.of("SPOOLED", "SPOOLER_COMPLETED").contains(jobs.getFirst().getString("status")) || jobs.getFirst().optString("spooler_job_id").isBlank()) + { + throw new PrintingException(409, "Only an owned job with a recorded spool identity can be canceled"); + } } } + else if("CLEAR_QUEUE".equals(action)) + { + if(authorizedQueue != null) + { + Set currentQueue = new java.util.HashSet<>(); + for(JSONObject row : query(connection, "SELECT id FROM printing_job WHERE tenant_id=? AND device_id=? AND status='QUEUED' LIMIT 10001", tenant, deviceId)) + { + currentQueue.add(row.getString("id")); + } + if(!currentQueue.equals(authorizedQueue)) + { + throw new PrintingException(409, "The queue changed; review the current authorized jobs before retrying"); + } + } + cancelled = update(connection, "UPDATE printing_job SET status='FAILED',message=? WHERE tenant_id=? AND device_id=? AND status='QUEUED'", PrintingService.CANCELED_BEFORE_DISPATCH, tenant, deviceId); + result = "QUEUED_JOBS_CANCELLED"; + } + else if("REVOKE".equals(action)) + { + update(connection, "UPDATE printing_device SET enabled=0,token_hash=?,revoked_at=?,dispatch_paused=1,connection_state='DISCONNECTED',control_revision=? WHERE tenant_id=? AND id=?", UUID.randomUUID().toString(), now, Math.min(9007199254740991L, device.getLong("control_revision") + 1), tenant, deviceId); + update(connection, "UPDATE printing_device_command SET status='OUTCOME_UNKNOWN' WHERE tenant_id=? AND device_id=? AND status='PENDING'", tenant, deviceId); + result = "CREDENTIAL_REVOKED"; + } + else + { + int paused = device.getInt("dispatch_paused"); + String connected = device.getString("connection_state"); + if("PAUSE_DISPATCH".equals(action)) + { + paused = 1; + } + if("RESUME_DISPATCH".equals(action)) + { + paused = 0; + } + if("DISCONNECT".equals(action)) + { + connected = "DISCONNECTED"; + } + if("RECONNECT".equals(action)) + { + connected = "CONNECTED"; + } + update(connection, "UPDATE printing_device SET dispatch_paused=?,connection_state=?,control_revision=control_revision+1 WHERE tenant_id=? AND id=?", paused, connected, tenant, deviceId); + result = "DESIRED_STATE_UPDATED"; + } + update(connection, "INSERT INTO printing_device_command (id,tenant_id,device_id,request_id,action,job_id,actor,request_json,status,created_at,expires_at,result_code,cancelled_job_count) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + id, tenant, deviceId, requestId, action, jobId, actor, request.toString(), REMOTE.contains(action) ? "PENDING" : "SUCCEEDED", now, expires, result, cancelled); + return record(command(connection, deviceId, id)); } diff --git a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java index 48c66e2..a282eab 100644 --- a/src/main/java/com/kingsrook/qbits/printing/PrintingService.java +++ b/src/main/java/com/kingsrook/qbits/printing/PrintingService.java @@ -331,8 +331,8 @@ public class PrintingService "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS computer_name VARCHAR(1024)", "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS printer_name VARCHAR(1024)", "ALTER TABLE printing_job ADD COLUMN IF NOT EXISTS queue_name VARCHAR(1024)", - "UPDATE printing_job SET device_name=(SELECT d.name FROM printing_device d WHERE d.id=printing_job.device_id), computer_name=(SELECT d.computer_name FROM printing_device d WHERE d.id=printing_job.device_id) WHERE device_name IS NULL", - "UPDATE printing_job SET printer_name=(SELECT p.name FROM printing_printer p WHERE p.id=printing_job.printer_row_id), queue_name=(SELECT p.queue_name FROM printing_printer p WHERE p.id=printing_job.printer_row_id) WHERE printer_name IS NULL", + "UPDATE printing_job SET device_name=(SELECT d.name FROM printing_device d WHERE d.id=printing_job.device_id), computer_name=(SELECT d.computer_name FROM printing_device d WHERE d.id=printing_job.device_id) WHERE device_name IS NULL AND EXISTS (SELECT 1 FROM printing_device d WHERE d.id=printing_job.device_id)", + "UPDATE printing_job SET printer_name=(SELECT p.name FROM printing_printer p WHERE p.id=printing_job.printer_row_id), queue_name=(SELECT p.queue_name FROM printing_printer p WHERE p.id=printing_job.printer_row_id) WHERE printer_name IS NULL AND EXISTS (SELECT 1 FROM printing_printer p WHERE p.id=printing_job.printer_row_id)", "CREATE TABLE IF NOT EXISTS printing_deletion (id VARCHAR(36) PRIMARY KEY, tenant_id VARCHAR(200) NOT NULL, kind VARCHAR(20) NOT NULL, record_id VARCHAR(36) NOT NULL, name VARCHAR(1024) NOT NULL, computer_name VARCHAR(1024), queue_name VARCHAR(1024), facility_id VARCHAR(200), device_id VARCHAR(36), actor VARCHAR(200) NOT NULL, deleted_time TIMESTAMP NOT NULL, printer_count INTEGER DEFAULT 0 NOT NULL, cancelled_job_count INTEGER DEFAULT 0 NOT NULL, unknown_job_count INTEGER DEFAULT 0 NOT NULL, kept_job_count INTEGER DEFAULT 0 NOT NULL)", "CREATE INDEX IF NOT EXISTS printing_deletion_tenant_idx ON printing_deletion(tenant_id, deleted_time, id)", "CREATE INDEX IF NOT EXISTS printing_job_dispatch_idx ON printing_job(tenant_id, device_id, status, created_at, id)", @@ -922,7 +922,8 @@ public class PrintingService { if(!query(connection, "SELECT id FROM printing_printer WHERE device_id=? AND printer_id=?", deviceId, printerId).isEmpty()) { - throw new PrintingException(409, "Printer was removed; share it from its " + PrintingNames.STATION_LOWER + " again before changing its approval"); + // The HTTP printer approval route returns this text; an existing client reads it as it always was. + throw new PrintingException(409, "Printer was removed; share it from the device again before changing its approval"); } throw new PrintingException(404, "Printer not found"); } @@ -1012,18 +1013,25 @@ public class PrintingService /*************************************************************************** ** Delete a print station and its printers for good. ** + ** All of it is one transaction that takes the tenant admission lock and then + ** the station's row lock, the lock a claim, a job event and a heartbeat take + ** before they change anything. Nothing the station does can therefore land + ** between the decision and the delete, and a delete that is refused or fails + ** changes nothing: the station is not left revoked. + ** ** 1. Refused (409) while the station is connected and has jobs it has been - ** sent but not reported on: their result would be lost. + ** sent but not reported on: their result would be lost. This is decided + ** under the locks, before anything is revoked. ** 2. Its credential is revoked through the REVOKE control, exactly as Revoke ** access does, so the computer is disconnected and cannot come back as ** this identity. An already revoked station skips this step. - ** 3. In one transaction: every job of the station gets the station and - ** printer names it was printed under (if it does not have them yet); - ** queued jobs become FAILED (canceled before dispatch); jobs sent to the - ** station without a reported result become OUTCOME_UNKNOWN; each of those - ** gets a job event naming the actor; the deletion is written to - ** printing_deletion (one row for the station, one for each printer); the - ** control receipts, the printers and the station row are deleted. + ** 3. Every job of the station gets the station and printer names it was + ** printed under (if it does not have them yet); queued jobs become FAILED + ** (canceled before dispatch); jobs sent to the station without a reported + ** result become OUTCOME_UNKNOWN; each of those gets a job event naming the + ** actor; the deletion is written to printing_deletion (one row for the + ** station, one for each printer); the control receipts, the printers and + ** the station row are deleted. ** ** Jobs are never deleted and keep device_id, printer_id and printer_row_id ** as the identifiers the station and printer had. A computer enrolled again @@ -1034,37 +1042,32 @@ public class PrintingService public JSONObject deleteDevice(String deviceId, String actor) throws SQLException { requireActor(actor); - JSONObject plan; - try(Connection connection = dataSource.getConnection()) - { - plan = deviceDeletionPlan(connection, deviceId, false); - // Checked before anything is revoked: a station is never left revoked but undeletable. - if(plan.getLong("jobs") > 0 && !jobDeviceForeignKeys(connection).isEmpty()) - { - throw new PrintingException(409, SCHEMA_STEP_MISSING); - } - } - refuseIfPlanned(plan); - boolean revoked = false; - if(plan.getBoolean("credentialLive")) - { - revokeBeforeDelete(deviceId, actor); - revoked = true; - } JSONObject result; try(Connection connection = dataSource.getConnection()) { + // Schema metadata only, read before any lock is held. + boolean jobsReferenceStation = !jobDeviceForeignKeys(connection).isEmpty(); PrintingAdmission.initializeTenant(connection, tenantId); connection.setAutoCommit(false); try { PrintingAdmission.lock(connection, tenantId); - plan = deviceDeletionPlan(connection, deviceId, true); + JSONObject plan = deviceDeletionPlan(connection, deviceId, true); + if(jobsReferenceStation && plan.getLong("jobs") > 0) + { + throw new PrintingException(409, SCHEMA_STEP_MISSING); + } + // The station row is locked: a claim or a job event waits for this transaction, so what is decided here still holds when the station is revoked and deleted below. refuseIfPlanned(plan); + boolean revoked = plan.getBoolean("credentialLive"); + if(revoked) + { + revokeBeforeDelete(connection, deviceId, actor); + } Instant now = Instant.now(); String name = plan.getString("name"); String computer = plan.getString("computerName"); - update(connection, "UPDATE printing_job SET device_name=COALESCE(device_name,?), computer_name=COALESCE(computer_name,?) WHERE tenant_id=? AND device_id=?", name, computer, tenantId, deviceId); + update(connection, "UPDATE printing_job SET device_name=COALESCE(device_name,?), computer_name=COALESCE(computer_name,?) WHERE tenant_id=? AND device_id=? AND (device_name IS NULL OR computer_name IS NULL)", name, computer, tenantId, deviceId); List printers = query(connection, "SELECT id, printer_id, name, queue_name FROM printing_printer WHERE tenant_id=? AND device_id=? ORDER BY name, id", tenantId, deviceId); for(JSONObject printer : printers) { @@ -1157,13 +1160,15 @@ public class PrintingService /*************************************************************************** - ** Revoke exactly as the Revoke access action does (credential rotated, - ** disconnected, pending commands closed, connection woken). Kept as its own - ** step so that anything a host hangs on revocation also runs before delete. + ** Revoke with the control the Revoke access action uses (credential rotated, + ** disconnected, pending commands closed), inside the delete's transaction + ** and under its station row lock: a delete that then fails rolls the + ** revocation back with everything else. The connection is woken once, after + ** the delete commits. ***************************************************************************/ - private void revokeBeforeDelete(String deviceId, String actor) throws SQLException + private void revokeBeforeDelete(Connection connection, String deviceId, String actor) throws SQLException { - deviceAction(deviceId, new JSONObject().put("requestId", "delete-" + UUID.randomUUID()).put("issuedAt", Instant.now().toString()).put("action", "REVOKE"), actor); + new PrintingDeviceControls(dataSource, tenantId).action(connection, deviceId, new JSONObject().put("requestId", "delete-" + UUID.randomUUID()).put("issuedAt", Instant.now().toString()).put("action", "REVOKE"), actor); } @@ -1289,7 +1294,7 @@ public class PrintingService private void namePrinterJobs(Connection connection, JSONObject printer, String deviceId) throws SQLException { String name = printer.getString("name"); - update(connection, "UPDATE printing_job SET printer_name=COALESCE(printer_name,?), queue_name=COALESCE(queue_name,?) WHERE tenant_id=? AND " + PRINTER_JOBS, + update(connection, "UPDATE printing_job SET printer_name=COALESCE(printer_name,?), queue_name=COALESCE(queue_name,?) WHERE tenant_id=? AND " + PRINTER_JOBS + " AND (printer_name IS NULL OR queue_name IS NULL)", name, printer.optString("queue_name", name), tenantId, printer.getString("id"), deviceId, printer.getString("printer_id")); } diff --git a/src/test/java/com/kingsrook/qbits/printing/PrintingDeleteTest.java b/src/test/java/com/kingsrook/qbits/printing/PrintingDeleteTest.java index 41cbead..d718653 100644 --- a/src/test/java/com/kingsrook/qbits/printing/PrintingDeleteTest.java +++ b/src/test/java/com/kingsrook/qbits/printing/PrintingDeleteTest.java @@ -21,6 +21,10 @@ package com.kingsrook.qbits.printing; +import javax.sql.DataSource; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.lang.reflect.Proxy; import java.nio.charset.StandardCharsets; import java.sql.Connection; import java.time.Instant; @@ -29,6 +33,12 @@ import java.util.Base64; import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import com.kingsrook.qqq.backend.core.actions.tables.AggregateAction; import com.kingsrook.qqq.backend.core.actions.tables.GetAction; import com.kingsrook.qqq.backend.core.actions.tables.QueryAction; @@ -56,12 +66,15 @@ 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.metadata.processes.QFrontendStepMetaData; +import com.kingsrook.qqq.backend.core.model.session.QSession; +import com.kingsrook.qqq.backend.core.model.session.QUser; import org.json.JSONArray; 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.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -485,6 +498,489 @@ class PrintingDeleteTest + /*************************************************************************** + ** A grouped read of print jobs counts the caller's tenant only, whichever + ** tenant asks, although every tenant's jobs are in the same table. + ***************************************************************************/ + @Test + void aggregateOverPrintJobsCountsOnlyTheCallersTenant() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + submit(fixture.service(), fixture.device()); + submit(fixture.service(), fixture.device()); + submit(fixture.service(), fixture.otherDevice()); + PrintingService otherTenant = new PrintingService(source(fixture), "tenant-b"); + for(int i = 0; i < 4; i++) + { + submit(otherTenant, fixture.foreignDevice()); + } + assertEquals(7, count(fixture, "SELECT COUNT(*) FROM printing_job")); + + Aggregate count = new Aggregate("id", AggregateOperator.COUNT); + GroupBy station = new GroupBy(QFieldType.STRING, "device_id"); + List all = new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count))).getResults(); + assertEquals(1, all.size()); + assertEquals("3", String.valueOf(all.getFirst().getAggregateValue(count))); + 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 java.util.HashSet<>(byStation.stream().map(row -> row.getGroupByValue(station) + "=" + row.getAggregateValue(count)).toList())); + + QContext.init(fixture.instance(), new QSession().withUser(new QUser().withIdReference("other-operator")).withPermissions("printing_admin").withSecurityKeyValue("printing_tenant", "tenant-b")); + List foreign = new AggregateAction().execute(enforced(new AggregateInput(PrintingTables.JOB).withAggregate(count).withGroupBy(station))).getResults(); + assertEquals(List.of(fixture.foreignDevice() + "=4"), foreign.stream().map(row -> row.getGroupByValue(station) + "=" + row.getAggregateValue(count)).toList()); + } + + + + /*************************************************************************** + ** The window the first version left open: the station is idle when the + ** delete starts, and claims a job after everything the delete reads without + ** a lock and before its first lock. The refusal is decided under the lock, + ** so the delete is refused and neither the job nor the station changes. + ***************************************************************************/ + @Test + void stationThatClaimsAJobBeforeTheDeleteLocksIsRefusedAndKeepsItsJob() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + PrintingService service = fixture.service(); + service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test")); + String job = submit(service, fixture.device()); + assertFalse(service.deviceDeletionPlan(fixture.device()).has("refusal")); + + PrintingService deleting = beforeFirstLock(fixture, () -> assertFalse(service.claim(fixture.device()).isNull("job"))); + PrintingException refused = assertThrows(PrintingException.class, () -> deleting.deleteDevice(fixture.device(), "operator")); + assertEquals(409, refused.getStatus()); + assertTrue(String.valueOf(refused.getMessage()).startsWith("Print station Packing station is connected and is printing 1 job right now"), refused.getMessage()); + assertUntouched(fixture, fixture.device(), job, "CLAIMED"); + + service.event(fixture.device(), job, new JSONObject().put("eventId", "finished-" + job).put("status", "SPOOLED").put("spoolerJobId", "4")); + JSONObject deleted = service.deleteDevice(fixture.device(), "operator"); + assertTrue(deleted.getBoolean("credentialRevoked")); + assertEquals(0, deleted.getInt("unknownJobs")); + assertEquals("SPOOLED", service.getJob(job).getString("status")); + } + + + + /*************************************************************************** + ** The same moment for a printer: its station claims a job for it just + ** before the delete locks, and the printer delete is refused. + ***************************************************************************/ + @Test + void printerWhoseStationClaimsAJobBeforeTheDeleteLocksIsRefused() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + PrintingService service = fixture.service(); + service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test")); + String job = submit(service, fixture.device()); + assertFalse(service.printerDeletionPlan(fixture.printerId()).has("refusal")); + + PrintingService deleting = beforeFirstLock(fixture, () -> assertFalse(service.claim(fixture.device()).isNull("job"))); + PrintingException refused = assertThrows(PrintingException.class, () -> deleting.deletePrinter(fixture.printerId(), "operator")); + assertEquals(409, refused.getStatus()); + assertTrue(String.valueOf(refused.getMessage()).startsWith("Printer Packing laser is printing 1 job right now on print station Packing station"), refused.getMessage()); + assertUntouched(fixture, fixture.device(), job, "CLAIMED"); + } + + + + /*************************************************************************** + ** A claim that is in flight holds the station's row lock with its job not + ** yet committed as claimed. The delete is seen waiting for that lock instead + ** of deciding from what it could read before, and refuses once the claim lands. + ***************************************************************************/ + @Test + void deleteWaitsForAClaimInFlightAndThenRefuses() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + PrintingService service = fixture.service(); + execute(fixture, "SET DEFAULT_LOCK_TIMEOUT 60000"); + service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test")); + String job = submit(service, fixture.device()); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try(Connection claiming = connection(fixture); var statement = claiming.createStatement()) + { + claiming.setAutoCommit(false); + statement.executeQuery("SELECT id FROM printing_device WHERE id='" + fixture.device() + "' FOR UPDATE").close(); + statement.executeUpdate("UPDATE printing_job SET status='CLAIMED' WHERE id='" + job + "'"); + Future delete = executor.submit(() -> service.deleteDevice(fixture.device(), "operator")); + awaitSessionWaitingForALock(fixture); + assertFalse(delete.isDone()); + claiming.commit(); + ExecutionException failed = assertThrows(ExecutionException.class, () -> delete.get(60, TimeUnit.SECONDS)); + PrintingException refused = assertInstanceOf(PrintingException.class, failed.getCause()); + assertEquals(409, refused.getStatus()); + assertTrue(String.valueOf(refused.getMessage()).startsWith("Print station Packing station is connected and is printing 1 job right now"), refused.getMessage()); + } + finally + { + executor.shutdownNow(); + } + assertUntouched(fixture, fixture.device(), job, "CLAIMED"); + } + + + + /*************************************************************************** + ** A printer that is printing on a connected station is refused by name, by + ** the service, by the review and by the final step, while another printer + ** of the selection is deleted. Once its station is disconnected the printer + ** is deleted and its job is closed as outcome unknown. + ***************************************************************************/ + @Test + void printerThatIsPrintingOnALiveStationIsRefused() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + grantDelete(); + PrintingService service = fixture.service(); + service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test")); + String job = submit(service, fixture.device()); + service.claim(fixture.device()); + String refusal = "Printer Packing laser is printing 1 job right now on print station Packing station. Wait until it finishes, or disconnect the print station first (the result is then recorded as unknown), and delete it again."; + + PrintingException refused = assertThrows(PrintingException.class, () -> service.deletePrinter(fixture.printerId(), "operator")); + assertEquals(409, refused.getStatus()); + assertEquals(refusal, refused.getMessage()); + assertEquals(refusal, service.printerDeletionPlan(fixture.printerId()).getString("refusal")); + + RunBackendStepInput input = input(PrintingDeleteProcesses.PRINTERS, fixture.printerId(), fixture.otherPrinterId()); + input.addValue(PrintingDeleteProcesses.CONFIRM, true); + PrintingDeleteProcesses.Transform review = new PrintingDeleteProcesses.Transform(); + RunBackendStepOutput reviewed = new RunBackendStepOutput(); + review.runOnePage(input, reviewed); + assertEquals(List.of(fixture.otherPrinterId()), reviewed.getRecords().stream().map(row -> row.getValueString("id")).toList()); + assertEquals(List.of(refusal), refusals(review.getProcessSummary(reviewed, false))); + + PrintingDeleteProcesses.Load load = new PrintingDeleteProcesses.Load(); + load.runOnePage(input, new RunBackendStepOutput()); + assertEquals(List.of(refusal), refusals(load.getProcessSummary(new RunBackendStepOutput(), true))); + assertNull(get(PrintingTables.PRINTER, fixture.otherPrinterId())); + assertNotNull(get(PrintingTables.PRINTER, fixture.printerId())); + assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_deletion WHERE record_id='" + fixture.printerId() + "'")); + assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_event WHERE job_id='" + job + "' AND event_origin='SERVER_DELETION'")); + assertEquals("CLAIMED", service.getJob(job).getString("status")); + + service.deviceAction(fixture.device(), new JSONObject().put("requestId", "disconnect-1").put("issuedAt", Instant.now().toString()).put("action", "DISCONNECT"), "operator"); + JSONObject deleted = service.deletePrinter(fixture.printerId(), "operator"); + assertEquals(1, deleted.getInt("unknownJobs")); + assertEquals("OUTCOME_UNKNOWN", service.getJob(job).getString("status")); + assertEquals("Its printer was deleted before a result was reported; check the printer for output before printing again", get(PrintingTables.JOB, job).getValueString("message")); + assertNull(get(PrintingTables.PRINTER, fixture.printerId())); + assertNull(get(PrintingTables.DEVICE, fixture.device()).getValueString("revoked_at")); + } + + + + /*************************************************************************** + ** A station whose access was revoked earlier is deleted without a second + ** revocation, even with a job it never reported on. A delete of it whose + ** transaction fails changes nothing and can simply be run again. This is + ** also the state the first version left behind when its delete failed after + ** its revocation had committed. + ***************************************************************************/ + @Test + void revokedStationIsDeletedWithoutASecondRevokeAndAFailedDeleteIsRetried() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + PrintingService service = fixture.service(); + service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test")); + String sent = submit(service, fixture.device()); + service.claim(fixture.device()); + String queued = submit(service, fixture.device()); + service.deviceAction(fixture.device(), new JSONObject().put("requestId", "revoke-1").put("issuedAt", Instant.now().toString()).put("action", "REVOKE"), "operator"); + JSONObject plan = service.deviceDeletionPlan(fixture.device()); + assertFalse(plan.getBoolean("credentialLive")); + assertFalse(plan.has("refusal")); + assertEquals(1, plan.getLong("jobsInProgress")); + + execute(fixture, "ALTER TABLE printing_deletion ADD CONSTRAINT deletion_fails CHECK (kind='NOTHING')"); + assertThrows(java.sql.SQLException.class, () -> service.deleteDevice(fixture.device(), "operator")); + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.device() + "' AND enabled=0 AND revoked_at IS NOT NULL")); + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_printer WHERE id='" + fixture.printerId() + "'")); + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device_command WHERE device_id='" + fixture.device() + "' AND action='REVOKE'")); + assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_event WHERE event_origin='SERVER_DELETION'")); + assertEquals("CLAIMED", service.getJob(sent).getString("status")); + assertEquals("QUEUED", service.getJob(queued).getString("status")); + execute(fixture, "ALTER TABLE printing_deletion DROP CONSTRAINT deletion_fails"); + + JSONObject deleted = service.deleteDevice(fixture.device(), "operator"); + assertFalse(deleted.getBoolean("credentialRevoked")); + assertEquals(1, deleted.getInt("printers")); + assertEquals(1, deleted.getInt("unknownJobs")); + assertEquals(1, deleted.getInt("cancelledJobs")); + assertEquals("OUTCOME_UNKNOWN", service.getJob(sent).getString("status")); + assertEquals("FAILED", service.getJob(queued).getString("status")); + assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.device() + "'")); + assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_device_command WHERE device_id='" + fixture.device() + "'")); + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_deletion WHERE kind='DEVICE' AND record_id='" + fixture.device() + "' AND unknown_job_count=1 AND cancelled_job_count=1")); + + execute(fixture, "UPDATE printing_job SET device_name=NULL, computer_name=NULL, printer_name=NULL, queue_name=NULL WHERE id='" + sent + "'"); + service.initialize(); + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_job WHERE id='" + sent + "' AND device_name IS NULL AND printer_name IS NULL")); + } + + + + /*************************************************************************** + ** A delete of a connected station whose transaction fails is rolled back + ** whole: the station keeps its access, its printer and its queued job, and + ** the final step says that nothing was changed for it. A foreign key from + ** print jobs that the delete runs into is named as the missing schema step. + ***************************************************************************/ + @Test + void failedDeleteOfALiveStationChangesNothingAndTheForeignKeyIsNamed() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + grantDelete(); + PrintingService service = fixture.service(); + service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test")); + String job = submit(service, fixture.device()); + + execute(fixture, "ALTER TABLE printing_deletion ADD CONSTRAINT deletion_fails CHECK (kind='NOTHING')"); + assertThrows(java.sql.SQLException.class, () -> service.deleteDevice(fixture.device(), "operator")); + assertUntouched(fixture, fixture.device(), job, "QUEUED"); + RunBackendStepInput input = input(PrintingDeleteProcesses.DEVICES, fixture.device()); + input.addValue(PrintingDeleteProcesses.CONFIRM, true); + QException failed = assertThrows(QException.class, () -> new PrintingDeleteProcesses.Load().runOnePage(input, new RunBackendStepOutput())); + assertEquals("Print station Packing station could not be deleted; nothing was changed for it. Earlier records in this selection were deleted.", failed.getMessage()); + assertUntouched(fixture, fixture.device(), job, "QUEUED"); + execute(fixture, "ALTER TABLE printing_deletion DROP CONSTRAINT deletion_fails"); + + execute(fixture, "ALTER TABLE printing_job ADD CONSTRAINT \"printing-job-station\" FOREIGN KEY (device_id) REFERENCES printing_device(id)"); + PrintingException refused = assertThrows(PrintingException.class, () -> service.deleteDevice(fixture.device(), "operator")); + assertEquals(409, refused.getStatus()); + assertEquals("Print jobs are still tied to their print station by a database foreign key (printing_job.device_id). Apply the schema step of this qbit-printing version, then delete again.", refused.getMessage()); + assertUntouched(fixture, fixture.device(), job, "QUEUED"); + execute(fixture, "ALTER TABLE printing_job DROP CONSTRAINT \"printing-job-station\""); + + JSONObject deleted = service.deleteDevice(fixture.device(), "operator"); + assertTrue(deleted.getBoolean("credentialRevoked")); + assertEquals(1, deleted.getInt("cancelledJobs")); + assertEquals("FAILED", service.getJob(job).getString("status")); + } + + + + /*************************************************************************** + ** A station that passed the review and then started printing is refused by + ** the final step, by name, and nothing changes for it. + ***************************************************************************/ + @Test + void finalStepRefusesAStationThatStartedPrintingAfterTheReview() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + grantDelete(); + PrintingService service = fixture.service(); + service.heartbeat(fixture.device(), new JSONObject().put("agentVersion", "test")); + String job = submit(service, fixture.device()); + RunBackendStepInput input = input(PrintingDeleteProcesses.DEVICES, fixture.device()); + input.addValue(PrintingDeleteProcesses.CONFIRM, true); + PrintingDeleteProcesses.Transform review = new PrintingDeleteProcesses.Transform(); + RunBackendStepOutput reviewed = new RunBackendStepOutput(); + review.runOnePage(input, reviewed); + assertEquals(List.of(fixture.device()), reviewed.getRecords().stream().map(row -> row.getValueString("id")).toList()); + assertTrue(refusals(review.getProcessSummary(reviewed, false)).isEmpty()); + + service.claim(fixture.device()); + PrintingDeleteProcesses.Load load = new PrintingDeleteProcesses.Load(); + RunBackendStepOutput loaded = new RunBackendStepOutput(); + load.runOnePage(input, loaded); + assertTrue(loaded.getRecords().isEmpty()); + List refused = refusals(load.getProcessSummary(loaded, true)); + assertEquals(1, refused.size()); + assertTrue(refused.getFirst().startsWith("Print station Packing station is connected and is printing 1 job right now"), refused.toString()); + assertUntouched(fixture, fixture.device(), job, "CLAIMED"); + } + + + + /*************************************************************************** + ** A record another administrator deletes while the review or the final step + ** is working on it is reported as no longer existing, and a plan that is + ** refused for another reason is reported with the record's name and the + ** reason. Neither stops the step. + ***************************************************************************/ + @Test + void reviewAndFinalStepNameARecordThatWentAwayOrConflicts() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + grantDelete(); + String dock = enroll(fixture.service(), "Dock computer").getString("deviceId"); + PrintingService racing = new PrintingService(source(fixture), "tenant-a") + { + // Another administrator deletes one station while its plan is read; the other is refused for a reason of its own. + @Override + public JSONObject deviceDeletionPlan(String deviceId) throws java.sql.SQLException + { + if(!deviceId.equals(fixture.otherDevice())) + { + throw new PrintingException(409, "It is being changed by another administrator"); + } + super.deleteDevice(deviceId, "another administrator"); + return super.deviceDeletionPlan(deviceId); + } + + + // Another administrator deletes the station just before this delete reaches it. + @Override + public JSONObject deleteDevice(String deviceId, String actor) throws java.sql.SQLException + { + super.deleteDevice(deviceId, "another administrator"); + return super.deleteDevice(deviceId, actor); + } + }; + fixture.instance().getQBits().values().stream().map(metadata -> metadata.getConfig()).filter(PrintingQBitConfig.class::isInstance).map(PrintingQBitConfig.class::cast).forEach(config -> config.withService(racing)); + + RunBackendStepInput input = input(PrintingDeleteProcesses.DEVICES, fixture.otherDevice(), fixture.device()); + input.addValue(PrintingDeleteProcesses.CONFIRM, true); + PrintingDeleteProcesses.Transform review = new PrintingDeleteProcesses.Transform(); + RunBackendStepOutput reviewed = new RunBackendStepOutput(); + review.runOnePage(input, reviewed); + assertTrue(reviewed.getRecords().isEmpty()); + assertEquals(List.of("Print station Other station no longer exists", "Print station Packing station: It is being changed by another administrator"), refusals(review.getProcessSummary(reviewed, false))); + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + fixture.device() + "' AND enabled=1 AND revoked_at IS NULL")); + + RunBackendStepInput last = input(PrintingDeleteProcesses.DEVICES, dock); + last.addValue(PrintingDeleteProcesses.CONFIRM, true); + PrintingDeleteProcesses.Load load = new PrintingDeleteProcesses.Load(); + RunBackendStepOutput loaded = new RunBackendStepOutput(); + load.runOnePage(last, loaded); + assertTrue(loaded.getRecords().isEmpty()); + assertEquals(List.of("Print station Dock computer no longer exists"), refusals(load.getProcessSummary(loaded, true))); + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_deletion WHERE kind='DEVICE' AND record_id='" + dock + "' AND actor='another administrator'")); + } + + + + /*************************************************************************** + ** The approval route of the HTTP API answers for a removed printer with the + ** text it had before stations were renamed in the administrator's screens. + ***************************************************************************/ + @Test + void approvalOfARemovedPrinterKeepsItsHttpApiText() throws Exception + { + var fixture = PrintingProcessesTest.fixture(); + fixture.service().removePrinter(fixture.device(), "laser"); + PrintingException refused = assertThrows(PrintingException.class, () -> fixture.service().enablePrinter(fixture.device(), "laser", true)); + assertEquals(409, refused.getStatus()); + assertEquals("Printer was removed; share it from the device again before changing its approval", refused.getMessage()); + } + + + + /*************************************************************************** + ** Nothing of a refused or failed delete is left behind: the station keeps + ** its access and its printers, the job its status, and there is no deletion + ** record, deletion event or control receipt. + ***************************************************************************/ + private static void assertUntouched(PrintingProcessesTest.Fixture fixture, String station, String job, String status) throws Exception + { + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_device WHERE id='" + station + "' AND enabled=1 AND revoked_at IS NULL")); + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_printer WHERE device_id='" + station + "'")); + assertEquals(1, count(fixture, "SELECT COUNT(*) FROM printing_job WHERE id='" + job + "' AND status='" + status + "'")); + assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_deletion")); + assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_event WHERE event_origin='SERVER_DELETION'")); + assertEquals(0, count(fixture, "SELECT COUNT(*) FROM printing_device_command WHERE device_id='" + station + "' AND action='REVOKE'")); + } + + + + /*************************************************************************** + ** Return once a database session is waiting for a lock that another holds. + ***************************************************************************/ + private static void awaitSessionWaitingForALock(PrintingProcessesTest.Fixture fixture) throws Exception + { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(60); + while(count(fixture, "SELECT COUNT(*) FROM INFORMATION_SCHEMA.SESSIONS WHERE BLOCKER_ID IS NOT NULL") == 0) + { + assertTrue(System.nanoTime() < deadline, "No session waited for a lock"); + TimeUnit.MILLISECONDS.sleep(10); + } + } + + + + /*************************************************************************** + ** The reasons listed on the line of records that are not deleted. + ***************************************************************************/ + private static List refusals(List lines) + { + return lines.stream().map(ProcessSummaryLine.class::cast).filter(line -> line.getStatus() == Status.ERROR).flatMap(line -> line.getBulletsOfText().stream()).toList(); + } + + + + /*************************************************************************** + ** The fixture's database as a data source, for another 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", ""); + } + + + + /*************************************************************************** + ** A step that arranges what another actor does at a chosen moment. + ***************************************************************************/ + @FunctionalInterface + private interface Step + { + /************************************************************************ + ** Do it. + ************************************************************************/ + void run() throws Exception; + } + + + + /*************************************************************************** + ** A service on the fixture's database that runs the step once, immediately + ** before the first statement that takes a row lock (FOR UPDATE): whatever + ** the service read earlier it read without a lock. + ***************************************************************************/ + private static PrintingService beforeFirstLock(PrintingProcessesTest.Fixture fixture, Step step) + { + DriverDataSource real = source(fixture); + AtomicBoolean ran = new AtomicBoolean(); + DataSource hooked = (DataSource) Proxy.newProxyInstance(DataSource.class.getClassLoader(), new Class[] {DataSource.class}, (proxy, method, arguments) -> + { + Object result = invoke(method, real, arguments); + if(!(result instanceof Connection connection)) + { + return result; + } + return Proxy.newProxyInstance(Connection.class.getClassLoader(), new Class[] {Connection.class}, (ignored, call, values) -> + { + if("prepareStatement".equals(call.getName()) && String.valueOf(values[0]).contains("FOR UPDATE") && ran.compareAndSet(false, true)) + { + step.run(); + } + return invoke(call, connection, values); + }); + }); + return new PrintingService(hooked, "tenant-a"); + } + + + + /*************************************************************************** + ** Call through a proxy and surface the real failure, not its wrapper. + ***************************************************************************/ + private static Object invoke(Method method, Object target, Object[] arguments) throws Throwable + { + try + { + return method.invoke(target, arguments); + } + catch(InvocationTargetException exception) + { + throw exception.getCause(); + } + } + + + /*************************************************************************** ** The aggregate asks for permission enforcement as a user's request does. ***************************************************************************/