feat(device-plane): add universal adapter acceptance boundary
This commit is contained in:
@@ -4,7 +4,9 @@ WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/device-protocol-contract ./packages/device-protocol-contract
|
||||
COPY packages/arusnavi-b2-adapter ./packages/arusnavi-b2-adapter
|
||||
COPY packages/device-adapter-runtime/package.json ./packages/device-adapter-runtime/package.json
|
||||
COPY packages/device-adapter-catalog/package.json ./packages/device-adapter-catalog/package.json
|
||||
COPY packages/arusnavi-b2-adapter/package.json ./packages/arusnavi-b2-adapter/package.json
|
||||
COPY services/device-control-core ./services/device-control-core
|
||||
COPY services/device-gateway/package.json ./services/device-gateway/package.json
|
||||
COPY services/device-edge-relay/package.json ./services/device-edge-relay/package.json
|
||||
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
begin;
|
||||
|
||||
create table if not exists device_gateway_message_receipts (
|
||||
id uuid primary key,
|
||||
idempotency_key text not null
|
||||
check (idempotency_key ~ '^sha256:[a-f0-9]{64}$'),
|
||||
request_digest text not null
|
||||
check (request_digest ~ '^sha256:[a-f0-9]{64}$'),
|
||||
edge_ref text not null
|
||||
check (length(btrim(edge_ref)) between 3 and 128),
|
||||
adapter_ref text not null
|
||||
check (adapter_ref ~ '^[a-z][a-z0-9-]{1,62}$'),
|
||||
protocol_profile_ref text not null
|
||||
references device_model_profiles(profile_ref),
|
||||
protocol text not null
|
||||
check (protocol ~ '^[A-Z][A-Z0-9_]{0,31}$'),
|
||||
route_id uuid references device_routes(id),
|
||||
project_id uuid references device_projects(id),
|
||||
session_ref text not null
|
||||
check (length(btrim(session_ref)) between 3 and 128),
|
||||
message_ref text not null
|
||||
check (length(btrim(message_ref)) between 3 and 128),
|
||||
message_type text not null
|
||||
check (message_type ~ '^[a-z][a-z0-9._-]{1,127}$'),
|
||||
sequence bigint not null check (sequence > 0),
|
||||
identifier_kind text not null
|
||||
check (identifier_kind ~ '^[a-z][a-z0-9._:-]{1,63}$'),
|
||||
identifier_digest text not null
|
||||
check (identifier_digest ~ '^hmac-sha256:[a-f0-9]{64}$'),
|
||||
identifier_masked text not null
|
||||
check (length(identifier_masked) between 5 and 128),
|
||||
payload_schema_ref text not null
|
||||
check (length(btrim(payload_schema_ref)) between 3 and 128),
|
||||
payload jsonb not null,
|
||||
observed_at timestamptz not null,
|
||||
accepted_at timestamptz not null default now(),
|
||||
unique (idempotency_key),
|
||||
unique (edge_ref, session_ref, message_ref),
|
||||
foreign key (route_id, project_id)
|
||||
references device_routes(id, project_id),
|
||||
check (
|
||||
(route_id is null and project_id is null)
|
||||
or (route_id is not null and project_id is not null)
|
||||
)
|
||||
);
|
||||
|
||||
create index if not exists device_gateway_message_receipts_route_time_idx
|
||||
on device_gateway_message_receipts (route_id, accepted_at desc)
|
||||
where route_id is not null;
|
||||
|
||||
create index if not exists device_gateway_message_receipts_identity_time_idx
|
||||
on device_gateway_message_receipts (
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
accepted_at desc
|
||||
);
|
||||
|
||||
drop trigger if exists device_gateway_message_receipts_immutable_guard
|
||||
on device_gateway_message_receipts;
|
||||
create trigger device_gateway_message_receipts_immutable_guard
|
||||
before update or delete or truncate
|
||||
on device_gateway_message_receipts
|
||||
for each statement
|
||||
execute function reject_device_immutable_record_mutation();
|
||||
|
||||
commit;
|
||||
@@ -5,9 +5,12 @@ import {
|
||||
assertSafeProjection,
|
||||
hashRestrictedIdentifier,
|
||||
maskRestrictedIdentifier,
|
||||
normalizeAdapterAcceptance,
|
||||
normalizeAdapterMessage,
|
||||
normalizeDiscoverySignal,
|
||||
normalizeRestrictedIdentifier,
|
||||
toSafeDiscoveryView,
|
||||
toSafeAdapterMessageView,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import {
|
||||
normalizeManagementActor,
|
||||
@@ -64,6 +67,9 @@ export function createControlCoreApp({
|
||||
if (typeof repository.upsertQuarantineDiscovery !== "function") {
|
||||
throw new TypeError("device_discovery_repository_required");
|
||||
}
|
||||
if (typeof repository.acceptAdapterMessage !== "function") {
|
||||
throw new TypeError("device_gateway_message_repository_required");
|
||||
}
|
||||
if (typeof gatewayToken !== "string" || gatewayToken.length < 32) {
|
||||
throw new TypeError("device_gateway_token_invalid");
|
||||
}
|
||||
@@ -249,6 +255,55 @@ export function createControlCoreApp({
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST"
|
||||
&& requestUrl.pathname === "/internal/v1/gateway/messages:accept"
|
||||
) {
|
||||
if (!discoveryIngestEnabled) {
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_gateway_message_ingest_disabled",
|
||||
});
|
||||
}
|
||||
if (!matchesBearer(request.headers.authorization, gatewayToken)) {
|
||||
return writeJson(response, 401, {
|
||||
ok: false,
|
||||
error: "device_gateway_auth_required",
|
||||
});
|
||||
}
|
||||
|
||||
const input = await readJsonBody(request, 1024 * 1024);
|
||||
const message = normalizeAdapterMessage(input);
|
||||
const identifierDigest = hashRestrictedIdentifier(
|
||||
message.identifier,
|
||||
identifierPepper,
|
||||
);
|
||||
const safeView = assertSafeProjection(toSafeAdapterMessageView(message));
|
||||
const requestDigest = gatewayMessageRequestDigest({
|
||||
edgeRef: safeView.edgeRef,
|
||||
adapterRef: safeView.adapterRef,
|
||||
protocolProfileRef: safeView.protocolProfileRef,
|
||||
protocol: safeView.protocol,
|
||||
routeRef: safeView.routeRef ?? null,
|
||||
idempotencyKey: safeView.idempotencyKey,
|
||||
identifierKind: safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
payloadSchemaRef: safeView.payloadSchemaRef,
|
||||
payload: safeView.payload,
|
||||
});
|
||||
const acceptance = normalizeAdapterAcceptance(
|
||||
await repository.acceptAdapterMessage({
|
||||
identifierDigest,
|
||||
requestDigest,
|
||||
safeView,
|
||||
}),
|
||||
);
|
||||
return writeJson(response, acceptance.replayed ? 200 : 201, {
|
||||
ok: true,
|
||||
acceptance,
|
||||
});
|
||||
}
|
||||
|
||||
return writeJson(response, 404, {
|
||||
ok: false,
|
||||
error: "device_control_core_route_not_found",
|
||||
@@ -366,6 +421,12 @@ function managementRequestDigest(value) {
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
function gatewayMessageRequestDigest(value) {
|
||||
return `sha256:${createHash("sha256")
|
||||
.update(JSON.stringify(value), "utf8")
|
||||
.digest("hex")}`;
|
||||
}
|
||||
|
||||
function matchesBearer(header, expected) {
|
||||
if (typeof header !== "string" || !header.startsWith("Bearer ")) return false;
|
||||
const actual = Buffer.from(header.slice("Bearer ".length), "utf8");
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export async function acceptGatewayMessage({
|
||||
pool,
|
||||
identifierDigest,
|
||||
requestDigest,
|
||||
safeView,
|
||||
}) {
|
||||
const routeId = safeView.routeRef == null
|
||||
? null
|
||||
: parseEntityRef(safeView.routeRef, "route");
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("begin");
|
||||
const route = routeId == null
|
||||
? null
|
||||
: await findActiveRoute(client, routeId, safeView);
|
||||
const id = randomUUID();
|
||||
const inserted = await client.query(
|
||||
`insert into device_gateway_message_receipts (
|
||||
id,
|
||||
idempotency_key,
|
||||
request_digest,
|
||||
edge_ref,
|
||||
adapter_ref,
|
||||
protocol_profile_ref,
|
||||
protocol,
|
||||
route_id,
|
||||
project_id,
|
||||
session_ref,
|
||||
message_ref,
|
||||
message_type,
|
||||
sequence,
|
||||
identifier_kind,
|
||||
identifier_digest,
|
||||
identifier_masked,
|
||||
payload_schema_ref,
|
||||
payload,
|
||||
observed_at
|
||||
) values (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
|
||||
$11, $12, $13, $14, $15, $16, $17, $18::jsonb, $19
|
||||
)
|
||||
on conflict (idempotency_key) do nothing
|
||||
returning id, idempotency_key, accepted_at`,
|
||||
[
|
||||
id,
|
||||
safeView.idempotencyKey,
|
||||
requestDigest,
|
||||
safeView.edgeRef,
|
||||
safeView.adapterRef,
|
||||
safeView.protocolProfileRef,
|
||||
safeView.protocol,
|
||||
route?.id ?? null,
|
||||
route?.project_id ?? null,
|
||||
safeView.sessionRef,
|
||||
safeView.messageRef,
|
||||
safeView.messageType,
|
||||
safeView.sequence,
|
||||
safeView.identifier.kind,
|
||||
identifierDigest,
|
||||
safeView.identifier.masked,
|
||||
safeView.payloadSchemaRef,
|
||||
JSON.stringify(safeView.payload),
|
||||
safeView.observedAt,
|
||||
],
|
||||
);
|
||||
if (inserted.rows[0]) {
|
||||
await client.query("commit");
|
||||
return acceptanceView(inserted.rows[0], false);
|
||||
}
|
||||
|
||||
const existing = await client.query(
|
||||
`select id, idempotency_key, request_digest, accepted_at
|
||||
from device_gateway_message_receipts
|
||||
where idempotency_key = $1
|
||||
for share`,
|
||||
[safeView.idempotencyKey],
|
||||
);
|
||||
const row = existing.rows[0];
|
||||
if (!row) throw domainError("device_gateway_receipt_missing", 409);
|
||||
if (row.request_digest !== requestDigest) {
|
||||
throw domainError("device_gateway_idempotency_conflict", 409);
|
||||
}
|
||||
await client.query("commit");
|
||||
return acceptanceView(row, true);
|
||||
} catch (error) {
|
||||
await client.query("rollback").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function findActiveRoute(client, routeId, safeView) {
|
||||
const edgeId = parseEntityRef(safeView.edgeRef, "edge");
|
||||
const result = await client.query(
|
||||
`select r.id, r.project_id, r.edge_id, r.model_profile_ref,
|
||||
r.protocol, r.lifecycle_state,
|
||||
e.lifecycle_state as edge_lifecycle_state,
|
||||
p.lifecycle_state as profile_lifecycle_state,
|
||||
ap.package_key as adapter_ref,
|
||||
ap.lifecycle_state as adapter_lifecycle_state,
|
||||
av.lifecycle_state as adapter_version_lifecycle_state
|
||||
from device_routes r
|
||||
join device_edges e on e.id = r.edge_id
|
||||
join device_model_profiles p on p.profile_ref = r.model_profile_ref
|
||||
join device_adapter_versions av on av.id = p.adapter_version_id
|
||||
join device_adapter_packages ap on ap.id = av.adapter_package_id
|
||||
where r.id = $1
|
||||
for share`,
|
||||
[routeId],
|
||||
);
|
||||
const route = result.rows[0];
|
||||
if (!route) throw domainError("device_gateway_route_not_found", 404);
|
||||
if (
|
||||
route.lifecycle_state !== "active"
|
||||
|| route.edge_lifecycle_state !== "active"
|
||||
|| route.profile_lifecycle_state !== "active"
|
||||
|| route.adapter_lifecycle_state !== "active"
|
||||
|| route.adapter_version_lifecycle_state !== "active"
|
||||
) {
|
||||
throw domainError("device_gateway_route_not_active", 409);
|
||||
}
|
||||
if (
|
||||
route.edge_id !== edgeId
|
||||
|| route.model_profile_ref !== safeView.protocolProfileRef
|
||||
|| route.protocol !== safeView.protocol
|
||||
|| route.adapter_ref !== safeView.adapterRef
|
||||
) {
|
||||
throw domainError("device_gateway_route_contract_mismatch", 409);
|
||||
}
|
||||
return route;
|
||||
}
|
||||
|
||||
function acceptanceView(row, replayed) {
|
||||
return {
|
||||
schemaVersion: "nodedc.device-adapter-acceptance.v1",
|
||||
acceptanceRef: `acceptance:${row.id}`,
|
||||
idempotencyKey: row.idempotency_key,
|
||||
status: "accepted",
|
||||
replayed,
|
||||
acceptedAt: new Date(row.accepted_at).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseEntityRef(value, prefix) {
|
||||
if (typeof value !== "string") {
|
||||
throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
}
|
||||
const match = value.match(new RegExp(
|
||||
`^${prefix}:([0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
|
||||
"i",
|
||||
));
|
||||
if (!match) throw new TypeError(`device_${prefix}_ref_invalid`);
|
||||
return match[1].toLowerCase();
|
||||
}
|
||||
|
||||
function domainError(code, statusCode) {
|
||||
const error = new Error(code);
|
||||
error.statusCode = statusCode;
|
||||
return error;
|
||||
}
|
||||
@@ -5,8 +5,8 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import pg from "pg";
|
||||
|
||||
import { ARUSNAVI_B2_MODEL_PROFILE } from "../../../packages/arusnavi-b2-adapter/src/index.mjs";
|
||||
import { observeQuarantineDiscovery } from "./discovery-repository.mjs";
|
||||
import { acceptGatewayMessage } from "./gateway-message-repository.mjs";
|
||||
import {
|
||||
applyControlResourceManagementCommand,
|
||||
authorizeControlResourceManagementReplay,
|
||||
@@ -56,6 +56,7 @@ const migrationFiles = [
|
||||
"009_device_sensitive_reference_commands.sql",
|
||||
"010_device_control_resources.sql",
|
||||
"011_device_control_resource_commands.sql",
|
||||
"012_device_gateway_message_receipts.sql",
|
||||
];
|
||||
|
||||
export class PostgresDeviceRepository {
|
||||
@@ -88,30 +89,6 @@ export class PostgresDeviceRepository {
|
||||
);
|
||||
await this.pool.query(sql);
|
||||
}
|
||||
await this.pool.query(
|
||||
`insert into device_model_profiles (
|
||||
profile_ref,
|
||||
schema_version,
|
||||
vendor,
|
||||
model,
|
||||
device_type,
|
||||
protocol,
|
||||
profile
|
||||
) values ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
on conflict (profile_ref) do update set
|
||||
schema_version = excluded.schema_version,
|
||||
profile = excluded.profile,
|
||||
updated_at = now()`,
|
||||
[
|
||||
ARUSNAVI_B2_MODEL_PROFILE.profileRef,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.schemaVersion,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.vendor,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.model,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.deviceType,
|
||||
ARUSNAVI_B2_MODEL_PROFILE.protocol,
|
||||
JSON.stringify(ARUSNAVI_B2_MODEL_PROFILE),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async health() {
|
||||
@@ -126,6 +103,13 @@ export class PostgresDeviceRepository {
|
||||
});
|
||||
}
|
||||
|
||||
async acceptAdapterMessage(input) {
|
||||
return acceptGatewayMessage({
|
||||
pool: this.pool,
|
||||
...input,
|
||||
});
|
||||
}
|
||||
|
||||
async executeManagementCommand({
|
||||
idempotencyKey,
|
||||
commandKind,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||
DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
} from "../../../packages/device-protocol-contract/src/index.mjs";
|
||||
import { createControlCoreApp } from "../src/app.mjs";
|
||||
@@ -362,6 +363,9 @@ test("authenticated ingest stores only digest and returns a masked view", async
|
||||
},
|
||||
};
|
||||
},
|
||||
acceptAdapterMessage: async () => {
|
||||
throw new Error("must_not_accept_message");
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
@@ -400,6 +404,54 @@ test("authenticated ingest stores only digest and returns a masked view", async
|
||||
}
|
||||
});
|
||||
|
||||
test("gateway message endpoint returns acceptance only after repository commit", async () => {
|
||||
let stored;
|
||||
const runtime = await startTestServer({
|
||||
discoveryIngestEnabled: true,
|
||||
gatewayToken,
|
||||
identifierPepper,
|
||||
repository: {
|
||||
health: async () => "ready",
|
||||
upsertQuarantineDiscovery: async () => {
|
||||
throw new Error("must_not_observe_discovery");
|
||||
},
|
||||
acceptAdapterMessage: async (value) => {
|
||||
stored = value;
|
||||
return {
|
||||
schemaVersion: "nodedc.device-adapter-acceptance.v1",
|
||||
acceptanceRef: "acceptance:test-001",
|
||||
idempotencyKey: value.safeView.idempotencyKey,
|
||||
status: "accepted",
|
||||
replayed: false,
|
||||
acceptedAt: "2026-08-11T12:00:00.000Z",
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${runtime.baseUrl}/internal/v1/gateway/messages:accept`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${gatewayToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(fakeAdapterMessage()),
|
||||
},
|
||||
);
|
||||
assert.equal(response.status, 201);
|
||||
const body = await response.json();
|
||||
assert.equal(body.acceptance.status, "accepted");
|
||||
assert.match(stored.identifierDigest, /^hmac-sha256:[a-f0-9]{64}$/);
|
||||
assert.match(stored.requestDigest, /^sha256:[a-f0-9]{64}$/);
|
||||
assert.equal(stored.safeView.identifier.masked, "***********0001");
|
||||
assert.equal(JSON.stringify(stored).includes(fakeImei), false);
|
||||
} finally {
|
||||
await runtime.close();
|
||||
}
|
||||
});
|
||||
|
||||
function fakeSignal() {
|
||||
return {
|
||||
schemaVersion: DEVICE_DISCOVERY_SIGNAL_SCHEMA,
|
||||
@@ -417,6 +469,29 @@ function fakeSignal() {
|
||||
};
|
||||
}
|
||||
|
||||
function fakeAdapterMessage() {
|
||||
return {
|
||||
schemaVersion: DEVICE_ADAPTER_MESSAGE_SCHEMA,
|
||||
edgeRef: "edge:test-001",
|
||||
adapterRef: "arusnavi-b2",
|
||||
protocolProfileRef: "arusnavi.b2.internal.v1",
|
||||
protocol: "INTERNAL",
|
||||
sessionRef: "session:test-001",
|
||||
messageRef: "package:1:test",
|
||||
messageType: "telemetry.package",
|
||||
sequence: 1,
|
||||
observedAt: "2026-08-11T12:00:00.000Z",
|
||||
idempotencyKey: `sha256:${"a".repeat(64)}`,
|
||||
identifier: { kind: "imei", value: fakeImei },
|
||||
payloadSchemaRef: "arusnavi.internal.package-metadata.v1",
|
||||
payload: {
|
||||
packageNumber: 1,
|
||||
packetCount: 1,
|
||||
packageDigest: `sha256:${"b".repeat(64)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function managementHeaders({ includeIdempotency = true } = {}) {
|
||||
return {
|
||||
Authorization: `Bearer ${managementToken}`,
|
||||
|
||||
@@ -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/012_device_gateway_message_receipts.sql",
|
||||
import.meta.url,
|
||||
);
|
||||
const repositoryUrl = new URL("../src/postgres-repository.mjs", import.meta.url);
|
||||
|
||||
test("gateway receipts persist only typed bounded Core acceptance evidence", async () => {
|
||||
const sql = await readFile(migrationUrl, "utf8");
|
||||
|
||||
assert.match(sql, /create table if not exists device_gateway_message_receipts/);
|
||||
assert.match(sql, /unique \(idempotency_key\)/);
|
||||
assert.match(sql, /unique \(edge_ref, session_ref, message_ref\)/);
|
||||
assert.match(sql, /identifier_digest text not null/);
|
||||
assert.match(sql, /identifier_masked text not null/);
|
||||
assert.match(sql, /payload_schema_ref text not null/);
|
||||
assert.match(sql, /payload jsonb not null/);
|
||||
assert.match(sql, /device_gateway_message_receipts_immutable_guard/);
|
||||
assert.doesNotMatch(sql, /raw_packet|raw_identifier|password|token|secret/i);
|
||||
assert.doesNotMatch(sql, /insert\s+into|arusnavi|gelios|\bb2\b|imei/i);
|
||||
});
|
||||
|
||||
test("gateway receipt migration follows the generic control resource schema", async () => {
|
||||
const repository = await readFile(repositoryUrl, "utf8");
|
||||
const controlResourceIndex = repository.indexOf(
|
||||
"011_device_control_resource_commands.sql",
|
||||
);
|
||||
const gatewayReceiptIndex = repository.indexOf(
|
||||
"012_device_gateway_message_receipts.sql",
|
||||
);
|
||||
|
||||
assert.notEqual(controlResourceIndex, -1);
|
||||
assert.notEqual(gatewayReceiptIndex, -1);
|
||||
assert.ok(controlResourceIndex < gatewayReceiptIndex);
|
||||
assert.doesNotMatch(repository, /arusnavi-b2-adapter/);
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { acceptGatewayMessage } from "../src/gateway-message-repository.mjs";
|
||||
|
||||
const acceptedAt = new Date("2026-08-11T12:00:00.000Z");
|
||||
const idempotencyKey = `sha256:${"a".repeat(64)}`;
|
||||
const requestDigest = `sha256:${"b".repeat(64)}`;
|
||||
|
||||
test("commits a gateway receipt before returning Core acceptance", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_gateway_message_receipts", {
|
||||
rows: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
idempotency_key: idempotencyKey,
|
||||
accepted_at: acceptedAt,
|
||||
}],
|
||||
}),
|
||||
step("commit"),
|
||||
]);
|
||||
|
||||
const result = await acceptGatewayMessage(messageInput(client));
|
||||
|
||||
assert.equal(result.status, "accepted");
|
||||
assert.equal(result.replayed, false);
|
||||
assert.equal(result.idempotencyKey, idempotencyKey);
|
||||
assert.equal(result.acceptedAt, acceptedAt.toISOString());
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("replays one durable receipt for the same normalized request", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_gateway_message_receipts", { rows: [] }),
|
||||
step("from device_gateway_message_receipts", {
|
||||
rows: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
idempotency_key: idempotencyKey,
|
||||
request_digest: requestDigest,
|
||||
accepted_at: acceptedAt,
|
||||
}],
|
||||
}),
|
||||
step("commit"),
|
||||
]);
|
||||
|
||||
const result = await acceptGatewayMessage(messageInput(client));
|
||||
|
||||
assert.equal(result.status, "accepted");
|
||||
assert.equal(result.replayed, true);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("rejects idempotency reuse with different content", async () => {
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("insert into device_gateway_message_receipts", { rows: [] }),
|
||||
step("from device_gateway_message_receipts", {
|
||||
rows: [{
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
idempotency_key: idempotencyKey,
|
||||
request_digest: `sha256:${"c".repeat(64)}`,
|
||||
accepted_at: acceptedAt,
|
||||
}],
|
||||
}),
|
||||
step("rollback"),
|
||||
]);
|
||||
|
||||
await assert.rejects(
|
||||
acceptGatewayMessage(messageInput(client)),
|
||||
/device_gateway_idempotency_conflict/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
test("fails closed when a route does not match its Edge contract", async () => {
|
||||
const routeId = "22222222-2222-4222-8222-222222222222";
|
||||
const client = scriptedClient([
|
||||
step("begin"),
|
||||
step("from device_routes r", {
|
||||
rows: [{
|
||||
id: routeId,
|
||||
project_id: "33333333-3333-4333-8333-333333333333",
|
||||
edge_id: "44444444-4444-4444-8444-444444444444",
|
||||
model_profile_ref: "generic.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
lifecycle_state: "active",
|
||||
edge_lifecycle_state: "active",
|
||||
profile_lifecycle_state: "active",
|
||||
adapter_ref: "generic-adapter",
|
||||
adapter_lifecycle_state: "active",
|
||||
adapter_version_lifecycle_state: "active",
|
||||
}],
|
||||
}),
|
||||
step("rollback"),
|
||||
]);
|
||||
const input = messageInput(client);
|
||||
input.safeView.routeRef = `route:${routeId}`;
|
||||
input.safeView.edgeRef = "edge:55555555-5555-4555-8555-555555555555";
|
||||
|
||||
await assert.rejects(
|
||||
acceptGatewayMessage(input),
|
||||
/device_gateway_route_contract_mismatch/,
|
||||
);
|
||||
assert.equal(client.remaining(), 0);
|
||||
assert.equal(client.released, true);
|
||||
});
|
||||
|
||||
function messageInput(client) {
|
||||
return {
|
||||
pool: {
|
||||
connect: async () => client,
|
||||
},
|
||||
identifierDigest: `hmac-sha256:${"d".repeat(64)}`,
|
||||
requestDigest,
|
||||
safeView: {
|
||||
edgeRef: "edge:test-001",
|
||||
adapterRef: "generic-adapter",
|
||||
protocolProfileRef: "generic.model.protocol.v1",
|
||||
protocol: "GENERIC_TCP",
|
||||
sessionRef: "session:test-001",
|
||||
messageRef: "message:test-001",
|
||||
messageType: "telemetry.sample",
|
||||
sequence: 1,
|
||||
idempotencyKey,
|
||||
identifier: {
|
||||
kind: "serial",
|
||||
masked: "********0001",
|
||||
},
|
||||
payloadSchemaRef: "generic.telemetry.v1",
|
||||
payload: { value: 1 },
|
||||
observedAt: "2026-08-11T12:00:00.000Z",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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