fix: stabilize live deployment progress
This commit is contained in:
@@ -3,6 +3,9 @@ import { cleanupExpiredSessions, createApp } from "../../server/app";
|
||||
import { hashToken, MemoryAuthStore } from "../../server/auth";
|
||||
import { MemoryAuditStore } from "../../server/audit-store";
|
||||
import { MemoryOperationStore } from "../../server/operation-store";
|
||||
import type { ManagementService } from "../../server/management";
|
||||
import { MemoryTrustStore } from "../../server/trust-store";
|
||||
import { MemoryWorkspaceStore } from "../../server/workspace-store";
|
||||
|
||||
const now = Date.parse("2026-09-05T00:00:00.000Z");
|
||||
|
||||
@@ -255,6 +258,67 @@ describe("API keys", () => {
|
||||
).toBe(403);
|
||||
});
|
||||
|
||||
test("allows scoped keys to apply only in their workspace and rejects deleted owners", async () => {
|
||||
const { store } = await setup();
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
});
|
||||
for (const id of ["shop", "other"])
|
||||
await workspaceStore.create({
|
||||
id,
|
||||
source: { uri: `oci://example/${id}`, digest: "sha256:abc" },
|
||||
});
|
||||
const trustStore = new MemoryTrustStore();
|
||||
const fingerprint = "a".repeat(64);
|
||||
await trustStore.grant("shop", fingerprint);
|
||||
let applies = 0;
|
||||
const app = createApp({
|
||||
store,
|
||||
workspaceStore,
|
||||
trustStore,
|
||||
operationStore: new MemoryOperationStore(() => new Date(now)),
|
||||
management: {
|
||||
applyResources: async () => {
|
||||
applies += 1;
|
||||
return [];
|
||||
},
|
||||
} as unknown as ManagementService,
|
||||
now: () => now,
|
||||
});
|
||||
await store.createApiKey({
|
||||
id: "key_apply_scope_1",
|
||||
tokenHash: hashToken("scoped-apply-key"),
|
||||
username: "ci",
|
||||
capabilities: ["kubernetes:write"],
|
||||
workspace: "shop",
|
||||
expiresAt: "2026-10-05T00:00:00.000Z",
|
||||
});
|
||||
const apply = (workspace: string) =>
|
||||
app(
|
||||
request(
|
||||
`/api/v2/workspaces/${workspace}/resources/apply`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"idempotency-key": `apply-${workspace}`,
|
||||
"x-kuber-trust-project": "shop",
|
||||
"x-kuber-trust-fingerprint": fingerprint,
|
||||
},
|
||||
body: JSON.stringify({ resources: [] }),
|
||||
},
|
||||
"scoped-apply-key",
|
||||
),
|
||||
);
|
||||
|
||||
expect((await apply("shop")).status).toBe(200);
|
||||
expect((await apply("other")).status).toBe(403);
|
||||
expect(applies).toBe(1);
|
||||
await store.deleteUser("ci");
|
||||
expect(
|
||||
(await app(request("/api/v2/me", {}, "scoped-apply-key"))).status,
|
||||
).toBe(401);
|
||||
});
|
||||
|
||||
test("limits workspace-scoped keys to their own audit records", async () => {
|
||||
const { app, store, auditStore } = await setup();
|
||||
await store.createApiKey({
|
||||
|
||||
@@ -391,6 +391,51 @@ describe("kuber v2 HTTP routes", () => {
|
||||
expect((await apply(app)).status).toBe(403);
|
||||
});
|
||||
|
||||
test("returns OPERATION_CONFLICT when an idempotency key is reused for another body", 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 = "c".repeat(64);
|
||||
await trustStore.grant("demo", fingerprint);
|
||||
const app = createApp({
|
||||
store: await authenticatedStore("operator"),
|
||||
workspaceStore,
|
||||
trustStore,
|
||||
operationStore: new MemoryOperationStore(),
|
||||
management: {
|
||||
applyResources: async () => [],
|
||||
} as unknown as ManagementService,
|
||||
});
|
||||
const apply = (resources: unknown[]) =>
|
||||
app(
|
||||
request(
|
||||
"/api/v2/workspaces/demo/resources/apply",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"idempotency-key": "same-apply",
|
||||
"x-kuber-trust-project": "demo",
|
||||
"x-kuber-trust-fingerprint": fingerprint,
|
||||
},
|
||||
body: JSON.stringify({ resources }),
|
||||
},
|
||||
"token",
|
||||
),
|
||||
);
|
||||
|
||||
expect((await apply([])).status).toBe(200);
|
||||
const conflict = await apply([
|
||||
{ apiVersion: "v1", kind: "Service", metadata: { name: "web" } },
|
||||
]);
|
||||
expect(conflict.status).toBe(409);
|
||||
expect(await conflict.json()).toMatchObject({ code: "OPERATION_CONFLICT" });
|
||||
});
|
||||
|
||||
test("starts preferred resource operations before returning so progress can be polled", async () => {
|
||||
const workspaceStore = new MemoryWorkspaceStore({
|
||||
uid: () => "workspace-uid",
|
||||
|
||||
@@ -236,6 +236,35 @@ describe("server management service", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("emits aborted resource progress when an execution signal aborts", async () => {
|
||||
const controller = new AbortController();
|
||||
const events: Array<{ state: string; resource: { name: string } }> = [];
|
||||
const service = createManagementService(
|
||||
dependencies({
|
||||
applyResource: async (_resource, execution) => {
|
||||
controller.abort();
|
||||
if (execution?.signal?.aborted)
|
||||
throw new Error("Workspace operation execution was cancelled");
|
||||
return _resource;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.applyResources(
|
||||
workspace,
|
||||
[object("Service", "web", "service-uid")],
|
||||
{
|
||||
signal: controller.signal,
|
||||
emit: async (event) => {
|
||||
events.push(event);
|
||||
},
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("cancelled");
|
||||
expect(events.map(({ state }) => state)).toEqual(["started", "aborted"]);
|
||||
});
|
||||
|
||||
test("rejects namespace and resource ownership mismatches", async () => {
|
||||
const wrongNamespace = createManagementService(
|
||||
dependencies({
|
||||
|
||||
Reference in New Issue
Block a user