fix(admin): decide a station delete's refusal under the lock, before revoking #40
No files matched your search
+50
-2
@@ -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
|
||||
|
||||
@@ -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<String> 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<String> authorizedQueue) throws SQLException
|
||||
{
|
||||
String requestId = valid.requestId();
|
||||
String action = valid.action();
|
||||
String jobId = valid.jobId();
|
||||
JSONObject device = lock(connection, deviceId, false);
|
||||
List<JSONObject> 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<JSONObject> 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<? AND expires_at<? ORDER BY created_at,id LIMIT 200)", tenant, deviceId, Instant.now().minusSeconds(7 * 86400).toString(), Instant.now().toString());
|
||||
long history = count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenant, deviceId);
|
||||
if(history >= 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<JSONObject> 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<String> 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<? AND expires_at<? ORDER BY created_at,id LIMIT 200)", tenant, deviceId, Instant.now().minusSeconds(7 * 86400).toString(), Instant.now().toString());
|
||||
long history = count(connection, "SELECT COUNT(*) AS n FROM printing_device_command WHERE tenant_id=? AND device_id=?", tenant, deviceId);
|
||||
if(history >= 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<JSONObject> 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<String> 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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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<JSONObject> 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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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<AggregateResult> 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<AggregateResult> 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<AggregateResult> 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<JSONObject> 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<String> 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<String> refusals(List<ProcessSummaryLineInterface> 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.
|
||||
***************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user