diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 06ff8d8ea..a7e59c3c8 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -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", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index 43e70c552..de8a572cd 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -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" diff --git a/web/workspace/package.json b/web/workspace/package.json index d158b403a..ad088cf3f 100644 --- a/web/workspace/package.json +++ b/web/workspace/package.json @@ -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" } } diff --git a/web/workspace/src/lib/workspace/api/repository-access-loader.ts b/web/workspace/src/lib/workspace/api/repository-access-loader.ts index 26c5384e5..9664d6c40 100644 --- a/web/workspace/src/lib/workspace/api/repository-access-loader.ts +++ b/web/workspace/src/lib/workspace/api/repository-access-loader.ts @@ -5,6 +5,61 @@ import { RepositoryAccessSchemaError, } from "./repository-access.ts"; +export type RepositoryAccessSectionResult = { + 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( + fetcher: typeof fetch, + path: string, + parse: (value: unknown) => T, + label: string, +): Promise> { + 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( + fetcher: typeof fetch, + path: string, + parse: (value: unknown) => T, + label: string, +): Promise> { + try { + return { + data: await loadRepositoryAccessJson(fetcher, path, parse), + error: null, + }; + } catch (cause) { + return { data: null, error: sectionErrorMessage(cause, label) }; + } +} + export async function loadRepositoryAccessJson( fetcher: typeof fetch, path: string, diff --git a/web/workspace/src/lib/workspace/repository-access/model.ts b/web/workspace/src/lib/workspace/repository-access/model.ts new file mode 100644 index 000000000..9c28f290b --- /dev/null +++ b/web/workspace/src/lib/workspace/repository-access/model.ts @@ -0,0 +1,107 @@ +export type RepositoryAccessFieldErrors = Record; + +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}.`, + ); +} diff --git a/web/workspace/src/lib/workspace/sidebar/workers.test.ts b/web/workspace/src/lib/workspace/sidebar/workers.test.ts index c3cc83454..241490a24 100644 --- a/web/workspace/src/lib/workspace/sidebar/workers.test.ts +++ b/web/workspace/src/lib/workspace/sidebar/workers.test.ts @@ -45,6 +45,7 @@ function worker(overrides: Partial): 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", () => { diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index d81fbb496..1d60b4aaa 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -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); + } + } } diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte index 88f9f2cf9..bb0c999f6 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.svelte @@ -1,5 +1,5 @@ Repository Access · Yoi Workspace -
-
-

owner only

Repository Access

- encrypted -
-

The Workspace default SSH key is generated separately from the Runtime authentication identity and is always offered during SSH clone. Without an explicit Repository binding, a unique pinned host trust matching the Repository URI is used with this default key and normal read/write Git access. An explicit binding can select read-only access or add one dedicated credential; OpenSSH receives both credential candidates and tries them through one operation-scoped agent. Private keys and passphrases remain write-only.

