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:
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview",
|
||||
|
||||
Generated
+33
-11
@@ -15,13 +15,13 @@
|
||||
"npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7____yaml@2.9.0___yaml@2.9.0__svelte@5.45.6__typescript@5.9.3__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_yaml@2.9.0",
|
||||
"npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7___yaml@2.9.0__yaml@2.9.0_svelte@5.45.6_typescript@5.9.3_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||
"npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_yaml@2.9.0",
|
||||
"npm:@testing-library/svelte@5.2.8": "5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@18.0.1_happy-dom@18.0.1",
|
||||
"npm:@testing-library/svelte@5.2.8": "5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@20.12.0_happy-dom@20.12.0",
|
||||
"npm:clsx@2.1.1": "2.1.1",
|
||||
"npm:cookie@0.6.0": "0.6.0",
|
||||
"npm:decodal-codemirror@0.3.0": "0.3.0_@codemirror+language@6.12.4_@codemirror+view@6.43.8_@lezer+highlight@1.2.3_@lezer+lr@1.4.10",
|
||||
"npm:devalue@5.6.4": "5.6.4",
|
||||
"npm:gen-interface-jp@0.8.0": "0.8.0",
|
||||
"npm:happy-dom@18.0.1": "18.0.1",
|
||||
"npm:happy-dom@20.12.0": "20.12.0",
|
||||
"npm:marked@18.0.13": "18.0.13",
|
||||
"npm:set-cookie-parser@2.7.2": "2.7.2",
|
||||
"npm:shiki@4.4.2": "4.4.2",
|
||||
@@ -30,7 +30,7 @@
|
||||
"npm:svelte@5.45.6": "5.45.6",
|
||||
"npm:typescript@5.9.3": "5.9.3",
|
||||
"npm:vite@7.2.7": "7.2.7_yaml@2.9.0",
|
||||
"npm:vitest@3.2.4": "3.2.4_happy-dom@18.0.1",
|
||||
"npm:vitest@3.2.4": "3.2.4_happy-dom@20.12.0",
|
||||
"npm:yaml@2.9.0": "2.9.0"
|
||||
},
|
||||
"jsr": {
|
||||
@@ -556,7 +556,7 @@
|
||||
"pretty-format"
|
||||
]
|
||||
},
|
||||
"@testing-library/svelte@5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@18.0.1_happy-dom@18.0.1": {
|
||||
"@testing-library/svelte@5.2.8_svelte@5.45.6_vite@7.2.7__yaml@2.9.0_vitest@3.2.4__happy-dom@20.12.0_happy-dom@20.12.0": {
|
||||
"integrity": "sha512-ucQOtGsJhtawOEtUmbR4rRh53e6RbM1KUluJIXRmh6D4UzxR847iIqqjRtg9mHNFmGQ8Vkam9yVcR5d1mhIHKA==",
|
||||
"dependencies": [
|
||||
"@testing-library/dom",
|
||||
@@ -612,6 +612,12 @@
|
||||
"@types/whatwg-mimetype@3.0.2": {
|
||||
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="
|
||||
},
|
||||
"@types/ws@8.18.1": {
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dependencies": [
|
||||
"@types/node"
|
||||
]
|
||||
},
|
||||
"@typescript-eslint/types@8.57.2": {
|
||||
"integrity": "sha512-/iZM6FnM4tnx9csuTxspMW4BOSegshwX5oBDznJ7S4WggL7Vczz5d2W11ecc4vRrQMQHXRSxzrCsyG5EsPPTbA=="
|
||||
},
|
||||
@@ -702,6 +708,12 @@
|
||||
"axobject-query@4.1.0": {
|
||||
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="
|
||||
},
|
||||
"buffer-image-size@0.6.4": {
|
||||
"integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
|
||||
"dependencies": [
|
||||
"@types/node"
|
||||
]
|
||||
},
|
||||
"cac@6.7.14": {
|
||||
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="
|
||||
},
|
||||
@@ -786,7 +798,7 @@
|
||||
"dependencies": [
|
||||
"domelementtype",
|
||||
"domhandler",
|
||||
"entities"
|
||||
"entities@8.1.0"
|
||||
]
|
||||
},
|
||||
"domelementtype@3.0.0": {
|
||||
@@ -806,6 +818,9 @@
|
||||
"domhandler"
|
||||
]
|
||||
},
|
||||
"entities@7.0.1": {
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="
|
||||
},
|
||||
"entities@8.1.0": {
|
||||
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="
|
||||
},
|
||||
@@ -884,12 +899,16 @@
|
||||
"github-slugger@2.0.0": {
|
||||
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="
|
||||
},
|
||||
"happy-dom@18.0.1": {
|
||||
"integrity": "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==",
|
||||
"happy-dom@20.12.0": {
|
||||
"integrity": "sha512-7uMYJu2SEwwL8vVcKp0C0lnt6d2LSGGe+T+oY79PiCJNNSgFpbxW8n5KuzpDQvrU4mt+fYiK1+Jy7Z2v39YR6g==",
|
||||
"dependencies": [
|
||||
"@types/node",
|
||||
"@types/whatwg-mimetype",
|
||||
"whatwg-mimetype"
|
||||
"@types/ws",
|
||||
"buffer-image-size",
|
||||
"entities@7.0.1",
|
||||
"whatwg-mimetype",
|
||||
"ws"
|
||||
]
|
||||
},
|
||||
"hast-util-to-html@9.0.5": {
|
||||
@@ -923,7 +942,7 @@
|
||||
"domelementtype",
|
||||
"domhandler",
|
||||
"domutils",
|
||||
"entities"
|
||||
"entities@8.1.0"
|
||||
]
|
||||
},
|
||||
"is-reference@3.0.3": {
|
||||
@@ -1332,7 +1351,7 @@
|
||||
"vite"
|
||||
]
|
||||
},
|
||||
"vitest@3.2.4_happy-dom@18.0.1": {
|
||||
"vitest@3.2.4_happy-dom@20.12.0": {
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dependencies": [
|
||||
"@types/chai",
|
||||
@@ -1379,6 +1398,9 @@
|
||||
],
|
||||
"bin": true
|
||||
},
|
||||
"ws@8.21.3": {
|
||||
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="
|
||||
},
|
||||
"yaml@2.9.0": {
|
||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||
"bin": true
|
||||
@@ -1419,7 +1441,7 @@
|
||||
"npm:@humanspeak/svelte-markdown@1.9.1",
|
||||
"npm:@testing-library/svelte@5.2.8",
|
||||
"npm:gen-interface-jp@0.8.0",
|
||||
"npm:happy-dom@18.0.1",
|
||||
"npm:happy-dom@20.12.0",
|
||||
"npm:shiki@4.4.2",
|
||||
"npm:vitest@3.2.4",
|
||||
"npm:yaml@2.9.0"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/svelte": "5.2.8",
|
||||
"happy-dom": "18.0.1",
|
||||
"happy-dom": "20.12.0",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,61 @@ import {
|
||||
RepositoryAccessSchemaError,
|
||||
} from "./repository-access.ts";
|
||||
|
||||
export type RepositoryAccessSectionResult<T> = {
|
||||
data: T | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function sectionErrorMessage(cause: unknown, label: string): string {
|
||||
if (cause && typeof cause === "object" && "body" in cause) {
|
||||
const body = (cause as { body?: unknown }).body;
|
||||
if (body && typeof body === "object" && "message" in body) {
|
||||
const message = (body as { message?: unknown }).message;
|
||||
if (typeof message === "string") return message;
|
||||
}
|
||||
}
|
||||
return `${label} could not be loaded.`;
|
||||
}
|
||||
|
||||
function httpErrorStatus(cause: unknown): number | null {
|
||||
if (!cause || typeof cause !== "object" || !("status" in cause)) return null;
|
||||
const status = (cause as { status?: unknown }).status;
|
||||
return typeof status === "number" ? status : null;
|
||||
}
|
||||
|
||||
export async function loadRepositoryAccessPermissionGate<T>(
|
||||
fetcher: typeof fetch,
|
||||
path: string,
|
||||
parse: (value: unknown) => T,
|
||||
label: string,
|
||||
): Promise<RepositoryAccessSectionResult<T>> {
|
||||
try {
|
||||
return {
|
||||
data: await loadRepositoryAccessJson(fetcher, path, parse),
|
||||
error: null,
|
||||
};
|
||||
} catch (cause) {
|
||||
if (httpErrorStatus(cause) === 403) throw cause;
|
||||
return { data: null, error: sectionErrorMessage(cause, label) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRepositoryAccessSection<T>(
|
||||
fetcher: typeof fetch,
|
||||
path: string,
|
||||
parse: (value: unknown) => T,
|
||||
label: string,
|
||||
): Promise<RepositoryAccessSectionResult<T>> {
|
||||
try {
|
||||
return {
|
||||
data: await loadRepositoryAccessJson(fetcher, path, parse),
|
||||
error: null,
|
||||
};
|
||||
} catch (cause) {
|
||||
return { data: null, error: sectionErrorMessage(cause, label) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadRepositoryAccessJson<T>(
|
||||
fetcher: typeof fetch,
|
||||
path: string,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export type RepositoryAccessFieldErrors = Record<string, string>;
|
||||
|
||||
const RESOURCE_ID = /^[A-Za-z0-9_.-]+$/;
|
||||
|
||||
function required(value: string, label: string): string | null {
|
||||
return value.trim() ? null : `${label} is required.`;
|
||||
}
|
||||
|
||||
function resourceId(value: string, label: string): string | null {
|
||||
const missing = required(value, label);
|
||||
if (missing) return missing;
|
||||
if (value.length > 128) return `${label} must be 128 characters or fewer.`;
|
||||
if (!RESOURCE_ID.test(value)) {
|
||||
return `${label} may contain only letters, numbers, period, underscore, and hyphen.`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function assign(
|
||||
errors: RepositoryAccessFieldErrors,
|
||||
field: string,
|
||||
message: string | null,
|
||||
): void {
|
||||
if (message) errors[field] = message;
|
||||
}
|
||||
|
||||
export function validateGeneratedCredential(input: {
|
||||
credentialId: string;
|
||||
name: string;
|
||||
}): RepositoryAccessFieldErrors {
|
||||
const errors: RepositoryAccessFieldErrors = {};
|
||||
assign(
|
||||
errors,
|
||||
"credentialId",
|
||||
resourceId(input.credentialId, "Credential id"),
|
||||
);
|
||||
assign(errors, "name", required(input.name, "Name"));
|
||||
if (input.name.length > 200) {
|
||||
errors.name = "Name must be 200 characters or fewer.";
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateImportedCredential(input: {
|
||||
credentialId: string;
|
||||
name: string;
|
||||
privateKey: string;
|
||||
}): RepositoryAccessFieldErrors {
|
||||
const errors = validateGeneratedCredential(input);
|
||||
assign(
|
||||
errors,
|
||||
"privateKey",
|
||||
required(input.privateKey, "OpenSSH private key"),
|
||||
);
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateCredentialRotation(
|
||||
privateKey: string,
|
||||
): RepositoryAccessFieldErrors {
|
||||
const errors: RepositoryAccessFieldErrors = {};
|
||||
assign(errors, "privateKey", required(privateKey, "New private key"));
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function validateHostTrust(input: {
|
||||
hostTrustId: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
hostKey: string;
|
||||
}): RepositoryAccessFieldErrors {
|
||||
const errors: RepositoryAccessFieldErrors = {};
|
||||
assign(errors, "hostTrustId", resourceId(input.hostTrustId, "Host trust id"));
|
||||
assign(errors, "hostname", required(input.hostname, "Hostname"));
|
||||
if (!Number.isInteger(input.port) || input.port < 1 || input.port > 65_535) {
|
||||
errors.port = "Port must be an integer from 1 to 65535.";
|
||||
}
|
||||
assign(errors, "hostKey", required(input.hostKey, "OpenSSH public host key"));
|
||||
return errors;
|
||||
}
|
||||
|
||||
export function repositoryAccessRequestError(
|
||||
status: number,
|
||||
operation: string,
|
||||
): Error {
|
||||
if (status === 401 || status === 403) {
|
||||
return new Error(`You do not have permission to ${operation}.`);
|
||||
}
|
||||
if (status === 409) {
|
||||
return new Error(
|
||||
`This item changed before it could be saved. Reload the page, review the current value, and try again.`,
|
||||
);
|
||||
}
|
||||
if (status === 400 || status === 422) {
|
||||
return new Error(
|
||||
`The server rejected the fields for ${operation}. Review the values and try again.`,
|
||||
);
|
||||
}
|
||||
if (status === 404) {
|
||||
return new Error(
|
||||
`The item for ${operation} no longer exists. Reload the page to see the current state.`,
|
||||
);
|
||||
}
|
||||
return new Error(
|
||||
`Unable to ${operation}. Repository Access returned status ${status}.`,
|
||||
);
|
||||
}
|
||||
@@ -45,6 +45,7 @@ function worker(overrides: Partial<Worker>): Worker {
|
||||
workdir_attachments: [],
|
||||
diagnostics: [],
|
||||
...overrides,
|
||||
availability: overrides.availability ?? "observed",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,7 +79,10 @@ Deno.test("sidebar workers sort running then idle then stopped", () => {
|
||||
worker({ worker_id: "1", display_name: "Idle A", state: "idle" }),
|
||||
];
|
||||
workers.sort(compareWorkersForSidebar);
|
||||
assertEquals(workers.map((candidate) => candidate.worker_id).join(","), "2,1,4,3");
|
||||
assertEquals(
|
||||
workers.map((candidate) => candidate.worker_id).join(","),
|
||||
"2,1,4,3",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("collapsed sidebar keeps the current Worker visible", () => {
|
||||
|
||||
@@ -616,6 +616,7 @@
|
||||
|
||||
.settings-page {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
.settings-section-card,
|
||||
@@ -1053,4 +1054,324 @@
|
||||
.status-message.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.repository-access-page {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
.repository-access-section {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.repository-access-section + .repository-access-section {
|
||||
padding-top: var(--space-5);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.repository-access-section h2,
|
||||
.repository-access-section h3,
|
||||
.repository-access-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.repository-access-section-heading,
|
||||
.repository-access-form-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.repository-access-section-heading > div,
|
||||
.repository-access-form-heading > div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.repository-access-section-heading p,
|
||||
.repository-access-form-heading p,
|
||||
.repository-access-guidance,
|
||||
.repository-access-form small,
|
||||
.repository-access-row-actions + small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.repository-access-actions,
|
||||
.repository-access-row-actions,
|
||||
.repository-access-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.repository-access-section button {
|
||||
min-height: 2.25rem;
|
||||
border-radius: var(--radius-soft);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.repository-access-section button.primary {
|
||||
border: 1px solid transparent;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.repository-access-section button.secondary {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.repository-access-section button.danger {
|
||||
border: 1px solid var(--danger);
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.repository-access-section button:hover:not(:disabled),
|
||||
.repository-access-section button:focus-visible:not(:disabled) {
|
||||
background: var(--interactive-hover);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.repository-access-section button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.repository-access-table-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.repository-access-table {
|
||||
width: 100%;
|
||||
min-width: 48rem;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--font-size-compact);
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.repository-access-binding-table {
|
||||
min-width: 40rem;
|
||||
}
|
||||
|
||||
.repository-access-credential-table {
|
||||
min-width: 62rem;
|
||||
}
|
||||
|
||||
.repository-access-host-table {
|
||||
min-width: 56rem;
|
||||
}
|
||||
|
||||
.repository-access-table th,
|
||||
.repository-access-table td {
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.repository-access-table th {
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.repository-access-table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.repository-access-table td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.repository-access-table td > strong,
|
||||
.repository-access-table td > code,
|
||||
.repository-access-table td > small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.repository-access-table td > strong {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.repository-access-table td > code + code,
|
||||
.repository-access-table td > strong + code,
|
||||
.repository-access-table td > strong + small,
|
||||
.repository-access-table td > code + small,
|
||||
.repository-access-row-actions + small {
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.repository-access-row-actions {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.repository-access-empty {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-4);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.repository-access-empty.error {
|
||||
border-left: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.repository-access-notice {
|
||||
padding-left: var(--space-3);
|
||||
border-left: 3px solid var(--line-strong);
|
||||
}
|
||||
|
||||
.repository-access-notice.success {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.repository-access-notice.error,
|
||||
.repository-access-section .field-error {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.repository-access-form {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-4);
|
||||
border-left: 3px solid var(--line-strong);
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.repository-access-form-heading > code,
|
||||
.repository-access-form-heading > div > code {
|
||||
display: block;
|
||||
max-width: 28rem;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.repository-access-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.repository-access-form label {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.repository-access-form label.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.repository-access-form input,
|
||||
.repository-access-form textarea {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-soft);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-strong);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.repository-access-form textarea {
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.repository-access-form input[aria-invalid="true"],
|
||||
.repository-access-form textarea[aria-invalid="true"] {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.repository-access-technical {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.repository-access-technical summary {
|
||||
width: fit-content;
|
||||
cursor: pointer;
|
||||
color: var(--text-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.repository-access-technical dl {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
margin: var(--space-3) 0 0;
|
||||
}
|
||||
|
||||
.repository-access-technical dl div {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(8rem, 0.25fr) minmax(0, 1fr);
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.repository-access-technical dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.repository-access-technical dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.repository-access-guidance {
|
||||
max-width: 64rem;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 48rem) {
|
||||
.repository-access-section-heading,
|
||||
.repository-access-form-heading {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.repository-access-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.repository-access-form label.wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.repository-access-technical dl div {
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+692
-195
File diff suppressed because it is too large
Load Diff
@@ -5,49 +5,67 @@ import {
|
||||
parseRepositorySshHostTrusts,
|
||||
parseRepositorySshPublicKey,
|
||||
} from "$lib/workspace/api/repository-access";
|
||||
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
|
||||
import {
|
||||
loadRepositoryAccessPermissionGate,
|
||||
loadRepositoryAccessSection,
|
||||
} from "$lib/workspace/api/repository-access-loader";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
const WORKSPACE_DEFAULT_CREDENTIAL_ID = "workspace-default";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const accessProjection = await loadRepositoryAccessJson(
|
||||
const accessProjectionResult = await loadRepositoryAccessPermissionGate(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/settings/repository-access"),
|
||||
parseRepositoryAccessProjection,
|
||||
"Repository bindings",
|
||||
);
|
||||
const [credentials, hostTrusts] = await Promise.all([
|
||||
loadRepositoryAccessJson(
|
||||
const [credentialsResult, hostTrustsResult] = await Promise.all([
|
||||
loadRepositoryAccessSection(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/settings/repository-access/credentials"),
|
||||
parseRepositorySshCredentials,
|
||||
"SSH credentials",
|
||||
),
|
||||
loadRepositoryAccessJson(
|
||||
loadRepositoryAccessSection(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/settings/repository-access/host-trusts"),
|
||||
parseRepositorySshHostTrusts,
|
||||
"Pinned SSH host keys",
|
||||
),
|
||||
]);
|
||||
|
||||
const publicKeys = await Promise.all(
|
||||
credentials.map((credential) =>
|
||||
loadRepositoryAccessJson(
|
||||
const credentials = credentialsResult.data ?? [];
|
||||
const defaultCredential = credentials.find(
|
||||
(credential) =>
|
||||
credential.credential_id === WORKSPACE_DEFAULT_CREDENTIAL_ID,
|
||||
);
|
||||
const defaultPublicKeyResult = defaultCredential
|
||||
? await loadRepositoryAccessSection(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/settings/repository-access/credentials/${
|
||||
encodeURIComponent(credential.credential_id)
|
||||
encodeURIComponent(defaultCredential.credential_id)
|
||||
}/public-key`,
|
||||
),
|
||||
parseRepositorySshPublicKey,
|
||||
"The Workspace default public key",
|
||||
)
|
||||
),
|
||||
);
|
||||
: { data: null, error: null };
|
||||
|
||||
return {
|
||||
workspaceId,
|
||||
credentials,
|
||||
publicKeys,
|
||||
hostTrusts,
|
||||
accessProjection,
|
||||
credentialsError: credentialsResult.error,
|
||||
publicKeys: defaultPublicKeyResult.data
|
||||
? [defaultPublicKeyResult.data]
|
||||
: [],
|
||||
defaultPublicKeyError: defaultPublicKeyResult.error,
|
||||
hostTrusts: hostTrustsResult.data ?? [],
|
||||
hostTrustsError: hostTrustsResult.error,
|
||||
accessProjection: accessProjectionResult.data,
|
||||
accessProjectionError: accessProjectionResult.error,
|
||||
};
|
||||
};
|
||||
|
||||
+486
@@ -0,0 +1,486 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import RepositoryAccessPage from "./+page.svelte";
|
||||
import type { PageProps } from "./$types";
|
||||
|
||||
const defaultCredential = {
|
||||
credential_id: "workspace-default",
|
||||
workspace_id: "workspace-1",
|
||||
name: "Workspace default",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:default",
|
||||
current_revision: 3,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
referenced_repositories: [],
|
||||
};
|
||||
|
||||
const deployCredential = {
|
||||
credential_id: "deploy",
|
||||
workspace_id: "workspace-1",
|
||||
name: "Deploy key",
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:deploy",
|
||||
current_revision: 2,
|
||||
status: "active",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
rotated_at: null,
|
||||
referenced_repositories: [],
|
||||
};
|
||||
|
||||
const hostTrust = {
|
||||
host_trust_id: "github-com",
|
||||
workspace_id: "workspace-1",
|
||||
hostname: "github.com",
|
||||
port: 22,
|
||||
key_algorithm: "ssh-ed25519",
|
||||
host_key: "ssh-ed25519 AAAA-current",
|
||||
fingerprint: "SHA256:host-current",
|
||||
current_revision: 4,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
referenced_repositories: [],
|
||||
};
|
||||
|
||||
function pageData(workspaceId = "workspace-1") {
|
||||
return {
|
||||
workspaceId,
|
||||
credentials: [
|
||||
{ ...defaultCredential, workspace_id: workspaceId },
|
||||
{ ...deployCredential, workspace_id: workspaceId },
|
||||
],
|
||||
credentialsError: null,
|
||||
publicKeys: [{
|
||||
credential_id: "workspace-default",
|
||||
current_revision: 3,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:default",
|
||||
public_key: "ssh-ed25519 AAAA-default",
|
||||
}],
|
||||
defaultPublicKeyError: null,
|
||||
hostTrusts: [{ ...hostTrust, workspace_id: workspaceId }],
|
||||
hostTrustsError: null,
|
||||
accessProjection: {
|
||||
workspace_id: workspaceId,
|
||||
config_revision: 1,
|
||||
projection_digest: "sha256:projection",
|
||||
bindings: [],
|
||||
},
|
||||
accessProjectionError: null,
|
||||
repositories: {
|
||||
items: [{
|
||||
repository_key: "platform",
|
||||
source: { kind: "ssh", uri: "git@github.com:team/platform.git" },
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function pageProps(data = pageData()): PageProps {
|
||||
return {
|
||||
data,
|
||||
params: { workspaceId: data.workspaceId },
|
||||
} as unknown as PageProps;
|
||||
}
|
||||
|
||||
function renderPage(data = pageData()) {
|
||||
return render(RepositoryAccessPage, pageProps(data));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("opens disclosures with focus, focuses invalid fields, and restores trigger focus on cancel", async () => {
|
||||
renderPage();
|
||||
const trigger = screen.getByRole("button", { name: "Generate credential" });
|
||||
|
||||
expect(screen.queryByRole("heading", { name: "Generate SSH credential" }))
|
||||
.toBeNull();
|
||||
trigger.focus();
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
const idInput = screen.getByRole("textbox", { name: "Credential id" });
|
||||
await waitFor(() => expect(document.activeElement).toBe(idInput));
|
||||
await fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "Generate credential" })[1],
|
||||
);
|
||||
expect(await screen.findByText("Credential id is required.")).not.toBeNull();
|
||||
expect(document.activeElement).toBe(idInput);
|
||||
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("heading", { name: "Generate SSH credential" }))
|
||||
.toBeNull()
|
||||
);
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
test("fences a deferred editor while an unrelated host draft remains active after success", async () => {
|
||||
const createResponse = deferred<Response>();
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/credentials/generate")) return createResponse.promise;
|
||||
if (url.endsWith("/credentials/generated/public-key")) {
|
||||
return Promise.resolve(Response.json({
|
||||
credential_id: "generated",
|
||||
current_revision: 1,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:generated",
|
||||
public_key: "ssh-ed25519 AAAA-generated",
|
||||
}));
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
renderPage();
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "Generate credential" });
|
||||
trigger.focus();
|
||||
await fireEvent.click(trigger);
|
||||
await fireEvent.input(
|
||||
screen.getByRole("textbox", { name: "Credential id" }),
|
||||
{ target: { value: "generated" } },
|
||||
);
|
||||
await fireEvent.input(screen.getByRole("textbox", { name: "Name" }), {
|
||||
target: { value: "Generated key" },
|
||||
});
|
||||
const submit =
|
||||
screen.getAllByRole("button", { name: "Generate credential" })[1];
|
||||
await fireEvent.click(submit);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Generating…" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true)
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Import credential",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
const deployRow = screen.getByText("Deploy key").closest("tr");
|
||||
if (!deployRow) throw new Error("missing deploy credential row");
|
||||
expect(
|
||||
(within(deployRow).getByRole("button", {
|
||||
name: "Rotate",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
const addHost = screen.getByRole("button", { name: "Add pinned key" });
|
||||
expect((addHost as HTMLButtonElement).disabled).toBe(false);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Generating…" }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await fireEvent.click(addHost);
|
||||
const hostDraft = screen.getByRole("textbox", { name: "Host trust id" });
|
||||
await fireEvent.input(hostDraft, { target: { value: "draft-host" } });
|
||||
hostDraft.focus();
|
||||
|
||||
createResponse.resolve(Response.json({
|
||||
...deployCredential,
|
||||
credential_id: "generated",
|
||||
name: "Generated key",
|
||||
public_key_fingerprint: "SHA256:generated",
|
||||
current_revision: 1,
|
||||
}));
|
||||
expect(await screen.findByText("Generated key")).not.toBeNull();
|
||||
expect(screen.getByRole("heading", { name: "Add pinned host key" })).not
|
||||
.toBeNull();
|
||||
expect((hostDraft as HTMLInputElement).value).toBe("draft-host");
|
||||
expect(document.activeElement).toBe(hostDraft);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("fences same-credential actions during a deferred row failure while unrelated controls remain enabled", async () => {
|
||||
const publicKeyResponse = deferred<Response>();
|
||||
const fetchMock = vi.fn(() => publicKeyResponse.promise);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
renderPage();
|
||||
|
||||
const row = screen.getByText("Deploy key").closest("tr");
|
||||
if (!row) throw new Error("missing credential row");
|
||||
const rotate = within(row).getByRole("button", { name: "Rotate" });
|
||||
await fireEvent.click(rotate);
|
||||
const rotateSubmit = screen.getByRole("button", {
|
||||
name: "Rotate credential",
|
||||
});
|
||||
expect((rotateSubmit as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
const copy = within(row).getByRole("button", { name: "Copy public key" });
|
||||
await fireEvent.click(copy);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(within(row).getByRole("button", {
|
||||
name: "Loading…",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true)
|
||||
);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Generate credential",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Add pinned key",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
const remove = within(row).getByRole("button", { name: "Delete" });
|
||||
expect((rotate as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((remove as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((rotateSubmit as HTMLButtonElement).disabled).toBe(true);
|
||||
await fireEvent.click(rotateSubmit);
|
||||
await fireEvent.click(rotate);
|
||||
await fireEvent.click(remove);
|
||||
expect(screen.getByRole("heading", { name: "Rotate Deploy key" })).not
|
||||
.toBeNull();
|
||||
await fireEvent.click(within(row).getByRole("button", { name: "Loading…" }));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
publicKeyResponse.resolve(new Response(null, { status: 500 }));
|
||||
const alert = await within(row).findByRole("alert");
|
||||
expect(alert.textContent).toContain("Unable to load this public key");
|
||||
expect(screen.getAllByRole("alert")).toHaveLength(1);
|
||||
expect((rotateSubmit as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(screen.getByRole("heading", { name: "Rotate Deploy key" })).not
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("rerendering for another Workspace resets local state and fences stale responses", async () => {
|
||||
const stalePublicKey = deferred<Response>();
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("/api/w/workspace-1/")) return stalePublicKey.promise;
|
||||
if (url.includes("/api/w/workspace-2/")) {
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const { rerender } = renderPage();
|
||||
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Generate credential" }),
|
||||
);
|
||||
await fireEvent.input(
|
||||
screen.getByRole("textbox", { name: "Credential id" }),
|
||||
{
|
||||
target: { value: "workspace-a-draft" },
|
||||
},
|
||||
);
|
||||
const workspaceARow = screen.getByText("Deploy key").closest("tr");
|
||||
if (!workspaceARow) throw new Error("missing Workspace A credential row");
|
||||
await fireEvent.click(
|
||||
within(workspaceARow).getByRole("button", { name: "Copy public key" }),
|
||||
);
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const workspaceBData = {
|
||||
...pageData("workspace-2"),
|
||||
credentials: [
|
||||
{
|
||||
...defaultCredential,
|
||||
workspace_id: "workspace-2",
|
||||
name: "Workspace B default",
|
||||
},
|
||||
{
|
||||
...deployCredential,
|
||||
workspace_id: "workspace-2",
|
||||
credential_id: "workspace-b-deploy",
|
||||
name: "Workspace B deploy",
|
||||
},
|
||||
],
|
||||
publicKeys: [],
|
||||
hostTrusts: [{
|
||||
...hostTrust,
|
||||
workspace_id: "workspace-2",
|
||||
host_trust_id: "workspace-b-host",
|
||||
hostname: "git.example.test",
|
||||
}],
|
||||
accessProjection: {
|
||||
workspace_id: "workspace-2",
|
||||
config_revision: 7,
|
||||
projection_digest: "sha256:workspace-b",
|
||||
bindings: [],
|
||||
},
|
||||
};
|
||||
await rerender(pageProps(workspaceBData));
|
||||
|
||||
expect(await screen.findByText("Workspace B deploy")).not.toBeNull();
|
||||
expect(screen.queryByText("Deploy key")).toBeNull();
|
||||
expect(screen.queryByDisplayValue("workspace-a-draft")).toBeNull();
|
||||
expect(screen.getByText("git.example.test:22")).not.toBeNull();
|
||||
|
||||
stalePublicKey.resolve(Response.json({
|
||||
credential_id: "deploy",
|
||||
current_revision: 2,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:deploy",
|
||||
public_key: "ssh-ed25519 AAAA-stale",
|
||||
}));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(screen.queryByText("Public key copied.")).toBeNull();
|
||||
|
||||
const workspaceBRow = screen.getByText("Workspace B deploy").closest("tr");
|
||||
if (!workspaceBRow) throw new Error("missing Workspace B credential row");
|
||||
await fireEvent.click(
|
||||
within(workspaceBRow).getByRole("button", { name: "Copy public key" }),
|
||||
);
|
||||
expect(await within(workspaceBRow).findByRole("alert")).not.toBeNull();
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain(
|
||||
"/api/w/workspace-2/settings/repository-access/credentials/workspace-b-deploy/public-key",
|
||||
);
|
||||
});
|
||||
|
||||
test("retries public-key loading after a post-rotation refresh failure", async () => {
|
||||
let publicKeyLoads = 0;
|
||||
const newPublicKey = "ssh-ed25519 AAAA-new-deploy";
|
||||
const fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/credentials/deploy/rotate")) {
|
||||
return Promise.resolve(Response.json({
|
||||
...deployCredential,
|
||||
public_key_fingerprint: "SHA256:deploy-new",
|
||||
current_revision: 3,
|
||||
rotated_at: "2026-02-01T00:00:00Z",
|
||||
}));
|
||||
}
|
||||
if (url.endsWith("/credentials/deploy/public-key")) {
|
||||
publicKeyLoads += 1;
|
||||
if (publicKeyLoads === 1) {
|
||||
return Promise.resolve(new Response(null, { status: 500 }));
|
||||
}
|
||||
return Promise.resolve(Response.json({
|
||||
credential_id: "deploy",
|
||||
current_revision: 3,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:deploy-new",
|
||||
public_key: newPublicKey,
|
||||
}));
|
||||
}
|
||||
throw new Error(`unexpected request: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const writeText = vi.spyOn(navigator.clipboard, "writeText")
|
||||
.mockResolvedValue();
|
||||
const data = pageData();
|
||||
data.publicKeys.push({
|
||||
credential_id: "deploy",
|
||||
current_revision: 2,
|
||||
public_key_algorithm: "ssh-ed25519",
|
||||
public_key_fingerprint: "SHA256:deploy",
|
||||
public_key: "ssh-ed25519 AAAA-old-deploy",
|
||||
});
|
||||
renderPage(data);
|
||||
|
||||
const row = screen.getByText("Deploy key").closest("tr");
|
||||
if (!row) throw new Error("missing credential row");
|
||||
await fireEvent.click(within(row).getByRole("button", { name: "Rotate" }));
|
||||
await fireEvent.input(
|
||||
screen.getByRole("textbox", { name: /^New OpenSSH private key/ }),
|
||||
{ target: { value: "-----BEGIN OPENSSH PRIVATE KEY-----" } },
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Rotate credential" }),
|
||||
);
|
||||
|
||||
const refreshAlert = await within(row).findByRole("alert");
|
||||
expect(refreshAlert.textContent).toContain("Unable to load this public key");
|
||||
expect(publicKeyLoads).toBe(1);
|
||||
|
||||
await fireEvent.click(
|
||||
within(row).getByRole("button", { name: "Copy public key" }),
|
||||
);
|
||||
expect(await within(row).findByText("Public key copied.")).not.toBeNull();
|
||||
expect(publicKeyLoads).toBe(2);
|
||||
expect(writeText).toHaveBeenCalledWith(newPublicKey);
|
||||
});
|
||||
|
||||
test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => {
|
||||
const rotateResponse = deferred<Response>();
|
||||
const fetchMock = vi.fn((_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
rotateResponse.promise
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
renderPage();
|
||||
|
||||
const row = screen.getByText("github.com:22").closest("tr");
|
||||
if (!row) throw new Error("missing host trust row");
|
||||
const trigger = within(row).getByRole("button", { name: "Rotate key" });
|
||||
trigger.focus();
|
||||
await fireEvent.click(trigger);
|
||||
|
||||
const hostname = screen.getByRole("textbox", { name: "Hostname" });
|
||||
const port = screen.getByRole("spinbutton", { name: "Port" });
|
||||
const hostKeyInput = screen.getByRole("textbox", {
|
||||
name: /^OpenSSH public host key/,
|
||||
});
|
||||
expect((hostname as HTMLInputElement).disabled).toBe(true);
|
||||
expect((port as HTMLInputElement).disabled).toBe(true);
|
||||
expect(screen.getByText(/keeps the endpoint fixed/).textContent).toContain(
|
||||
"may also select it automatically",
|
||||
);
|
||||
expect(screen.getByText("Current fingerprint").parentElement?.textContent)
|
||||
.toContain("SHA256:host-current");
|
||||
await waitFor(() => expect(document.activeElement).toBe(hostKeyInput));
|
||||
|
||||
await fireEvent.input(hostKeyInput, {
|
||||
target: { value: "ssh-ed25519 AAAA-new" },
|
||||
});
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Save new key" }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
const body = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body));
|
||||
expect(body.hostname).toBe("github.com");
|
||||
expect(body.port).toBe(22);
|
||||
expect(body.expected_revision).toBe(4);
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Saving…" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Add pinned key",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Generate credential",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false);
|
||||
|
||||
rotateResponse.resolve(Response.json({
|
||||
...hostTrust,
|
||||
host_key: "ssh-ed25519 AAAA-new",
|
||||
fingerprint: "SHA256:host-new",
|
||||
current_revision: 5,
|
||||
}));
|
||||
expect(await within(row).findByText(/Rotated the key for github.com:22/)).not
|
||||
.toBeNull();
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "Rotate key" }),
|
||||
)
|
||||
);
|
||||
});
|
||||
@@ -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