feat: add CI deployment and live progress
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { cleanupExpiredSessions, createApp } from "../../server/app";
|
||||
import { hashToken, MemoryAuthStore } from "../../server/auth";
|
||||
import { MemoryAuditStore } from "../../server/audit-store";
|
||||
import { MemoryOperationStore } from "../../server/operation-store";
|
||||
|
||||
const now = Date.parse("2026-09-05T00:00:00.000Z");
|
||||
|
||||
function request(path: string, init: RequestInit = {}, token = "admin-token") {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("authorization", `Bearer ${token}`);
|
||||
return new Request(`https://kuber.astrxl.dev${path}`, { ...init, headers });
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const store = new MemoryAuthStore();
|
||||
const auditStore = new MemoryAuditStore(() => new Date(now));
|
||||
const operationStore = new MemoryOperationStore(() => new Date(now));
|
||||
await store.putUser({
|
||||
username: "admin",
|
||||
passwordHash: "hash",
|
||||
roles: ["admin"],
|
||||
});
|
||||
await store.putUser({
|
||||
username: "ci",
|
||||
passwordHash: "hash",
|
||||
roles: ["operator"],
|
||||
});
|
||||
await store.putSession({
|
||||
tokenHash: hashToken("admin-token"),
|
||||
username: "admin",
|
||||
authVersion: 1,
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
return {
|
||||
store,
|
||||
auditStore,
|
||||
operationStore,
|
||||
app: createApp({ store, auditStore, operationStore, now: () => now }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("API keys", () => {
|
||||
test("creates once, lists without a token or hash, and revokes", async () => {
|
||||
const { app, auditStore } = await setup();
|
||||
const created = await app(
|
||||
request("/api/v2/users/ci/keys", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
const key = (await created.json()) as { id: string; token: string };
|
||||
expect(key.token).toHaveLength(43);
|
||||
|
||||
const listed = await app(request("/api/v2/users/ci/keys"));
|
||||
const body = JSON.stringify(await listed.json());
|
||||
expect(body).not.toContain(key.token);
|
||||
expect(body).not.toContain(hashToken(key.token));
|
||||
expect(JSON.stringify(await auditStore.list())).not.toContain(key.token);
|
||||
expect(JSON.stringify(await auditStore.list())).not.toContain(
|
||||
hashToken(key.token),
|
||||
);
|
||||
|
||||
expect(
|
||||
(
|
||||
await app(
|
||||
request(`/api/v2/users/ci/keys/${key.id}`, { method: "DELETE" }),
|
||||
)
|
||||
).status,
|
||||
).toBe(204);
|
||||
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(401);
|
||||
});
|
||||
|
||||
test("uses key capabilities rather than owner roles and invalidates disabled owners", async () => {
|
||||
const { app, store } = await setup();
|
||||
await store.createApiKey({
|
||||
id: "key_capability_test",
|
||||
tokenHash: hashToken("ci-key"),
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
expect((await app(request("/api/v2/users", {}, "ci-key"))).status).toBe(
|
||||
403,
|
||||
);
|
||||
expect((await app(request("/api/v2/me", {}, "ci-key"))).status).toBe(200);
|
||||
await store.updateUser("ci", { disabled: true });
|
||||
expect((await app(request("/api/v2/me", {}, "ci-key"))).status).toBe(401);
|
||||
});
|
||||
|
||||
test("limits API key children to the parent's user, capabilities, and workspace", async () => {
|
||||
const { app, store, auditStore } = await setup();
|
||||
await store.createApiKey({
|
||||
id: "key_delegation_parent",
|
||||
tokenHash: hashToken("delegation-parent"),
|
||||
username: "ci",
|
||||
capabilities: ["users:write", "kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
const create = (body: unknown) =>
|
||||
app(
|
||||
request(
|
||||
"/api/v2/users/ci/keys",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
"delegation-parent",
|
||||
),
|
||||
);
|
||||
|
||||
const capabilities = await create({
|
||||
capabilities: ["kubernetes:write"],
|
||||
workspace: "shop",
|
||||
});
|
||||
expect(capabilities.status).toBe(403);
|
||||
const capabilityError = (await capabilities.json()) as { code: string };
|
||||
expect(capabilityError.code).toBe("API_KEY_DELEGATION_FORBIDDEN");
|
||||
|
||||
const workspace = await create({ capabilities: ["kubernetes:read"] });
|
||||
expect(workspace.status).toBe(403);
|
||||
|
||||
const differentWorkspace = await create({
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "other",
|
||||
});
|
||||
expect(differentWorkspace.status).toBe(403);
|
||||
|
||||
const subset = await create({
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
});
|
||||
expect(subset.status).toBe(201);
|
||||
expect(
|
||||
((await subset.json()) as { capabilities: string[] }).capabilities,
|
||||
).toEqual(["kubernetes:read"]);
|
||||
|
||||
const otherUser = await app(
|
||||
request(
|
||||
"/api/v2/users/admin/keys",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
}),
|
||||
},
|
||||
"delegation-parent",
|
||||
),
|
||||
);
|
||||
expect(otherUser.status).toBe(403);
|
||||
|
||||
const denied = await auditStore.list();
|
||||
expect(
|
||||
denied.filter(
|
||||
(event) =>
|
||||
event.spec.action === "api_key.create" &&
|
||||
event.spec.outcome === "denied",
|
||||
),
|
||||
).toHaveLength(4);
|
||||
expect(JSON.stringify(denied)).not.toContain("delegation-parent");
|
||||
|
||||
await store.createApiKey({
|
||||
id: "key_unscoped_delegation",
|
||||
tokenHash: hashToken("unscoped-delegation"),
|
||||
username: "ci",
|
||||
capabilities: ["users:write", "kubernetes:read"],
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
const unscopedSubset = await app(
|
||||
request(
|
||||
"/api/v2/users/ci/keys",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
|
||||
},
|
||||
"unscoped-delegation",
|
||||
),
|
||||
);
|
||||
expect(unscopedSubset.status).toBe(201);
|
||||
expect(await unscopedSubset.json()).not.toHaveProperty("workspace");
|
||||
});
|
||||
|
||||
test("rejects expired keys and expiry longer than 365 days", async () => {
|
||||
const { app, store } = await setup();
|
||||
await store.createApiKey({
|
||||
id: "key_expiry_test_1",
|
||||
tokenHash: hashToken("expired-key"),
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2026-09-04T00:00:00.000Z",
|
||||
});
|
||||
expect((await app(request("/api/v2/me", {}, "expired-key"))).status).toBe(
|
||||
401,
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await app(
|
||||
request("/api/v2/users/ci/keys", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
capabilities: ["kubernetes:read"],
|
||||
expiresAt: "2027-09-06T00:00:00.000Z",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
test("uses the default expiry, cleans up expired keys, and does not log keys out", async () => {
|
||||
const { app, store } = await setup();
|
||||
const created = await app(
|
||||
request("/api/v2/users/ci/keys", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ capabilities: ["kubernetes:read"] }),
|
||||
}),
|
||||
);
|
||||
const key = (await created.json()) as { token: string; expiresAt: string };
|
||||
expect(key.expiresAt).toBe("2026-12-04T00:00:00.000Z");
|
||||
expect(
|
||||
(await app(request("/api/v2/logout", { method: "POST" }, key.token)))
|
||||
.status,
|
||||
).toBe(204);
|
||||
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(200);
|
||||
expect(await cleanupExpiredSessions(store, Date.parse(key.expiresAt))).toBe(
|
||||
2,
|
||||
);
|
||||
expect((await app(request("/api/v2/me", {}, key.token))).status).toBe(401);
|
||||
});
|
||||
|
||||
test("denies a workspace-scoped key outside its workspace", async () => {
|
||||
const { app, store } = await setup();
|
||||
await store.createApiKey({
|
||||
id: "key_scope_test_1",
|
||||
tokenHash: hashToken("scoped-key"),
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
expect(
|
||||
(await app(request("/api/v2/workspaces/other", {}, "scoped-key"))).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
test("limits workspace-scoped keys to their own audit records", async () => {
|
||||
const { app, store, auditStore } = await setup();
|
||||
await store.createApiKey({
|
||||
id: "key_audit_scope_1",
|
||||
tokenHash: hashToken("scoped-audit-key"),
|
||||
username: "ci",
|
||||
capabilities: ["users:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
await auditStore.append({
|
||||
actor: { username: "admin" },
|
||||
action: "workspace.shop",
|
||||
workspaceId: "shop",
|
||||
outcome: "success",
|
||||
});
|
||||
await auditStore.append({
|
||||
actor: { username: "admin" },
|
||||
action: "workspace.other",
|
||||
workspaceId: "other",
|
||||
outcome: "success",
|
||||
});
|
||||
await auditStore.append({
|
||||
actor: { username: "admin" },
|
||||
action: "platform.global",
|
||||
outcome: "success",
|
||||
});
|
||||
|
||||
const unfiltered = await app(
|
||||
request("/api/v2/audit", {}, "scoped-audit-key"),
|
||||
);
|
||||
expect(unfiltered.status).toBe(200);
|
||||
const audit = (await unfiltered.json()) as {
|
||||
items: { spec: { action: string } }[];
|
||||
};
|
||||
expect(audit.items.map((event) => event.spec.action)).toEqual([
|
||||
"workspace.shop",
|
||||
]);
|
||||
expect(
|
||||
(
|
||||
await app(
|
||||
request("/api/v2/audit?workspaceId=other", {}, "scoped-audit-key"),
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
test("automatically scopes unfiltered operation lists for workspace keys", async () => {
|
||||
const { app, store, operationStore } = await setup();
|
||||
await operationStore.create({
|
||||
workspaceId: "shop",
|
||||
action: "resources.apply",
|
||||
idempotencyKey: "shop-operation",
|
||||
});
|
||||
await operationStore.create({
|
||||
workspaceId: "other",
|
||||
action: "resources.apply",
|
||||
idempotencyKey: "other-operation",
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "key_operation_scope_1",
|
||||
tokenHash: hashToken("scoped-operation-key"),
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const unfiltered = await app(
|
||||
request("/api/v2/operations", {}, "scoped-operation-key"),
|
||||
);
|
||||
expect(unfiltered.status).toBe(200);
|
||||
expect(
|
||||
(
|
||||
(await unfiltered.json()) as {
|
||||
items: { spec: { workspaceId: string } }[];
|
||||
}
|
||||
).items.map((operation) => operation.spec.workspaceId),
|
||||
).toEqual(["shop"]);
|
||||
expect(
|
||||
(
|
||||
await app(
|
||||
request(
|
||||
"/api/v2/operations?workspaceId=shop",
|
||||
{},
|
||||
"scoped-operation-key",
|
||||
),
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await app(
|
||||
request(
|
||||
"/api/v2/operations?workspaceId=other",
|
||||
{},
|
||||
"scoped-operation-key",
|
||||
),
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
test("does not inherit an admin owner's platform adoption privilege", async () => {
|
||||
const { store } = await setup();
|
||||
const adopted: string[] = [];
|
||||
const app = createApp({
|
||||
store,
|
||||
adoption: {
|
||||
adopt: async () => ({
|
||||
workspaceId: "",
|
||||
workspaceUid: "",
|
||||
resourcesAdopted: 0,
|
||||
}),
|
||||
adoptPlatform: async (workspaceUid) => {
|
||||
adopted.push(workspaceUid);
|
||||
return {
|
||||
workspaceId: "kuber-system",
|
||||
workspaceUid,
|
||||
resourcesAdopted: 1,
|
||||
};
|
||||
},
|
||||
},
|
||||
now: () => now,
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "key_platform_owner",
|
||||
tokenHash: hashToken("admin-owner-key"),
|
||||
username: "admin",
|
||||
capabilities: ["kubernetes:write"],
|
||||
workspace: "kuber-system",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "key_platform_scope",
|
||||
tokenHash: hashToken("wrong-scope-key"),
|
||||
username: "admin",
|
||||
capabilities: ["platform:adopt"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "key_platform_allowed",
|
||||
tokenHash: hashToken("platform-key"),
|
||||
username: "admin",
|
||||
capabilities: ["platform:adopt"],
|
||||
workspace: "kuber-system",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
const adopt = (token: string) =>
|
||||
app(
|
||||
request(
|
||||
"/api/v2/platform/kuber-system/adopt",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ workspaceUid: "platform" }),
|
||||
},
|
||||
token,
|
||||
),
|
||||
);
|
||||
|
||||
expect((await adopt("admin-owner-key")).status).toBe(403);
|
||||
expect((await adopt("wrong-scope-key")).status).toBe(403);
|
||||
expect((await adopt("platform-key")).status).toBe(200);
|
||||
expect(adopted).toEqual(["platform"]);
|
||||
});
|
||||
|
||||
test("allows a workspace-scoped key to use matching project build routes", async () => {
|
||||
const { app, store } = await setup();
|
||||
await store.createApiKey({
|
||||
id: "key_scope_build_1",
|
||||
tokenHash: hashToken("scoped-build-key"),
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:write"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
const matching = await app(
|
||||
request(
|
||||
"/api/v2/images/resolve",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ project: "shop", service: "web" }),
|
||||
},
|
||||
"scoped-build-key",
|
||||
),
|
||||
);
|
||||
expect(matching.status).toBe(503);
|
||||
expect(
|
||||
(
|
||||
await app(
|
||||
request(
|
||||
"/api/v2/images/resolve",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ project: "other", service: "web" }),
|
||||
},
|
||||
"scoped-build-key",
|
||||
),
|
||||
)
|
||||
).status,
|
||||
).toBe(403);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user