Files
multica/packages/core/api/client.test.ts
LinYushen c968c13c87 feat(auth): support mcn_ Cloud Node PATs verified via Fleet (#3349)
* feat(auth): support mcn_ Cloud Node PATs verified via Fleet

Adds a new token kind, mcn_ (multica cloud node), recognized in both
the regular Auth and DaemonAuth middlewares. mcn_ tokens are minted
and owned by Multica Cloud (not the local personal_access_tokens
table); the server validates them by POSTing to the Fleet's
/api/v1/pat/verify endpoint and uses the returned owner_id as
X-User-ID for downstream handlers.

Cloud is the authoritative owner of token status, so this is a
verifier-only path with no DB fallback:

  * Fleet says valid:false -> 401 (token genuinely bad)
  * Fleet unreachable / 5xx -> 503 (transient, retry)
  * No MULTICA_CLOUD_FLEET_URL configured -> 401 (fail closed)

Verification results are cached in Redis for 60s under
mul:auth:mcn:<sha256> to bound the per-request load on Fleet without
extending the revocation window beyond what the Cloud doc allows.
Negative results are NOT cached, so a freshly minted token doesn't
get locked out by a stale 'token_not_found'.

Reuses MULTICA_CLOUD_FLEET_URL (the same env the cloud-runtime proxy
already uses) so deployments don't need a second config knob.

Tests cover the happy path, every documented invalid reason, 4xx/5xx
mapping, network error, decode error, ctx cancellation, the
fail-closed valid:true-without-owner_id case, trailing-slash URL
normalization, and the Redis cache short-circuit + negative
no-cache contract. Middleware tests pin the four 401/503/200 outcomes
in both Auth and DaemonAuth.

* auth(mcn): require owner_id to map to a real local user; drop X-User-PAT plumbing

Two related changes:

1. Cloud-verified owner_id is now checked against our local users table.
   The Cloud owner_id and our users.id share the same UUID space by
   contract; a missing local user means either the row was deleted
   under an active node or something is forging owner_ids — either
   way, fail closed.

   CloudPATVerifier.Verify takes a new OwnerLookupFunc:
     - returns (true, nil)   -> success, cache + return
     - returns (false, nil)  -> ErrCloudPATInvalid (reason='owner_unknown'),
                                NOT cached (so a freshly-created user
                                doesn't get locked out for a TTL window)
     - returns (_, error)    -> ErrCloudPATUnavailable (transient,
                                middleware emits 503)

   Both Auth and DaemonAuth wire ownerLookupFor(queries), a new shared
   helper that wraps queries.GetUser, mapping pgx.ErrNoRows / unparseable
   UUIDs to (false, nil) and other errors to a real Go error.

2. Removed all X-User-PAT plumbing. Cloud now mints node-scoped mcn_
   PATs itself during /api/v1/nodes (see multica-cloud
   docs/api/node-pat.md) and ships them into the EC2 instance via SSM,
   so multica-api no longer needs to forward the caller's mul_ PAT.
   Propagating a long-lived user PAT into a remote machine widened
   the blast radius of any node compromise; that's gone now.

   Removed:
     - cloud_runtime.go: withUserPAT option, cloudRuntimeUserPAT,
       generateCloudRuntimePAT, revokeGeneratedPAT
     - cloudruntime/Request.UserPAT field + X-User-PAT header
     - X-User-PAT from CORS allowed headers
     - obsolete handler tests:
         TestCreateCloudRuntimeNodeForwardsValidatedPAT
         TestCreateCloudRuntimeNodeRejectsUnownedPAT
         TestCreateCloudRuntimeNodeRejectsExpiredPAT
         TestCreateCloudRuntimeNodeAutoGeneratesPAT
       replaced with TestCreateCloudRuntimeNodeForwardsBody
     - X-User-PAT references in packages/core/api/client.test.ts

Tests:
  * 3 new verifier-level tests (owner_unknown not cached, lookup error
    -> Unavailable, success path is cached for both fleet AND lookup)
  * 5 new owner_lookup_test.go tests (nil queries, existing user,
    missing user, malformed UUID, DB error)
  * 1 new end-to-end DaemonAuth test (cloud says valid, no local user
    -> 401)
  * Existing X-User-PAT TS assertions removed; full vitest run passes.
  * go test ./... and go vet ./... clean on the server module.
2026-05-27 14:52:03 +08:00

429 lines
14 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiClient, ApiError } from "./client";
afterEach(() => {
vi.unstubAllGlobals();
});
describe("ApiClient", () => {
it("preserves HTTP status on failed requests", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ error: "workspace slug already exists" }), {
status: 409,
statusText: "Conflict",
headers: { "Content-Type": "application/json" },
}),
),
);
const client = new ApiClient("https://api.example.test");
try {
await client.createWorkspace({ name: "Test", slug: "test" });
throw new Error("expected createWorkspace to fail");
} catch (error) {
expect(error).toBeInstanceOf(ApiError);
expect(error).toMatchObject({
message: "workspace slug already exists",
status: 409,
statusText: "Conflict",
});
}
});
it("uses the expected HTTP contract for autopilot endpoints", async () => {
const fetchMock = vi.fn().mockImplementation(() => Promise.resolve(
new Response(JSON.stringify({ autopilots: [], runs: [], total: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
));
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.listAutopilots({ status: "active" });
await client.getAutopilot("ap-1");
await client.createAutopilot({
title: "Daily triage",
project_id: "project-1",
assignee_id: "agent-1",
execution_mode: "create_issue",
});
await client.updateAutopilot("ap-1", { status: "paused", project_id: null });
await client.deleteAutopilot("ap-1");
await client.triggerAutopilot("ap-1");
await client.listAutopilotRuns("ap-1", { limit: 10, offset: 20 });
await client.createAutopilotTrigger("ap-1", {
kind: "schedule",
cron_expression: "0 9 * * *",
timezone: "UTC",
});
await client.updateAutopilotTrigger("ap-1", "tr-1", { enabled: false });
await client.deleteAutopilotTrigger("ap-1", "tr-1");
await client.rotateAutopilotTriggerWebhookToken("ap-1", "tr-1");
const calls = fetchMock.mock.calls.map(([url, init]) => ({
url,
method: init?.method ?? "GET",
body: init?.body,
}));
expect(calls).toMatchObject([
{ url: "https://api.example.test/api/autopilots?status=active", method: "GET" },
{ url: "https://api.example.test/api/autopilots/ap-1", method: "GET" },
{
url: "https://api.example.test/api/autopilots",
method: "POST",
body: JSON.stringify({
title: "Daily triage",
project_id: "project-1",
assignee_id: "agent-1",
execution_mode: "create_issue",
}),
},
{
url: "https://api.example.test/api/autopilots/ap-1",
method: "PATCH",
body: JSON.stringify({ status: "paused", project_id: null }),
},
{ url: "https://api.example.test/api/autopilots/ap-1", method: "DELETE" },
{ url: "https://api.example.test/api/autopilots/ap-1/trigger", method: "POST" },
{ url: "https://api.example.test/api/autopilots/ap-1/runs?limit=10&offset=20", method: "GET" },
{
url: "https://api.example.test/api/autopilots/ap-1/triggers",
method: "POST",
body: JSON.stringify({
kind: "schedule",
cron_expression: "0 9 * * *",
timezone: "UTC",
}),
},
{
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1",
method: "PATCH",
body: JSON.stringify({ enabled: false }),
},
{ url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1", method: "DELETE" },
{
url: "https://api.example.test/api/autopilots/ap-1/triggers/tr-1/rotate-webhook-token",
method: "POST",
},
]);
});
it("emits X-Client-* headers when identity is configured", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test", {
identity: { platform: "desktop", version: "1.2.3", os: "macos" },
});
await client.listWorkspaces();
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
expect(headers["X-Client-Platform"]).toBe("desktop");
expect(headers["X-Client-Version"]).toBe("1.2.3");
expect(headers["X-Client-OS"]).toBe("macos");
});
it("omits X-Client-* headers when identity is not configured", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.listWorkspaces();
const headers = fetchMock.mock.calls[0]![1]!.headers as Record<string, string>;
expect(headers["X-Client-Platform"]).toBeUndefined();
expect(headers["X-Client-Version"]).toBeUndefined();
expect(headers["X-Client-OS"]).toBeUndefined();
});
it("uses the Cloud Runtime node API contract", async () => {
const node = {
id: "node-1",
owner_id: "user-1",
instance_id: "i-0123456789abcdef0",
region: "us-west-2",
instance_type: "g5.xlarge",
image_id: "ami-1",
subnet_id: "subnet-1",
name: "gpu-dev-01",
status: "launching",
tags: {},
metadata: {},
created_at: "2026-05-21T08:30:00Z",
updated_at: "2026-05-21T08:30:00Z",
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify(node), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.listCloudRuntimeNodes({ limit: 20, offset: 5 });
await client.createCloudRuntimeNode(
{ instance_type: "g5.xlarge", name: "gpu-dev-01" },
);
const listCall = fetchMock.mock.calls[0]!;
const createCall = fetchMock.mock.calls[1]!;
expect(listCall[0]).toBe(
"https://api.example.test/api/cloud-runtime/nodes?limit=20&offset=5",
);
expect(createCall[0]).toBe(
"https://api.example.test/api/cloud-runtime/nodes",
);
expect(createCall[1]).toMatchObject({
method: "POST",
body: JSON.stringify({
instance_type: "g5.xlarge",
name: "gpu-dev-01",
}),
});
});
it("falls back when Cloud Runtime node responses drift", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
new Response(JSON.stringify([{ id: 123 }]), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify({ id: 123 }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await expect(client.listCloudRuntimeNodes()).resolves.toEqual([]);
await expect(
client.createCloudRuntimeNode({ instance_type: "g5.xlarge" }),
).resolves.toMatchObject({ id: "", status: "" });
});
it("deleteCloudRuntimeNode sends DELETE with JSON body containing instance id", async () => {
const fetchMock = vi.fn().mockResolvedValueOnce(
new Response(null, { status: 204 }),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.deleteCloudRuntimeNode("i-0123456789abcdef0");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.example.test/api/cloud-runtime/nodes");
expect(opts).toMatchObject({
method: "DELETE",
body: JSON.stringify({ instance_id: "i-0123456789abcdef0" }),
});
expect((opts.headers as Record<string, string>)["Content-Type"]).toBe(
"application/json",
);
});
describe("getAttachment", () => {
it("returns the parsed attachment for a well-formed response", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
id: "att-1",
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
uploader_type: "member",
uploader_id: "u-1",
filename: "report.md",
url: "https://static.example.test/ws/att-1.md",
download_url:
"https://static.example.test/ws/att-1.md?Policy=p&Signature=s&Key-Pair-Id=k",
content_type: "text/markdown",
size_bytes: 123,
created_at: "2026-05-11T00:00:00Z",
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
),
);
const client = new ApiClient("https://api.example.test");
const att = await client.getAttachment("att-1");
expect(att.id).toBe("att-1");
expect(att.download_url).toContain("Policy=");
});
it("falls back to an empty attachment when the response is missing download_url", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "att-1" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
),
);
const client = new ApiClient("https://api.example.test");
const att = await client.getAttachment("att-1");
// parseWithFallback returns the EMPTY_ATTACHMENT record so callers can
// safely read `download_url` without crashing — they'll see "" and
// surface a user-facing error instead of opening `undefined`.
expect(att.id).toBe("");
expect(att.download_url).toBe("");
});
});
describe("getAttachmentTextContent", () => {
it("returns body text and the original content type from the X-* header", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("# heading\n\nbody\n", {
status: 200,
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Original-Content-Type": "text/markdown",
},
}),
),
);
const client = new ApiClient("https://api.example.test");
const { text, originalContentType } =
await client.getAttachmentTextContent("att-1");
expect(text).toBe("# heading\n\nbody\n");
expect(originalContentType).toBe("text/markdown");
});
it("throws PreviewTooLargeError on 413", async () => {
const { PreviewTooLargeError } = await import("./client");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("", { status: 413, statusText: "Payload Too Large" }),
),
);
const client = new ApiClient("https://api.example.test");
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
PreviewTooLargeError,
);
});
it("throws PreviewUnsupportedError on 415", async () => {
const { PreviewUnsupportedError } = await import("./client");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response("", { status: 415, statusText: "Unsupported Media Type" }),
),
);
const client = new ApiClient("https://api.example.test");
await expect(client.getAttachmentTextContent("att-1")).rejects.toBeInstanceOf(
PreviewUnsupportedError,
);
});
});
describe("chat attachment wiring", () => {
it("uploadFile includes chat_session_id in the FormData body", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ id: "att-1", url: "https://cdn/x" }), {
status: 200,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
const file = new File(["hi"], "hi.png", { type: "image/png" });
await client.uploadFile(file, { chatSessionId: "session-123" });
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0]!;
expect(url).toBe("https://api.example.test/api/upload-file");
expect(init?.method).toBe("POST");
const body = init?.body as FormData;
expect(body).toBeInstanceOf(FormData);
expect(body.get("chat_session_id")).toBe("session-123");
expect(body.get("issue_id")).toBeNull();
expect(body.get("comment_id")).toBeNull();
});
it("sendChatMessage serialises attachment_ids onto the JSON body when present", async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.sendChatMessage("session-1", "hello", ["att-1", "att-2"]);
const [, init] = fetchMock.mock.calls[0]!;
expect(JSON.parse(init?.body as string)).toEqual({
content: "hello",
attachment_ids: ["att-1", "att-2"],
});
});
it("sendChatMessage omits attachment_ids when the list is empty or undefined", async () => {
const fetchMock = vi.fn().mockImplementation(() =>
Promise.resolve(
new Response(JSON.stringify({ message_id: "m1", task_id: "t1", created_at: "" }), {
status: 201,
headers: { "Content-Type": "application/json" },
}),
),
);
vi.stubGlobal("fetch", fetchMock);
const client = new ApiClient("https://api.example.test");
await client.sendChatMessage("session-1", "hello");
await client.sendChatMessage("session-1", "again", []);
expect(JSON.parse(fetchMock.mock.calls[0]![1]?.body as string)).toEqual({ content: "hello" });
expect(JSON.parse(fetchMock.mock.calls[1]![1]?.body as string)).toEqual({ content: "again" });
});
});
});