diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 32b0e4f91..53e03d3b0 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/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/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/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/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/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 b93424a76..9664d6c40 100644 --- a/web/workspace/src/lib/workspace/api/repository-access-loader.ts +++ b/web/workspace/src/lib/workspace/api/repository-access-loader.ts @@ -21,6 +21,29 @@ function sectionErrorMessage(cause: unknown, label: string): string { 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, diff --git a/web/workspace/src/lib/workspace/styles/settings.css b/web/workspace/src/lib/workspace/styles/settings.css index b0bb6b92e..1d60b4aaa 100644 --- a/web/workspace/src/lib/workspace/styles/settings.css +++ b/web/workspace/src/lib/workspace/styles/settings.css @@ -1254,9 +1254,12 @@ background: var(--bg-subtle); } - .repository-access-form-heading > code { + .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); } 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 979b67110..f97322c0e 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 @@ @@ -402,12 +515,18 @@

Explicit overrides for Repository authentication and host identity.

- {#if accessProjection.bindings.length === 0} + {#if data.accessProjectionError} + + {:else if accessProjection && accessProjection.bindings.length === 0}
No explicit bindings

SSH Repositories can still use the Workspace default credential and a unique pinned key that matches their host and port.

- {:else} + {:else if accessProjection}
@@ -425,16 +544,18 @@
{/if} -
- Technical details -
-
Config revision
{accessProjection.config_revision}
-
Projection digest
{accessProjection.projection_digest}
-
-
+ {#if accessProjection} +
+ Technical details +
+
Config revision
{accessProjection.config_revision}
+
Projection digest
{accessProjection.projection_digest}
+
+
+ {/if} -
+

SSH credentials

@@ -442,8 +563,8 @@
{#if !data.credentialsError}
- - + +
{/if}
@@ -467,34 +588,37 @@
- + {#each credentials as credential (credential.credential_id)} - +
CredentialRoleFingerprintUsed byActions
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} - All SSH Repositories + Automatic for all SSH Repositories {:else if credential.referenced_repositories.length > 0} - {credential.referenced_repositories.join(', ')} + Explicit: {credential.referenced_repositories.join(', ')} {:else} - Not referenced + 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} @@ -511,38 +635,41 @@ {/if} {#if credentialForm === 'generate'} -
{ event.preventDefault(); void generateCredential(); }}> + { 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(); }}> + { 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); }}> + { event.preventDefault(); void rotateCredential(selectedCredential); }}>
-

Rotate {selectedCredential.name}

Used by {selectedCredential.referenced_repositories.join(', ') || 'no Repositories'}. Existing provider keys must be replaced with the new public key after rotation.

+

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} @@ -554,13 +681,13 @@ {/if} -
+

Pinned SSH host keys

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

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

{hostNotice.text}

