feat(device-core): add idempotent project management
This commit is contained in:
@@ -8,6 +8,7 @@ import { createControlCoreApp } from "../src/app.mjs";
|
||||
|
||||
const gatewayToken = "test-only-gateway-token-with-32-bytes";
|
||||
const identifierPepper = "test-only-identifier-pepper-with-32-bytes";
|
||||
const managementToken = "test-only-management-token-with-32-bytes";
|
||||
const fakeImei = "000000000000001";
|
||||
|
||||
test("health reports database readiness and disabled command transport", async () => {
|
||||
@@ -24,6 +25,7 @@ test("health reports database readiness and disabled command transport", async (
|
||||
service: "nodedc-device-control-core",
|
||||
database: "ready",
|
||||
discoveryIngest: "disabled",
|
||||
managementApi: "disabled",
|
||||
commandTransport: "disabled",
|
||||
});
|
||||
} finally {
|
||||
@@ -31,6 +33,210 @@ test("health reports database readiness and disabled command transport", async (
|
||||
}
|
||||
});
|
||||
|
||||
test("management API is closed by default", async () => {
|
||||
const runtime = await startTestServer({
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal((await response.json()).error, "device_management_api_disabled");
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("management API cannot start without its repository boundary and strong token", () => {
|
||||
assert.throws(
|
||||
() => createControlCoreApp({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: { health: async () => "ready" },
|
||||
}),
|
||||
/device_management_repository_required/,
|
||||
);
|
||||
assert.throws(
|
||||
() => createControlCoreApp({
|
||||
managementApiEnabled: true,
|
||||
managementToken: "short",
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async () => ({}),
|
||||
},
|
||||
}),
|
||||
/device_management_token_invalid/,
|
||||
);
|
||||
});
|
||||
|
||||
test("management API requires service auth and an idempotency key", async () => {
|
||||
const runtime = await startTestServer({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async () => {
|
||||
throw new Error("must_not_execute");
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const unauthorized = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
assert.equal(unauthorized.status, 401);
|
||||
|
||||
const missingIdempotency = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: managementHeaders({ includeIdempotency: false }),
|
||||
body: JSON.stringify(ownerScopeCommand()),
|
||||
},
|
||||
);
|
||||
assert.equal(missingIdempotency.status, 400);
|
||||
assert.equal(
|
||||
(await missingIdempotency.json()).error,
|
||||
"device_management_header_required",
|
||||
);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("management API forwards only normalized actor and command data", async () => {
|
||||
let executed;
|
||||
const runtime = await startTestServer({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async (value) => {
|
||||
executed = value;
|
||||
return {
|
||||
replayed: false,
|
||||
result: {
|
||||
created: true,
|
||||
ownerScope: {
|
||||
ownerScopeRef: "owner-scope:11111111-1111-4111-8111-111111111111",
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:example",
|
||||
displayName: "Example Company",
|
||||
lifecycleState: "active",
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: managementHeaders(),
|
||||
body: JSON.stringify(ownerScopeCommand()),
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("idempotency-key"), "phase2-test-0001");
|
||||
assert.equal(response.headers.get("idempotency-replayed"), "false");
|
||||
assert.match(executed.requestDigest, /^sha256:[a-f0-9]{64}$/);
|
||||
assert.equal(executed.commandKind, "owner_scope.ensure");
|
||||
assert.deepEqual(executed.command, ownerScopeCommand());
|
||||
assert.deepEqual(executed.actor, {
|
||||
userRef: "user:engineer",
|
||||
hubRole: "admin",
|
||||
groupRefs: ["group:engineers", "group:operators"],
|
||||
ownerScopes: [{ scopeKind: "company", ownerRef: "client:example" }],
|
||||
});
|
||||
assert.equal((await response.json()).result.created, true);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("management API rejects unexpected fields before repository execution", async () => {
|
||||
let executions = 0;
|
||||
const runtime = await startTestServer({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async () => {
|
||||
executions += 1;
|
||||
return { replayed: false, result: {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: managementHeaders(),
|
||||
body: JSON.stringify({
|
||||
...ownerScopeCommand(),
|
||||
credential: "must-never-cross-boundary",
|
||||
}),
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(
|
||||
(await response.json()).error,
|
||||
/device_management_command_field_unexpected:credential/,
|
||||
);
|
||||
assert.equal(executions, 0);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("management API exposes a repository idempotency conflict without retrying", async () => {
|
||||
let executions = 0;
|
||||
const runtime = await startTestServer({
|
||||
managementApiEnabled: true,
|
||||
managementToken,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
executeManagementCommand: async () => {
|
||||
executions += 1;
|
||||
const error = new Error("device_idempotency_key_conflict");
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/management/owner-scopes:ensure`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: managementHeaders(),
|
||||
body: JSON.stringify(ownerScopeCommand()),
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 409);
|
||||
assert.equal((await response.json()).error, "device_idempotency_key_conflict");
|
||||
assert.equal(executions, 1);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("discovery ingest is closed by default", async () => {
|
||||
const runtime = await startTestServer({
|
||||
repository: {
|
||||
@@ -127,6 +333,26 @@ function fakeSignal() {
|
||||
};
|
||||
}
|
||||
|
||||
function managementHeaders({ includeIdempotency = true } = {}) {
|
||||
return {
|
||||
Authorization: `Bearer ${managementToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...(includeIdempotency ? { "Idempotency-Key": "phase2-test-0001" } : {}),
|
||||
"X-NODEDC-User-Ref": "user:engineer",
|
||||
"X-NODEDC-Hub-Role": "admin",
|
||||
"X-NODEDC-Group-Refs": "group:operators,group:engineers",
|
||||
"X-NODEDC-Owner-Scopes": "company=client:example",
|
||||
};
|
||||
}
|
||||
|
||||
function ownerScopeCommand() {
|
||||
return {
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:example",
|
||||
displayName: "Example Company",
|
||||
};
|
||||
}
|
||||
|
||||
async function startTestServer(options) {
|
||||
const server = createControlCoreApp(options);
|
||||
await new Promise((resolve, reject) => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const migrationUrl = new URL(
|
||||
"../migrations/003_device_management_commands.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
|
||||
test("management migration pins idempotency, audit and owner invariants", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.match(sql, /device_project_grants_owner_user_only/);
|
||||
assert.match(sql, /project_role <> 'owner' or principal_kind = 'user'/);
|
||||
assert.match(sql, /add column if not exists project_id uuid references device_projects\(id\)/);
|
||||
assert.match(sql, /create table if not exists device_management_command_receipts/);
|
||||
assert.match(sql, /unique \(actor_ref, command_kind, idempotency_key\)/);
|
||||
assert.match(sql, /request_digest ~ '\^sha256:\[a-f0-9\]\{64\}\$'/);
|
||||
assert.match(sql, /lifecycle_state in \('pending', 'completed'\)/);
|
||||
});
|
||||
|
||||
test("management migration stores no tenant, device or credential seed", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.doesNotMatch(sql, /insert\s+into/i);
|
||||
assert.doesNotMatch(sql, /dcctouch|arusnavi|b2|imei/i);
|
||||
assert.doesNotMatch(sql, /password|secret|credential_ref/i);
|
||||
});
|
||||
|
||||
test("repository applies management migration after project access", async () => {
|
||||
const source = await readFile(repositoryUrl, "utf8");
|
||||
const projectAccessIndex = source.indexOf("002_device_project_access.sql");
|
||||
const managementIndex = source.indexOf("003_device_management_commands.sql");
|
||||
|
||||
assert.notEqual(projectAccessIndex, -1);
|
||||
assert.notEqual(managementIndex, -1);
|
||||
assert.ok(projectAccessIndex < managementIndex);
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { PostgresDeviceRepository } from "../src/postgres-repository.mjs";
|
||||
import {
|
||||
normalizeManagementActor,
|
||||
normalizeManagementCommand,
|
||||
} from "../src/project-management.mjs";
|
||||
|
||||
const actor = normalizeManagementActor({
|
||||
userRef: "user:engineer",
|
||||
hubRole: "admin",
|
||||
groupRefs: [],
|
||||
ownerScopes: [{ scopeKind: "company", ownerRef: "client:example" }],
|
||||
});
|
||||
const command = normalizeManagementCommand("owner_scope.ensure", {
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:example",
|
||||
displayName: "Example Company",
|
||||
});
|
||||
const baseInput = {
|
||||
idempotencyKey: "phase2-repository-0001",
|
||||
commandKind: "owner_scope.ensure",
|
||||
requestDigest: `sha256:${"a".repeat(64)}`,
|
||||
actor,
|
||||
command,
|
||||
};
|
||||
|
||||
test("replays a completed command without executing the domain mutation", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_management_command_receipts", { rows: [] }),
|
||||
step("from device_management_command_receipts", {
|
||||
rows: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
request_digest: baseInput.requestDigest,
|
||||
lifecycle_state: "completed",
|
||||
response_body: { created: true, ownerScope: { ownerRef: "client:example" } },
|
||||
}],
|
||||
}),
|
||||
step("from device_owner_scopes", {
|
||||
rows: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
scope_kind: "company",
|
||||
owner_ref: "client:example",
|
||||
display_name: "Example Company",
|
||||
lifecycle_state: "active",
|
||||
created_at: new Date("2026-08-10T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-08-10T00:00:00.000Z"),
|
||||
}],
|
||||
}),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(baseInput);
|
||||
|
||||
assert.equal(result.replayed, true);
|
||||
assert.equal(result.result.ownerScope.ownerRef, "client:example");
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("rejects idempotency-key reuse with a different normalized request", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_management_command_receipts", { rows: [] }),
|
||||
step("from device_management_command_receipts", {
|
||||
rows: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
request_digest: `sha256:${"b".repeat(64)}`,
|
||||
lifecycle_state: "completed",
|
||||
response_body: { created: true },
|
||||
}],
|
||||
}),
|
||||
step("rollback"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
await assert.rejects(
|
||||
repository.executeManagementCommand(baseInput),
|
||||
/device_idempotency_key_conflict/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("does not replay a completed project command after project access is revoked", async () => {
|
||||
const collectionCommand = normalizeManagementCommand("collection.ensure", {
|
||||
projectRef: "project:11111111-1111-4111-8111-111111111111",
|
||||
collectionKey: "field-devices",
|
||||
name: "Field Devices",
|
||||
});
|
||||
const input = {
|
||||
idempotencyKey: "phase2-repository-collection-0001",
|
||||
commandKind: "collection.ensure",
|
||||
requestDigest: `sha256:${"c".repeat(64)}`,
|
||||
actor,
|
||||
command: collectionCommand,
|
||||
};
|
||||
const now = new Date("2026-08-10T00:00:00.000Z");
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_management_command_receipts", { rows: [] }),
|
||||
step("from device_management_command_receipts", {
|
||||
rows: [{
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
request_digest: input.requestDigest,
|
||||
lifecycle_state: "completed",
|
||||
response_body: { created: true },
|
||||
}],
|
||||
}),
|
||||
step("from device_projects p", {
|
||||
rows: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
owner_scope_id: "33333333-3333-4333-8333-333333333333",
|
||||
project_key: "field-devices",
|
||||
name: "Field Devices",
|
||||
description: null,
|
||||
lifecycle_state: "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
scope_kind: "company",
|
||||
owner_ref: "client:example",
|
||||
owner_display_name: "Example Company",
|
||||
owner_lifecycle_state: "active",
|
||||
owner_created_at: now,
|
||||
owner_updated_at: now,
|
||||
}],
|
||||
}),
|
||||
step("from device_project_grants", { rows: [] }),
|
||||
step("rollback"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
await assert.rejects(
|
||||
repository.executeManagementCommand(input),
|
||||
/device_project_capability_denied/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("refuses to revoke the last active project owner", async () => {
|
||||
const ownerActor = normalizeManagementActor({
|
||||
...actor,
|
||||
hubRole: "owner",
|
||||
});
|
||||
const revokeOwner = normalizeManagementCommand("project_grant.upsert", {
|
||||
projectRef: "project:11111111-1111-4111-8111-111111111111",
|
||||
principalKind: "user",
|
||||
principalRef: ownerActor.userRef,
|
||||
projectRole: "owner",
|
||||
lifecycleState: "revoked",
|
||||
});
|
||||
const input = {
|
||||
idempotencyKey: "phase2-repository-owner-0001",
|
||||
commandKind: "project_grant.upsert",
|
||||
requestDigest: `sha256:${"d".repeat(64)}`,
|
||||
actor: ownerActor,
|
||||
command: revokeOwner,
|
||||
};
|
||||
const now = new Date("2026-08-10T00:00:00.000Z");
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_management_command_receipts", {
|
||||
rows: [{ id: "receipt-created" }],
|
||||
}),
|
||||
step("from device_projects p", {
|
||||
rows: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
owner_scope_id: "33333333-3333-4333-8333-333333333333",
|
||||
project_key: "field-devices",
|
||||
name: "Field Devices",
|
||||
description: null,
|
||||
lifecycle_state: "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
scope_kind: "company",
|
||||
owner_ref: "client:example",
|
||||
owner_display_name: "Example Company",
|
||||
owner_lifecycle_state: "active",
|
||||
owner_created_at: now,
|
||||
owner_updated_at: now,
|
||||
}],
|
||||
}),
|
||||
step("from device_project_grants", {
|
||||
rows: [{
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
principal_kind: "user",
|
||||
principal_ref: ownerActor.userRef,
|
||||
project_role: "owner",
|
||||
capability_allow: [],
|
||||
capability_deny: [],
|
||||
lifecycle_state: "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}],
|
||||
}),
|
||||
step("rollback"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
await assert.rejects(
|
||||
repository.executeManagementCommand(input),
|
||||
/device_project_last_owner_required/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("commits an authorized generic owner-scope command and durable receipt", async () => {
|
||||
const now = new Date("2026-08-10T00:00:00.000Z");
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_management_command_receipts", {
|
||||
rows: [{ id: "receipt-created" }],
|
||||
}),
|
||||
step("insert into device_owner_scopes", {
|
||||
rows: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
scope_kind: "company",
|
||||
owner_ref: "client:example",
|
||||
display_name: "Example Company",
|
||||
lifecycle_state: "active",
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
created: true,
|
||||
}],
|
||||
}),
|
||||
step("insert into device_audit_events"),
|
||||
step("update device_management_command_receipts"),
|
||||
step("commit"),
|
||||
]);
|
||||
const repository = repositoryWithClient(client);
|
||||
|
||||
const result = await repository.executeManagementCommand(baseInput);
|
||||
|
||||
assert.equal(result.replayed, false);
|
||||
assert.equal(result.result.created, true);
|
||||
assert.equal(result.result.ownerScope.ownerRef, "client:example");
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
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, "\\$&");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const appUrl = new URL("../src/app.mjs", import.meta.url);
|
||||
const serverUrl = new URL("../src/server.mjs", import.meta.url);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
const composeUrl = new URL("../../../docker-compose.device-plane.yml", import.meta.url);
|
||||
|
||||
test("management surface is internal, POST-only and disabled by default", async () => {
|
||||
const source = await readFile(appUrl, "utf8");
|
||||
|
||||
assert.match(source, /managementApiEnabled = false/);
|
||||
assert.match(source, /\/internal\/v1\/management\/owner-scopes:ensure/);
|
||||
assert.match(source, /\/internal\/v1\/management\/projects:ensure/);
|
||||
assert.match(source, /\/internal\/v1\/management\/collections:ensure/);
|
||||
assert.match(source, /\/internal\/v1\/management\/project-grants:upsert/);
|
||||
assert.match(source, /request\.method === "POST" && managementCommandKind/);
|
||||
assert.doesNotMatch(source, /\/api\/public\/.*management/);
|
||||
});
|
||||
|
||||
test("management token remains file-backed and is not enabled by current Compose", async () => {
|
||||
const server = await readFile(serverUrl, "utf8");
|
||||
const compose = await readFile(composeUrl, "utf8");
|
||||
|
||||
assert.match(server, /DEVICE_MANAGEMENT_API_ENABLED/);
|
||||
assert.match(server, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/);
|
||||
assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_API_ENABLED/);
|
||||
assert.doesNotMatch(compose, /DEVICE_MANAGEMENT_CORE_TOKEN_FILE/);
|
||||
});
|
||||
|
||||
test("repository pins idempotency, audit and last-owner checks inside one transaction", async () => {
|
||||
const source = await readFile(repositoryUrl, "utf8");
|
||||
|
||||
assert.match(source, /await client\.query\("begin"\)/);
|
||||
assert.match(source, /await client\.query\("commit"\)/);
|
||||
assert.match(source, /await client\.query\("rollback"\)/);
|
||||
assert.match(source, /device_idempotency_key_conflict/);
|
||||
assert.match(source, /device_project_last_owner_required/);
|
||||
assert.match(source, /for update of p/);
|
||||
assert.match(source, /authorizeManagementReplay/);
|
||||
assert.match(source, /insert into device_audit_events/);
|
||||
assert.match(source, /update device_management_command_receipts/);
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
assertActorCanManageOwnerScope,
|
||||
assertGrantMutationAllowed,
|
||||
assertProjectCapability,
|
||||
normalizeManagementActor,
|
||||
normalizeManagementCommand,
|
||||
resolveProjectAccess,
|
||||
} from "../src/project-management.mjs";
|
||||
|
||||
const projectRef = "project:11111111-1111-4111-8111-111111111111";
|
||||
|
||||
test("normalizes strict generic management commands without seeded entities", () => {
|
||||
const project = normalizeManagementCommand("project.ensure", {
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:example",
|
||||
projectKey: "field-devices",
|
||||
name: "Field Devices",
|
||||
description: "Generic project",
|
||||
});
|
||||
assert.deepEqual(project, {
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:example",
|
||||
projectKey: "field-devices",
|
||||
name: "Field Devices",
|
||||
description: "Generic project",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => normalizeManagementCommand("project.ensure", {
|
||||
scopeKind: "company",
|
||||
ownerRef: "client:example",
|
||||
projectKey: "field-devices",
|
||||
name: "Field Devices",
|
||||
rawPayload: "forbidden",
|
||||
}),
|
||||
/device_management_command_field_unexpected:rawPayload/,
|
||||
);
|
||||
});
|
||||
|
||||
test("company scope requires an asserted scope and Hub admin ceiling", () => {
|
||||
const scope = { scopeKind: "company", ownerRef: "client:example" };
|
||||
assert.doesNotThrow(() => assertActorCanManageOwnerScope(actor({
|
||||
hubRole: "admin",
|
||||
ownerScopes: [scope],
|
||||
}), scope));
|
||||
assert.throws(
|
||||
() => assertActorCanManageOwnerScope(actor({
|
||||
hubRole: "viewer",
|
||||
ownerScopes: [scope],
|
||||
}), scope),
|
||||
/device_owner_scope_access_denied/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertActorCanManageOwnerScope(actor({ hubRole: "owner" }), scope),
|
||||
/device_owner_scope_access_denied/,
|
||||
);
|
||||
});
|
||||
|
||||
test("personal scope is isolated to the matching Hub user", () => {
|
||||
const scope = { scopeKind: "personal", ownerRef: "user:engineer" };
|
||||
assert.doesNotThrow(() => assertActorCanManageOwnerScope(
|
||||
actor({ hubRole: "owner" }),
|
||||
scope,
|
||||
));
|
||||
assert.throws(
|
||||
() => assertActorCanManageOwnerScope(
|
||||
actor({ hubRole: "member" }),
|
||||
scope,
|
||||
),
|
||||
/device_owner_scope_access_denied/,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertActorCanManageOwnerScope(
|
||||
actor({ userRef: "user:other", hubRole: "owner" }),
|
||||
scope,
|
||||
),
|
||||
/device_owner_scope_access_denied/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Hub owner has no project access without an explicit project grant", () => {
|
||||
const access = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "owner" }),
|
||||
grants: [],
|
||||
});
|
||||
assert.equal(access.allowed, false);
|
||||
assert.deepEqual(access.capabilities, []);
|
||||
});
|
||||
|
||||
test("a direct user grant overrides broader group grants", () => {
|
||||
const access = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "owner", groupRefs: ["group:admins"] }),
|
||||
grants: [
|
||||
grant({
|
||||
grantRef: "grant:group-admin",
|
||||
principalKind: "group",
|
||||
principalRef: "group:admins",
|
||||
projectRole: "admin",
|
||||
}),
|
||||
grant({
|
||||
grantRef: "grant:direct-viewer",
|
||||
principalKind: "user",
|
||||
principalRef: "user:engineer",
|
||||
projectRole: "viewer",
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(access.projectRole, "viewer");
|
||||
assert.equal(access.capabilities.includes("access.manage"), false);
|
||||
});
|
||||
|
||||
test("matching group grants combine bounded operator and engineer capabilities", () => {
|
||||
const access = resolveProjectAccess({
|
||||
actor: actor({
|
||||
hubRole: "member",
|
||||
groupRefs: ["group:operators", "group:engineers"],
|
||||
}),
|
||||
grants: [
|
||||
grant({
|
||||
grantRef: "grant:operator",
|
||||
principalKind: "group",
|
||||
principalRef: "group:operators",
|
||||
projectRole: "operator",
|
||||
}),
|
||||
grant({
|
||||
grantRef: "grant:engineer",
|
||||
principalKind: "group",
|
||||
principalRef: "group:engineers",
|
||||
projectRole: "engineer",
|
||||
}),
|
||||
],
|
||||
});
|
||||
assert.equal(access.capabilities.includes("device.enroll"), true);
|
||||
assert.equal(access.capabilities.includes("command.dispatch"), true);
|
||||
assert.equal(access.capabilities.includes("access.manage"), false);
|
||||
});
|
||||
|
||||
test("Hub ceiling and explicit deny prevent privilege escalation", () => {
|
||||
const ownerGrant = grant({
|
||||
grantRef: "grant:owner",
|
||||
principalKind: "user",
|
||||
principalRef: "user:engineer",
|
||||
projectRole: "owner",
|
||||
capabilityDeny: ["credential.manage"],
|
||||
});
|
||||
const hubAdmin = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "admin" }),
|
||||
grants: [ownerGrant],
|
||||
});
|
||||
const hubOwner = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "owner" }),
|
||||
grants: [ownerGrant],
|
||||
});
|
||||
|
||||
assert.equal(hubAdmin.capabilities.includes("device.transfer"), false);
|
||||
assert.equal(hubOwner.capabilities.includes("device.transfer"), true);
|
||||
assert.equal(hubOwner.capabilities.includes("credential.manage"), false);
|
||||
});
|
||||
|
||||
test("owner grant mutations require both Hub and project ownership authority", () => {
|
||||
const grants = [grant({
|
||||
grantRef: "grant:owner",
|
||||
principalKind: "user",
|
||||
principalRef: "user:engineer",
|
||||
projectRole: "owner",
|
||||
})];
|
||||
const ownerCommand = normalizeManagementCommand("project_grant.upsert", {
|
||||
projectRef,
|
||||
principalKind: "user",
|
||||
principalRef: "user:second-owner",
|
||||
projectRole: "owner",
|
||||
});
|
||||
|
||||
assert.throws(
|
||||
() => assertGrantMutationAllowed(
|
||||
actor({ hubRole: "admin" }),
|
||||
grants,
|
||||
ownerCommand,
|
||||
),
|
||||
/device_project_owner_transfer_denied/,
|
||||
);
|
||||
assert.doesNotThrow(() => assertGrantMutationAllowed(
|
||||
actor({ hubRole: "owner" }),
|
||||
grants,
|
||||
ownerCommand,
|
||||
));
|
||||
});
|
||||
|
||||
test("grant normalization rejects group owners and capability overlap", () => {
|
||||
assert.throws(
|
||||
() => normalizeManagementCommand("project_grant.upsert", {
|
||||
projectRef,
|
||||
principalKind: "group",
|
||||
principalRef: "group:owners",
|
||||
projectRole: "owner",
|
||||
}),
|
||||
/device_project_owner_must_be_user/,
|
||||
);
|
||||
assert.throws(
|
||||
() => normalizeManagementCommand("project_grant.upsert", {
|
||||
projectRef,
|
||||
principalKind: "user",
|
||||
principalRef: "user:operator",
|
||||
projectRole: "operator",
|
||||
capabilityAllow: ["command.dispatch"],
|
||||
capabilityDeny: ["command.dispatch"],
|
||||
}),
|
||||
/device_project_capability_overlap/,
|
||||
);
|
||||
});
|
||||
|
||||
test("capability checks fail closed for inactive or unrelated grants", () => {
|
||||
assert.throws(
|
||||
() => assertProjectCapability(
|
||||
actor({ hubRole: "owner" }),
|
||||
[grant({ lifecycleState: "revoked" })],
|
||||
"project.read",
|
||||
),
|
||||
/device_project_capability_denied/,
|
||||
);
|
||||
});
|
||||
|
||||
test("denying project.read collapses every derived capability", () => {
|
||||
const access = resolveProjectAccess({
|
||||
actor: actor({ hubRole: "owner" }),
|
||||
grants: [grant({
|
||||
projectRole: "owner",
|
||||
capabilityDeny: ["project.read"],
|
||||
})],
|
||||
});
|
||||
|
||||
assert.equal(access.allowed, false);
|
||||
assert.deepEqual(access.capabilities, []);
|
||||
assert.throws(
|
||||
() => assertProjectCapability(
|
||||
actor({ hubRole: "owner" }),
|
||||
[grant({
|
||||
projectRole: "owner",
|
||||
capabilityDeny: ["project.read"],
|
||||
})],
|
||||
"access.manage",
|
||||
),
|
||||
/device_project_capability_denied/,
|
||||
);
|
||||
});
|
||||
|
||||
function actor(overrides = {}) {
|
||||
return normalizeManagementActor({
|
||||
userRef: "user:engineer",
|
||||
hubRole: "member",
|
||||
groupRefs: [],
|
||||
ownerScopes: [],
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function grant(overrides = {}) {
|
||||
return {
|
||||
grantRef: "grant:default",
|
||||
principalKind: "user",
|
||||
principalRef: "user:engineer",
|
||||
projectRole: "viewer",
|
||||
capabilityAllow: [],
|
||||
capabilityDeny: [],
|
||||
lifecycleState: "active",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user