feat: v2
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test";
|
||||
import { cleanupExpiredSessions, createApp } from "../../server/app";
|
||||
import { hashToken, MemoryAuthStore } from "../../server/auth";
|
||||
import { MemoryAuditStore } from "../../server/audit-store";
|
||||
import type { ManagementService } from "../../server/management";
|
||||
import {
|
||||
MemoryOperationStore,
|
||||
MemoryWorkspaceLeaseProvider,
|
||||
} from "../../server/operation-store";
|
||||
import { MemoryWorkspaceStore } from "../../server/workspace-store";
|
||||
|
||||
function request(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
token?: string,
|
||||
): Request {
|
||||
const headers = new Headers(init.headers);
|
||||
if (token) headers.set("authorization", `Bearer ${token}`);
|
||||
return new Request(`https://kuber.astrxl.dev${path}`, { ...init, headers });
|
||||
}
|
||||
|
||||
describe("kuber API authentication", () => {
|
||||
test("logs in, resolves identity, and revokes the session", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
username: "dmgnr",
|
||||
passwordHash: "stored-hash",
|
||||
roles: ["admin"],
|
||||
});
|
||||
const app = createApp({
|
||||
store,
|
||||
now: () => Date.parse("2026-09-02T00:00:00.000Z"),
|
||||
verifyPassword: async (password, hash) =>
|
||||
password === "correct" && hash === "stored-hash",
|
||||
});
|
||||
|
||||
const login = await app(
|
||||
request("/api/v2/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: "dmgnr",
|
||||
password: "correct",
|
||||
persistent: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(login.status).toBe(200);
|
||||
const session = (await login.json()) as {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
expect(session.expiresAt).toBe("2026-09-03T00:00:00.000Z");
|
||||
|
||||
const me = await app(request("/api/v2/me", {}, session.token));
|
||||
expect(await me.json()).toEqual({ username: "dmgnr", roles: ["admin"] });
|
||||
|
||||
const logout = await app(
|
||||
request("/api/v2/logout", { method: "POST" }, session.token),
|
||||
);
|
||||
expect(logout.status).toBe(204);
|
||||
expect((await app(request("/api/v2/me", {}, session.token))).status).toBe(
|
||||
401,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects invalid credentials and unauthenticated requests", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
username: "dmgnr",
|
||||
passwordHash: "stored-hash",
|
||||
roles: ["admin"],
|
||||
});
|
||||
const app = createApp({
|
||||
store,
|
||||
verifyPassword: async () => false,
|
||||
});
|
||||
|
||||
const login = await app(
|
||||
request("/api/v2/login", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ username: "dmgnr", password: "wrong" }),
|
||||
}),
|
||||
);
|
||||
expect(login.status).toBe(401);
|
||||
expect((await app(request("/api/v2/me"))).status).toBe(401);
|
||||
});
|
||||
|
||||
test("provides an unauthenticated health endpoint", async () => {
|
||||
const app = createApp({ store: new MemoryAuthStore() });
|
||||
const response = await app(request("/api/v2/health"));
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toEqual({ status: "ok" });
|
||||
});
|
||||
|
||||
test("provides an explicit expired-session startup cleanup helper", async () => {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({
|
||||
username: "user",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
await store.putSession({
|
||||
tokenHash: hashToken("expired"),
|
||||
username: "user",
|
||||
authVersion: 1,
|
||||
expiresAt: "2026-09-01T00:00:00.000Z",
|
||||
});
|
||||
expect(
|
||||
await cleanupExpiredSessions(
|
||||
store,
|
||||
Date.parse("2026-09-02T00:00:00.000Z"),
|
||||
),
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
test("rate limits repeated failed logins", async () => {
|
||||
const app = createApp({
|
||||
store: new MemoryAuthStore(),
|
||||
now: () => 0,
|
||||
});
|
||||
const login = () =>
|
||||
app(
|
||||
request("/api/v2/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: "missing", password: "wrong" }),
|
||||
}),
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
expect((await login()).status).toBe(401);
|
||||
}
|
||||
const limited = await login();
|
||||
expect(limited.status).toBe(429);
|
||||
expect(limited.headers.get("retry-after")).toBe("300");
|
||||
});
|
||||
});
|
||||
|
||||
async function authenticatedStore(role: "viewer" | "operator" | "admin") {
|
||||
const store = new MemoryAuthStore();
|
||||
await store.putUser({ username: role, passwordHash: "hash", roles: [role] });
|
||||
await store.putSession({
|
||||
tokenHash: hashToken("token"),
|
||||
username: role,
|
||||
authVersion: 1,
|
||||
expiresAt: "2030-01-01T00:00:00.000Z",
|
||||
});
|
||||
return store;
|
||||
}
|
||||
|
||||
describe("kuber v2 HTTP routes", () => {
|
||||
test("uses exact origins, request IDs, and problem+json errors", async () => {
|
||||
const app = createApp({
|
||||
store: new MemoryAuthStore(),
|
||||
allowedOrigins: ["https://console.example"],
|
||||
requestId: () => "generated-id",
|
||||
});
|
||||
const denied = await app(
|
||||
request("/api/v2/health", {
|
||||
headers: { origin: "https://console.example.evil" },
|
||||
}),
|
||||
);
|
||||
expect(denied.status).toBe(403);
|
||||
expect(denied.headers.get("content-type")).toContain(
|
||||
"application/problem+json",
|
||||
);
|
||||
expect(denied.headers.get("x-request-id")).toBe("generated-id");
|
||||
expect(await denied.json()).toMatchObject({
|
||||
code: "ORIGIN_NOT_ALLOWED",
|
||||
requestId: "generated-id",
|
||||
});
|
||||
|
||||
const allowed = await app(
|
||||
request("/api/v2/health", {
|
||||
headers: {
|
||||
origin: "https://console.example",
|
||||
"x-request-id": "caller-id",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(allowed.headers.get("access-control-allow-origin")).toBe(
|
||||
"https://console.example",
|
||||
);
|
||||
expect(allowed.headers.get("x-request-id")).toBe("caller-id");
|
||||
});
|
||||
|
||||
test("enforces capabilities and supports user CRUD with revocation", async () => {
|
||||
const viewerStore = await authenticatedStore("viewer");
|
||||
const viewerApp = createApp({ store: viewerStore });
|
||||
expect(
|
||||
(await viewerApp(request("/api/v2/users", {}, "token"))).status,
|
||||
).toBe(403);
|
||||
|
||||
const store = await authenticatedStore("admin");
|
||||
const auditStore = new MemoryAuditStore();
|
||||
const app = createApp({
|
||||
store,
|
||||
auditStore,
|
||||
hashPassword: async (password) => `hashed:${password}`,
|
||||
});
|
||||
const created = await app(
|
||||
request(
|
||||
"/api/v2/users",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
roles: ["operator"],
|
||||
}),
|
||||
},
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
expect(await created.json()).toEqual({
|
||||
username: "alice",
|
||||
roles: ["operator"],
|
||||
disabled: false,
|
||||
});
|
||||
expect((await store.getUser("alice"))?.passwordHash).toBe("hashed:secret");
|
||||
|
||||
const revoke = await app(
|
||||
request(
|
||||
"/api/v2/users/alice/sessions/revoke",
|
||||
{ method: "POST" },
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(revoke.status).toBe(200);
|
||||
expect(await revoke.json()).toEqual({ username: "alice", revoked: 0 });
|
||||
expect((await auditStore.list()).map((event) => event.spec.action)).toEqual(
|
||||
["user.create", "sessions.revoke"],
|
||||
);
|
||||
});
|
||||
|
||||
test("provides workspace ETags and idempotent synchronous operations", async () => {
|
||||
const store = await authenticatedStore("operator");
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
now: () => new Date("2026-09-02T00:00:00.000Z"),
|
||||
});
|
||||
const operationStore = new MemoryOperationStore(
|
||||
() => new Date("2026-09-02T00:00:00.000Z"),
|
||||
() => "operation-uid",
|
||||
);
|
||||
let stops = 0;
|
||||
const management = {
|
||||
stop: async () => {
|
||||
stops += 1;
|
||||
return ["api"];
|
||||
},
|
||||
} as unknown as ManagementService;
|
||||
const app = createApp({
|
||||
store,
|
||||
workspaceStore,
|
||||
operationStore,
|
||||
management,
|
||||
});
|
||||
|
||||
const created = await app(
|
||||
request(
|
||||
"/api/v2/workspaces",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
}),
|
||||
},
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
expect(created.headers.get("etag")).toBe('"1"');
|
||||
|
||||
const missingPrecondition = await app(
|
||||
request(
|
||||
"/api/v2/workspaces/demo",
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
source: { uri: "oci://example/demo", digest: "sha256:def" },
|
||||
}),
|
||||
},
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(missingPrecondition.status).toBe(428);
|
||||
|
||||
const stop = () =>
|
||||
app(
|
||||
request(
|
||||
"/api/v2/workspaces/demo/lifecycle",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "idempotency-key": "stop-once" },
|
||||
body: JSON.stringify({ action: "stop", services: ["api"] }),
|
||||
},
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect((await stop()).status).toBe(200);
|
||||
expect((await stop()).status).toBe(200);
|
||||
expect(stops).toBe(1);
|
||||
expect(await operationStore.list("demo")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("rejects JSON bodies over the configured limit", async () => {
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("admin"),
|
||||
workspaceStore: new MemoryWorkspaceStore(),
|
||||
jsonBodyLimit: 32,
|
||||
});
|
||||
const result = await app(
|
||||
request(
|
||||
"/api/v2/workspaces",
|
||||
{ method: "POST", body: JSON.stringify({ value: "x".repeat(64) }) },
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(result.status).toBe(413);
|
||||
expect(await result.json()).toMatchObject({ code: "BODY_TOO_LARGE" });
|
||||
});
|
||||
|
||||
test("does not expose request internals or corrupt success when auditing fails", async () => {
|
||||
const reported = spyOn(console, "error").mockImplementation(() => {});
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
});
|
||||
await workspaceStore.create({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
});
|
||||
const operationStore = new MemoryOperationStore(
|
||||
undefined,
|
||||
() => "operation-uid",
|
||||
);
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("operator"),
|
||||
workspaceStore,
|
||||
operationStore,
|
||||
management: { stop: async () => ["api"] } as unknown as ManagementService,
|
||||
auditStore: {
|
||||
append: async () => {
|
||||
throw new Error("audit unavailable");
|
||||
},
|
||||
list: async () => [],
|
||||
},
|
||||
});
|
||||
const result = await app(
|
||||
request(
|
||||
"/api/v2/workspaces/demo/lifecycle",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "idempotency-key": "private-request" },
|
||||
body: JSON.stringify({ action: "stop", password: "do-not-store" }),
|
||||
},
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(result.status).toBe(200);
|
||||
const body = (await result.json()) as Record<string, any>;
|
||||
expect(body.operation.spec).toEqual({
|
||||
workspaceId: "demo",
|
||||
action: "workspace.stop",
|
||||
});
|
||||
expect(body.operation.status.state).toBe("succeeded");
|
||||
expect(reported).toHaveBeenCalledTimes(1);
|
||||
reported.mockRestore();
|
||||
expect(
|
||||
await operationStore.get("operation-operation-uid"),
|
||||
).not.toHaveProperty("spec.request");
|
||||
});
|
||||
|
||||
test("fails and identifies operations that cannot acquire the workspace lease", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
});
|
||||
await workspaceStore.create({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
});
|
||||
const operationStore = new MemoryOperationStore(undefined, () => "blocked");
|
||||
const leases = new MemoryWorkspaceLeaseProvider();
|
||||
await leases.acquire("demo", "other");
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("operator"),
|
||||
workspaceStore,
|
||||
operationStore,
|
||||
leases,
|
||||
management: { stop: async () => [] } as unknown as ManagementService,
|
||||
});
|
||||
const result = await app(
|
||||
request(
|
||||
"/api/v2/workspaces/demo/lifecycle",
|
||||
{ method: "POST", body: JSON.stringify({ action: "stop" }) },
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(result.status).toBe(409);
|
||||
expect(await result.json()).toMatchObject({
|
||||
code: "WORKSPACE_BUSY",
|
||||
operationId: "operation-blocked",
|
||||
});
|
||||
expect((await operationStore.get("operation-blocked"))?.status.state).toBe(
|
||||
"failed",
|
||||
);
|
||||
});
|
||||
|
||||
test("routes workspace adoption and keeps platform adoption admin-only", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
});
|
||||
await workspaceStore.create({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
});
|
||||
const adopted: string[] = [];
|
||||
const adoption = {
|
||||
adopt: async (workspaceId: string, workspaceUid: string) => {
|
||||
adopted.push(`${workspaceId}:${workspaceUid}`);
|
||||
return { workspaceId, workspaceUid, resourcesAdopted: 2 };
|
||||
},
|
||||
adoptPlatform: async (workspaceUid: string) => ({
|
||||
workspaceId: "kuber-system",
|
||||
workspaceUid,
|
||||
resourcesAdopted: 1,
|
||||
}),
|
||||
};
|
||||
const operatorApp = createApp({
|
||||
store: await authenticatedStore("operator"),
|
||||
workspaceStore,
|
||||
adoption,
|
||||
});
|
||||
const regular = await operatorApp(
|
||||
request("/api/v2/workspaces/demo/adopt", { method: "POST" }, "token"),
|
||||
);
|
||||
expect(regular.status).toBe(200);
|
||||
expect(adopted).toEqual(["demo:workspace-uid"]);
|
||||
expect(
|
||||
(
|
||||
await operatorApp(
|
||||
request(
|
||||
"/api/v2/platform/kuber-system/adopt",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspaceUid: "platform" }),
|
||||
},
|
||||
"token",
|
||||
),
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
|
||||
const adminApp = createApp({
|
||||
store: await authenticatedStore("admin"),
|
||||
workspaceStore,
|
||||
adoption,
|
||||
});
|
||||
const platform = await adminApp(
|
||||
request(
|
||||
"/api/v2/platform/kuber-system/adopt",
|
||||
{ method: "POST", body: JSON.stringify({ workspaceUid: "platform" }) },
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(platform.status).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user