feat(device-core): add device ownership lifecycle
This commit is contained in:
@@ -310,6 +310,8 @@ test("authenticated ingest stores only digest and returns a masked view", async
|
||||
assert.equal(serialized.includes(fakeImei), false);
|
||||
assert.equal(body.discovery.identifier.masked, "***********0001");
|
||||
assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
|
||||
assert.equal(stored.sessionRef, "session:test-001");
|
||||
assert.equal(stored.routeRef, null);
|
||||
assert.equal(JSON.stringify(stored).includes(fakeImei), false);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/007_device_lifecycle_commands.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
|
||||
test("lifecycle command migration extends the durable receipt allowlist", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
for (const kind of [
|
||||
"device.claim",
|
||||
"device.transfer",
|
||||
"discovery.reject",
|
||||
"discovery.expire",
|
||||
]) {
|
||||
assert.match(sql, new RegExp(`'${kind.replace(".", "\\.")}'`));
|
||||
}
|
||||
});
|
||||
|
||||
test("lifecycle command migration contains no runtime entity or secret", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.doesNotMatch(sql, /insert\s+into/i);
|
||||
assert.doesNotMatch(sql, /dcctouch|arusnavi|\bb2\b|imei|gelios/i);
|
||||
assert.doesNotMatch(sql, /password|secret|credential|private_key/i);
|
||||
});
|
||||
|
||||
test("repository applies lifecycle commands after ownership schema", async () => {
|
||||
const source = await readFile(repositoryUrl, "utf8");
|
||||
const lifecycleIndex = source.indexOf("006_device_lifecycle_ownership.sql");
|
||||
const commandsIndex = source.indexOf("007_device_lifecycle_commands.sql");
|
||||
|
||||
assert.notEqual(lifecycleIndex, -1);
|
||||
assert.notEqual(commandsIndex, -1);
|
||||
assert.ok(lifecycleIndex < commandsIndex);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/006_device_lifecycle_ownership.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
|
||||
test("lifecycle migration separates direct ownership from legacy contours", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.match(sql, /alter column contour_id drop not null/);
|
||||
assert.match(sql, /add column if not exists owner_scope_id uuid/);
|
||||
assert.match(sql, /device_instances_project_owner_fk/);
|
||||
assert.match(sql, /device_instances_ownership_mode_check/);
|
||||
assert.match(sql, /references device_projects\(id, owner_scope_id\)/);
|
||||
assert.match(sql, /\) not valid;/);
|
||||
});
|
||||
|
||||
test("route-bound discovery and enrollment evidence are DB constrained", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.match(sql, /device_discoveries_route_context_fk/);
|
||||
assert.match(sql, /device_discoveries_enrollment_context_fk/);
|
||||
assert.match(sql, /device_enrollment_observed_discovery_fk/);
|
||||
assert.match(sql, /device_enrollment_intents_active_identity_idx/);
|
||||
assert.match(sql, /where lifecycle_state in \('pending', 'observed', 'claimed'\)/);
|
||||
});
|
||||
|
||||
test("ownership history supports transfer without rewriting session provenance", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.match(sql, /create table if not exists device_ownership_transitions/);
|
||||
assert.match(sql, /transition_kind in \('claim', 'transfer'\)/);
|
||||
assert.match(sql, /device_ownership_single_claim_idx/);
|
||||
assert.match(sql, /device_assert_session_current_project/);
|
||||
assert.match(sql, /device_assert_enrollment_current_project/);
|
||||
assert.match(sql, /drop constraint if exists device_sessions_device_id_project_id_fkey/);
|
||||
});
|
||||
|
||||
test("lifecycle migration contains no tenant, device, route or credential seed", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.doesNotMatch(sql, /insert\s+into/i);
|
||||
assert.doesNotMatch(sql, /dcctouch|arusnavi|\bb2\b|imei|gelios/i);
|
||||
assert.doesNotMatch(sql, /155\.212\.|device\.nodedc\.ru|synology/i);
|
||||
assert.doesNotMatch(sql, /password|secret|private_key|credential_ref/i);
|
||||
});
|
||||
|
||||
test("repository applies lifecycle migration after registry commands", async () => {
|
||||
const source = await readFile(repositoryUrl, "utf8");
|
||||
const commandsIndex = source.indexOf("005_device_registry_commands.sql");
|
||||
const lifecycleIndex = source.indexOf("006_device_lifecycle_ownership.sql");
|
||||
|
||||
assert.notEqual(commandsIndex, -1);
|
||||
assert.notEqual(lifecycleIndex, -1);
|
||||
assert.ok(commandsIndex < lifecycleIndex);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { observeQuarantineDiscovery } from "../src/discovery-repository.mjs";
|
||||
|
||||
const observedAt = "2026-08-10T00:00:00.000Z";
|
||||
const projectId = "11111111-1111-4111-8111-111111111111";
|
||||
const routeId = "22222222-2222-4222-8222-222222222222";
|
||||
const enrollmentId = "33333333-3333-4333-8333-333333333333";
|
||||
const discoveryId = "44444444-4444-4444-8444-444444444444";
|
||||
const identifierDigest = `hmac-sha256:${"a".repeat(64)}`;
|
||||
|
||||
test("observation expires stale intents before matching an identity", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/discovery-repository.mjs", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
assert.match(source, /expires_at <= \$6/);
|
||||
assert.match(source, /expires_at is null or expires_at > \$6/);
|
||||
assert.match(source, /resolution_code = 'deadline_elapsed'/);
|
||||
});
|
||||
|
||||
test("legacy discovery remains unbound quarantine without a route reference", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_discoveries", {
|
||||
rows: [discoveryRow({ project_id: null, route_id: null })],
|
||||
}),
|
||||
step("commit"),
|
||||
]);
|
||||
|
||||
const result = await observeQuarantineDiscovery({
|
||||
pool: poolWithClient(client),
|
||||
identifierDigest,
|
||||
safeView: safeView(),
|
||||
sessionRef: "session:legacy-test",
|
||||
});
|
||||
|
||||
assert.equal(result.created, true);
|
||||
assert.equal("routeRef" in result.value, false);
|
||||
assert.equal("enrollmentIntentRef" in result.value, false);
|
||||
assertSafeProjection(result.value);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("route-bound discovery atomically observes only its matching enrollment", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("from device_routes", {
|
||||
rows: [{
|
||||
id: routeId,
|
||||
project_id: projectId,
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
lifecycle_state: "active",
|
||||
}],
|
||||
}),
|
||||
step("update device_enrollment_intents"),
|
||||
step("from device_enrollment_intents", {
|
||||
rows: [{
|
||||
id: enrollmentId,
|
||||
project_id: projectId,
|
||||
route_id: routeId,
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
lifecycle_state: "pending",
|
||||
}],
|
||||
}),
|
||||
step("insert into device_discoveries", {
|
||||
rows: [discoveryRow({
|
||||
project_id: projectId,
|
||||
route_id: routeId,
|
||||
enrollment_intent_id: enrollmentId,
|
||||
})],
|
||||
}),
|
||||
step("update device_enrollment_intents", {
|
||||
rows: [{ id: enrollmentId }],
|
||||
}),
|
||||
step("commit"),
|
||||
]);
|
||||
|
||||
const result = await observeQuarantineDiscovery({
|
||||
pool: poolWithClient(client),
|
||||
identifierDigest,
|
||||
safeView: safeView(`route:${routeId}`),
|
||||
sessionRef: "session:route-test",
|
||||
routeRef: `route:${routeId}`,
|
||||
});
|
||||
|
||||
assert.equal(result.value.routeRef, `route:${routeId}`);
|
||||
assert.equal(
|
||||
result.value.enrollmentIntentRef,
|
||||
`enrollment-intent:${enrollmentId}`,
|
||||
);
|
||||
assertSafeProjection(result.value);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("inactive or mismatched routes fail before a discovery is stored", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("from device_routes", {
|
||||
rows: [{
|
||||
id: routeId,
|
||||
project_id: projectId,
|
||||
model_profile_ref: "other.profile.v1",
|
||||
protocol: "OTHER_TCP",
|
||||
lifecycle_state: "active",
|
||||
}],
|
||||
}),
|
||||
step("rollback"),
|
||||
]);
|
||||
|
||||
await assert.rejects(
|
||||
observeQuarantineDiscovery({
|
||||
pool: poolWithClient(client),
|
||||
identifierDigest,
|
||||
safeView: safeView(`route:${routeId}`),
|
||||
sessionRef: "session:mismatch-test",
|
||||
routeRef: `route:${routeId}`,
|
||||
}),
|
||||
/device_discovery_route_profile_mismatch/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
function safeView(routeRef = null) {
|
||||
return {
|
||||
schemaVersion: "nodedc.device.discovery-view.v1",
|
||||
...(routeRef ? { routeRef } : {}),
|
||||
modelProfileRef: "vendor.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
observedAt,
|
||||
lifecycleState: "quarantine",
|
||||
identifier: { kind: "serial", masked: "********0001" },
|
||||
evidence: {
|
||||
transport: "tcp",
|
||||
bytesObserved: 32,
|
||||
framingStatus: "verified",
|
||||
specificationRef: "vendor.protocol.v1",
|
||||
},
|
||||
commandTransport: "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
function discoveryRow(overrides = {}) {
|
||||
return {
|
||||
id: discoveryId,
|
||||
lifecycle_state: "quarantine",
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
identifier_kind: "serial",
|
||||
identifier_masked: "********0001",
|
||||
first_observed_at: new Date(observedAt),
|
||||
last_observed_at: new Date(observedAt),
|
||||
evidence: safeView().evidence,
|
||||
project_id: null,
|
||||
route_id: null,
|
||||
enrollment_intent_id: null,
|
||||
created: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function poolWithClient(client) {
|
||||
return { connect: async () => client };
|
||||
}
|
||||
|
||||
function step(includes, result = { rows: [] }) {
|
||||
return { includes, result };
|
||||
}
|
||||
|
||||
function scriptedClient(steps) {
|
||||
const queue = [...steps];
|
||||
return {
|
||||
released: false,
|
||||
async query(sql) {
|
||||
const next = queue.shift();
|
||||
assert.ok(next, `Unexpected query: ${sql}`);
|
||||
assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i"));
|
||||
return next.result;
|
||||
},
|
||||
release() {
|
||||
this.released = true;
|
||||
},
|
||||
remaining() {
|
||||
return queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -76,6 +76,46 @@ test("management API exposes no user-owned session mutation", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("management API forwards claim as evidence references without identity input", async () => {
|
||||
let executed;
|
||||
const runtime = await startServer({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async (input) => {
|
||||
executed = input;
|
||||
return { replayed: false, result: { created: true } };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/devices:claim`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: managementHeaders(),
|
||||
body: JSON.stringify({
|
||||
projectRef: "project:11111111-1111-4111-8111-111111111111",
|
||||
enrollmentIntentRef:
|
||||
"enrollment-intent:22222222-2222-4222-8222-222222222222",
|
||||
discoveryRef: "discovery:33333333-3333-4333-8333-333333333333",
|
||||
deviceKey: "pilot-device",
|
||||
displayName: "Pilot device",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(executed.commandKind, "device.claim");
|
||||
assert.equal(executed.command.deviceKey, "pilot-device");
|
||||
assert.equal("identifier" in executed.command, false);
|
||||
assert.equal("credentialRef" in executed.command, false);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
async function startServer(options) {
|
||||
const server = createControlCoreApp(options);
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_LIFECYCLE_COMMAND_KINDS,
|
||||
normalizeLifecycleManagementCommand,
|
||||
} from "../src/lifecycle-management.mjs";
|
||||
import {
|
||||
ALL_DEVICE_MANAGEMENT_COMMAND_KINDS,
|
||||
normalizeDeviceManagementCommand,
|
||||
} from "../src/management-command.mjs";
|
||||
|
||||
const projectRef = "project:11111111-1111-4111-8111-111111111111";
|
||||
const targetProjectRef = "project:22222222-2222-4222-8222-222222222222";
|
||||
const discoveryRef = "discovery:33333333-3333-4333-8333-333333333333";
|
||||
const enrollmentIntentRef =
|
||||
"enrollment-intent:44444444-4444-4444-8444-444444444444";
|
||||
const deviceRef = "device:55555555-5555-4555-8555-555555555555";
|
||||
|
||||
test("lifecycle commands join the same strict management command surface", () => {
|
||||
for (const kind of DEVICE_LIFECYCLE_COMMAND_KINDS) {
|
||||
assert.equal(ALL_DEVICE_MANAGEMENT_COMMAND_KINDS.includes(kind), true);
|
||||
}
|
||||
assert.equal(
|
||||
normalizeDeviceManagementCommand("device.claim", claimInput()).projectId,
|
||||
projectRef.slice("project:".length),
|
||||
);
|
||||
});
|
||||
|
||||
test("claim accepts only opaque evidence references and presentation fields", () => {
|
||||
const command = normalizeLifecycleManagementCommand(
|
||||
"device.claim",
|
||||
claimInput(),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
command.enrollmentIntentId,
|
||||
enrollmentIntentRef.slice("enrollment-intent:".length),
|
||||
);
|
||||
assert.equal(command.discoveryId, discoveryRef.slice("discovery:".length));
|
||||
assert.equal(command.deviceKey, "pilot-device");
|
||||
assert.equal("identifier" in command, false);
|
||||
assert.equal("credentialRef" in command, false);
|
||||
});
|
||||
|
||||
test("claim rejects raw identity and credential-shaped input", () => {
|
||||
assert.throws(
|
||||
() => normalizeLifecycleManagementCommand("device.claim", {
|
||||
...claimInput(),
|
||||
identifier: "000000000000001",
|
||||
}),
|
||||
/device_management_command_field_unexpected:identifier/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeLifecycleManagementCommand("device.claim", {
|
||||
...claimInput(),
|
||||
credentialRef: "secret:test",
|
||||
}),
|
||||
/device_management_command_field_unexpected:credentialRef/,
|
||||
);
|
||||
});
|
||||
|
||||
test("transfer binds both project boundaries and rejects a no-op", () => {
|
||||
const command = normalizeLifecycleManagementCommand("device.transfer", {
|
||||
deviceRef,
|
||||
sourceProjectRef: projectRef,
|
||||
targetProjectRef,
|
||||
targetDeviceKey: "transferred-device",
|
||||
});
|
||||
|
||||
assert.equal(command.deviceId, deviceRef.slice("device:".length));
|
||||
assert.notEqual(command.sourceProjectId, command.targetProjectId);
|
||||
assert.throws(
|
||||
() => normalizeLifecycleManagementCommand("device.transfer", {
|
||||
deviceRef,
|
||||
sourceProjectRef: projectRef,
|
||||
targetProjectRef: projectRef,
|
||||
targetDeviceKey: "same-project",
|
||||
}),
|
||||
/device_transfer_target_same_as_source/,
|
||||
);
|
||||
});
|
||||
|
||||
test("reject and expire require bounded machine-readable resolution codes", () => {
|
||||
for (const kind of ["discovery.reject", "discovery.expire"]) {
|
||||
const command = normalizeLifecycleManagementCommand(kind, {
|
||||
projectRef,
|
||||
discoveryRef,
|
||||
resolutionCode: "operator.identity_mismatch",
|
||||
});
|
||||
assert.equal(command.resolutionCode, "operator.identity_mismatch");
|
||||
}
|
||||
assert.throws(
|
||||
() => normalizeLifecycleManagementCommand("discovery.reject", {
|
||||
projectRef,
|
||||
discoveryRef,
|
||||
resolutionCode: "free form reason is forbidden",
|
||||
}),
|
||||
/device_discovery_resolution_code_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
function claimInput() {
|
||||
return {
|
||||
projectRef,
|
||||
enrollmentIntentRef,
|
||||
discoveryRef,
|
||||
deviceKey: "pilot-device",
|
||||
displayName: "Pilot device",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { assertSafeProjection } from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { normalizeDeviceManagementCommand } from "../src/management-command.mjs";
|
||||
import { PostgresDeviceRepository } from "../src/postgres-repository.mjs";
|
||||
import { normalizeManagementActor } from "../src/project-management.mjs";
|
||||
|
||||
const now = new Date("2026-08-10T00:00:00.000Z");
|
||||
const sourceProjectId = "11111111-1111-4111-8111-111111111111";
|
||||
const targetProjectId = "22222222-2222-4222-8222-222222222222";
|
||||
const sourceOwnerId = "33333333-3333-4333-8333-333333333333";
|
||||
const targetOwnerId = "44444444-4444-4444-8444-444444444444";
|
||||
const enrollmentId = "55555555-5555-4555-8555-555555555555";
|
||||
const discoveryId = "66666666-6666-4666-8666-666666666666";
|
||||
const deviceId = "77777777-7777-4777-8777-777777777777";
|
||||
const routeId = "88888888-8888-4888-8888-888888888888";
|
||||
const identifierDigest = `hmac-sha256:${"a".repeat(64)}`;
|
||||
|
||||
test("claims only matching observed enrollment evidence into direct ownership", async () => {
|
||||
const actor = managementActor("member");
|
||||
const command = normalizeDeviceManagementCommand("device.claim", {
|
||||
projectRef: `project:${sourceProjectId}`,
|
||||
enrollmentIntentRef: `enrollment-intent:${enrollmentId}`,
|
||||
discoveryRef: `discovery:${discoveryId}`,
|
||||
deviceKey: "pilot-device",
|
||||
displayName: "Pilot device",
|
||||
});
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-claim"),
|
||||
projectStep(sourceProjectId, sourceOwnerId),
|
||||
grantsStep(actor, "engineer"),
|
||||
step("from device_enrollment_intents", {
|
||||
rows: [enrollmentRow()],
|
||||
}),
|
||||
step("from device_discoveries", {
|
||||
rows: [discoveryRow()],
|
||||
}),
|
||||
step("insert into device_instances", {
|
||||
rows: [deviceRow({
|
||||
owner_scope_id: sourceOwnerId,
|
||||
project_id: sourceProjectId,
|
||||
device_key: command.deviceKey,
|
||||
display_name: command.displayName,
|
||||
})],
|
||||
}),
|
||||
step("update device_discoveries", { rows: [{ id: discoveryId }] }),
|
||||
step("update device_enrollment_intents", { rows: [{ id: enrollmentId }] }),
|
||||
step("insert into device_ownership_transitions"),
|
||||
step("insert into device_audit_events"),
|
||||
step("update device_management_command_receipts"),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "device.claim",
|
||||
command,
|
||||
digestCharacter: "b",
|
||||
}));
|
||||
|
||||
assert.equal(result.result.device.projectRef, `project:${sourceProjectId}`);
|
||||
assert.equal(result.result.device.identifier.masked, "********0001");
|
||||
assert.equal(JSON.stringify(result.result).includes(identifierDigest), false);
|
||||
assertSafeProjection(result.result);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("transfer requires explicit authority in the target project", async () => {
|
||||
const actor = managementActor("owner");
|
||||
const command = transferCommand();
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-transfer-denied"),
|
||||
step("from device_instances", { rows: [deviceRow()] }),
|
||||
projectStep(sourceProjectId, sourceOwnerId),
|
||||
grantsStep(actor, "owner"),
|
||||
projectStep(targetProjectId, targetOwnerId),
|
||||
step("from device_project_grants", { rows: [] }),
|
||||
step("rollback"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
await assert.rejects(
|
||||
repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "device.transfer",
|
||||
command,
|
||||
digestCharacter: "c",
|
||||
})),
|
||||
/device_project_capability_denied/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("authorized transfer preserves history and detaches source collections", async () => {
|
||||
const actor = managementActor("owner");
|
||||
const command = transferCommand();
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-transfer"),
|
||||
step("from device_instances", { rows: [deviceRow()] }),
|
||||
projectStep(sourceProjectId, sourceOwnerId),
|
||||
grantsStep(actor, "owner"),
|
||||
projectStep(targetProjectId, targetOwnerId),
|
||||
grantsStep(actor, "owner"),
|
||||
step("from device_sessions", { rows: [{ active: false }] }),
|
||||
step("delete from device_collection_members", { rows: [], rowCount: 2 }),
|
||||
step("update device_instances", {
|
||||
rows: [deviceRow({
|
||||
owner_scope_id: targetOwnerId,
|
||||
project_id: targetProjectId,
|
||||
device_key: command.targetDeviceKey,
|
||||
})],
|
||||
}),
|
||||
step("insert into device_ownership_transitions"),
|
||||
step("insert into device_audit_events"),
|
||||
step("insert into device_audit_events"),
|
||||
step("update device_management_command_receipts"),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "device.transfer",
|
||||
command,
|
||||
digestCharacter: "d",
|
||||
}));
|
||||
|
||||
assert.equal(result.result.transferred, true);
|
||||
assert.equal(result.result.device.projectRef, `project:${targetProjectId}`);
|
||||
assert.equal(result.result.detachedCollectionCount, 2);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("reject resolves both quarantine and enrollment without exposing a digest", async () => {
|
||||
const actor = managementActor("member");
|
||||
const command = normalizeDeviceManagementCommand("discovery.reject", {
|
||||
projectRef: `project:${sourceProjectId}`,
|
||||
discoveryRef: `discovery:${discoveryId}`,
|
||||
resolutionCode: "operator.identity_mismatch",
|
||||
});
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
receiptStep("receipt-reject"),
|
||||
projectStep(sourceProjectId, sourceOwnerId),
|
||||
grantsStep(actor, "engineer"),
|
||||
step("from device_discoveries", { rows: [discoveryRow()] }),
|
||||
step("from device_enrollment_intents", { rows: [enrollmentRow()] }),
|
||||
step("update device_discoveries"),
|
||||
step("update device_enrollment_intents"),
|
||||
step("insert into device_audit_events"),
|
||||
step("update device_management_command_receipts"),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(commandInput({
|
||||
actor,
|
||||
commandKind: "discovery.reject",
|
||||
command,
|
||||
digestCharacter: "e",
|
||||
}));
|
||||
|
||||
assert.equal(result.result.discovery.lifecycleState, "rejected");
|
||||
assert.equal(JSON.stringify(result.result).includes(identifierDigest), false);
|
||||
assertSafeProjection(result.result);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
function transferCommand() {
|
||||
return normalizeDeviceManagementCommand("device.transfer", {
|
||||
deviceRef: `device:${deviceId}`,
|
||||
sourceProjectRef: `project:${sourceProjectId}`,
|
||||
targetProjectRef: `project:${targetProjectId}`,
|
||||
targetDeviceKey: "transferred-device",
|
||||
});
|
||||
}
|
||||
|
||||
function enrollmentRow() {
|
||||
return {
|
||||
id: enrollmentId,
|
||||
project_id: sourceProjectId,
|
||||
route_id: routeId,
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
expected_identifier_kind: "serial",
|
||||
expected_identifier_digest: identifierDigest,
|
||||
expected_identifier_masked: "********0001",
|
||||
lifecycle_state: "observed",
|
||||
observed_discovery_id: discoveryId,
|
||||
claimed_device_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
function discoveryRow() {
|
||||
return {
|
||||
id: discoveryId,
|
||||
project_id: sourceProjectId,
|
||||
route_id: routeId,
|
||||
enrollment_intent_id: enrollmentId,
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
identifier_kind: "serial",
|
||||
identifier_digest: identifierDigest,
|
||||
identifier_masked: "********0001",
|
||||
lifecycle_state: "quarantine",
|
||||
claimed_device_id: null,
|
||||
};
|
||||
}
|
||||
|
||||
function deviceRow(overrides = {}) {
|
||||
return {
|
||||
id: deviceId,
|
||||
contour_id: null,
|
||||
owner_scope_id: sourceOwnerId,
|
||||
project_id: sourceProjectId,
|
||||
device_key: "pilot-device",
|
||||
model_profile_ref: "vendor.model.protocol.v1",
|
||||
display_name: "Pilot device",
|
||||
identifier_kind: "serial",
|
||||
identifier_masked: "********0001",
|
||||
lifecycle_state: "claimed",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function managementActor(hubRole) {
|
||||
return normalizeManagementActor({
|
||||
userRef: "user:lifecycle-operator",
|
||||
hubRole,
|
||||
groupRefs: [],
|
||||
ownerScopes: [],
|
||||
});
|
||||
}
|
||||
|
||||
function projectStep(projectId, ownerScopeId) {
|
||||
return step("from device_projects p", {
|
||||
rows: [{
|
||||
id: projectId,
|
||||
owner_scope_id: ownerScopeId,
|
||||
lifecycle_state: "active",
|
||||
scope_kind: "company",
|
||||
owner_ref: `client:${ownerScopeId}`,
|
||||
owner_display_name: "Example Company",
|
||||
owner_lifecycle_state: "active",
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
function grantsStep(actor, projectRole) {
|
||||
return step("from device_project_grants", {
|
||||
rows: [{
|
||||
id: "99999999-9999-4999-8999-999999999999",
|
||||
principal_kind: "user",
|
||||
principal_ref: actor.userRef,
|
||||
project_role: projectRole,
|
||||
capability_allow: [],
|
||||
capability_deny: [],
|
||||
lifecycle_state: "active",
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
function receiptStep(id) {
|
||||
return step("insert into device_management_command_receipts", {
|
||||
rows: [{ id }],
|
||||
});
|
||||
}
|
||||
|
||||
function commandInput({ actor, commandKind, command, digestCharacter }) {
|
||||
return {
|
||||
idempotencyKey: `phase24-${commandKind.replaceAll(".", "-")}-0001`,
|
||||
commandKind,
|
||||
requestDigest: `sha256:${digestCharacter.repeat(64)}`,
|
||||
actor,
|
||||
command,
|
||||
};
|
||||
}
|
||||
|
||||
function repositoryWithClient(client) {
|
||||
return new PostgresDeviceRepository({
|
||||
pool: {
|
||||
query: async () => ({ rows: [] }),
|
||||
connect: async () => client,
|
||||
end: async () => undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function step(includes, result = { rows: [] }) {
|
||||
return { includes, result };
|
||||
}
|
||||
|
||||
function scriptedClient(steps) {
|
||||
const queue = [...steps];
|
||||
return {
|
||||
released: false,
|
||||
async query(sql) {
|
||||
const next = queue.shift();
|
||||
assert.ok(next, `Unexpected query: ${sql}`);
|
||||
assert.match(String(sql), new RegExp(escapeRegExp(next.includes), "i"));
|
||||
return next.result;
|
||||
},
|
||||
release() {
|
||||
this.released = true;
|
||||
},
|
||||
remaining() {
|
||||
return queue.length;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
Reference in New Issue
Block a user