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);
|
||||
});
|
||||
});
|
||||
@@ -391,6 +391,94 @@ describe("kuber v2 HTTP routes", () => {
|
||||
expect((await apply(app)).status).toBe(403);
|
||||
});
|
||||
|
||||
test("starts preferred resource operations before returning so progress can be polled", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
});
|
||||
await workspaceStore.create({
|
||||
id: "demo",
|
||||
source: { uri: "oci://example/demo", digest: "sha256:abc" },
|
||||
});
|
||||
const trustStore = new MemoryTrustStore();
|
||||
const fingerprint = "b".repeat(64);
|
||||
let start!: () => void;
|
||||
let finish!: () => void;
|
||||
let complete!: () => void;
|
||||
const started = new Promise<void>((resolve) => (start = resolve));
|
||||
const unblock = new Promise<void>((resolve) => (finish = resolve));
|
||||
const completed = new Promise<void>((resolve) => (complete = resolve));
|
||||
const management = {
|
||||
applyResources: async (
|
||||
_workspace: unknown,
|
||||
_resources: unknown,
|
||||
execution: {
|
||||
emit?: (event: {
|
||||
resource: { apiVersion: string; kind: string; name: string };
|
||||
phase: "apply";
|
||||
state: "started" | "succeeded";
|
||||
}) => Promise<void>;
|
||||
},
|
||||
) => {
|
||||
await execution.emit?.({
|
||||
resource: { apiVersion: "v1", kind: "Service", name: "web" },
|
||||
phase: "apply",
|
||||
state: "started",
|
||||
});
|
||||
start();
|
||||
await unblock;
|
||||
await execution.emit?.({
|
||||
resource: { apiVersion: "v1", kind: "Service", name: "web" },
|
||||
phase: "apply",
|
||||
state: "succeeded",
|
||||
});
|
||||
complete();
|
||||
return [];
|
||||
},
|
||||
} as unknown as ManagementService;
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("operator"),
|
||||
workspaceStore,
|
||||
operationStore: new MemoryOperationStore(),
|
||||
trustStore,
|
||||
management,
|
||||
});
|
||||
await app(
|
||||
request(
|
||||
"/api/v2/workspaces/demo/trust",
|
||||
{ method: "POST", body: JSON.stringify({ fingerprint }) },
|
||||
"token",
|
||||
),
|
||||
);
|
||||
|
||||
const submitted = await app(
|
||||
request(
|
||||
"/api/v2/workspaces/demo/resources/apply",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"idempotency-key": "progress-once",
|
||||
prefer: "respond-async",
|
||||
"x-kuber-trust-project": "demo",
|
||||
"x-kuber-trust-fingerprint": fingerprint,
|
||||
},
|
||||
body: JSON.stringify({ resources: [] }),
|
||||
},
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(submitted.status).toBe(202);
|
||||
const { operationId } = (await submitted.json()) as { operationId: string };
|
||||
await started;
|
||||
const events = await app(
|
||||
request(`/api/v2/operations/${operationId}/events?after=0`, {}, "token"),
|
||||
);
|
||||
expect(await events.json()).toMatchObject({
|
||||
items: [{ sequence: 1, data: { state: "started" } }],
|
||||
});
|
||||
finish();
|
||||
await completed;
|
||||
});
|
||||
|
||||
test("uses exact origins, request IDs, and problem+json errors", async () => {
|
||||
const app = createApp({
|
||||
store: new MemoryAuthStore(),
|
||||
@@ -548,6 +636,108 @@ describe("kuber v2 HTTP routes", () => {
|
||||
expect(await operationStore.list("demo")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("lists persisted operation events with capability and workspace scope checks", async () => {
|
||||
const store = await authenticatedStore("operator");
|
||||
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,
|
||||
() => "event-id",
|
||||
);
|
||||
const operation = await operationStore.create({
|
||||
workspaceId: "demo",
|
||||
action: "resources.apply",
|
||||
idempotencyKey: "event-key",
|
||||
});
|
||||
await operationStore.emit(operation.metadata.name, {
|
||||
resource: { apiVersion: "v1", kind: "Service", name: "web" },
|
||||
phase: "apply",
|
||||
state: "succeeded",
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "demo-reader-key-01",
|
||||
tokenHash: hashToken("demo-reader-token"),
|
||||
username: "operator",
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "demo",
|
||||
expiresAt: "2030-01-01T00:00:00.000Z",
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "other-reader-key-1",
|
||||
tokenHash: hashToken("other-reader-token"),
|
||||
username: "operator",
|
||||
capabilities: ["kubernetes:read"],
|
||||
workspace: "other",
|
||||
expiresAt: "2030-01-01T00:00:00.000Z",
|
||||
});
|
||||
const app = createApp({ store, workspaceStore, operationStore });
|
||||
const path = `/api/v2/operations/${operation.metadata.name}/events?after=0`;
|
||||
const allowed = await app(request(path, {}, "demo-reader-token"));
|
||||
expect(allowed.status).toBe(200);
|
||||
expect(await allowed.json()).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
sequence: 1,
|
||||
data: { resource: { kind: "Service", name: "web" }, phase: "apply" },
|
||||
},
|
||||
],
|
||||
nextCursor: 1,
|
||||
retainedFirstSequence: 1,
|
||||
cursorGap: false,
|
||||
});
|
||||
expect((await app(request(path, {}, "other-reader-token"))).status).toBe(
|
||||
403,
|
||||
);
|
||||
expect(
|
||||
(await app(request(`${path}x`, {}, "demo-reader-token"))).status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
test("reports operation event cursor gaps after retained history is truncated", async () => {
|
||||
const operationStore = new MemoryOperationStore(
|
||||
undefined,
|
||||
() => "retained",
|
||||
);
|
||||
const operation = await operationStore.create({
|
||||
workspaceId: "demo",
|
||||
action: "resources.apply",
|
||||
idempotencyKey: "retained-events",
|
||||
});
|
||||
for (let sequence = 0; sequence < 257; sequence += 1) {
|
||||
await operationStore.emit(operation.metadata.name, {
|
||||
resource: { apiVersion: "v1", kind: "ConfigMap", name: "config" },
|
||||
phase: "apply",
|
||||
state: "succeeded",
|
||||
});
|
||||
}
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("operator"),
|
||||
operationStore,
|
||||
});
|
||||
|
||||
const response = await app(
|
||||
request(
|
||||
`/api/v2/operations/${operation.metadata.name}/events?after=0`,
|
||||
{},
|
||||
"token",
|
||||
),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const events = (await response.json()) as {
|
||||
retainedFirstSequence: number;
|
||||
cursorGap: boolean;
|
||||
items: { sequence: number }[];
|
||||
};
|
||||
expect(events.retainedFirstSequence).toBe(2);
|
||||
expect(events.cursorGap).toBe(true);
|
||||
expect(events.items[0]?.sequence).toBe(2);
|
||||
});
|
||||
|
||||
test("rejects JSON bodies over the configured limit", async () => {
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("admin"),
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
KubernetesWorkspacePersistence,
|
||||
type LeaseObjects,
|
||||
} from "../../server/kubernetes-state";
|
||||
import type { Operation } from "../../server/operation-store";
|
||||
import {
|
||||
PersistentOperationStore,
|
||||
type Operation,
|
||||
} from "../../server/operation-store";
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceRevision,
|
||||
@@ -183,6 +186,42 @@ describe("Kubernetes state persistence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps operation progress in the Kubernetes-backed operation record", async () => {
|
||||
const fake = new FakeObjects();
|
||||
const store = new PersistentOperationStore(
|
||||
new KubernetesOperationPersistence(
|
||||
fake as unknown as KubernetesObjectApi,
|
||||
),
|
||||
() => new Date(timestamp),
|
||||
() => "event-id",
|
||||
);
|
||||
const created = await store.create({
|
||||
workspaceId: "demo",
|
||||
action: "resources.apply",
|
||||
idempotencyKey: "events",
|
||||
});
|
||||
await store.emit(created.metadata.name, {
|
||||
resource: { apiVersion: "v1", kind: "ConfigMap", name: "settings" },
|
||||
phase: "apply",
|
||||
state: "succeeded",
|
||||
});
|
||||
const reloaded = new PersistentOperationStore(
|
||||
new KubernetesOperationPersistence(
|
||||
fake as unknown as KubernetesObjectApi,
|
||||
),
|
||||
);
|
||||
expect(await reloaded.events(created.metadata.name)).toMatchObject({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
sequence: 1,
|
||||
data: expect.objectContaining({ phase: "apply" }),
|
||||
}),
|
||||
],
|
||||
retainedFirstSequence: 1,
|
||||
cursorGap: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("recovers replacement from a matching precreated revision", async () => {
|
||||
const fake = new FakeObjects();
|
||||
const persistence = new KubernetesWorkspacePersistence(
|
||||
@@ -365,7 +404,11 @@ class FakeLeaseStore implements LeaseObjects {
|
||||
return structuredClone(stored);
|
||||
}
|
||||
|
||||
async delete(name: string, namespace: string, expectedResourceVersion?: string) {
|
||||
async delete(
|
||||
name: string,
|
||||
namespace: string,
|
||||
expectedResourceVersion?: string,
|
||||
) {
|
||||
this.beforeDelete?.();
|
||||
this.beforeDelete = undefined;
|
||||
const key = this.key(name, namespace);
|
||||
|
||||
@@ -49,8 +49,10 @@ class FakeObjects {
|
||||
readonly secrets = new Map<string, StoredSecret>();
|
||||
readonly patches: StoredSecret[] = [];
|
||||
readonly deleted: string[] = [];
|
||||
reads = 0;
|
||||
|
||||
async read(value: KubernetesObject): Promise<StoredSecret> {
|
||||
this.reads += 1;
|
||||
const found = this.secrets.get(value.metadata?.name ?? "");
|
||||
if (!found) throw { code: 404 };
|
||||
return found;
|
||||
@@ -97,7 +99,10 @@ class FakeObjects {
|
||||
}
|
||||
}
|
||||
|
||||
function setup(): { fake: FakeObjects; store: KubernetesAuthStore } {
|
||||
function setup(): {
|
||||
fake: FakeObjects;
|
||||
store: KubernetesAuthStore;
|
||||
} {
|
||||
const fake = new FakeObjects();
|
||||
return {
|
||||
fake,
|
||||
@@ -106,6 +111,39 @@ function setup(): { fake: FakeObjects; store: KubernetesAuthStore } {
|
||||
}
|
||||
|
||||
describe("KubernetesAuthStore", () => {
|
||||
test("validates the session and user from the store on every request", async () => {
|
||||
const { fake, store } = setup();
|
||||
const tokenHash = hashToken("fresh");
|
||||
await store.putUser({
|
||||
username: "alice",
|
||||
passwordHash: "hash",
|
||||
roles: ["viewer"],
|
||||
});
|
||||
await store.putSession({
|
||||
tokenHash,
|
||||
username: "alice",
|
||||
roles: ["viewer"],
|
||||
expiresAt: "2026-09-03T00:00:00.000Z",
|
||||
});
|
||||
fake.reads = 0;
|
||||
|
||||
await expect(store.getSession(tokenHash)).resolves.toMatchObject({
|
||||
tokenHash,
|
||||
});
|
||||
fake.secrets.set(
|
||||
objectName("user", "alice"),
|
||||
secret("user", objectName("user", "alice"), {
|
||||
username: "alice",
|
||||
passwordHash: "hash",
|
||||
roles: JSON.stringify(["viewer"]),
|
||||
authVersion: "1",
|
||||
disabled: "true",
|
||||
}),
|
||||
);
|
||||
await expect(store.getSession(tokenHash)).resolves.toBeUndefined();
|
||||
expect(fake.reads).toBe(4);
|
||||
});
|
||||
|
||||
test("persists authVersion and not copied roles in new sessions", async () => {
|
||||
const { fake, store } = setup();
|
||||
await store.putUser({
|
||||
|
||||
@@ -163,6 +163,79 @@ describe("server management service", () => {
|
||||
expect(deleted).toEqual(plan.stale);
|
||||
});
|
||||
|
||||
test("emits safe per-resource apply, wait, delete, and failure progress", async () => {
|
||||
const events: Array<{
|
||||
phase: string;
|
||||
state: string;
|
||||
resource: { name: string };
|
||||
}> = [];
|
||||
const service = createManagementService(
|
||||
dependencies({
|
||||
listDeployments: async () => [
|
||||
object("Deployment", "web", "web-uid") as V1Deployment,
|
||||
],
|
||||
applyResource: async (resource) => resource,
|
||||
deleteResource: async () => {},
|
||||
}),
|
||||
);
|
||||
const execution = {
|
||||
emit: async (event: (typeof events)[number]) => {
|
||||
events.push(event);
|
||||
},
|
||||
};
|
||||
await service.applyResources(
|
||||
workspace,
|
||||
[object("Service", "web", "service-uid")],
|
||||
execution,
|
||||
);
|
||||
await service.waitForResources(workspace, ["web"], undefined, execution);
|
||||
await service.deleteResources(
|
||||
workspace,
|
||||
[
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "Service",
|
||||
name: "old",
|
||||
uid: "old-uid",
|
||||
workspaceUid: workspace.uid,
|
||||
},
|
||||
],
|
||||
execution,
|
||||
);
|
||||
expect(
|
||||
events.map(
|
||||
({ phase, state, resource }) => `${phase}:${resource.name}:${state}`,
|
||||
),
|
||||
).toEqual([
|
||||
"apply:web:started",
|
||||
"apply:web:succeeded",
|
||||
"wait:web:started",
|
||||
"wait:web:succeeded",
|
||||
"delete:old:started",
|
||||
"delete:old:succeeded",
|
||||
]);
|
||||
|
||||
const failing = createManagementService(
|
||||
dependencies({
|
||||
applyResource: async () => {
|
||||
throw new Error("provider failed");
|
||||
},
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
failing.applyResources(
|
||||
workspace,
|
||||
[object("Service", "broken", "broken-uid")],
|
||||
execution,
|
||||
),
|
||||
).rejects.toThrow("provider failed");
|
||||
expect(events.at(-1)).toMatchObject({
|
||||
phase: "apply",
|
||||
state: "failed",
|
||||
resource: { name: "broken" },
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects namespace and resource ownership mismatches", async () => {
|
||||
const wrongNamespace = createManagementService(
|
||||
dependencies({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
MemoryOperationStore,
|
||||
MemoryWorkspaceLeaseProvider,
|
||||
MAX_OPERATION_EVENTS,
|
||||
OperationConflictError,
|
||||
OperationValidationError,
|
||||
recoverStaleOperations,
|
||||
@@ -153,6 +154,58 @@ describe("operation store", () => {
|
||||
).rejects.toBeInstanceOf(OperationValidationError);
|
||||
});
|
||||
|
||||
test("persists monotonic bounded progress events with cursors", async () => {
|
||||
const store = new MemoryOperationStore(undefined, () => "event-operation");
|
||||
const operation = await store.create({
|
||||
workspaceId: "demo",
|
||||
action: "resources.apply",
|
||||
idempotencyKey: "events",
|
||||
});
|
||||
await store.emit(operation.metadata.name, {
|
||||
resource: { apiVersion: "v1", kind: "Secret", name: "credentials" },
|
||||
phase: "apply",
|
||||
state: "started",
|
||||
});
|
||||
const complete = await store.emit(operation.metadata.name, {
|
||||
resource: { apiVersion: "v1", kind: "Secret", name: "credentials" },
|
||||
phase: "apply",
|
||||
state: "succeeded",
|
||||
});
|
||||
expect(complete.sequence).toBe(2);
|
||||
expect(await store.events(operation.metadata.name, 1)).toMatchObject({
|
||||
items: [expect.objectContaining({ sequence: 2 })],
|
||||
retainedFirstSequence: 1,
|
||||
cursorGap: false,
|
||||
});
|
||||
await store.transition(operation.metadata.name, "running");
|
||||
await store.transition(operation.metadata.name, "succeeded");
|
||||
expect(await store.events(operation.metadata.name, 1)).toMatchObject({
|
||||
items: [expect.objectContaining({ sequence: 2 })],
|
||||
retainedFirstSequence: 1,
|
||||
cursorGap: false,
|
||||
});
|
||||
await expect(
|
||||
store.events(operation.metadata.name, -1),
|
||||
).rejects.toBeInstanceOf(OperationValidationError);
|
||||
for (let index = 0; index < MAX_OPERATION_EVENTS; index++) {
|
||||
await store.emit(operation.metadata.name, {
|
||||
resource: { apiVersion: "v1", kind: "Service", name: `web-${index}` },
|
||||
phase: "apply",
|
||||
state: "succeeded",
|
||||
});
|
||||
}
|
||||
const bounded = await store.events(operation.metadata.name);
|
||||
expect(bounded.items).toHaveLength(MAX_OPERATION_EVENTS);
|
||||
expect(bounded.items[0]?.sequence).toBe(3);
|
||||
expect(bounded).toMatchObject({
|
||||
retainedFirstSequence: 3,
|
||||
cursorGap: true,
|
||||
});
|
||||
expect(await store.events(operation.metadata.name, 2)).toMatchObject({
|
||||
cursorGap: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("provides exclusive, renewable per-workspace leases", async () => {
|
||||
let now = 0;
|
||||
const leases = new MemoryWorkspaceLeaseProvider(() => now);
|
||||
|
||||
@@ -54,6 +54,69 @@ describe("OCI registry digest resolution", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("caches successful digest resolutions with deterministic expiry and bounds", async () => {
|
||||
let now = 0;
|
||||
let calls = 0;
|
||||
const fetcher = async () => {
|
||||
calls += 1;
|
||||
return new Response("manifest", {
|
||||
headers: { "docker-content-digest": `sha256:${"d".repeat(64)}` },
|
||||
});
|
||||
};
|
||||
const options = {
|
||||
fetch: fetcher,
|
||||
cacheTtlMs: 100,
|
||||
cacheMaxEntries: 1,
|
||||
clock: () => now,
|
||||
};
|
||||
|
||||
await resolveRegistryDigest("cache.example.com/team/first:v1", options);
|
||||
await resolveRegistryDigest("cache.example.com/team/first:v1", options);
|
||||
expect(calls).toBe(1);
|
||||
|
||||
now = 100;
|
||||
await resolveRegistryDigest("cache.example.com/team/first:v1", options);
|
||||
expect(calls).toBe(2);
|
||||
|
||||
await resolveRegistryDigest("cache.example.com/team/second:v1", options);
|
||||
await resolveRegistryDigest("cache.example.com/team/first:v1", options);
|
||||
expect(calls).toBe(4);
|
||||
});
|
||||
|
||||
test("does not cache failed resolutions or share entries across credentials", async () => {
|
||||
let calls = 0;
|
||||
const failing = async () => {
|
||||
calls += 1;
|
||||
return new Response("unavailable", { status: 503 });
|
||||
};
|
||||
const options = { fetch: failing, cacheTtlMs: 1_000 };
|
||||
await expect(
|
||||
resolveRegistryDigest("failure.example.com/team/image:v1", options),
|
||||
).rejects.toThrow("503");
|
||||
await expect(
|
||||
resolveRegistryDigest("failure.example.com/team/image:v1", options),
|
||||
).rejects.toThrow("503");
|
||||
expect(calls).toBe(2);
|
||||
|
||||
const authorizedCalls: string[] = [];
|
||||
const authorized = async (
|
||||
_input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => {
|
||||
authorizedCalls.push(new Headers(init?.headers).get("authorization")!);
|
||||
return new Response("manifest", {
|
||||
headers: { "docker-content-digest": `sha256:${"e".repeat(64)}` },
|
||||
});
|
||||
};
|
||||
for (const username of ["one", "two"]) {
|
||||
await resolveRegistryDigest("credentials.example.com/team/image:v1", {
|
||||
fetch: authorized,
|
||||
credentials: { username, password: "password" },
|
||||
});
|
||||
}
|
||||
expect(authorizedCalls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("resolves through a trusted internal registry origin", async () => {
|
||||
const expected = `sha256:${"c".repeat(64)}` as const;
|
||||
let requested = "";
|
||||
|
||||
Reference in New Issue
Block a user