chore: merge develop into hare/develop
# Conflicts: # crates/client/src/lib.rs # web/workspace/deno.json
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
parseRepositoryAccessProjection,
|
||||
parseRepositorySshCredentials,
|
||||
parseRepositorySshHostTrusts,
|
||||
RepositoryAccessSchemaError,
|
||||
} from "../../src/lib/workspace/api/repository-access.ts";
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSchemaError(body: () => unknown, path: string): void {
|
||||
try {
|
||||
body();
|
||||
} catch (error) {
|
||||
if (!(error instanceof RepositoryAccessSchemaError)) {
|
||||
throw error;
|
||||
}
|
||||
if (!error.message.includes(path)) {
|
||||
throw new Error(
|
||||
`expected schema error path ${path}, got ${error.message}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`expected RepositoryAccessSchemaError for ${path}`);
|
||||
}
|
||||
|
||||
const credential = {
|
||||
credential_id: "deploy-key",
|
||||
workspace_id: "workspace-1",
|
||||
name: "Deploy key",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:credential",
|
||||
current_revision: 2,
|
||||
status: "active",
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
referenced_repositories: ["main"],
|
||||
};
|
||||
|
||||
const hostTrust = {
|
||||
host_trust_id: "gitea",
|
||||
workspace_id: "workspace-1",
|
||||
hostname: "gitea.example.test",
|
||||
port: 22,
|
||||
key_algorithm: "ssh-ed25519",
|
||||
host_key: "ssh-ed25519 AAAA",
|
||||
fingerprint: "SHA256:host",
|
||||
current_revision: 3,
|
||||
created_at: "2026-09-01T00:00:00Z",
|
||||
updated_at: "2026-09-02T00:00:00Z",
|
||||
referenced_repositories: ["main"],
|
||||
};
|
||||
|
||||
Deno.test("Repository Access parsers accept generated response contracts", () => {
|
||||
assertEquals(parseRepositorySshCredentials([credential]), [credential]);
|
||||
assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
|
||||
assertEquals(
|
||||
parseRepositoryAccessProjection({
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 4,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [{
|
||||
repository_id: "main",
|
||||
credential_id: "deploy-key",
|
||||
host_trust_id: "gitea",
|
||||
access: "read_only",
|
||||
}],
|
||||
}),
|
||||
{
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 4,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [{
|
||||
repository_id: "main",
|
||||
credential_id: "deploy-key",
|
||||
host_trust_id: "gitea",
|
||||
access: "read_only",
|
||||
}],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository Access parsers reject malformed list responses", () => {
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshCredentials({ credentials: [credential] }),
|
||||
"credentials",
|
||||
);
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshHostTrusts({ host_trusts: [hostTrust] }),
|
||||
"host_trusts",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository Access parsers reject missing and wrong-typed fields", () => {
|
||||
const { current_revision: _revision, ...missingRevision } = credential;
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshCredentials([missingRevision]),
|
||||
"credentials[0].current_revision",
|
||||
);
|
||||
assertSchemaError(
|
||||
() => parseRepositorySshHostTrusts([{ ...hostTrust, port: "22" }]),
|
||||
"host_trusts[0].port",
|
||||
);
|
||||
assertSchemaError(
|
||||
() =>
|
||||
parseRepositoryAccessProjection({
|
||||
workspace_id: "workspace-1",
|
||||
config_revision: 4,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [{
|
||||
repository_id: "main",
|
||||
credential_id: "deploy-key",
|
||||
host_trust_id: "gitea",
|
||||
access: "admin",
|
||||
}],
|
||||
}),
|
||||
"access_projection.bindings[0].access",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository Access parsers reject unknown response fields", () => {
|
||||
assertSchemaError(
|
||||
() =>
|
||||
parseRepositorySshCredentials([{ ...credential, private_key: "secret" }]),
|
||||
"credentials[0].private_key",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { loadRepositoryAccessJson } from "../../src/lib/workspace/api/repository-access-loader.ts";
|
||||
import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts";
|
||||
|
||||
type HttpFailure = { status?: number; body?: { message?: string } };
|
||||
|
||||
async function captureHttpFailure(
|
||||
run: () => Promise<unknown>,
|
||||
expectedStatus: number,
|
||||
expectedMessage: string,
|
||||
): Promise<HttpFailure> {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
const failure = error as HttpFailure;
|
||||
if (failure.status !== expectedStatus) {
|
||||
throw new Error(
|
||||
`expected bounded ${expectedStatus}, got ${String(failure.status)}`,
|
||||
);
|
||||
}
|
||||
if (failure.body?.message !== expectedMessage) {
|
||||
throw new Error(
|
||||
`unexpected bounded error: ${JSON.stringify(failure.body)}`,
|
||||
);
|
||||
}
|
||||
return failure;
|
||||
}
|
||||
throw new Error(`expected bounded ${expectedStatus} error`);
|
||||
}
|
||||
|
||||
for (const status of [401, 403]) {
|
||||
Deno.test(`Repository Access loader maps ${status} to bounded permission unavailable`, async () => {
|
||||
let requests = 0;
|
||||
await captureHttpFailure(
|
||||
() =>
|
||||
loadRepositoryAccessJson(
|
||||
() => {
|
||||
requests += 1;
|
||||
return Promise.resolve(new Response(null, { status }));
|
||||
},
|
||||
"/api/w/workspace-1/settings/repository-access",
|
||||
(value) => value,
|
||||
),
|
||||
403,
|
||||
"Repository Access is unavailable for this account.",
|
||||
);
|
||||
if (requests !== 1) {
|
||||
throw new Error(`expected one bounded request, got ${requests}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Deno.test("Repository Access loader maps invalid JSON to safe bounded 502", async () => {
|
||||
const upstreamSecret = "private-key-must-not-leak";
|
||||
const failure = await captureHttpFailure(
|
||||
() =>
|
||||
loadRepositoryAccessJson(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
new Response(upstreamSecret, {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||
(value) => value,
|
||||
),
|
||||
502,
|
||||
"Repository Access returned an invalid JSON response.",
|
||||
);
|
||||
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
|
||||
throw new Error("invalid JSON error exposed upstream response content");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502", async () => {
|
||||
const failure = await captureHttpFailure(
|
||||
() =>
|
||||
loadRepositoryAccessJson(
|
||||
() => Promise.resolve(Response.json({ stale: true })),
|
||||
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||
() => {
|
||||
throw new RepositoryAccessSchemaError("credentials", "an array");
|
||||
},
|
||||
),
|
||||
502,
|
||||
"Repository Access response schema mismatch at credentials: expected an array",
|
||||
);
|
||||
if (!failure.body?.message?.includes("credentials")) {
|
||||
throw new Error("schema mismatch error omitted the failing response path");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository Access loader never exposes failed upstream response bodies", async () => {
|
||||
const upstreamSecret = "secret-ref-must-not-leak";
|
||||
const failure = await captureHttpFailure(
|
||||
() =>
|
||||
loadRepositoryAccessJson(
|
||||
() =>
|
||||
Promise.resolve(
|
||||
Response.json(
|
||||
{ message: upstreamSecret, secret_ref: upstreamSecret },
|
||||
{ status: 500 },
|
||||
),
|
||||
),
|
||||
"/api/w/workspace-1/settings/repository-access/host-trusts",
|
||||
(value) => value,
|
||||
),
|
||||
502,
|
||||
"Repository Access request failed with status 500.",
|
||||
);
|
||||
if (JSON.stringify(failure.body).includes(upstreamSecret)) {
|
||||
throw new Error("bounded upstream error exposed response content");
|
||||
}
|
||||
});
|
||||
@@ -13,6 +13,58 @@ const source = await Deno.readTextFile(
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const loaderSource = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
test("Repository Access Web code consumes workspace-api generated DTOs", () => {
|
||||
assert(
|
||||
source.includes("$lib/generated/repository-access-api"),
|
||||
"mutation code should import generated request and response contracts",
|
||||
);
|
||||
assert(
|
||||
loaderSource.includes("parseRepositorySshCredentials") &&
|
||||
loaderSource.includes("parseRepositorySshHostTrusts") &&
|
||||
loaderSource.includes("parseRepositoryAccessProjection"),
|
||||
"loader should validate unknown JSON before exposing generated DTOs to Svelte",
|
||||
);
|
||||
assert(
|
||||
loaderSource.indexOf('"/settings/repository-access"') <
|
||||
loaderSource.indexOf("Promise.all"),
|
||||
"loader should check Repository Access permission before starting list preloads",
|
||||
);
|
||||
for (
|
||||
const duplicate of [
|
||||
"interface RepositorySshCredential",
|
||||
"interface RepositorySshHostTrust",
|
||||
"interface RepositoryAccessProjection",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
!loaderSource.includes(duplicate) && !source.includes(duplicate),
|
||||
`Web code must not redeclare ${duplicate}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Repository Access renders the shared access projection fields", () => {
|
||||
for (
|
||||
const field of [
|
||||
"accessProjection.config_revision",
|
||||
"accessProjection.projection_digest",
|
||||
"accessProjection.bindings",
|
||||
"binding.repository_id",
|
||||
"binding.credential_id",
|
||||
"binding.host_trust_id",
|
||||
"binding.access",
|
||||
]
|
||||
) {
|
||||
assert(source.includes(field), `missing access projection field ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
||||
const createStart = source.indexOf("async function createCredential()");
|
||||
|
||||
Reference in New Issue
Block a user