{/if} @@ -577,19 +704,23 @@
- + {#each hostTrusts as hostTrust (hostTrust.host_trust_id)} - + - + {/each} @@ -602,18 +733,22 @@ {sshRepository ? `Check ${sshRepository.repository_key} SSH connection` : 'Open Repository settings'} {#if hostEditorOpen} - { event.preventDefault(); void saveHostTrust(); }}> + { event.preventDefault(); void saveHostTrust(); }}>
-

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

{hostExpectedRevision === null ? 'Confirm the host, port, and fingerprint against a trusted source before saving.' : 'The new key replaces the current trusted identity for this host and port.'}

- {#if hostExpectedRevision !== null}{hostTrusts.find((entry) => entry.host_trust_id === hostTrustId)?.fingerprint}{/if} +
+

{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} 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 a17e8bb5f..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 @@ -6,7 +6,7 @@ import { parseRepositorySshPublicKey, } from "$lib/workspace/api/repository-access"; import { - loadRepositoryAccessJson, + loadRepositoryAccessPermissionGate, loadRepositoryAccessSection, } from "$lib/workspace/api/repository-access-loader"; import type { PageLoad } from "./$types"; @@ -15,10 +15,11 @@ 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 [credentialsResult, hostTrustsResult] = await Promise.all([ loadRepositoryAccessSection( @@ -64,6 +65,7 @@ export const load: PageLoad = async ({ fetch, params }) => { defaultPublicKeyError: defaultPublicKeyResult.error, hostTrusts: hostTrustsResult.data ?? [], hostTrustsError: hostTrustsResult.error, - accessProjection, + 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..eea952d4c --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/repository-access/repository-access.browser.spec.ts @@ -0,0 +1,282 @@ +// @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() { + return { + workspaceId: "workspace-1", + credentials: [defaultCredential, deployCredential], + 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], + hostTrustsError: null, + accessProjection: { + workspace_id: "workspace-1", + 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 renderPage() { + const props = { + data: pageData(), + params: { workspaceId: "workspace-1" }, + } as unknown as PageProps; + return render(RepositoryAccessPage, props); +} + +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 submission without disabling unrelated controls and restores focus 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(false); + expect( + (screen.getByRole("button", { + name: "Add pinned key", + }) as HTMLButtonElement).disabled, + ).toBe(false); + await fireEvent.click(screen.getByRole("button", { name: "Generating…" })); + expect(fetchMock).toHaveBeenCalledTimes(1); + + 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(); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + expect(fetchMock).toHaveBeenCalledTimes(2); +}); + +test("keeps a deferred row failure on that row while unrelated actions 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 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); + 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); +}); + +test("locks a rotation to its host and port and discloses explicit and automatic impact", async () => { + const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => + Promise.resolve(Response.json({ + ...hostTrust, + host_key: "ssh-ed25519 AAAA-new", + fingerprint: "SHA256:host-new", + current_revision: 5, + })) + ); + 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(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 1df716f69..840e2882e 100644 --- a/web/workspace/test/repository-access/loader.test.ts +++ b/web/workspace/test/repository-access/loader.test.ts @@ -1,11 +1,92 @@ import { loadRepositoryAccessJson, + loadRepositoryAccessPermissionGate, loadRepositoryAccessSection, } from "../../src/lib/workspace/api/repository-access-loader.ts"; import { RepositoryAccessSchemaError } from "../../src/lib/workspace/api/repository-access.ts"; +import { load as loadRepositoryAccessPage } from "../../src/routes/w/[workspaceId]/settings/repository-access/+page.ts"; type HttpFailure = { status?: number; body?: { message?: string } }; +type LoadedPage = { + accessProjection: unknown; + accessProjectionError: string | null; + credentials: Array<{ credential_id: string }>; + credentialsError: string | null; + publicKeys: Array<{ credential_id: string }>; + defaultPublicKeyError: string | null; + hostTrusts: Array<{ host_trust_id: string }>; + hostTrustsError: string | null; +}; + +const projectionFixture = { + workspace_id: "workspace-1", + config_revision: 1, + projection_digest: "sha256:projection", + bindings: [], +}; +const credentialFixture = { + credential_id: "workspace-default", + workspace_id: "workspace-1", + name: "Workspace default", + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:credential", + current_revision: 1, + status: "active", + created_at: "2026-01-01T00:00:00Z", + rotated_at: null, + referenced_repositories: [], +}; +const publicKeyFixture = { + credential_id: "workspace-default", + current_revision: 1, + public_key_algorithm: "ssh-ed25519", + public_key_fingerprint: "SHA256:credential", + public_key: "ssh-ed25519 AAAA-workspace-default", +}; +const hostTrustFixture = { + host_trust_id: "github-com", + workspace_id: "workspace-1", + hostname: "github.com", + port: 22, + key_algorithm: "ssh-ed25519", + host_key: "ssh-ed25519 AAAA-host", + fingerprint: "SHA256:host", + current_revision: 1, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + referenced_repositories: [], +}; + +async function loadPageWithFailure(failedSuffix: string): Promise { + 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, @@ -30,6 +111,66 @@ async function captureHttpFailure( throw new Error(`expected bounded ${expectedStatus} error`); } +Deno.test("Repository Access page loader preserves successful siblings for every section failure", async () => { + const projectionFailure = await loadPageWithFailure( + "/settings/repository-access", + ); + if ( + projectionFailure.accessProjection !== null || + !projectionFailure.accessProjectionError + ) { + throw new Error( + "projection failure was not contained to Repository bindings", + ); + } + if ( + projectionFailure.credentials.length !== 1 || + projectionFailure.hostTrusts.length !== 1 + ) { + throw new Error("projection failure hid a successful sibling section"); + } + + const credentialFailure = await loadPageWithFailure("/credentials"); + if ( + credentialFailure.credentials.length !== 0 || + !credentialFailure.credentialsError + ) { + throw new Error("credential failure was not contained to SSH credentials"); + } + if ( + !credentialFailure.accessProjection || + credentialFailure.hostTrusts.length !== 1 + ) { + throw new Error("credential failure hid a successful sibling section"); + } + + const hostFailure = await loadPageWithFailure("/host-trusts"); + if (hostFailure.hostTrusts.length !== 0 || !hostFailure.hostTrustsError) { + throw new Error("host-trust failure was not contained to pinned host keys"); + } + if (!hostFailure.accessProjection || hostFailure.credentials.length !== 1) { + throw new Error("host-trust failure hid a successful sibling section"); + } + + const publicKeyFailure = await loadPageWithFailure( + "/credentials/workspace-default/public-key", + ); + if ( + publicKeyFailure.publicKeys.length !== 0 || + !publicKeyFailure.defaultPublicKeyError + ) { + throw new Error( + "public-key failure was not contained to its credential row", + ); + } + if ( + publicKeyFailure.credentials.length !== 1 || + publicKeyFailure.hostTrusts.length !== 1 + ) { + throw new Error("public-key failure hid successful sections"); + } +}); + for (const status of [401, 403]) { Deno.test(`Repository Access loader maps ${status} to bounded permission unavailable`, async () => { let requests = 0; @@ -93,6 +234,37 @@ Deno.test("Repository Access loader maps schema mismatch to explicit bounded 502 } }); +Deno.test("Repository Access permission gate contains projection failure so sibling sections can load", async () => { + const result = await loadRepositoryAccessPermissionGate( + () => Promise.resolve(new Response(null, { status: 503 })), + "/api/w/workspace-1/settings/repository-access", + (value) => value, + "Repository bindings", + ); + if (result.data !== null) { + throw new Error("failed permission projection must not expose data"); + } + if (result.error !== "Repository Access request failed with status 503.") { + throw new Error( + `unexpected contained projection error: ${String(result.error)}`, + ); + } +}); + +Deno.test("Repository Access permission gate still aborts on forbidden access", async () => { + await captureHttpFailure( + () => + loadRepositoryAccessPermissionGate( + () => Promise.resolve(new Response(null, { status: 403 })), + "/api/w/workspace-1/settings/repository-access", + (value) => value, + "Repository bindings", + ), + 403, + "Repository Access is unavailable for this account.", + ); +}); + Deno.test("Repository Access section loader contains one failed sibling", async () => { const result = await loadRepositoryAccessSection( () => Promise.resolve(new Response(null, { status: 503 })), diff --git a/web/workspace/test/repository-access/ui.test.ts b/web/workspace/test/repository-access/structure.test.ts similarity index 93% rename from web/workspace/test/repository-access/ui.test.ts rename to web/workspace/test/repository-access/structure.test.ts index f484b9536..67ecba005 100644 --- a/web/workspace/test/repository-access/ui.test.ts +++ b/web/workspace/test/repository-access/structure.test.ts @@ -67,6 +67,7 @@ test("Repository Access authorizes first and preserves independently available s "credentialsError", "hostTrustsError", "defaultPublicKeyError", + "accessProjectionError", ] ) { assert( @@ -143,7 +144,7 @@ test("Repository Access explains automatic application without inventing a bindi ); }); -test("Repository Access opens one scoped form with validation, cancellation, and pending fences", () => { +test("Repository Access keeps mutation forms disclosed and operation state scoped", () => { for ( const token of [ "credentialForm === 'generate'", @@ -156,16 +157,20 @@ test("Repository Access opens one scoped form with validation, cancellation, and "validateHostTrust", 'class="field-error"', "aria-invalid", - "disabled={busy}", + "pendingOperations", + "credentialFormNotice", + "credentialRowNotices", + "hostFormNotice", + "hostRowNotices", ">Cancel", - "Generating…", - "Importing…", - "Rotating…", - "Saving…", ] ) { - assert(source.includes(token), `missing scoped form behavior ${token}`); + assert(source.includes(token), `missing scoped form structure ${token}`); } + assert( + !source.includes("disabled={busy}") && !source.includes("activeOperation"), + "unrelated controls must not share page-wide mutation state", + ); const permanentFormStart = source.indexOf("
Host and portFingerprintUsed byActions
Host and portFingerprintRepository applicationActions
{hostTrust.hostname}:{hostTrust.port}{hostTrust.host_trust_id} {hostTrust.key_algorithm}{hostTrust.fingerprint}{hostTrust.referenced_repositories.join(', ') || 'Not referenced'}{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 hostTrust.referenced_repositories.length === 0}{/if}
- {#if hostTrust.referenced_repositories.length > 0}Remove Repository references before deletion.{/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}