feat(workspace): generate repository API contracts from OpenAPI
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import {
|
||||
createWorkspaceRepository,
|
||||
loadWorkspaceRepositoryList,
|
||||
REPOSITORY_API_MAX_RESPONSE_BYTES,
|
||||
} from "../src/lib/workspace/api/repositories.ts";
|
||||
|
||||
const repository = {
|
||||
repository_key: "main",
|
||||
kind: "git",
|
||||
provider: "git",
|
||||
source: { kind: "local_path", uri: "/srv/main" },
|
||||
source_revision: 1,
|
||||
source_fingerprint: "sha256:main",
|
||||
observed_status: "ready",
|
||||
record_authority: "workspace-control-plane",
|
||||
};
|
||||
|
||||
Deno.test("Repository API loaders parse unknown JSON through the strict generated contract", async () => {
|
||||
const valid = await loadWorkspaceRepositoryList(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json({
|
||||
workspace_id: "workspace-a",
|
||||
items: [repository],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
}),
|
||||
),
|
||||
"workspace-a",
|
||||
);
|
||||
if (valid.data?.items[0]?.repository_key !== "main") {
|
||||
throw new Error(`unexpected load result: ${valid.error}`);
|
||||
}
|
||||
|
||||
const invalid = await loadWorkspaceRepositoryList(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json({
|
||||
workspace_id: "workspace-a",
|
||||
items: [{ ...repository, observed_status: "future_status" }],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
}),
|
||||
),
|
||||
"workspace-a",
|
||||
);
|
||||
if (
|
||||
invalid.data !== null ||
|
||||
!invalid.error?.includes("Repository API response is invalid")
|
||||
) {
|
||||
throw new Error("unknown enum values must fail closed in the loader");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository create client validates request, response, and typed API errors", async () => {
|
||||
const success = await createWorkspaceRepository(
|
||||
(_input, init) => {
|
||||
const request: unknown = JSON.parse(String(init?.body));
|
||||
if (
|
||||
typeof request !== "object" || request === null ||
|
||||
!("repository_key" in request) || request.repository_key !== "main"
|
||||
) {
|
||||
throw new Error("typed create request was not serialized");
|
||||
}
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
workspace_id: "workspace-a",
|
||||
repository_key: "main",
|
||||
replayed: false,
|
||||
}),
|
||||
);
|
||||
},
|
||||
"workspace-a",
|
||||
{ repository_key: "main", source: "/srv/main", default_ref: null },
|
||||
);
|
||||
if (success.data?.repository_key !== "main" || success.data.replayed) {
|
||||
throw new Error(`unexpected create result: ${success.error}`);
|
||||
}
|
||||
|
||||
const rejected = await createWorkspaceRepository(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json(
|
||||
{ error: "invalid_source", message: "source is invalid" },
|
||||
{ status: 400 },
|
||||
),
|
||||
),
|
||||
"workspace-a",
|
||||
{ repository_key: "main", source: "bad", default_ref: null },
|
||||
);
|
||||
if (rejected.data !== null || rejected.error !== "source is invalid") {
|
||||
throw new Error("typed RepositoryApiError message was not preserved");
|
||||
}
|
||||
|
||||
const invalid = await createWorkspaceRepository(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json({
|
||||
workspace_id: "workspace-a",
|
||||
repository_key: "main",
|
||||
replayed: null,
|
||||
}),
|
||||
),
|
||||
"workspace-a",
|
||||
{ repository_key: "main", source: "/srv/main", default_ref: null },
|
||||
);
|
||||
if (
|
||||
invalid.data !== null ||
|
||||
invalid.error !== "Repository API response is invalid"
|
||||
) {
|
||||
throw new Error("invalid create success payload must fail closed");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository API clients bound response bodies before parsing JSON", async () => {
|
||||
const result = await loadWorkspaceRepositoryList(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Response("{}", {
|
||||
headers: {
|
||||
"content-length": String(REPOSITORY_API_MAX_RESPONSE_BYTES + 1),
|
||||
},
|
||||
}),
|
||||
),
|
||||
"workspace-a",
|
||||
);
|
||||
if (
|
||||
result.data !== null || !result.error?.includes("exceeds its byte limit")
|
||||
) {
|
||||
throw new Error(`oversized response was not rejected: ${result.error}`);
|
||||
}
|
||||
});
|
||||
@@ -4,11 +4,14 @@ declare const Deno: {
|
||||
};
|
||||
|
||||
import {
|
||||
parseCreateWorkspaceRepositoryResponse,
|
||||
parseRepositoryApiError,
|
||||
parseRepositoryListApiResult,
|
||||
parseRepositoryListResponse,
|
||||
parseWorkspaceDeletionOperationResponse,
|
||||
parseWorkspaceDeletionPreflightResponse,
|
||||
parseWorkspaceResponse,
|
||||
REPOSITORY_API_LIMITS,
|
||||
} from "../src/lib/workspace/api/workspace-model.ts";
|
||||
|
||||
function assertThrows(operation: () => unknown, expected: string): void {
|
||||
@@ -71,6 +74,63 @@ Deno.test("stale repository aliases fail closed at the JSON boundary", () => {
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("repository create/error responses use the generated strict contract", () => {
|
||||
const created = parseCreateWorkspaceRepositoryResponse({
|
||||
workspace_id: repositoryList.workspace_id,
|
||||
repository_key: "main",
|
||||
replayed: false,
|
||||
});
|
||||
if (created.replayed || created.repository_key !== "main") {
|
||||
throw new Error("create response fields were not preserved");
|
||||
}
|
||||
const error = parseRepositoryApiError({
|
||||
error: "invalid_request",
|
||||
message: "source is invalid",
|
||||
});
|
||||
if (error.error !== "invalid_request") {
|
||||
throw new Error("error response fields were not preserved");
|
||||
}
|
||||
assertThrows(
|
||||
() =>
|
||||
parseCreateWorkspaceRepositoryResponse({
|
||||
workspace_id: repositoryList.workspace_id,
|
||||
repository_key: "main",
|
||||
replayed: null,
|
||||
}),
|
||||
".replayed must be a boolean",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseRepositoryApiError({
|
||||
error: "invalid_request",
|
||||
message: "source is invalid",
|
||||
context: {},
|
||||
}),
|
||||
".context is not part",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("repository responses retain bounded strings and collections", () => {
|
||||
const oversizedString = structuredClone(repositoryList);
|
||||
oversizedString.items[0].provider = "x".repeat(
|
||||
REPOSITORY_API_LIMITS.maxStringCodeUnits + 1,
|
||||
);
|
||||
assertThrows(
|
||||
() => parseRepositoryListResponse(oversizedString),
|
||||
"string limit",
|
||||
);
|
||||
|
||||
const oversizedCollection = structuredClone(repositoryList);
|
||||
oversizedCollection.items = Array.from(
|
||||
{ length: REPOSITORY_API_LIMITS.maxCollectionEntries + 1 },
|
||||
() => repositoryList.items[0],
|
||||
);
|
||||
assertThrows(
|
||||
() => parseRepositoryListResponse(oversizedCollection),
|
||||
"collection limit",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("repository API result converts stale payloads into bounded page errors", () => {
|
||||
const result = parseRepositoryListApiResult({
|
||||
data: {
|
||||
@@ -227,7 +287,7 @@ Deno.test("Workspace settings exposes owner-gated typed destructive confirmation
|
||||
});
|
||||
|
||||
Deno.test("Repository settings consume the validated shared wire shape", async () => {
|
||||
const [loadSource, pageSource] = await Promise.all([
|
||||
const [loadSource, pageSource, apiSource, modelSource] = await Promise.all([
|
||||
Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/repositories/+page.ts",
|
||||
@@ -240,21 +300,41 @@ Deno.test("Repository settings consume the validated shared wire shape", async (
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
Deno.readTextFile(
|
||||
new URL("../src/lib/workspace/api/repositories.ts", import.meta.url),
|
||||
),
|
||||
Deno.readTextFile(
|
||||
new URL("../src/lib/workspace/api/workspace-model.ts", import.meta.url),
|
||||
),
|
||||
]);
|
||||
const targetSources = `${loadSource}\n${pageSource}\n${apiSource}`;
|
||||
|
||||
for (
|
||||
const token of [
|
||||
"loadWorkspaceRepositoryList",
|
||||
"createWorkspaceRepository",
|
||||
"parseRepositoryListResponse",
|
||||
"parseCreateWorkspaceRepositoryResponse",
|
||||
"parseRepositoryApiError",
|
||||
"repository.repository_key",
|
||||
"repository.observed_status",
|
||||
"sourceLabel(repository.source.kind)",
|
||||
"supportsRepositoryAccess(repository.source.kind)",
|
||||
]
|
||||
) {
|
||||
if (!loadSource.includes(token) && !pageSource.includes(token)) {
|
||||
if (!targetSources.includes(token) && !modelSource.includes(token)) {
|
||||
throw new Error(`Repository settings should include ${token}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
pageSource.includes("generated/legacy-server-api") ||
|
||||
pageSource.includes("response.json()") ||
|
||||
pageSource.includes(" as CreateWorkspaceRepository")
|
||||
) {
|
||||
throw new Error(
|
||||
"Repository create UI must use the generated contract and strict API client",
|
||||
);
|
||||
}
|
||||
for (const kind of ["ssh", "https"]) {
|
||||
if (!pageSource.includes(`kind === '${kind}'`)) {
|
||||
throw new Error(`Repository Access should support ${kind}`);
|
||||
|
||||
Reference in New Issue
Block a user