- OperationRegistry.execute() now returns Promise<ResponseEnvelope<TOutput>> - Applies shared result pipeline: detect → wrap → normalize → validate - Uses KindGuard.IsUnknown() to check if Value.Cast should be applied - PendingRequestMap.call() returns Promise<ResponseEnvelope> - PendingRequestMap.respond() validates envelope via isResponseEnvelope() - CallHandler captures handler result, wraps, normalizes, validates, publishes - CallEventSchema call.responded.output changed to ResponseEnvelopeSchema - subscribe() yields ResponseEnvelope with isResponseEnvelope() passthrough - OperationEnv inner functions return Promise<ResponseEnvelope> - Tests updated for all new return types and behaviors - 171 tests passing, build and lint clean
263 lines
7.6 KiB
TypeScript
263 lines
7.6 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { PendingRequestMap, buildCallHandler } from "../src/call.js";
|
|
import { CallError, InfrastructureErrorCode } from "../src/error.js";
|
|
import { OperationRegistry } from "../src/registry.js";
|
|
import { Type } from "@alkdev/typebox";
|
|
import { OperationType } from "../src/types.js";
|
|
import type { Identity } from "../src/types.js";
|
|
import { localEnvelope, isResponseEnvelope, type ResponseEnvelope } from "../src/response-envelope.js";
|
|
|
|
describe("PendingRequestMap", () => {
|
|
it("creates instance without event target", () => {
|
|
const map = new PendingRequestMap();
|
|
expect(map.getPendingCount()).toBe(0);
|
|
});
|
|
|
|
it("creates instance with event target", () => {
|
|
const target = new EventTarget();
|
|
const map = new PendingRequestMap(target);
|
|
expect(map.getPendingCount()).toBe(0);
|
|
});
|
|
|
|
it("call() resolves when respond() is called with envelope", async () => {
|
|
const map = new PendingRequestMap();
|
|
|
|
const callPromise = map.call("test.op", { value: "hello" });
|
|
|
|
setTimeout(() => {
|
|
const requestId = [...map["requests"].keys()][0];
|
|
map.respond(requestId, localEnvelope({ result: "world" }, "test.op"));
|
|
}, 10);
|
|
|
|
const result = await callPromise;
|
|
expect(isResponseEnvelope(result)).toBe(true);
|
|
expect(result.meta.source).toBe("local");
|
|
expect(result.data).toEqual({ result: "world" });
|
|
});
|
|
|
|
it("respond() throws when called with non-envelope value", () => {
|
|
const map = new PendingRequestMap();
|
|
expect(() => map.respond("req-1", { result: "world" } as any)).toThrow("ResponseEnvelope");
|
|
});
|
|
|
|
it("call() rejects when emitError() is called", async () => {
|
|
const map = new PendingRequestMap();
|
|
|
|
const callPromise = map.call("test.op", { value: "hello" });
|
|
|
|
setTimeout(() => {
|
|
const requestId = [...map["requests"].keys()][0];
|
|
map.emitError(requestId, "CUSTOM_ERROR", "Something went wrong");
|
|
}, 10);
|
|
|
|
await expect(callPromise).rejects.toThrow("Something went wrong");
|
|
await expect(callPromise).rejects.toBeInstanceOf(CallError);
|
|
});
|
|
|
|
it("abort() rejects the pending call", async () => {
|
|
const map = new PendingRequestMap();
|
|
|
|
const callPromise = map.call("test.op", { value: "hello" });
|
|
|
|
setTimeout(() => {
|
|
const requestId = [...map["requests"].keys()][0];
|
|
map.abort(requestId);
|
|
}, 10);
|
|
|
|
await expect(callPromise).rejects.toThrow("was aborted");
|
|
await expect(callPromise).rejects.toBeInstanceOf(CallError);
|
|
});
|
|
|
|
it("call() with deadline times out", async () => {
|
|
const map = new PendingRequestMap();
|
|
|
|
const deadline = Date.now() + 50;
|
|
const callPromise = map.call("test.op", { value: "hello" }, { deadline });
|
|
|
|
await expect(callPromise).rejects.toThrow("timed out");
|
|
await expect(callPromise).rejects.toBeInstanceOf(CallError);
|
|
});
|
|
|
|
it("tracks pending requests", () => {
|
|
const map = new PendingRequestMap();
|
|
map.call("test.op1", {});
|
|
map.call("test.op2", {});
|
|
expect(map.getPendingCount()).toBe(2);
|
|
});
|
|
|
|
it("cleans up after call resolves", async () => {
|
|
const map = new PendingRequestMap();
|
|
const callPromise = map.call("test.op", { value: "hello" });
|
|
expect(map.getPendingCount()).toBe(1);
|
|
|
|
const requestId = [...map["requests"].keys()][0];
|
|
map.respond(requestId, localEnvelope({ result: "done" }, "test.op"));
|
|
|
|
await callPromise;
|
|
expect(map.getPendingCount()).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("checkAccess resource access control", () => {
|
|
function makeRegistry(accessControlOverrides: Record<string, unknown> = {}) {
|
|
const registry = new OperationRegistry();
|
|
registry.register({
|
|
name: "guarded",
|
|
namespace: "test",
|
|
version: "1.0.0",
|
|
type: OperationType.QUERY,
|
|
description: "guarded op",
|
|
inputSchema: Type.Object({}),
|
|
outputSchema: Type.Object({ ok: Type.Boolean() }),
|
|
accessControl: {
|
|
requiredScopes: [],
|
|
resourceType: "project",
|
|
resourceAction: "read",
|
|
...accessControlOverrides,
|
|
},
|
|
handler: async () => ({ ok: true }),
|
|
});
|
|
registry.register({
|
|
name: "open",
|
|
namespace: "test",
|
|
version: "1.0.0",
|
|
type: OperationType.QUERY,
|
|
description: "open op",
|
|
inputSchema: Type.Object({}),
|
|
outputSchema: Type.Object({ ok: Type.Boolean() }),
|
|
accessControl: {
|
|
requiredScopes: [],
|
|
},
|
|
handler: async () => ({ ok: true }),
|
|
});
|
|
return registry;
|
|
}
|
|
|
|
it("denies access when resourceType/resourceAction are set and identity.resources is undefined", async () => {
|
|
const registry = makeRegistry();
|
|
const handler = buildCallHandler({ registry });
|
|
|
|
const identity: Identity = { id: "user1", scopes: [] };
|
|
|
|
await expect(
|
|
handler({
|
|
requestId: "r1",
|
|
operationId: "test.guarded",
|
|
input: {},
|
|
identity,
|
|
}),
|
|
).rejects.toThrow("Access denied");
|
|
});
|
|
|
|
it("denies access when resourceType/resourceAction are set and identity.resources is empty", async () => {
|
|
const registry = makeRegistry();
|
|
const handler = buildCallHandler({ registry });
|
|
|
|
const identity: Identity = { id: "user1", scopes: [], resources: {} };
|
|
|
|
await expect(
|
|
handler({
|
|
requestId: "r1",
|
|
operationId: "test.guarded",
|
|
input: {},
|
|
identity,
|
|
}),
|
|
).rejects.toThrow("Access denied");
|
|
});
|
|
|
|
it("denies access when identity.resources has no matching resource type", async () => {
|
|
const registry = makeRegistry();
|
|
const handler = buildCallHandler({ registry });
|
|
|
|
const identity: Identity = {
|
|
id: "user1",
|
|
scopes: [],
|
|
resources: { "document:abc": ["read"] },
|
|
};
|
|
|
|
await expect(
|
|
handler({
|
|
requestId: "r1",
|
|
operationId: "test.guarded",
|
|
input: {},
|
|
identity,
|
|
}),
|
|
).rejects.toThrow("Access denied");
|
|
});
|
|
|
|
it("denies access when identity.resources has matching type but wrong action", async () => {
|
|
const registry = makeRegistry();
|
|
const handler = buildCallHandler({ registry });
|
|
|
|
const identity: Identity = {
|
|
id: "user1",
|
|
scopes: [],
|
|
resources: { "project:abc": ["write"] },
|
|
};
|
|
|
|
await expect(
|
|
handler({
|
|
requestId: "r1",
|
|
operationId: "test.guarded",
|
|
input: {},
|
|
identity,
|
|
}),
|
|
).rejects.toThrow("Access denied");
|
|
});
|
|
|
|
it("grants access when identity.resources has matching type and action", async () => {
|
|
const registry = makeRegistry();
|
|
const handler = buildCallHandler({ registry });
|
|
|
|
const identity: Identity = {
|
|
id: "user1",
|
|
scopes: [],
|
|
resources: { "project:abc": ["read"] },
|
|
};
|
|
|
|
await expect(
|
|
handler({
|
|
requestId: "r1",
|
|
operationId: "test.guarded",
|
|
input: {},
|
|
identity,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("grants access when neither resourceType nor resourceAction are set", async () => {
|
|
const registry = makeRegistry();
|
|
const handler = buildCallHandler({ registry });
|
|
|
|
const identity: Identity = { id: "user1", scopes: [] };
|
|
|
|
await expect(
|
|
handler({
|
|
requestId: "r1",
|
|
operationId: "test.open",
|
|
input: {},
|
|
identity,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
it("grants access when identity.resources matches and identity has no scopes required", async () => {
|
|
const registry = makeRegistry();
|
|
const handler = buildCallHandler({ registry });
|
|
|
|
const identity: Identity = {
|
|
id: "user1",
|
|
scopes: ["some:scope"],
|
|
resources: { "project:xyz": ["read", "write"] },
|
|
};
|
|
|
|
await expect(
|
|
handler({
|
|
requestId: "r1",
|
|
operationId: "test.guarded",
|
|
input: {},
|
|
identity,
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
}); |