chore: merge develop into hare/develop
# Conflicts: # web/workspace/deno.json # web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte # web/workspace/test/repository-access/ui.test.ts
This commit is contained in:
@@ -1,8 +1,92 @@
|
||||
import { loadRepositoryAccessJson } from "../../src/lib/workspace/api/repository-access-loader.ts";
|
||||
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,
|
||||
@@ -27,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;
|
||||
@@ -90,6 +234,52 @@ 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 })),
|
||||
"/api/w/workspace-1/settings/repository-access/credentials",
|
||||
(value) => value,
|
||||
"SSH credentials",
|
||||
);
|
||||
if (result.data !== null) {
|
||||
throw new Error("failed section must not expose data");
|
||||
}
|
||||
if (result.error !== "Repository Access request failed with status 503.") {
|
||||
throw new Error(`unexpected scoped error: ${String(result.error)}`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Repository Access loader never exposes failed upstream response bodies", async () => {
|
||||
const upstreamSecret = "secret-ref-must-not-leak";
|
||||
const failure = await captureHttpFailure(
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
repositoryAccessRequestError,
|
||||
validateCredentialRotation,
|
||||
validateGeneratedCredential,
|
||||
validateHostTrust,
|
||||
validateImportedCredential,
|
||||
} from "../../src/lib/workspace/repository-access/model.ts";
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
Deno.test("Repository Access validates disclosed credential forms before mutation", () => {
|
||||
const generated = validateGeneratedCredential({
|
||||
credentialId: "invalid id",
|
||||
name: "",
|
||||
});
|
||||
assert(
|
||||
generated.credentialId?.includes("letters"),
|
||||
"invalid credential id should have a field error",
|
||||
);
|
||||
assert(
|
||||
generated.name === "Name is required.",
|
||||
"missing name should have a field error",
|
||||
);
|
||||
|
||||
const imported = validateImportedCredential({
|
||||
credentialId: "deploy-key",
|
||||
name: "Deploy key",
|
||||
privateKey: "",
|
||||
});
|
||||
assert(
|
||||
imported.privateKey?.includes("required"),
|
||||
"missing private key should have a field error",
|
||||
);
|
||||
assert(
|
||||
Object.keys(validateCredentialRotation(" ")).length === 1,
|
||||
"rotation should reject an empty private key",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository Access validates host identity fields in the editor scope", () => {
|
||||
const errors = validateHostTrust({
|
||||
hostTrustId: "host trust",
|
||||
hostname: "",
|
||||
port: 70_000,
|
||||
hostKey: "",
|
||||
});
|
||||
for (const field of ["hostTrustId", "hostname", "port", "hostKey"]) {
|
||||
assert(
|
||||
Boolean(errors[field]),
|
||||
`missing host trust field error for ${field}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
Object.keys(validateHostTrust({
|
||||
hostTrustId: "github-com",
|
||||
hostname: "github.com",
|
||||
port: 22,
|
||||
hostKey: "ssh-ed25519 fixture-public-host-key",
|
||||
})).length === 0,
|
||||
"valid host trust fields should pass",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Repository Access distinguishes permission, conflict, field, and missing-item failures", () => {
|
||||
assert(
|
||||
repositoryAccessRequestError(403, "rotate the key").message.includes(
|
||||
"permission",
|
||||
),
|
||||
"403 should explain permission",
|
||||
);
|
||||
assert(
|
||||
repositoryAccessRequestError(409, "rotate the key").message.includes(
|
||||
"Reload",
|
||||
),
|
||||
"409 should explain revision recovery",
|
||||
);
|
||||
assert(
|
||||
repositoryAccessRequestError(422, "rotate the key").message.includes(
|
||||
"fields",
|
||||
),
|
||||
"422 should point to fields",
|
||||
);
|
||||
assert(
|
||||
repositoryAccessRequestError(404, "rotate the key").message.includes(
|
||||
"no longer exists",
|
||||
),
|
||||
"404 should explain stale target",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
type TestRegistrar = (name: string, body: () => void | Promise<void>) => void;
|
||||
|
||||
const test =
|
||||
(globalThis as unknown as { Deno: { test: TestRegistrar } }).Deno.test;
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/routes/w/[workspaceId]/settings/repository-access/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const loaderSource = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
const settingsCss = await Deno.readTextFile(
|
||||
new URL("../../src/lib/workspace/styles/settings.css", import.meta.url),
|
||||
);
|
||||
|
||||
test("Repository Access consumes generated contracts and validates every response boundary", () => {
|
||||
assert(
|
||||
source.includes("$lib/generated/repository-access-api"),
|
||||
"mutation code should import generated request and response contracts",
|
||||
);
|
||||
for (
|
||||
const parser of [
|
||||
"parseRepositorySshCredentials",
|
||||
"parseRepositorySshHostTrusts",
|
||||
"parseRepositorySshPublicKey",
|
||||
"parseRepositoryAccessProjection",
|
||||
]
|
||||
) {
|
||||
assert(loaderSource.includes(parser), `loader should use ${parser}`);
|
||||
}
|
||||
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 authorizes first and preserves independently available sections", () => {
|
||||
assert(
|
||||
loaderSource.indexOf('"/settings/repository-access"') <
|
||||
loaderSource.indexOf("Promise.all"),
|
||||
"loader should check Repository Access permission before section preloads",
|
||||
);
|
||||
assert(
|
||||
loaderSource.match(/loadRepositoryAccessSection/g)?.length === 4,
|
||||
"credentials, host trusts, and the default public key should have independent failures",
|
||||
);
|
||||
for (
|
||||
const field of [
|
||||
"credentialsError",
|
||||
"hostTrustsError",
|
||||
"defaultPublicKeyError",
|
||||
"accessProjectionError",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
source.includes(`data.${field}`),
|
||||
`page should render scoped ${field}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
loaderSource.includes("WORKSPACE_DEFAULT_CREDENTIAL_ID") &&
|
||||
!loaderSource.includes("credentials.map("),
|
||||
"loader should preload only the default public key instead of every public key",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access presents current state as tables with technical disclosure", () => {
|
||||
for (
|
||||
const token of [
|
||||
"Repository bindings",
|
||||
"SSH credentials",
|
||||
"Pinned SSH host keys",
|
||||
"repository-access-table-wrap",
|
||||
"Technical details",
|
||||
"Credential revisions",
|
||||
"Host key revisions",
|
||||
"accessProjection.config_revision",
|
||||
"accessProjection.projection_digest",
|
||||
"binding.repository_key",
|
||||
"binding.credential_id",
|
||||
"binding.host_trust_id",
|
||||
"binding.access",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
source.includes(token),
|
||||
`missing information structure token ${token}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
!source.includes('class="card'),
|
||||
"Repository Access should not nest generic cards",
|
||||
);
|
||||
assert(
|
||||
!source.includes("<h2>Repository Access</h2>"),
|
||||
"the route title should remain owned by the Header",
|
||||
);
|
||||
assert(
|
||||
!source.includes("<textarea readonly"),
|
||||
"public keys should not occupy permanent textareas",
|
||||
);
|
||||
assert(
|
||||
settingsCss.includes(".repository-access-table-wrap") &&
|
||||
settingsCss.includes("overflow-x: auto") &&
|
||||
settingsCss.includes(".repository-access-form-grid"),
|
||||
"Settings CSS should own bounded table overflow and responsive forms",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access explains automatic application without inventing a binding", () => {
|
||||
for (
|
||||
const token of [
|
||||
"No explicit bindings",
|
||||
"Workspace default credential",
|
||||
"normal read/write Git access",
|
||||
"unique pinned key that matches their host and port",
|
||||
"Explicit binding",
|
||||
"plus <code>{binding.credential_id}",
|
||||
]
|
||||
) {
|
||||
assert(source.includes(token), `missing access authority copy ${token}`);
|
||||
}
|
||||
assert(
|
||||
source.includes("A successful probe only observes a key") &&
|
||||
source.includes("Git authentication succeeds only"),
|
||||
"probe, trust persistence, and Git authentication must remain distinct",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access keeps mutation forms disclosed and operation state scoped", () => {
|
||||
for (
|
||||
const token of [
|
||||
"credentialForm === 'generate'",
|
||||
"credentialForm === 'import'",
|
||||
"hostEditorOpen",
|
||||
"selectedCredential",
|
||||
"validateGeneratedCredential",
|
||||
"validateImportedCredential",
|
||||
"validateCredentialRotation",
|
||||
"validateHostTrust",
|
||||
'class="field-error"',
|
||||
"aria-invalid",
|
||||
"pendingOperations",
|
||||
"credentialFormNotice",
|
||||
"credentialRowNotices",
|
||||
"hostFormNotice",
|
||||
"hostRowNotices",
|
||||
">Cancel</button>",
|
||||
]
|
||||
) {
|
||||
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'}",
|
||||
);
|
||||
assert(
|
||||
permanentFormStart > generateDisclosure,
|
||||
"forms should render only after an explicit disclosure action",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access keeps public key copy easy without exposing key material", () => {
|
||||
for (
|
||||
const token of [
|
||||
"/public-key",
|
||||
"navigator.clipboard.writeText",
|
||||
"Copy public key",
|
||||
"workspace-default",
|
||||
"publicKeys[credential.credential_id]",
|
||||
"data-web-ux-redact",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
source.includes(token),
|
||||
`missing safe public key or secret handling ${token}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
source.includes("publicKeys = { ...publicKeys") &&
|
||||
source.includes("loadPublicKey(credential.credential_id)"),
|
||||
"non-default public keys should load only on demand",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access protects default and referenced resources from destructive controls", () => {
|
||||
const defaultGuard = source.indexOf(
|
||||
"{#if credential.credential_id !== workspaceDefaultCredentialId}",
|
||||
);
|
||||
const rotateAction = source.indexOf(
|
||||
"openCredentialRotation(credential)",
|
||||
defaultGuard,
|
||||
);
|
||||
const unreferencedGuard = source.indexOf(
|
||||
"{#if credential.referenced_repositories.length === 0}",
|
||||
rotateAction,
|
||||
);
|
||||
const deleteAction = source.indexOf(
|
||||
"deleteCredential(credential)",
|
||||
unreferencedGuard,
|
||||
);
|
||||
assert(
|
||||
defaultGuard >= 0 && rotateAction > defaultGuard &&
|
||||
unreferencedGuard > rotateAction && deleteAction > unreferencedGuard,
|
||||
"default and referenced credentials should not render destructive actions",
|
||||
);
|
||||
assert(
|
||||
source.includes("Remove Repository references before deletion") &&
|
||||
source.includes("hostTrust.referenced_repositories.length === 0"),
|
||||
"reference constraints should be visible beside affected rows",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository credential submissions clear write-only fields after attempts", () => {
|
||||
const createStart = source.indexOf("async function createCredential()");
|
||||
const rotateStart = source.indexOf("async function rotateCredential(");
|
||||
const deleteStart = source.indexOf("async function deleteCredential(");
|
||||
const createBody = source.slice(createStart, rotateStart);
|
||||
const rotateBody = source.slice(rotateStart, deleteStart);
|
||||
for (const token of ["finally", "privateKey = ''", "passphrase = ''"]) {
|
||||
assert(
|
||||
createBody.includes(token),
|
||||
`create handler should contain ${token}`,
|
||||
);
|
||||
}
|
||||
for (
|
||||
const token of [
|
||||
"finally",
|
||||
"rotatePrivateKey = ''",
|
||||
"rotatePassphrase = ''",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
rotateBody.includes(token),
|
||||
`rotate handler should contain ${token}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
type TestRegistrar = (name: string, body: () => void | Promise<void>) => void;
|
||||
|
||||
const test =
|
||||
(globalThis as unknown as { Deno: { test: TestRegistrar } }).Deno.test;
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../src/routes/w/[workspaceId]/settings/repository-access/+page.svelte",
|
||||
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 server-api legacy 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("parseRepositorySshPublicKey") &&
|
||||
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_key",
|
||||
"binding.credential_id",
|
||||
"binding.host_trust_id",
|
||||
"binding.access",
|
||||
]
|
||||
) {
|
||||
assert(source.includes(field), `missing access projection field ${field}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("Repository Access generates and copies selectable public keys", () => {
|
||||
for (
|
||||
const token of [
|
||||
"/credentials/generate",
|
||||
"/public-key",
|
||||
"Generate Repository SSH credential",
|
||||
"navigator.clipboard.writeText",
|
||||
"publicKeys[credential.credential_id]",
|
||||
"workspace-default",
|
||||
"always offered during SSH clone",
|
||||
"normal read/write Git access",
|
||||
"explicit binding can select read-only access",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
source.includes(token),
|
||||
`missing generated public key flow ${token}`,
|
||||
);
|
||||
}
|
||||
assert(
|
||||
source.includes("binding.credential_id"),
|
||||
"Repository bindings should identify the selected credential",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository Access hides Rotate for the Workspace default credential", () => {
|
||||
const credentialsStart = source.indexOf("<h3>SSH credentials</h3>");
|
||||
const generateStart = source.indexOf(
|
||||
"<h3>Generate Repository SSH credential</h3>",
|
||||
);
|
||||
assert(
|
||||
credentialsStart >= 0 && generateStart > credentialsStart,
|
||||
"credential list should appear before the generation form",
|
||||
);
|
||||
|
||||
const credentialList = source.slice(credentialsStart, generateStart);
|
||||
const additionalCredentialGuard = credentialList.indexOf(
|
||||
"{#if credential.credential_id !== workspaceDefaultCredentialId}",
|
||||
);
|
||||
const rotateAction = credentialList.indexOf(
|
||||
"rotateCredentialId = rotateCredentialId === credential.credential_id",
|
||||
additionalCredentialGuard,
|
||||
);
|
||||
const deleteAction = credentialList.indexOf(
|
||||
"onclick={() => void deleteCredential(credential)}",
|
||||
rotateAction,
|
||||
);
|
||||
const guardEnd = credentialList.indexOf("{/if}", deleteAction);
|
||||
|
||||
assert(
|
||||
additionalCredentialGuard >= 0 &&
|
||||
rotateAction > additionalCredentialGuard &&
|
||||
deleteAction > rotateAction &&
|
||||
guardEnd > deleteAction,
|
||||
"Rotate and Delete should render only for additional credentials",
|
||||
);
|
||||
assert(
|
||||
credentialList.indexOf(
|
||||
"{#if rotateCredentialId === credential.credential_id}",
|
||||
guardEnd,
|
||||
) > guardEnd,
|
||||
"the existing rotation form should remain available after selecting an additional credential",
|
||||
);
|
||||
});
|
||||
|
||||
test("Repository credential submissions clear write-only fields in finally blocks", () => {
|
||||
const createStart = source.indexOf("async function createCredential()");
|
||||
const rotateStart = source.indexOf("async function rotateCredential(");
|
||||
const deleteStart = source.indexOf("async function deleteCredential(");
|
||||
assert(
|
||||
createStart >= 0 && rotateStart > createStart && deleteStart > rotateStart,
|
||||
"credential handlers should appear in source order",
|
||||
);
|
||||
|
||||
const createBody = source.slice(createStart, rotateStart);
|
||||
const rotateBody = source.slice(rotateStart, deleteStart);
|
||||
for (const token of ["finally", "privateKey = ''", "passphrase = ''"]) {
|
||||
assert(
|
||||
createBody.includes(token),
|
||||
`create handler should contain ${token}`,
|
||||
);
|
||||
}
|
||||
for (
|
||||
const token of [
|
||||
"finally",
|
||||
"rotatePrivateKey = ''",
|
||||
"rotatePassphrase = ''",
|
||||
]
|
||||
) {
|
||||
assert(
|
||||
rotateBody.includes(token),
|
||||
`rotate handler should contain ${token}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user