fix(web): scope repository access operations

This commit is contained in:
2026-09-24 18:44:23 +09:00
parent 4c458cbf74
commit d5508a7e35
10 changed files with 779 additions and 135 deletions
@@ -1,11 +1,92 @@
import {
loadRepositoryAccessJson,
loadRepositoryAccessPermissionGate,
loadRepositoryAccessSection,
} from "../../src/lib/workspace/api/repository-access-loader.ts";
import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts";
import { load as loadRepositoryAccessPage } from "../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts";
type HttpFailure = { status?: number; body?: { message?: string } };
type LoadedPage = {
accessProjection: unknown;
accessProjectionError: string | null;
credentials: Array<{ credential_id: string }>;
credentialsError: string | null;
publicKeys: Array<{ credential_id: string }>;
defaultPublicKeyError: string | null;
hostTrusts: Array<{ host_trust_id: string }>;
hostTrustsError: string | null;
};
const projectionFixture = {
workspace_id: "workspace-1",
config_revision: 1,
projection_digest: "sha256:projection",
bindings: [],
};
const credentialFixture = {
credential_id: "workspace-default",
workspace_id: "workspace-1",
name: "Workspace default",
public_key_algorithm: "ssh-ed25519",
public_key_fingerprint: "SHA256:credential",
current_revision: 1,
status: "active",
created_at: "2026-01-01T00:00:00Z",
rotated_at: null,
referenced_repositories: [],
};
const publicKeyFixture = {
credential_id: "workspace-default",
current_revision: 1,
public_key_algorithm: "ssh-ed25519",
public_key_fingerprint: "SHA256:credential",
public_key: "ssh-ed25519 AAAA-workspace-default",
};
const hostTrustFixture = {
host_trust_id: "github-com",
workspace_id: "workspace-1",
hostname: "github.com",
port: 22,
key_algorithm: "ssh-ed25519",
host_key: "ssh-ed25519 AAAA-host",
fingerprint: "SHA256:host",
current_revision: 1,
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
referenced_repositories: [],
};
async function loadPageWithFailure(failedSuffix: string): Promise<LoadedPage> {
const fetcher = (input: RequestInfo | URL): Promise<Response> => {
const path = String(input);
if (path.endsWith(failedSuffix)) {
return Promise.resolve(new Response(null, { status: 503 }));
}
if (path.endsWith("/settings/repository-access")) {
return Promise.resolve(Response.json(projectionFixture));
}
if (path.endsWith("/credentials")) {
return Promise.resolve(Response.json([credentialFixture]));
}
if (path.endsWith("/credentials/workspace-default/public-key")) {
return Promise.resolve(Response.json(publicKeyFixture));
}
if (path.endsWith("/host-trusts")) {
return Promise.resolve(Response.json([hostTrustFixture]));
}
throw new Error(`unexpected loader request: ${path}`);
};
return await (loadRepositoryAccessPage as unknown as (event: {
fetch: typeof fetch;
params: { workspaceId: string };
}) => Promise<LoadedPage>)({
fetch: fetcher as typeof fetch,
params: { workspaceId: "workspace-1" },
});
}
async function captureHttpFailure(
run: () => Promise<unknown>,
expectedStatus: number,
@@ -30,6 +111,66 @@ async function captureHttpFailure(
throw new Error(`expected bounded ${expectedStatus} error`);
}
Deno.test("Repository Access page loader preserves successful siblings for every section failure", async () => {
const projectionFailure = await loadPageWithFailure(
"/settings/repository-access",
);
if (
projectionFailure.accessProjection !== null ||
!projectionFailure.accessProjectionError
) {
throw new Error(
"projection failure was not contained to Repository bindings",
);
}
if (
projectionFailure.credentials.length !== 1 ||
projectionFailure.hostTrusts.length !== 1
) {
throw new Error("projection failure hid a successful sibling section");
}
const credentialFailure = await loadPageWithFailure("/credentials");
if (
credentialFailure.credentials.length !== 0 ||
!credentialFailure.credentialsError
) {
throw new Error("credential failure was not contained to SSH credentials");
}
if (
!credentialFailure.accessProjection ||
credentialFailure.hostTrusts.length !== 1
) {
throw new Error("credential failure hid a successful sibling section");
}
const hostFailure = await loadPageWithFailure("/host-trusts");
if (hostFailure.hostTrusts.length !== 0 || !hostFailure.hostTrustsError) {
throw new Error("host-trust failure was not contained to pinned host keys");
}
if (!hostFailure.accessProjection || hostFailure.credentials.length !== 1) {
throw new Error("host-trust failure hid a successful sibling section");
}
const publicKeyFailure = await loadPageWithFailure(
"/credentials/workspace-default/public-key",
);
if (
publicKeyFailure.publicKeys.length !== 0 ||
!publicKeyFailure.defaultPublicKeyError
) {
throw new Error(
"public-key failure was not contained to its credential row",
);
}
if (
publicKeyFailure.credentials.length !== 1 ||
publicKeyFailure.hostTrusts.length !== 1
) {
throw new Error("public-key failure hid successful sections");
}
});
for (const status of [401, 403]) {
Deno.test(`Repository Access loader maps ${status} to bounded permission unavailable`, async () => {
let requests = 0;
@@ -93,6 +234,37 @@ Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502
}
});
Deno.test("Repository Access permission gate contains projection failure so sibling sections can load", async () => {
const result = await loadRepositoryAccessPermissionGate(
() => Promise.resolve(new Response(null, { status: 503 })),
"/api/w/workspace-1/settings/repository-access",
(value) => value,
"Repository bindings",
);
if (result.data !== null) {
throw new Error("failed permission projection must not expose data");
}
if (result.error !== "Repository Access request failed with status 503.") {
throw new Error(
`unexpected contained projection error: ${String(result.error)}`,
);
}
});
Deno.test("Repository Access permission gate still aborts on forbidden access", async () => {
await captureHttpFailure(
() =>
loadRepositoryAccessPermissionGate(
() => Promise.resolve(new Response(null, { status: 403 })),
"/api/w/workspace-1/settings/repository-access",
(value) => value,
"Repository bindings",
),
403,
"Repository Access is unavailable for this account.",
);
});
Deno.test("Repository Access section loader contains one failed sibling", async () => {
const result = await loadRepositoryAccessSection(
() => Promise.resolve(new Response(null, { status: 503 })),
@@ -67,6 +67,7 @@ test("Repository Access authorizes first and preserves independently available s
"credentialsError",
"hostTrustsError",
"defaultPublicKeyError",
"accessProjectionError",
]
) {
assert(
@@ -143,7 +144,7 @@ test("Repository Access explains automatic application without inventing a bindi
);
});
test("Repository Access opens one scoped form with validation, cancellation, and pending fences", () => {
test("Repository Access keeps mutation forms disclosed and operation state scoped", () => {
for (
const token of [
"credentialForm === 'generate'",
@@ -156,16 +157,20 @@ test("Repository Access opens one scoped form with validation, cancellation, and
"validateHostTrust",
'class="field-error"',
"aria-invalid",
"disabled={busy}",
"pendingOperations",
"credentialFormNotice",
"credentialRowNotices",
"hostFormNotice",
"hostRowNotices",
">Cancel</button>",
"Generating…",
"Importing…",
"Rotating…",
"Saving…",
]
) {
assert(source.includes(token), `missing scoped form behavior ${token}`);
assert(source.includes(token), `missing scoped form structure ${token}`);
}
assert(
!source.includes("disabled={busy}") && !source.includes("activeOperation"),
"unrelated controls must not share page-wide mutation state",
);
const permanentFormStart = source.indexOf("<form");
const generateDisclosure = source.indexOf(
"{#if credentialForm === 'generate'}",