mirror of
https://github.com/saltbo/agent-kanban.git
synced 2026-09-19 09:01:47 +08:00
1368 lines
64 KiB
TypeScript
1368 lines
64 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { calculateJwkThumbprint, exportJWK, generateKeyPair, SignJWT } from "jose";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { createBoard } from "../../../server/adapters/d1/boardRepo";
|
|
import { createRepository } from "../../../server/adapters/d1/repositoryRepo";
|
|
import { addTaskAction, createTask } from "../../../server/adapters/d1/taskRepo";
|
|
import { d1TaskAssignmentRepository } from "../../../server/adapters/d1/tasks/d1TaskAssignments";
|
|
import { d1TaskReviewSubmissionRepository } from "../../../server/adapters/d1/tasks/d1TaskReviewSubmissions";
|
|
import type { Env } from "../../../server/env";
|
|
import { api } from "../../../server/http/app";
|
|
import { replaceTaskAssignment } from "../../../server/usecases/tasks/replaceTaskAssignment";
|
|
import { replaceTaskReviewSubmission } from "../../../server/usecases/tasks/replaceTaskReviewSubmission";
|
|
import { createTestEnv, createTestWebSession, seedUser, setupMiniflare } from "../../helpers/db";
|
|
|
|
const issuer = "https://id.realmroot.dev/api/auth";
|
|
const resource = "https://ak.toolbox.test/api";
|
|
const jwksUri = `${issuer}/jwks`;
|
|
const issuerKeysPromise = generateKeyPair("ES256", { extractable: true });
|
|
const tenantId = "tenant-toolbox-generic";
|
|
const foreignTenantId = "tenant-toolbox-foreign";
|
|
const inboxResource = "https://inbox.test/api";
|
|
|
|
let mf: Awaited<ReturnType<typeof setupMiniflare>>["mf"];
|
|
let db: D1Database;
|
|
let env: Env;
|
|
let inboxResponseStatus: number;
|
|
let oidcDiscoveryRequests: number;
|
|
let machineTokenRequests: Request[];
|
|
let inboxMessageRequests: Request[];
|
|
|
|
beforeEach(async () => {
|
|
({ mf, db } = await setupMiniflare());
|
|
env = { ...createTestEnv(), DB: db, AK_PUBLIC_ORIGIN: new URL(resource).origin } as Env;
|
|
inboxResponseStatus = 201;
|
|
oidcDiscoveryRequests = 0;
|
|
machineTokenRequests = [];
|
|
inboxMessageRequests = [];
|
|
await seedUser(db, tenantId, "toolbox-generic@example.test");
|
|
await seedUser(db, foreignTenantId, "toolbox-foreign@example.test");
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
await mf.dispose();
|
|
});
|
|
|
|
describe("Realmroot Agent generic Toolbox operations", () => {
|
|
it("[spec: tasks/cancel] notifies Inbox after Task lifecycle writes, retries notification on idempotent replay, and maps failure to 503", async () => {
|
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
env = {
|
|
...env,
|
|
OIDC_ISSUER: issuer,
|
|
INBOX_RESOURCE: inboxResource,
|
|
INBOX_API_VERSION: "2026-08-31",
|
|
OIDC_SERVICE_CLIENT_ID: "agent-kanban",
|
|
OIDC_SERVICE_CLIENT_SECRET: "inbox-client-secret",
|
|
};
|
|
|
|
for (const kind of ["assignment", "rejection", "completion", "cancellation"] as const) {
|
|
inboxResponseStatus = 201;
|
|
oidcDiscoveryRequests = 0;
|
|
machineTokenRequests = [];
|
|
inboxMessageRequests = [];
|
|
const assigneeActorId = `notified-${kind}-agent`;
|
|
const board = await createBoard(db, tenantId, `Inbox ${kind}`, "ops");
|
|
const task = await createTask(db, tenantId, { title: `Inbox ${kind} Task`, board_id: board.id });
|
|
let path: string;
|
|
let scope: string;
|
|
let body: unknown;
|
|
let event: string;
|
|
|
|
if (kind === "assignment") {
|
|
path = `/task-assignments/${task.id}`;
|
|
scope = "task:assign";
|
|
body = { agentActorId: assigneeActorId };
|
|
event = "assigned";
|
|
} else if (kind === "cancellation") {
|
|
await replaceTaskAssignment(d1TaskAssignmentRepository(db), {
|
|
ownerId: tenantId,
|
|
taskId: task.id,
|
|
assigneeActorId,
|
|
assignedByActorId: "assigner",
|
|
});
|
|
path = `/task-cancellations/${task.id}`;
|
|
scope = "task:cancel";
|
|
body = undefined;
|
|
event = "cancelled";
|
|
} else {
|
|
await replaceTaskAssignment(d1TaskAssignmentRepository(db), {
|
|
ownerId: tenantId,
|
|
taskId: task.id,
|
|
assigneeActorId,
|
|
assignedByActorId: "assigner",
|
|
});
|
|
await db.prepare("UPDATE tasks SET status = 'in_progress' WHERE id = ?").bind(task.id).run();
|
|
const submission = await replaceTaskReviewSubmission(d1TaskReviewSubmissionRepository(db), {
|
|
ownerId: tenantId,
|
|
taskId: task.id,
|
|
agentActorId: assigneeActorId,
|
|
pullRequestUrl: null,
|
|
});
|
|
path = `/task-review-${kind === "rejection" ? "rejections" : "completions"}/${task.id}`;
|
|
scope = kind === "rejection" ? "task:reject" : "task:complete";
|
|
body = { reviewSubmissionVersion: submission.version, ...(kind === "rejection" ? { reason: "needs changes" } : {}) };
|
|
event = kind === "rejection" ? "review_rejected" : "completed";
|
|
}
|
|
|
|
const invoke = () => request("PUT", path, scope, body, true, "2026-08-29", null, "lifecycle-reviewer");
|
|
const created = await invoke();
|
|
expect([200, 201], `${kind} create: ${await created.clone().text()}`).toContain(created.status);
|
|
const replayed = await invoke();
|
|
expect(replayed.status, `${kind} replay: ${await replayed.clone().text()}`).toBe(200);
|
|
inboxResponseStatus = 503;
|
|
const unavailable = await invoke();
|
|
expect(unavailable.status).toBe(503);
|
|
expect(unavailable.headers.get("Retry-After")).toBe("5");
|
|
await expect(unavailable.json()).resolves.toMatchObject({
|
|
status: 503,
|
|
type: `${resource}/problems/task-notification-unavailable`,
|
|
});
|
|
|
|
expect(oidcDiscoveryRequests).toBeGreaterThanOrEqual(3);
|
|
expect(machineTokenRequests).toHaveLength(3);
|
|
expect(inboxMessageRequests).toHaveLength(3);
|
|
for (const tokenRequest of machineTokenRequests) {
|
|
expect(tokenRequest.headers.get("Authorization")).toBe(`Basic ${btoa("agent-kanban:inbox-client-secret")}`);
|
|
await expect(tokenRequest.clone().text()).resolves.toContain(`resource=${encodeURIComponent(inboxResource)}`);
|
|
await expect(tokenRequest.clone().text()).resolves.toContain("scope=messages%3Acreate");
|
|
}
|
|
const idempotencyKeys = new Set<string>();
|
|
for (const messageRequest of inboxMessageRequests) {
|
|
expect(messageRequest.headers.get("Authorization")).toBe("Bearer inbox-machine-token");
|
|
expect(messageRequest.headers.get("API-Version")).toBe("2026-08-31");
|
|
idempotencyKeys.add(messageRequest.headers.get("Idempotency-Key")!);
|
|
const message = (await messageRequest.clone().json()) as {
|
|
recipients: string[];
|
|
content: { text: string };
|
|
routingKey: string;
|
|
};
|
|
expect(message).toMatchObject({
|
|
recipients: [`agent:${assigneeActorId}`],
|
|
content: { text: expect.stringContaining(event) },
|
|
routingKey: `agent-kanban:task:${task.id}`,
|
|
});
|
|
expect(message.content.text).toContain(`${resource}/tasks/${task.id}`);
|
|
expect(message.content.text).not.toContain("API-Version");
|
|
expect(message.content.text).not.toContain("agent-kanban");
|
|
}
|
|
expect(idempotencyKeys.size).toBe(1);
|
|
if (kind === "cancellation") {
|
|
await expect(db.prepare("SELECT status FROM tasks WHERE id = ?").bind(task.id).first()).resolves.toEqual({ status: "cancelled" });
|
|
}
|
|
}
|
|
|
|
const completionEvents = consoleError.mock.calls
|
|
.map(([entry]) => JSON.parse(String(entry)) as Record<string, unknown>)
|
|
.filter((entry) => entry.name === "api" && entry.msg === "request completed" && entry.status === 503);
|
|
expect(completionEvents).toHaveLength(4);
|
|
for (const event of completionEvents) {
|
|
expect(event).toEqual(
|
|
expect.objectContaining({
|
|
result: "server_error",
|
|
error_name: "TaskLifecycleNotificationFailure",
|
|
error_message: "Inbox rejected the task notification",
|
|
error_stack: expect.stringContaining("Inbox rejected the task notification"),
|
|
error_cause: expect.objectContaining({
|
|
name: "Error",
|
|
message: "Inbox responded with HTTP 503",
|
|
stack: expect.stringContaining("Inbox responded with HTTP 503"),
|
|
}),
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("rejects schema-invalid resource writes without durable side effects", async () => {
|
|
const board = await createBoard(db, tenantId, "Schema validation board", "ops");
|
|
const noteParent = await createTask(db, tenantId, { title: "Schema validation note parent", board_id: board.id });
|
|
const before = await Promise.all([
|
|
db.prepare("SELECT COUNT(*) AS count FROM boards WHERE owner_id = ?").bind(tenantId).first<{ count: number }>(),
|
|
db.prepare("SELECT COUNT(*) AS count FROM repositories WHERE owner_id = ?").bind(tenantId).first<{ count: number }>(),
|
|
db.prepare("SELECT COUNT(*) AS count FROM tasks WHERE board_id = ?").bind(board.id).first<{ count: number }>(),
|
|
db.prepare("SELECT COUNT(*) AS count FROM task_actions WHERE task_id = ?").bind(noteParent.id).first<{ count: number }>(),
|
|
]);
|
|
|
|
for (const [path, scope, body] of [
|
|
["/boards", "board:write", { name: 42, type: "ops" }],
|
|
["/repositories", "repository:write", { name: 42, url: "https://github.com/example/schema-invalid.git" }],
|
|
["/tasks", "task:write", { title: 42, boardId: board.id }],
|
|
["/tasks", "task:write", { title: "Array input", boardId: board.id, input: [] }],
|
|
["/tasks", "task:write", { title: "Null schedule", boardId: board.id, scheduledAt: null }],
|
|
[`/tasks/${noteParent.id}/notes`, "task:write", { detail: { text: "not a string" } }],
|
|
] as const) {
|
|
const response = await request("POST", path, scope, body);
|
|
expect([400, 422], `${path} ${JSON.stringify(body)}`).toContain(response.status);
|
|
expect(response.headers.get("content-type")).toContain("application/problem+json");
|
|
await expect(response.json()).resolves.toMatchObject({ status: response.status, type: expect.stringContaining("/problems/") });
|
|
}
|
|
|
|
await expect(
|
|
Promise.all([
|
|
db.prepare("SELECT COUNT(*) AS count FROM boards WHERE owner_id = ?").bind(tenantId).first(),
|
|
db.prepare("SELECT COUNT(*) AS count FROM repositories WHERE owner_id = ?").bind(tenantId).first(),
|
|
db.prepare("SELECT COUNT(*) AS count FROM tasks WHERE board_id = ?").bind(board.id).first(),
|
|
db.prepare("SELECT COUNT(*) AS count FROM task_actions WHERE task_id = ?").bind(noteParent.id).first(),
|
|
]),
|
|
).resolves.toEqual(before);
|
|
await expect(db.prepare("SELECT COUNT(*) AS count FROM resource_idempotency_records").first()).resolves.toEqual({ count: 0 });
|
|
});
|
|
|
|
it("coalesces concurrent same-key Task POSTs into one durable response snapshot", async () => {
|
|
const board = await createBoard(db, tenantId, "Concurrent idempotency board", "ops");
|
|
const key = "concurrent-task-create";
|
|
const body = { title: "Concurrent idempotent Task", boardId: board.id };
|
|
|
|
const responses = await Promise.all(Array.from({ length: 8 }, () => request("POST", "/tasks", "task:write", body, true, "2026-08-29", key)));
|
|
expect(responses.map((response) => response.status)).toEqual(Array.from({ length: 8 }, () => 201));
|
|
|
|
const snapshot = await db
|
|
.prepare(
|
|
`SELECT resource_id, response_status, response_body, response_location, response_etag
|
|
FROM resource_idempotency_records
|
|
WHERE owner_id = ? AND actor_id = ? AND api_version = ? AND idempotency_key = ?`,
|
|
)
|
|
.bind(tenantId, "actor-toolbox", "2026-08-29", key)
|
|
.first<{
|
|
resource_id: string;
|
|
response_status: number;
|
|
response_body: string;
|
|
response_location: string;
|
|
response_etag: string;
|
|
}>();
|
|
expect(snapshot).not.toBeNull();
|
|
for (const response of responses) {
|
|
expect(response.status).toBe(snapshot?.response_status);
|
|
expect(await response.text()).toBe(snapshot?.response_body);
|
|
expect(response.headers.get("Location")).toBe(snapshot?.response_location);
|
|
expect(response.headers.get("ETag")).toBe(snapshot?.response_etag);
|
|
}
|
|
expect(responses.filter((response) => response.headers.get("Idempotency-Replayed") === null)).toHaveLength(1);
|
|
expect(responses.filter((response) => response.headers.get("Idempotency-Replayed") === "true")).toHaveLength(7);
|
|
|
|
await expect(db.prepare("SELECT COUNT(*) AS count FROM tasks WHERE board_id = ?").bind(board.id).first()).resolves.toEqual({ count: 1 });
|
|
await expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM task_actions WHERE task_id = ? AND action = 'created'").bind(snapshot?.resource_id).first(),
|
|
).resolves.toEqual({ count: 1 });
|
|
await expect(
|
|
db
|
|
.prepare(
|
|
"SELECT COUNT(*) AS count FROM resource_idempotency_records WHERE owner_id = ? AND actor_id = ? AND api_version = ? AND idempotency_key = ?",
|
|
)
|
|
.bind(tenantId, "actor-toolbox", "2026-08-29", key)
|
|
.first(),
|
|
).resolves.toEqual({ count: 1 });
|
|
});
|
|
|
|
it.each(["human", "agent"] as const)("rejects a Bearer %s Resource token while accepting valid DPoP", async (principalType) => {
|
|
const bearerToken = await realmrootBearerToken(principalType);
|
|
const bearer = await api.fetch(
|
|
new Request(`${resource}/boards`, {
|
|
headers: { authorization: `Bearer ${bearerToken}`, "API-Version": "2026-08-29" },
|
|
}),
|
|
env,
|
|
);
|
|
|
|
expect(bearer.status).toBe(401);
|
|
await expect(bearer.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/authentication-required`,
|
|
title: "Authentication required",
|
|
status: 401,
|
|
detail: "Realmroot Resource token requires DPoP",
|
|
instance: expect.stringMatching(/^urn:request:/),
|
|
});
|
|
|
|
const dpop = await request("GET", "/boards", "board:read");
|
|
expect(dpop.status, await dpop.clone().text()).toBe(200);
|
|
});
|
|
|
|
it.each(["ak_legacy", "opaque-resource-token"])("returns RFC 9457 authentication failure for non-JWT Bearer token %s", async (token) => {
|
|
const response = await api.fetch(
|
|
new Request(`${resource}/boards`, {
|
|
headers: { authorization: `Bearer ${token}`, "API-Version": "2026-08-29" },
|
|
}),
|
|
env,
|
|
);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.headers.get("Content-Type")).toContain("application/problem+json");
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/authentication-required`,
|
|
title: "Authentication required",
|
|
status: 401,
|
|
detail: "Invalid OIDC access token",
|
|
instance: expect.stringMatching(/^urn:request:/),
|
|
});
|
|
});
|
|
|
|
it("returns a 5xx Problem for OIDC discovery and signing-key failures while malformed credentials remain 401", async () => {
|
|
const url = `${resource}/boards`;
|
|
const dependencyIssuer = `https://id-dependency-${randomUUID()}.test/api/auth`;
|
|
const dependencyJwksUri = `${dependencyIssuer}/jwks`;
|
|
env = { ...env, OIDC_ISSUER: dependencyIssuer };
|
|
const authority = await realmrootAgentAuthority(url, "GET", "board:read", "actor-toolbox", {
|
|
issuer: dependencyIssuer,
|
|
jwksUri: dependencyJwksUri,
|
|
});
|
|
const requestWithAuthority = () =>
|
|
api.fetch(
|
|
new Request(url, {
|
|
headers: {
|
|
authorization: `DPoP ${authority.accessToken}`,
|
|
dpop: authority.proof,
|
|
"API-Version": "2026-08-29",
|
|
},
|
|
}),
|
|
env,
|
|
);
|
|
|
|
const unavailableFetch = vi.fn(async () => Promise.reject(new Error("discovery unavailable sentinel")));
|
|
vi.stubGlobal("fetch", unavailableFetch);
|
|
const malformedCompact = await api.fetch(new Request(url, { headers: { authorization: "Bearer aaa.bbb.ccc" } }), env);
|
|
expect(malformedCompact.status).toBe(401);
|
|
await expect(malformedCompact.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/authentication-required`,
|
|
status: 401,
|
|
detail: "Invalid OIDC access token",
|
|
});
|
|
expect(unavailableFetch).not.toHaveBeenCalled();
|
|
|
|
const discoveryFailure = await requestWithAuthority();
|
|
expect(discoveryFailure.status).toBe(500);
|
|
await expect(discoveryFailure.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/internal-error`,
|
|
status: 500,
|
|
detail: "The server could not complete the request",
|
|
});
|
|
|
|
const invalidMetadata = [
|
|
{ name: "null document", value: null },
|
|
{
|
|
name: "wrong-type endpoint",
|
|
value: {
|
|
issuer: "replace-at-request-time",
|
|
authorization_endpoint: 42,
|
|
token_endpoint: "https://id.example.test/token",
|
|
jwks_uri: "https://id.example.test/jwks",
|
|
},
|
|
},
|
|
{
|
|
name: "invalid endpoint URL",
|
|
value: {
|
|
issuer: "replace-at-request-time",
|
|
authorization_endpoint: "not-an-absolute-url",
|
|
token_endpoint: "https://id.example.test/token",
|
|
jwks_uri: "https://id.example.test/jwks",
|
|
},
|
|
},
|
|
{
|
|
name: "non-HTTPS endpoint",
|
|
value: {
|
|
issuer: "replace-at-request-time",
|
|
authorization_endpoint: "http://id.example.test/authorize",
|
|
token_endpoint: "https://id.example.test/token",
|
|
jwks_uri: "https://id.example.test/jwks",
|
|
},
|
|
},
|
|
{
|
|
name: "empty algorithms",
|
|
value: {
|
|
issuer: "replace-at-request-time",
|
|
authorization_endpoint: "https://id.example.test/authorize",
|
|
token_endpoint: "https://id.example.test/token",
|
|
jwks_uri: "https://id.example.test/jwks",
|
|
id_token_signing_alg_values_supported: [],
|
|
},
|
|
},
|
|
{
|
|
name: "only unsafe algorithms",
|
|
value: {
|
|
issuer: "replace-at-request-time",
|
|
authorization_endpoint: "https://id.example.test/authorize",
|
|
token_endpoint: "https://id.example.test/token",
|
|
jwks_uri: "https://id.example.test/jwks",
|
|
id_token_signing_alg_values_supported: ["none", "HS256"],
|
|
},
|
|
},
|
|
] as const;
|
|
for (const invalid of invalidMetadata) {
|
|
const metadataIssuer = `https://id-metadata-${randomUUID()}.test/api/auth`;
|
|
env = { ...env, OIDC_ISSUER: metadataIssuer };
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const target = input instanceof Request ? input.url : String(input);
|
|
expect(target, invalid.name).toBe(`${metadataIssuer}/.well-known/openid-configuration`);
|
|
const value = invalid.value && typeof invalid.value === "object" ? { ...invalid.value, issuer: metadataIssuer } : invalid.value;
|
|
return Response.json(value);
|
|
}),
|
|
);
|
|
|
|
const response = await requestWithAuthority();
|
|
expect(response.status, invalid.name).toBe(500);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/internal-error`,
|
|
status: 500,
|
|
detail: "The server could not complete the request",
|
|
});
|
|
}
|
|
|
|
env = { ...env, OIDC_ISSUER: dependencyIssuer };
|
|
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const target = input instanceof Request ? input.url : String(input);
|
|
if (target === `${dependencyIssuer}/.well-known/openid-configuration`) {
|
|
return Response.json({
|
|
issuer: dependencyIssuer,
|
|
authorization_endpoint: `${dependencyIssuer}/oauth2/authorize`,
|
|
token_endpoint: `${dependencyIssuer}/oauth2/token`,
|
|
jwks_uri: dependencyJwksUri,
|
|
});
|
|
}
|
|
if (target === dependencyJwksUri) return new Response(null, { status: 503 });
|
|
throw new Error(`Unexpected request: ${target}`);
|
|
}),
|
|
);
|
|
const jwksFailure = await requestWithAuthority();
|
|
expect(jwksFailure.status).toBe(500);
|
|
await expect(jwksFailure.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/internal-error`,
|
|
status: 500,
|
|
detail: "The server could not complete the request",
|
|
});
|
|
});
|
|
|
|
it.each(["boards", "repositories"] as const)(
|
|
"[spec: resource-server/generic-operations] creates %s without an Idempotency-Key",
|
|
async (resourceKind) => {
|
|
const fixture = await creationFixture(resourceKind);
|
|
const before = await fixture.count();
|
|
|
|
const created = await request("POST", fixture.path, fixture.scope, fixture.body, true, "2026-08-29", null);
|
|
|
|
expect(created.status, await created.clone().text()).toBe(201);
|
|
await expect(fixture.count()).resolves.toBe(before + 1);
|
|
},
|
|
);
|
|
|
|
it.each(["tasks", "notes"] as const)(
|
|
"[spec: resource-server/generic-operations] requires and replays an RFC 8941 Idempotency-Key without duplicating %s creations",
|
|
async (resourceKind) => {
|
|
const fixture = await creationFixture(resourceKind);
|
|
const before = await fixture.count();
|
|
|
|
const missing = await request("POST", fixture.path, fixture.scope, fixture.body, true, "2026-08-29", null);
|
|
expect(missing.status).toBe(400);
|
|
await expect(missing.json()).resolves.toMatchObject({ type: expect.stringContaining("idempotency-key-required") });
|
|
await expect(fixture.count()).resolves.toBe(before);
|
|
|
|
const invalidRaw = await request("POST", fixture.path, fixture.scope, fixture.body, true, "2026-08-29", "unquoted-key", "actor-toolbox", false);
|
|
expect(invalidRaw.status).toBe(400);
|
|
await expect(invalidRaw.json()).resolves.toMatchObject({ type: expect.stringContaining("invalid-idempotency-key") });
|
|
await expect(fixture.count()).resolves.toBe(before);
|
|
|
|
const key = `generic-${resourceKind}-same-key`;
|
|
const created = await request("POST", fixture.path, fixture.scope, fixture.body, true, "2026-08-29", key);
|
|
expect(created.status, await created.clone().text()).toBe(201);
|
|
const createdBody = await created.clone().text();
|
|
const createdHeaders = {
|
|
location: created.headers.get("Location"),
|
|
etag: created.headers.get("ETag"),
|
|
};
|
|
expect(createdHeaders.location).toMatch(/^https:\/\/ak\.toolbox\.test\/api\//);
|
|
expect(createdHeaders.etag).toMatch(/^".+"$/);
|
|
const expectedCount = await mutateCreatedResource(resourceKind, (JSON.parse(createdBody) as { id: string }).id, before);
|
|
|
|
const replayed = await request("POST", fixture.path, fixture.scope, fixture.body, true, "2026-08-29", key);
|
|
expect(replayed.status).toBe(201);
|
|
expect(await replayed.text()).toBe(createdBody);
|
|
expect({ location: replayed.headers.get("Location"), etag: replayed.headers.get("ETag") }).toEqual(createdHeaders);
|
|
expect(replayed.headers.get("Idempotency-Replayed")).toBe("true");
|
|
await expect(fixture.count()).resolves.toBe(expectedCount);
|
|
|
|
const conflict = await request("POST", fixture.path, fixture.scope, fixture.conflictingBody, true, "2026-08-29", key);
|
|
expect(conflict.status).toBe(422);
|
|
await expect(conflict.json()).resolves.toMatchObject({ type: expect.stringContaining("idempotency-key-conflict") });
|
|
await expect(fixture.count()).resolves.toBe(expectedCount);
|
|
},
|
|
);
|
|
|
|
it("returns paginated lowerCamel Agent collections while preserving browser arrays and snake_case", async () => {
|
|
const firstBoard = await createBoard(db, tenantId, "Collection board one", "ops");
|
|
const secondBoard = await createBoard(db, tenantId, "Collection board two", "ops");
|
|
await createRepository(db, tenantId, { name: "collection-repository", url: "https://github.com/example/collection.git" });
|
|
const task = await createTask(db, tenantId, { title: "Collection Task", board_id: firstBoard.id });
|
|
await db.prepare("UPDATE tasks SET status = 'in_progress' WHERE id = ?").bind(task.id).run();
|
|
await addTaskAction(db, task.id, "realmroot:agent", "actor-toolbox", "commented", "Collection note");
|
|
|
|
const firstPage = await request("GET", "/boards?pageSize=1", "board:read");
|
|
expect(firstPage.status).toBe(200);
|
|
const firstPageBody = (await firstPage.json()) as {
|
|
items: Array<Record<string, unknown>>;
|
|
pagination: { pageSize: number; nextPageToken?: string };
|
|
};
|
|
expect(firstPageBody.items).toHaveLength(1);
|
|
expect(firstPageBody.pagination.nextPageToken).toEqual(expect.any(String));
|
|
expect(firstPageBody.pagination.pageSize).toBe(1);
|
|
expect(firstPage.headers.get("Link")).toContain('rel="next"');
|
|
expect(firstPageBody.items[0]).toHaveProperty("createdAt");
|
|
expect(firstPageBody.items[0]).not.toHaveProperty("created_at");
|
|
|
|
const secondPage = await request("GET", `/boards?pageSize=1&pageToken=${firstPageBody.pagination.nextPageToken}`, "board:read");
|
|
const secondPageBody = (await secondPage.json()) as { items: Array<{ id: string }> };
|
|
expect(secondPageBody.items).toHaveLength(1);
|
|
expect(secondPageBody.items[0].id).not.toBe(firstPageBody.items[0].id);
|
|
|
|
const tampered = await request("GET", `/boards?pageToken=${firstPageBody.pagination.nextPageToken}x`, "board:read");
|
|
expect(tampered.status).toBe(400);
|
|
const crossActor = await request(
|
|
"GET",
|
|
`/boards?pageToken=${firstPageBody.pagination.nextPageToken}`,
|
|
"board:read",
|
|
undefined,
|
|
true,
|
|
"2026-08-29",
|
|
null,
|
|
"actor-other",
|
|
);
|
|
expect(crossActor.status).toBe(400);
|
|
|
|
for (const [path, scope] of [
|
|
["/repositories", "repository:read"],
|
|
["/tasks?status=in-progress", "task:read"],
|
|
[`/tasks/${task.id}/notes`, "task:read"],
|
|
] as const) {
|
|
const response = await request("GET", path, scope);
|
|
expect(response.status, path).toBe(200);
|
|
await expect(response.json()).resolves.toMatchObject({ items: expect.any(Array), pagination: { pageSize: expect.any(Number) } });
|
|
}
|
|
const agentTasks = await request("GET", "/tasks?status=in-progress", "task:read");
|
|
await expect(agentTasks.json()).resolves.toMatchObject({
|
|
items: [expect.objectContaining({ id: task.id, boardId: firstBoard.id, status: "in-progress" })],
|
|
});
|
|
const agentNotes = await request("GET", `/tasks/${task.id}/notes`, "task:read");
|
|
await expect(agentNotes.json()).resolves.toMatchObject({ items: expect.arrayContaining([expect.objectContaining({ taskId: task.id })]) });
|
|
|
|
const session = await createTestWebSession(db, tenantId);
|
|
for (const path of ["/boards", "/repositories", "/tasks", `/tasks/${task.id}/notes`] as const) {
|
|
const response = await browserRequest(session, path);
|
|
expect(response.status, path).toBe(200);
|
|
expect(await response.json()).toEqual(expect.any(Array));
|
|
}
|
|
const browserTasks = await browserRequest(session, "/tasks");
|
|
await expect(browserTasks.json()).resolves.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ board_id: firstBoard.id, status: "in_progress" })]),
|
|
);
|
|
const browserNotes = await browserRequest(session, `/tasks/${task.id}/notes`);
|
|
await expect(browserNotes.json()).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ task_id: task.id })]));
|
|
expect([firstBoard.id, secondBoard.id]).toHaveLength(2);
|
|
});
|
|
|
|
it("[spec: resource-server/generic-operations] [spec: tasks/wait] defaults API-Version and signs pagination and Task Event cursors independently of the Web secret", async () => {
|
|
const boardResponse = await request("POST", "/boards", "board:write", { name: "Default version board", type: "ops" }, false, "2026-08-29", null);
|
|
expect(boardResponse.status, await boardResponse.clone().text()).toBe(201);
|
|
const boardId = (await boardResponse.json<{ id: string }>()).id;
|
|
const key = "default-version-idempotency";
|
|
const createBody = { title: "Default version Task", boardId };
|
|
const created = await request("POST", "/tasks", "task:write", createBody, false, "2026-08-29", key);
|
|
expect(created.status, await created.clone().text()).toBe(201);
|
|
expect(created.headers.get("API-Version")).toBe("2026-08-29");
|
|
const replayed = await request("POST", "/tasks", "task:write", createBody, true, "2026-08-29", key);
|
|
expect(replayed.status).toBe(201);
|
|
expect(replayed.headers.get("Idempotency-Replayed")).toBe("true");
|
|
await expect(
|
|
db
|
|
.prepare("SELECT api_version, request_hash FROM resource_idempotency_records WHERE owner_id = ? AND actor_id = ? AND idempotency_key = ?")
|
|
.bind(tenantId, "actor-toolbox", key)
|
|
.first(),
|
|
).resolves.toMatchObject({ api_version: "2026-08-29", request_hash: expect.any(String) });
|
|
|
|
await createBoard(db, tenantId, "Default version second page", "ops");
|
|
const firstPage = await request("GET", "/boards?pageSize=1", "board:read", undefined, false);
|
|
const firstPageBody = (await firstPage.json()) as { pagination: { nextPageToken: string } };
|
|
env = { ...env, OIDC_WEB_CLIENT_SECRET: "rotated-web-secret" };
|
|
const nextPage = await request(
|
|
"GET",
|
|
`/boards?pageSize=1&pageToken=${encodeURIComponent(firstPageBody.pagination.nextPageToken)}`,
|
|
"board:read",
|
|
undefined,
|
|
false,
|
|
);
|
|
expect(nextPage.status, await nextPage.clone().text()).toBe(200);
|
|
expect(nextPage.headers.get("API-Version")).toBe("2026-08-29");
|
|
|
|
const task = await createTask(db, tenantId, { title: "Default version Task Event", board_id: boardId });
|
|
await db.prepare("UPDATE tasks SET status = 'in_review' WHERE id = ?").bind(task.id).run();
|
|
const event = await request("GET", `/task-events?taskId=${task.id}&until=in-review&waitSeconds=0`, "task:read", undefined, false);
|
|
const eventBody = (await event.json()) as { cursor: string };
|
|
env = { ...env, OIDC_WEB_CLIENT_SECRET: "rotated-web-secret-again" };
|
|
const eventContinuation = await request(
|
|
"GET",
|
|
`/task-events?taskId=${task.id}&until=in-review&waitSeconds=0&cursor=${encodeURIComponent(eventBody.cursor)}`,
|
|
"task:read",
|
|
undefined,
|
|
false,
|
|
);
|
|
expect(eventContinuation.status, await eventContinuation.clone().text()).toBe(200);
|
|
expect(eventContinuation.headers.get("API-Version")).toBe("2026-08-29");
|
|
});
|
|
|
|
it("[spec: resource-server/generic-operations] surfaces an invalid AK_SIGNING_KEY during page-token decoding instead of reporting an invalid cursor", async () => {
|
|
await createBoard(db, tenantId, "Signing key first board", "ops");
|
|
await createBoard(db, tenantId, "Signing key second board", "ops");
|
|
const firstPage = await request("GET", "/boards?pageSize=1", "board:read");
|
|
const token = ((await firstPage.json()) as { pagination: { nextPageToken: string } }).pagination.nextPageToken;
|
|
|
|
env = { ...env, AK_SIGNING_KEY: btoa("short") };
|
|
const response = await request("GET", `/boards?pageSize=1&pageToken=${encodeURIComponent(token)}`, "board:read");
|
|
|
|
expect(response.status).toBe(500);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/internal-error`,
|
|
status: 500,
|
|
detail: "The server could not complete the request",
|
|
});
|
|
});
|
|
|
|
it("[spec: tasks/notes-and-stream] returns only commented Notes and exposes one Agent Note with lowerCamel fields and ETag", async () => {
|
|
const board = await createBoard(db, tenantId, "Task Note resources", "ops");
|
|
const task = await createTask(db, tenantId, { title: "Task Note parent", board_id: board.id });
|
|
const claim = await addTaskAction(db, task.id, "realmroot:agent", "actor-toolbox", "claimed", null, "agency-session");
|
|
await addTaskAction(db, task.id, "realmroot:agent", "actor-toolbox", "review_requested", null, "agency-session");
|
|
const note = await addTaskAction(db, task.id, "realmroot:agent", "actor-toolbox", "commented", "Only this is a Note", "agency-session");
|
|
|
|
const collection = await request("GET", `/tasks/${task.id}/notes`, "task:read");
|
|
expect(collection.status).toBe(200);
|
|
await expect(collection.json()).resolves.toMatchObject({
|
|
items: [expect.objectContaining({ id: note.id, taskId: task.id, action: "commented" })],
|
|
pagination: { pageSize: expect.any(Number) },
|
|
});
|
|
|
|
const single = await request("GET", `/tasks/${task.id}/notes/${note.id}`, "task:read");
|
|
expect(single.status).toBe(200);
|
|
expect(single.headers.get("ETag")).toBe(`"${note.id}"`);
|
|
await expect(single.json()).resolves.toMatchObject({
|
|
id: note.id,
|
|
taskId: task.id,
|
|
actorType: "realmroot:agent",
|
|
actorId: "actor-toolbox",
|
|
createdAt: expect.any(String),
|
|
});
|
|
|
|
const nonNote = await request("GET", `/tasks/${task.id}/notes/${claim.id}`, "task:read");
|
|
expect(nonNote.status).toBe(404);
|
|
await expect(nonNote.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/request-rejected`,
|
|
status: 404,
|
|
detail: "Task Note not found",
|
|
});
|
|
});
|
|
|
|
it("represents Task Events with lowerCamel Task fields and kebab-case status, until, and outcome", async () => {
|
|
const board = await createBoard(db, tenantId, "Task Event representation", "ops");
|
|
const task = await createTask(db, tenantId, { title: "Event representation", board_id: board.id });
|
|
await db.prepare("UPDATE tasks SET status = 'in_review' WHERE id = ?").bind(task.id).run();
|
|
|
|
const response = await request("GET", `/task-events?taskId=${task.id}&until=in-review&waitSeconds=0`, "task:read");
|
|
expect(response.status, await response.clone().text()).toBe(200);
|
|
const snapshot = (await response.json()) as { cursor: string };
|
|
expect(snapshot).toMatchObject({
|
|
cursor: expect.stringMatching(/^v2:[0-9]+:[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/),
|
|
outcome: "reached",
|
|
until: "in-review",
|
|
tasks: [
|
|
expect.objectContaining({
|
|
id: task.id,
|
|
boardId: board.id,
|
|
status: "in-review",
|
|
createdAt: expect.any(String),
|
|
updatedAt: expect.any(String),
|
|
}),
|
|
],
|
|
});
|
|
const continuation = await request(
|
|
"GET",
|
|
`/task-events?taskId=${task.id}&until=in-review&waitSeconds=0&cursor=${encodeURIComponent(snapshot.cursor)}`,
|
|
"task:read",
|
|
);
|
|
expect(continuation.status, await continuation.clone().text()).toBe(200);
|
|
});
|
|
|
|
it.each([
|
|
["POST", "/boards/missing/labels", "board:write", { name: "private", color: "#112233" }],
|
|
["PATCH", "/boards/missing", "board:write", { name: "private" }],
|
|
["DELETE", "/boards/missing", "board:write", undefined],
|
|
["PATCH", "/tasks/missing", "task:write", { title: "private" }],
|
|
["DELETE", "/tasks/missing", "task:write", undefined],
|
|
["DELETE", "/repositories/missing", "repository:write", undefined],
|
|
["GET", "/tasks/missing/session", "task:read", undefined],
|
|
["GET", "/tasks/missing/stream", "task:read", undefined],
|
|
["GET", "/github-app/config", "repository:read", undefined],
|
|
] as const)("denies unpublished Agent management operation %s %s", async (method, path, scope, body) => {
|
|
const response = await request(method, path, scope, body);
|
|
expect(response.status).toBe(403);
|
|
await expect(response.json()).resolves.toMatchObject({ error: { code: "FORBIDDEN" } });
|
|
});
|
|
|
|
it("[spec: tasks/assign] allows an authenticated human to create an Assignment for the requested Realmroot Agent", async () => {
|
|
const board = await createBoard(db, tenantId, "Human assignment board", "ops");
|
|
const task = await createTask(db, tenantId, { title: "Human assigned Task", board_id: board.id });
|
|
const session = await createTestWebSession(db, tenantId, { subjectId: "human-assigner" });
|
|
env = {
|
|
...env,
|
|
OIDC_ISSUER: issuer,
|
|
INBOX_RESOURCE: inboxResource,
|
|
INBOX_API_VERSION: "2026-08-31",
|
|
OIDC_SERVICE_CLIENT_ID: "agent-kanban",
|
|
OIDC_SERVICE_CLIENT_SECRET: "inbox-client-secret",
|
|
};
|
|
await realmrootAgentAuthority(`${resource}/task-assignments/${task.id}`, "PUT", "task:assign");
|
|
|
|
const response = await api.fetch(
|
|
new Request(`${resource}/task-assignments/${task.id}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
cookie: session.cookie,
|
|
"x-csrf-token": session.csrfToken,
|
|
"content-type": "application/json",
|
|
"API-Version": "2026-08-29",
|
|
},
|
|
body: JSON.stringify({ agentActorId: "realmroot-agent-target" }),
|
|
}),
|
|
env,
|
|
);
|
|
|
|
expect(response.status, await response.clone().text()).toBe(200);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
agentActorId: "realmroot-agent-target",
|
|
assignedByActorId: "human-assigner",
|
|
});
|
|
expect(inboxMessageRequests).toHaveLength(1);
|
|
await expect(db.prepare("SELECT assigned_to, assignee_identity_type FROM tasks WHERE id = ?").bind(task.id).first()).resolves.toEqual({
|
|
assigned_to: "realmroot-agent-target",
|
|
assignee_identity_type: "realmroot_actor",
|
|
});
|
|
});
|
|
|
|
it("[spec: tasks/create] creates only an unassigned Task through the generic collection operation", async () => {
|
|
const board = await createBoard(db, tenantId, "Unassigned task board", "ops");
|
|
|
|
const response = await request("POST", "/tasks", "task:write", { title: "Unassigned v2 Task", boardId: board.id });
|
|
|
|
expect(response.status, await response.clone().text()).toBe(201);
|
|
const task = (await response.json()) as { id: string; assignedTo: string | null };
|
|
expect(task).toMatchObject({ assignedTo: null });
|
|
expect(task).not.toHaveProperty("assigneeIdentityType");
|
|
await expect(db.prepare("SELECT actor_type, action FROM task_actions WHERE task_id = ?").bind(task.id).first()).resolves.toEqual({
|
|
actor_type: "realmroot:agent",
|
|
action: "created",
|
|
});
|
|
});
|
|
|
|
it.each(["assigned_to", "agent_id"] as const)("rejects Task create field %s before Task, action, or AMA side effects", async (field) => {
|
|
const board = await createBoard(db, tenantId, `Rejected ${field} board`, "ops");
|
|
const before = await Promise.all([
|
|
db.prepare("SELECT COUNT(*) AS count FROM tasks").first(),
|
|
db.prepare("SELECT COUNT(*) AS count FROM task_actions").first(),
|
|
]);
|
|
|
|
const response = await request("POST", "/tasks", "task:write", {
|
|
title: `Rejected ${field} Task`,
|
|
boardId: board.id,
|
|
[field]: "actor-not-assignable-at-create",
|
|
});
|
|
|
|
expect(response.status, await response.clone().text()).toBe(422);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/request-rejected`,
|
|
status: 422,
|
|
detail: `Task contains unsupported properties: ${field}`,
|
|
});
|
|
await expect(
|
|
Promise.all([db.prepare("SELECT COUNT(*) AS count FROM tasks").first(), db.prepare("SELECT COUNT(*) AS count FROM task_actions").first()]),
|
|
).resolves.toEqual(before);
|
|
});
|
|
|
|
it("rejects an unknown Task create field before Task or action side effects", async () => {
|
|
const board = await createBoard(db, tenantId, "Rejected unknown field board", "ops");
|
|
const before = await Promise.all([
|
|
db.prepare("SELECT COUNT(*) AS count FROM tasks").first(),
|
|
db.prepare("SELECT COUNT(*) AS count FROM task_actions").first(),
|
|
]);
|
|
|
|
const response = await request("POST", "/tasks", "task:write", {
|
|
title: "Rejected unknown field Task",
|
|
boardId: board.id,
|
|
unexpected: true,
|
|
});
|
|
|
|
expect(response.status, await response.clone().text()).toBe(422);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/request-rejected`,
|
|
status: 422,
|
|
detail: "Task contains unsupported properties: unexpected",
|
|
});
|
|
await expect(
|
|
Promise.all([db.prepare("SELECT COUNT(*) AS count FROM tasks").first(), db.prepare("SELECT COUNT(*) AS count FROM task_actions").first()]),
|
|
).resolves.toEqual(before);
|
|
});
|
|
|
|
it.each([
|
|
["claim", "task:claim"],
|
|
["assign", "task:assign"],
|
|
["release", "task:release"],
|
|
["review", "task:review"],
|
|
["complete", "task:complete"],
|
|
["reject", "task:reject"],
|
|
["cancel", "task:cancel"],
|
|
] as const)("does not expose the removed POST Task %s command to a Realmroot Agent", async (command, scope) => {
|
|
const response = await request("POST", `/tasks/missing-task/${command}`, scope, {});
|
|
|
|
expect(response.status).toBe(403);
|
|
await expect(db.prepare("SELECT COUNT(*) AS count FROM task_actions").first()).resolves.toEqual({ count: 0 });
|
|
});
|
|
|
|
it.each(["assigned_to", "agent_id", "assignee_identity_type"] as const)(
|
|
"rejects Task PATCH field %s without creating a wire or persisted assignment",
|
|
async (field) => {
|
|
const board = await createBoard(db, tenantId, `Rejected patch ${field}`, "ops");
|
|
const task = await createTask(db, tenantId, { title: `Unassigned ${field}`, board_id: board.id });
|
|
const before = await db.prepare("SELECT * FROM tasks WHERE id = ?").bind(task.id).first();
|
|
|
|
const response = await request("PATCH", `/tasks/${task.id}`, "task:write", {
|
|
[field]: field === "assignee_identity_type" ? "realmroot_actor" : "actor-not-assignable-by-patch",
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
await expect(response.json()).resolves.toMatchObject({
|
|
error: {
|
|
code: "FORBIDDEN",
|
|
message: "Operation is not published by the Agent Kanban Resource Server",
|
|
},
|
|
});
|
|
await expect(db.prepare("SELECT * FROM tasks WHERE id = ?").bind(task.id).first()).resolves.toEqual(before);
|
|
const read = await request("GET", `/tasks/${task.id}`, "task:read");
|
|
expect(read.status).toBe(200);
|
|
const representation = (await read.json()) as Record<string, unknown>;
|
|
expect(representation).toMatchObject({ assignedTo: null });
|
|
expect(representation).not.toHaveProperty("assigneeIdentityType");
|
|
},
|
|
);
|
|
|
|
it("[spec: resource-server/generic-operations] executes representative verb-first Board, unassigned Task, Note, and Repository operations with exact scopes", async () => {
|
|
const ownBoard = await createBoard(db, tenantId, "Own board", "ops");
|
|
const foreignBoard = await createBoard(db, foreignTenantId, "Foreign board", "ops");
|
|
|
|
const boards = await request("GET", "/boards", "board:read");
|
|
expect(boards.status).toBe(200);
|
|
const boardItems = ((await boards.json()) as { items: Array<{ id: string }> }).items;
|
|
expect(boardItems).toEqual(expect.arrayContaining([expect.objectContaining({ id: ownBoard.id })]));
|
|
expect(boardItems).not.toEqual(expect.arrayContaining([expect.objectContaining({ id: foreignBoard.id })]));
|
|
|
|
const createdTask = await request("POST", "/tasks", "task:write", { title: "Toolbox Task", boardId: ownBoard.id });
|
|
expect(createdTask.status).toBe(201);
|
|
const task = (await createdTask.json()) as { id: string; assignedTo: string | null };
|
|
expect(task.assignedTo).toBeNull();
|
|
|
|
const note = await request("POST", `/tasks/${task.id}/notes`, "task:write", { detail: "Toolbox note" });
|
|
expect(note.status).toBe(201);
|
|
await expect(note.json()).resolves.toMatchObject({ taskId: task.id, action: "commented", detail: "Toolbox note" });
|
|
|
|
const repository = await request("POST", "/repositories", "repository:write", {
|
|
name: "toolbox-repository",
|
|
url: "https://gitlab.com/example/toolbox-repository.git",
|
|
});
|
|
expect(repository.status).toBe(201);
|
|
const repositoryBody = (await repository.json()) as Record<string, unknown>;
|
|
expect(repositoryBody).toMatchObject({ name: "toolbox-repository" });
|
|
expect(repositoryBody).not.toHaveProperty("ownerId");
|
|
|
|
const wrongScope = await request("POST", "/repositories", "task:read", {
|
|
name: "forbidden-repository",
|
|
url: "https://gitlab.com/example/forbidden.git",
|
|
});
|
|
expect(wrongScope.status).toBe(403);
|
|
await expect(wrongScope.json()).resolves.toMatchObject({
|
|
type: `${resource}/problems/permission-denied`,
|
|
title: "Permission denied",
|
|
status: 403,
|
|
detail: "Missing scope: repository:write",
|
|
});
|
|
|
|
const foreignTask = await createTask(db, foreignTenantId, { title: "Foreign Task", board_id: foreignBoard.id });
|
|
const hidden = await request("POST", `/tasks/${foreignTask.id}/notes`, "task:write", { detail: "Must not write" });
|
|
expect(hidden.status).toBe(404);
|
|
await expect(
|
|
db.prepare("SELECT COUNT(*) AS count FROM task_actions WHERE task_id = ? AND detail = ?").bind(foreignTask.id, "Must not write").first(),
|
|
).resolves.toEqual({ count: 0 });
|
|
});
|
|
|
|
it("[spec: tasks/submit-review] lets the assigned Agent submit its Task through the HTTP resource", async () => {
|
|
const board = await createBoard(db, tenantId, "Submit review", "ops");
|
|
const task = await reviewReadyTask(tenantId, board.id, "Submit through HTTP", "actor-toolbox");
|
|
|
|
const response = await request("PUT", `/task-review-submissions/${task.id}`, "task:review", undefined, true);
|
|
|
|
expect(response.status, await response.clone().text()).toBe(201);
|
|
const submission = (await response.json()) as { taskId: string; agentActorId: string; reviewSubmissionVersion: string };
|
|
expect(submission).toMatchObject({ taskId: task.id, agentActorId: "actor-toolbox", reviewSubmissionVersion: expect.any(String) });
|
|
expect(response.headers.get("ETag")).toBe(`"${submission.reviewSubmissionVersion}"`);
|
|
await expect(db.prepare("SELECT status FROM tasks WHERE id = ?").bind(task.id).first()).resolves.toEqual({ status: "in_review" });
|
|
});
|
|
|
|
it("accepts an empty Toolbox Task Cancellation body without Content-Type and rejects an untyped non-empty body", async () => {
|
|
const board = await createBoard(db, tenantId, "Cancellation route", "ops");
|
|
const task = await createTask(db, tenantId, { title: "Cancel through HTTP", board_id: board.id });
|
|
|
|
const response = await request("PUT", `/task-cancellations/${task.id}`, "task:cancel");
|
|
|
|
expect(response.status, await response.clone().text()).toBe(201);
|
|
expect(response.headers.get("Location")).toBe(`${resource}/task-cancellations/${task.id}`);
|
|
expect(response.headers.get("ETag")).toMatch(/^".+"$/);
|
|
await expect(response.json()).resolves.toMatchObject({ taskId: task.id, cancelledByActorId: "actor-toolbox" });
|
|
await expect(db.prepare("SELECT status FROM tasks WHERE id = ?").bind(task.id).first()).resolves.toEqual({ status: "cancelled" });
|
|
|
|
const invalidTask = await createTask(db, tenantId, { title: "Reject untyped body", board_id: board.id });
|
|
const url = `${resource}/task-cancellations/${invalidTask.id}`;
|
|
const authority = await realmrootAgentAuthority(url, "PUT", "task:cancel", "actor-toolbox");
|
|
const invalidRequest = new Request(url, {
|
|
method: "PUT",
|
|
headers: {
|
|
authorization: `DPoP ${authority.accessToken}`,
|
|
dpop: authority.proof,
|
|
"API-Version": "2026-08-29",
|
|
},
|
|
body: new TextEncoder().encode("{}"),
|
|
});
|
|
expect(invalidRequest.headers.get("Content-Type")).toBeNull();
|
|
const invalid = await api.fetch(invalidRequest, env);
|
|
expect(invalid.status).toBe(415);
|
|
await expect(invalid.json()).resolves.toMatchObject({
|
|
status: 415,
|
|
type: `${resource}/problems/unsupported-media-type`,
|
|
});
|
|
await expect(db.prepare("SELECT status FROM tasks WHERE id = ?").bind(invalidTask.id).first()).resolves.toEqual({ status: "todo" });
|
|
});
|
|
|
|
it("[spec: tasks/reject-review] [spec: tasks/complete-review] lets a different human decide Review Submissions through HTTP", async () => {
|
|
const board = await createBoard(db, tenantId, "Human review decisions", "ops");
|
|
const session = await createTestWebSession(db, tenantId, { subjectId: "human-reviewer" });
|
|
env = {
|
|
...env,
|
|
OIDC_ISSUER: issuer,
|
|
INBOX_RESOURCE: inboxResource,
|
|
INBOX_API_VERSION: "2026-08-31",
|
|
OIDC_SERVICE_CLIENT_ID: "agent-kanban",
|
|
OIDC_SERVICE_CLIENT_SECRET: "inbox-client-secret",
|
|
};
|
|
await realmrootAgentAuthority(`${resource}/task-review-rejections/bootstrap`, "PUT", "task:reject");
|
|
|
|
for (const kind of ["rejections", "completions"] as const) {
|
|
const task = await reviewReadyTask(tenantId, board.id, `Human ${kind}`, "assigned-agent");
|
|
const submitted = await replaceTaskReviewSubmission(d1TaskReviewSubmissionRepository(db), {
|
|
ownerId: tenantId,
|
|
taskId: task.id,
|
|
agentActorId: "assigned-agent",
|
|
pullRequestUrl: null,
|
|
});
|
|
const response = await api.fetch(
|
|
new Request(`${resource}/task-review-${kind}/${task.id}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
cookie: session.cookie,
|
|
"x-csrf-token": session.csrfToken,
|
|
"API-Version": "2026-08-29",
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({ reviewSubmissionVersion: submitted.version, ...(kind === "rejections" ? { reason: "needs changes" } : {}) }),
|
|
}),
|
|
env,
|
|
);
|
|
|
|
expect(response.status, `${kind}: ${await response.clone().text()}`).toBe(201);
|
|
await expect(db.prepare("SELECT status FROM tasks WHERE id = ?").bind(task.id).first()).resolves.toEqual({
|
|
status: kind === "rejections" ? "in_progress" : "done",
|
|
});
|
|
}
|
|
|
|
expect(machineTokenRequests).toHaveLength(2);
|
|
expect(inboxMessageRequests).toHaveLength(2);
|
|
const messages = await Promise.all(
|
|
inboxMessageRequests.map(
|
|
async (messageRequest) =>
|
|
(await messageRequest.clone().json()) as {
|
|
recipients: string[];
|
|
content: { text: string };
|
|
},
|
|
),
|
|
);
|
|
expect(messages.map(({ recipients }) => recipients)).toEqual([["agent:assigned-agent"], ["agent:assigned-agent"]]);
|
|
expect(messages[0].content.text).toContain("review_rejected");
|
|
expect(messages[1].content.text).toContain("completed");
|
|
});
|
|
|
|
it("rejects stale reviewSubmissionVersion bodies for rejection and completion", async () => {
|
|
const board = await createBoard(db, tenantId, "Stale review decisions", "ops");
|
|
const session = await createTestWebSession(db, tenantId, { subjectId: "stale-reviewer" });
|
|
|
|
for (const kind of ["rejections", "completions"] as const) {
|
|
const task = await reviewReadyTask(tenantId, board.id, `Stale ${kind}`, "assigned-agent");
|
|
await replaceTaskReviewSubmission(d1TaskReviewSubmissionRepository(db), {
|
|
ownerId: tenantId,
|
|
taskId: task.id,
|
|
agentActorId: "assigned-agent",
|
|
pullRequestUrl: null,
|
|
});
|
|
|
|
const response = await reviewDecisionRequest(session, kind, task.id, {
|
|
reviewSubmissionVersion: "stale-review-version",
|
|
...(kind === "rejections" ? { reason: "stale" } : {}),
|
|
});
|
|
|
|
expect(response.status, kind).toBe(412);
|
|
await expect(response.json()).resolves.toMatchObject({ type: expect.stringContaining("task-review-precondition-failed") });
|
|
await expect(db.prepare("SELECT status FROM tasks WHERE id = ?").bind(task.id).first()).resolves.toEqual({ status: "in_review" });
|
|
}
|
|
});
|
|
|
|
it("rejects malformed rejection and completion bodies without changing the Task", async () => {
|
|
const board = await createBoard(db, tenantId, "Malformed review decisions", "ops");
|
|
const session = await createTestWebSession(db, tenantId, { subjectId: "malformed-reviewer" });
|
|
|
|
for (const kind of ["rejections", "completions"] as const) {
|
|
const task = await reviewReadyTask(tenantId, board.id, `Malformed ${kind}`, "assigned-agent");
|
|
await replaceTaskReviewSubmission(d1TaskReviewSubmissionRepository(db), {
|
|
ownerId: tenantId,
|
|
taskId: task.id,
|
|
agentActorId: "assigned-agent",
|
|
pullRequestUrl: null,
|
|
});
|
|
|
|
const response = await reviewDecisionRequest(session, kind, task.id, {});
|
|
|
|
expect(response.status, kind).toBe(422);
|
|
await expect(response.json()).resolves.toMatchObject({ type: expect.stringContaining(`invalid-task-review-${kind.slice(0, -1)}`) });
|
|
await expect(db.prepare("SELECT status FROM tasks WHERE id = ?").bind(task.id).first()).resolves.toEqual({ status: "in_review" });
|
|
}
|
|
});
|
|
|
|
it("defaults and echoes API-Version on a published generic route while rejecting a wrong explicit version", async () => {
|
|
const missing = await request("GET", "/boards", "board:read", undefined, false);
|
|
expect(missing.status).toBe(200);
|
|
expect(missing.headers.get("API-Version")).toBe("2026-08-29");
|
|
expect(missing.headers.get("Vary")).toContain("API-Version");
|
|
|
|
const empty = await request("GET", "/boards", "board:read", undefined, true, "");
|
|
expect(empty.status).toBe(400);
|
|
await expect(empty.json()).resolves.toMatchObject({ detail: "Unsupported API-Version: " });
|
|
|
|
const wrong = await request("GET", "/boards", "board:read", undefined, true, "1999-01-01");
|
|
expect(wrong.status).toBe(400);
|
|
await expect(wrong.json()).resolves.toMatchObject({ detail: "Unsupported API-Version: 1999-01-01" });
|
|
});
|
|
|
|
it.each([
|
|
["text/plain", JSON.stringify({ title: "Wrong media type" }), 415, "unsupported-media-type"],
|
|
["application/json", "{malformed", 400, "invalid-json"],
|
|
] as const)("maps %s Task creation input to a concrete Problem", async (contentType, body, status, problemType) => {
|
|
const url = `${resource}/tasks`;
|
|
const authority = await realmrootAgentAuthority(url, "POST", "task:write");
|
|
const response = await api.fetch(
|
|
new Request(url, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `DPoP ${authority.accessToken}`,
|
|
dpop: authority.proof,
|
|
"API-Version": "2026-08-29",
|
|
"Idempotency-Key": JSON.stringify(`invalid-${problemType}`),
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
}),
|
|
env,
|
|
);
|
|
|
|
expect(response.status).toBe(status);
|
|
expect(response.headers.get("content-type")).toContain("application/problem+json");
|
|
await expect(response.json()).resolves.toMatchObject({ status, type: `${resource}/problems/${problemType}` });
|
|
});
|
|
|
|
it("reads the current Task Review Submission with ETag and hides absent or cross-tenant submissions", async () => {
|
|
const ownBoard = await createBoard(db, tenantId, "Review board", "ops");
|
|
const ownTask = await reviewReadyTask(tenantId, ownBoard.id, "Current review", "actor-reviewer");
|
|
const submitted = await replaceTaskReviewSubmission(d1TaskReviewSubmissionRepository(db), {
|
|
ownerId: tenantId,
|
|
taskId: ownTask.id,
|
|
agentActorId: "actor-reviewer",
|
|
pullRequestUrl: "https://github.com/example/repository/pull/42",
|
|
});
|
|
|
|
const current = await request("GET", `/task-review-submissions/${ownTask.id}`, "task:read", undefined, true);
|
|
expect(current.status).toBe(200);
|
|
expect(current.headers.get("ETag")).toBe(`"${submitted.version}"`);
|
|
const currentBody = (await current.json()) as { reviewSubmissionVersion: string };
|
|
expect(currentBody).toEqual(submitted.submission);
|
|
expect(currentBody.reviewSubmissionVersion).toBe(submitted.version);
|
|
expect(current.headers.get("ETag")).toBe(`"${currentBody.reviewSubmissionVersion}"`);
|
|
|
|
const webSession = await createTestWebSession(db, tenantId);
|
|
const webCurrent = await api.fetch(
|
|
new Request(`${resource}/task-review-submissions/${ownTask.id}`, {
|
|
headers: { cookie: webSession.cookie, "API-Version": "2026-08-29" },
|
|
}),
|
|
env,
|
|
);
|
|
expect(webCurrent.status).toBe(200);
|
|
expect(webCurrent.headers.get("ETag")).toBe(`"${submitted.version}"`);
|
|
await expect(webCurrent.json()).resolves.toEqual(submitted.submission);
|
|
|
|
const withoutSubmission = await createTask(db, tenantId, { title: "No submission", board_id: ownBoard.id });
|
|
const absent = await request("GET", `/task-review-submissions/${withoutSubmission.id}`, "task:read", undefined, true);
|
|
await expectProblem(absent, "task-review-submission-not-found");
|
|
|
|
const foreignBoard = await createBoard(db, foreignTenantId, "Foreign review", "ops");
|
|
const foreignTask = await reviewReadyTask(foreignTenantId, foreignBoard.id, "Foreign current review", "actor-foreign");
|
|
await replaceTaskReviewSubmission(d1TaskReviewSubmissionRepository(db), {
|
|
ownerId: foreignTenantId,
|
|
taskId: foreignTask.id,
|
|
agentActorId: "actor-foreign",
|
|
pullRequestUrl: null,
|
|
});
|
|
const hidden = await request("GET", `/task-review-submissions/${foreignTask.id}`, "task:read", undefined, true);
|
|
await expectProblem(hidden, "task-not-found");
|
|
});
|
|
});
|
|
|
|
async function reviewDecisionRequest(
|
|
session: Awaited<ReturnType<typeof createTestWebSession>>,
|
|
kind: "rejections" | "completions",
|
|
taskId: string,
|
|
body: Record<string, unknown>,
|
|
): Promise<Response> {
|
|
return api.fetch(
|
|
new Request(`${resource}/task-review-${kind}/${taskId}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
cookie: session.cookie,
|
|
"x-csrf-token": session.csrfToken,
|
|
"API-Version": "2026-08-29",
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify(body),
|
|
}),
|
|
env,
|
|
);
|
|
}
|
|
|
|
async function request(
|
|
method: string,
|
|
path: string,
|
|
scope: string,
|
|
body?: unknown,
|
|
versioned = true,
|
|
apiVersion = "2026-08-29",
|
|
idempotencyKey: string | null = method === "POST" ? `test-${randomUUID()}` : null,
|
|
actorId = "actor-toolbox",
|
|
structuredIdempotencyKey = true,
|
|
): Promise<Response> {
|
|
const url = `${resource}${path}`;
|
|
const requestUrl = new URL(url);
|
|
const authority = await realmrootAgentAuthority(`${requestUrl.origin}${requestUrl.pathname}`, method, scope, actorId);
|
|
return api.fetch(
|
|
new Request(url, {
|
|
method,
|
|
headers: {
|
|
authorization: `DPoP ${authority.accessToken}`,
|
|
dpop: authority.proof,
|
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
...(versioned ? { "API-Version": apiVersion } : {}),
|
|
...(idempotencyKey ? { "Idempotency-Key": structuredIdempotencyKey ? JSON.stringify(idempotencyKey) : idempotencyKey } : {}),
|
|
},
|
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
}),
|
|
env,
|
|
);
|
|
}
|
|
|
|
async function browserRequest(session: Awaited<ReturnType<typeof createTestWebSession>>, path: string): Promise<Response> {
|
|
return api.fetch(new Request(`${resource}${path}`, { headers: { cookie: session.cookie, "API-Version": "2026-08-29" } }), env);
|
|
}
|
|
|
|
async function creationFixture(resourceKind: "boards" | "repositories" | "tasks" | "notes") {
|
|
if (resourceKind === "boards") {
|
|
return {
|
|
path: "/boards",
|
|
scope: "board:write",
|
|
body: { name: "Idempotent board", type: "ops" },
|
|
conflictingBody: { name: "Different board", type: "ops" },
|
|
count: async () =>
|
|
(await db.prepare("SELECT COUNT(*) AS count FROM boards WHERE owner_id = ?").bind(tenantId).first<{ count: number }>())!.count,
|
|
};
|
|
}
|
|
if (resourceKind === "repositories") {
|
|
return {
|
|
path: "/repositories",
|
|
scope: "repository:write",
|
|
body: { name: "idempotent-repository", url: "https://github.com/example/idempotent.git" },
|
|
conflictingBody: { name: "different-repository", url: "https://github.com/example/different.git" },
|
|
count: async () =>
|
|
(await db.prepare("SELECT COUNT(*) AS count FROM repositories WHERE owner_id = ?").bind(tenantId).first<{ count: number }>())!.count,
|
|
};
|
|
}
|
|
const board = await createBoard(db, tenantId, `Idempotent ${resourceKind} board`, "ops");
|
|
if (resourceKind === "tasks") {
|
|
return {
|
|
path: "/tasks",
|
|
scope: "task:write",
|
|
body: { title: "Idempotent Task", boardId: board.id },
|
|
conflictingBody: { title: "Different Task", boardId: board.id },
|
|
count: async () =>
|
|
(await db.prepare("SELECT COUNT(*) AS count FROM tasks WHERE board_id = ?").bind(board.id).first<{ count: number }>())!.count,
|
|
};
|
|
}
|
|
const task = await createTask(db, tenantId, { title: "Note parent", board_id: board.id });
|
|
return {
|
|
path: `/tasks/${task.id}/notes`,
|
|
scope: "task:write",
|
|
body: { detail: "Idempotent note" },
|
|
conflictingBody: { detail: "Different note" },
|
|
count: async () =>
|
|
(await db
|
|
.prepare("SELECT COUNT(*) AS count FROM task_actions WHERE task_id = ? AND action = 'commented'")
|
|
.bind(task.id)
|
|
.first<{ count: number }>())!.count,
|
|
};
|
|
}
|
|
|
|
async function mutateCreatedResource(resourceKind: "boards" | "repositories" | "tasks" | "notes", id: string, before: number): Promise<number> {
|
|
if (resourceKind === "boards") {
|
|
await db.prepare("UPDATE boards SET name = 'mutated after creation' WHERE id = ?").bind(id).run();
|
|
return before + 1;
|
|
}
|
|
if (resourceKind === "tasks") {
|
|
await db.prepare("UPDATE tasks SET title = 'mutated after creation' WHERE id = ?").bind(id).run();
|
|
return before + 1;
|
|
}
|
|
const table = resourceKind === "repositories" ? "repositories" : "task_actions";
|
|
await db.prepare(`DELETE FROM ${table} WHERE id = ?`).bind(id).run();
|
|
return before;
|
|
}
|
|
|
|
async function realmrootAgentAuthority(
|
|
htu: string,
|
|
method: string,
|
|
scope: string,
|
|
actorId = "actor-toolbox",
|
|
authorityUrls: { issuer: string; jwksUri: string } = { issuer, jwksUri },
|
|
) {
|
|
const authorityIssuer = authorityUrls.issuer;
|
|
const authorityJwksUri = authorityUrls.jwksUri;
|
|
const issuerKeys = await issuerKeysPromise;
|
|
const issuerPublicJwk = await exportJWK(issuerKeys.publicKey);
|
|
issuerPublicJwk.kid = "toolbox-generic-issuer-key";
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (target: RequestInfo | URL, init?: RequestInit) => {
|
|
const request = target instanceof Request ? target : new Request(String(target), init);
|
|
const url = request.url;
|
|
if (url === `${authorityIssuer}/.well-known/openid-configuration`) {
|
|
oidcDiscoveryRequests += 1;
|
|
return Response.json({
|
|
issuer: authorityIssuer,
|
|
authorization_endpoint: `${authorityIssuer}/oauth2/authorize`,
|
|
token_endpoint: `${authorityIssuer}/oauth2/token`,
|
|
jwks_uri: authorityJwksUri,
|
|
});
|
|
}
|
|
if (url === authorityJwksUri) return Response.json({ keys: [issuerPublicJwk] });
|
|
if (url === `${authorityIssuer}/oauth2/token`) {
|
|
machineTokenRequests.push(request);
|
|
return Response.json({ access_token: "inbox-machine-token" });
|
|
}
|
|
if (url === `${inboxResource}/messages`) {
|
|
inboxMessageRequests.push(request);
|
|
return new Response(null, { status: inboxResponseStatus });
|
|
}
|
|
throw new Error(`Unexpected request: ${url}`);
|
|
}),
|
|
);
|
|
const dpopKeys = await generateKeyPair("ES256", { extractable: true });
|
|
const dpopPublicJwk = await exportJWK(dpopKeys.publicKey);
|
|
const thumbprint = await calculateJwkThumbprint(dpopPublicJwk);
|
|
const accessToken = await new SignJWT({
|
|
scope,
|
|
client_id: "realmroot-cli",
|
|
cnf: { jkt: thumbprint },
|
|
act: { iss: authorityIssuer, sub: actorId },
|
|
"urn:realmroot:params:oauth:org": tenantId,
|
|
})
|
|
.setProtectedHeader({ alg: "ES256", kid: issuerPublicJwk.kid, typ: "at+jwt" })
|
|
.setIssuer(authorityIssuer)
|
|
.setAudience(resource)
|
|
.setSubject("controller-toolbox")
|
|
.setIssuedAt()
|
|
.setExpirationTime("5m")
|
|
.sign(issuerKeys.privateKey);
|
|
const proof = await new SignJWT({ htu, htm: method, ath: createHash("sha256").update(accessToken).digest("base64url") })
|
|
.setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk: dpopPublicJwk })
|
|
.setJti(randomUUID())
|
|
.setIssuedAt()
|
|
.sign(dpopKeys.privateKey);
|
|
return { accessToken, proof };
|
|
}
|
|
|
|
async function realmrootBearerToken(principalType: "human" | "agent"): Promise<string> {
|
|
const issuerKeys = await issuerKeysPromise;
|
|
const issuerPublicJwk = await exportJWK(issuerKeys.publicKey);
|
|
issuerPublicJwk.kid = "toolbox-generic-issuer-key";
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (target: RequestInfo | URL) => {
|
|
const url = target instanceof Request ? target.url : String(target);
|
|
if (url === `${issuer}/.well-known/openid-configuration`) {
|
|
return Response.json({
|
|
issuer,
|
|
authorization_endpoint: `${issuer}/oauth2/authorize`,
|
|
token_endpoint: `${issuer}/oauth2/token`,
|
|
jwks_uri: jwksUri,
|
|
});
|
|
}
|
|
if (url === jwksUri) return Response.json({ keys: [issuerPublicJwk] });
|
|
throw new Error(`Unexpected request: ${url}`);
|
|
}),
|
|
);
|
|
return new SignJWT({
|
|
scope: "task:read",
|
|
client_id: "realmroot-cli",
|
|
...(principalType === "agent" ? { act: { iss: issuer, sub: "actor-toolbox" } } : {}),
|
|
"urn:realmroot:params:oauth:org": tenantId,
|
|
})
|
|
.setProtectedHeader({ alg: "ES256", kid: issuerPublicJwk.kid, typ: "at+jwt" })
|
|
.setIssuer(issuer)
|
|
.setAudience(resource)
|
|
.setSubject("controller-toolbox")
|
|
.setIssuedAt()
|
|
.setExpirationTime("5m")
|
|
.sign(issuerKeys.privateKey);
|
|
}
|
|
|
|
async function reviewReadyTask(ownerId: string, boardId: string, title: string, actorId: string) {
|
|
const task = await createTask(db, ownerId, { title, board_id: boardId });
|
|
await db
|
|
.prepare("UPDATE tasks SET status = 'in_progress', assigned_to = ?, assignee_identity_type = 'realmroot_actor' WHERE id = ?")
|
|
.bind(actorId, task.id)
|
|
.run();
|
|
return task;
|
|
}
|
|
|
|
async function expectProblem(response: Response, type: string): Promise<void> {
|
|
expect(response.status).toBe(404);
|
|
expect(response.headers.get("API-Version")).toBe("2026-08-29");
|
|
await expect(response.json()).resolves.toMatchObject({ type: `${resource}/problems/${type}`, status: 404 });
|
|
}
|