- {#if message}

{message}

{/if} - -
-

Active access projection

-

Config revision {accessProjection.config_revision} · {accessProjection.projection_digest}

- {#if accessProjection.bindings.length === 0}

No repository access bindings are active.

{/if} - {#each accessProjection.bindings as binding (binding.repository_key)} -
- {binding.repository_key} -

{binding.access} · additional credential {binding.credential_id} · always includes {workspaceDefaultCredentialId} · host trust {binding.host_trust_id}

+
+
+
+
+

Repository bindings

+

Explicit overrides for Repository authentication and host identity.

- {/each} -
- -
-

SSH credentials

- {#if credentials.length === 0}

No credentials configured.

{/if} - {#each credentials as credential (credential.credential_id)} -
- {credential.name} {credential.credential_id} - {#if credential.credential_id === workspaceDefaultCredentialId}Workspace default{/if} -

{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}

- {#if publicKeys[credential.credential_id]} - - - {/if} -

References: {credential.credential_id === workspaceDefaultCredentialId ? 'all SSH repository operations' : credential.referenced_repositories.join(', ') || 'none'}

- {#if credential.credential_id !== workspaceDefaultCredentialId} -
- - -
- {/if} - {#if rotateCredentialId === credential.credential_id} -
{ event.preventDefault(); void rotateCredential(credential); }}> - - - -
- {/if} +
+ {#if data.accessProjectionError} + - {/each} + {:else if accessProjection && accessProjection.bindings.length === 0} +
+ No explicit bindings +

SSH Repositories can still use the Workspace default credential with normal read/write Git access and a unique pinned key that matches their host and port.

+
+ {:else if accessProjection} + +
+ + + + {#each accessProjection.bindings as binding (binding.repository_key)} + + + + + + + {/each} + +
RepositoryAccessAuthenticationHost identity
{binding.repository_key}Explicit binding{binding.access === 'read_write' ? 'Read and write' : 'Read only'}{workspaceDefaultCredentialId}plus {binding.credential_id}{binding.host_trust_id}
+
+ {/if} + {#if accessProjection} +
+ Technical details +
+
Config revision
{accessProjection.config_revision}
+
Projection digest
{accessProjection.projection_digest}
+
+
+ {/if} +
-
{ event.preventDefault(); void generateCredential(); }}> -

Generate Repository SSH credential

-

Create an additional Ed25519 key for a Repository binding. The Workspace default SSH key is already generated automatically and is included separately.

- - - -
- -
{ event.preventDefault(); void createCredential(); }}> -

Import existing SSH credential

- - - - - -
-
- -
-

Pinned SSH host keys

- {#if hostTrusts.length === 0}

No host trust records configured.

{/if} - {#each hostTrusts as hostTrust (hostTrust.host_trust_id)} -
- {hostTrust.hostname}:{hostTrust.port} {hostTrust.host_trust_id} -

{hostTrust.key_algorithm} · {hostTrust.fingerprint} · revision {hostTrust.current_revision}

-

References: {hostTrust.referenced_repositories.join(', ') || 'none'}

-
- - +
+
+
+

SSH credentials

+

Keys this Workspace can offer when it authenticates to an SSH Repository.

+
+ {#if !data.credentialsError} +
+ +
-
- {/each} + {/if} +
-
{ event.preventDefault(); void createHostTrust(); }}> -

{hostExpectedRevision === null ? 'Add pinned host key' : 'Rotate pinned host key'}

- - - - - - {#if hostExpectedRevision !== null}{/if} -
-
-
+ {#if credentialNotice} +

{credentialNotice.text}

+ {/if} + + {#if data.credentialsError} + + {:else if credentials.length === 0} +
+ No SSH credentials are available +

Generate a credential to get a new public key, or import an existing Ed25519 private key.

+
+ {:else} + +
+ + + + {#each credentials as credential (credential.credential_id)} + + + + + + + + {/each} + +
CredentialRoleFingerprintRepository applicationActions
{credential.name}{credential.credential_id}{credential.credential_id === workspaceDefaultCredentialId ? 'Workspace default' : 'Additional'}{credential.public_key_algorithm}{credential.public_key_fingerprint} + {#if credential.credential_id === workspaceDefaultCredentialId} + Automatic for all SSH Repositories + {:else if credential.referenced_repositories.length > 0} + Explicit: {credential.referenced_repositories.join(', ')} + {:else} + No explicit references + {/if} + +
+ + {#if credential.credential_id !== workspaceDefaultCredentialId} + + {#if credential.referenced_repositories.length === 0} + + {/if} + {/if} +
+ {#if credentialRowNotices[credential.credential_id]} + {credentialRowNotices[credential.credential_id].text} + {/if} + {#if publicKeyNotices[credential.credential_id]} + {publicKeyNotices[credential.credential_id].text} + {:else if credential.credential_id === workspaceDefaultCredentialId && data.defaultPublicKeyError} + {data.defaultPublicKeyError} Use Copy public key to retry. + {:else if credential.referenced_repositories.length > 0 && credential.credential_id !== workspaceDefaultCredentialId} + Remove Repository references before deletion. + {/if} +
+
+ {/if} + + {#if credentialForm === 'generate'} +
{ event.preventDefault(); void generateCredential(); }}> +

Generate SSH credential

Creates an Ed25519 key. Copy the public key to the Repository provider after saving.

+ {#if credentialFormNotice}

{credentialFormNotice.text}

{/if} +
+ + +
+
+
+ {:else if credentialForm === 'import'} +
{ event.preventDefault(); void createCredential(); }}> +

Import SSH credential

The private key and optional passphrase are write-only and cleared after every save attempt.

+ {#if credentialFormNotice}

{credentialFormNotice.text}

{/if} +
+ + + + +
+
+
+ {/if} + + {#if selectedCredential} +
{ event.preventDefault(); void rotateCredential(selectedCredential); }}> +
+

Rotate {selectedCredential.name}

{selectedCredential.referenced_repositories.length > 0 ? `Explicitly referenced by ${selectedCredential.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this credential. '}Every external provider where the current public key is installed must be updated after rotation.

+ {selectedCredential.public_key_fingerprint} +
+ {#if credentialFormNotice}

{credentialFormNotice.text}

{/if} +
+ + +
+
+
+ {/if} + + {#if credentials.length > 0 && !data.credentialsError} +
+ Credential revisions +
{#each credentials as credential (credential.credential_id)}
{credential.credential_id}
Revision {credential.current_revision}
{/each}
+
+ {/if} + + +
+
+
+

Pinned SSH host keys

+

Keys used to verify the identity of a specific SSH host and port.

+
+ {#if !data.hostTrustsError}{/if} +
+ + {#if hostNotice}

{hostNotice.text}

{/if} + + {#if data.hostTrustsError} + + {:else if hostTrusts.length === 0} +
+ No SSH host keys are pinned +

Observe a host key from the Runtime that will clone the Repository, then compare its fingerprint before trusting it.

+ {sshRepository ? `Open ${sshRepository.repository_key} SSH connection test` : 'Choose an SSH Repository'} +
+ {:else} + +
+ + + + {#each hostTrusts as hostTrust (hostTrust.host_trust_id)} + + + + + + + {/each} + +
Host and portFingerprintRepository applicationActions
{hostTrust.hostname}:{hostTrust.port}{hostTrust.host_trust_id}{hostTrust.key_algorithm}{hostTrust.fingerprint}{hostTrust.referenced_repositories.length > 0 ? `Explicit: ${hostTrust.referenced_repositories.join(', ')}` : 'No explicit references; may apply automatically when this is the unique pin for a matching host and port.'} +
+ + {#if hostTrust.referenced_repositories.length === 0}{/if} +
+ {#if hostRowNotices[hostTrust.host_trust_id]} + {hostRowNotices[hostTrust.host_trust_id].text} + {:else if hostTrust.referenced_repositories.length > 0} + Remove explicit Repository references before deletion. + {/if} +
+
+ {/if} + +

A successful probe only observes a key. Saving a pin verifies future host identity; Git authentication succeeds only when a later Repository connection completes.

+ {sshRepository ? `Check ${sshRepository.repository_key} SSH connection` : 'Open Repository settings'} + + {#if hostEditorOpen} +
{ event.preventDefault(); void saveHostTrust(); }}> +
+
+

{hostExpectedRevision === null ? 'Add pinned host key' : `Rotate key for ${hostname}:${port}`}

+

{hostExpectedRevision === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : `This keeps the endpoint fixed and replaces its current trusted identity. ${editingHostTrust?.referenced_repositories.length ? `Explicitly referenced by ${editingHostTrust.referenced_repositories.join(', ')}. ` : 'No Repository explicitly references this pin. '}Repositories without an explicit host-key binding may also select it automatically when it is the unique pin matching this host and port.`}

+
+ {#if hostExpectedRevision !== null}
Current fingerprint{editingHostTrust?.fingerprint}
{/if} +
+ {#if hostFormNotice}

{hostFormNotice.text}

{/if} +
+ + + + +
+
+
+ {/if} + + {#if hostTrusts.length > 0 && !data.hostTrustsError} +
+ Host key revisions +
{#each hostTrusts as hostTrust (hostTrust.host_trust_id)}
{hostTrust.host_trust_id}
Revision {hostTrust.current_revision}
{/each}
+
+ {/if} +
+ diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts index dc1905d2f..a152808c6 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/+page.ts @@ -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( - fetch, - workspaceApiPath( - workspaceId, - `/settings/repository-access/credentials/${ - encodeURIComponent(credential.credential_id) - }/public-key`, - ), - parseRepositorySshPublicKey, - ) - ), + 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(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, }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts new file mode 100644 index 000000000..63a340328 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts @@ -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() { + let resolve!: (value: T) => void; + const promise = new Promise((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(); + 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(); + 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(); + 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(); + 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" }), + ) + ); +}); diff --git a/web/workspace/test/repository-access/loader.test.ts b/web/workspace/test/repository-access/loader.test.ts index 21b46be31..840e2882e 100644 --- a/web/workspace/test/repository-access/loader.test.ts +++ b/web/workspace/test/repository-access/loader.test.ts @@ -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 { + const fetcher = (input: RequestInfo | URL): Promise => { + 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)({ + fetch: fetcher as typeof fetch, + params: { workspaceId: "workspace-1" }, + }); +} + async function captureHttpFailure( run: () => Promise, 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( diff --git a/web/workspace/test/repository-access/model.test.ts b/web/workspace/test/repository-access/model.test.ts new file mode 100644 index 000000000..46c2a88b3 --- /dev/null +++ b/web/workspace/test/repository-access/model.test.ts @@ -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", + ); +}); diff --git a/web/workspace/test/repository-access/structure.test.ts b/web/workspace/test/repository-access/structure.test.ts new file mode 100644 index 000000000..17ee06dee --- /dev/null +++ b/web/workspace/test/repository-access/structure.test.ts @@ -0,0 +1,260 @@ +type TestRegistrar = (name: string, body: () => void | Promise) => 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("

Repository Access

"), + "the route title should remain owned by the Header", + ); + assert( + !source.includes("