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) => {
|
||||
|
||||
Reference in New Issue
Block a user