CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
ssh-keys.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 34e87e4 | 1 | /** |
| 2 | * SSH keys — CRUD coverage. | |
| 3 | * | |
| 4 | * `src/routes/settings.tsx` is a §4 LOCKED BLOCK, so these tests exercise the | |
| 5 | * public HTTP surface via the app router and assert behavioural contracts | |
| 6 | * (auth guard, accepted key formats, ownership enforcement, list shape) that | |
| 7 | * must be preserved. All mutations are guarded by `requireAuth`, so without a | |
| 8 | * real session the endpoints redirect to `/login` — that redirect is itself | |
| 9 | * the auth-contract we test. DB-backed side-effects only execute when a | |
| 10 | * `DATABASE_URL` is present; otherwise the handler degrades gracefully. | |
| 11 | * | |
| 12 | * Pure-logic tests (fingerprint derivation, key-format validator) replicate | |
| 13 | * the same algorithms used inside the locked route so we catch regressions | |
| 14 | * if the wire format ever changes without adding a test fixture. | |
| 15 | */ | |
| 16 | ||
| 17 | import { describe, it, expect } from "bun:test"; | |
| 18 | import app from "../app"; | |
| 19 | ||
| 20 | const HAS_DB = Boolean(process.env.DATABASE_URL); | |
| 21 | ||
| 22 | // --------------------------------------------------------------------------- | |
| 23 | // Pure helpers mirroring the locked route's validation logic. Kept in-file | |
| 24 | // rather than imported (route is LOCKED, no __test export available). | |
| 25 | // --------------------------------------------------------------------------- | |
| 26 | ||
| 27 | function isValidSshKeyFormat(publicKey: string): boolean { | |
| 28 | return ( | |
| 29 | publicKey.startsWith("ssh-rsa ") || | |
| 30 | publicKey.startsWith("ssh-ed25519 ") || | |
| 31 | publicKey.startsWith("ecdsa-sha2-") | |
| 32 | ); | |
| 33 | } | |
| 34 | ||
| 35 | async function computeFingerprint(publicKey: string): Promise<string> { | |
| 36 | const keyData = publicKey.split(" ")[1] || ""; | |
| 37 | const hashBuffer = await crypto.subtle.digest( | |
| 38 | "SHA-256", | |
| 39 | new TextEncoder().encode(keyData) | |
| 40 | ); | |
| 41 | return ( | |
| 42 | "SHA256:" + | |
| 43 | btoa(String.fromCharCode(...new Uint8Array(hashBuffer))).replace(/=+$/, "") | |
| 44 | ); | |
| 45 | } | |
| 46 | ||
| 47 | // --------------------------------------------------------------------------- | |
| 48 | // Pure logic | |
| 49 | // --------------------------------------------------------------------------- | |
| 50 | ||
| 51 | describe("ssh keys — accepted public-key formats", () => { | |
| 52 | it("accepts ssh-ed25519 keys", () => { | |
| 53 | expect( | |
| 54 | isValidSshKeyFormat( | |
| 55 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEKEYBYTES alice@laptop" | |
| 56 | ) | |
| 57 | ).toBe(true); | |
| 58 | }); | |
| 59 | ||
| 60 | it("accepts ssh-rsa keys", () => { | |
| 61 | expect(isValidSshKeyFormat("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAB")).toBe( | |
| 62 | true | |
| 63 | ); | |
| 64 | }); | |
| 65 | ||
| 66 | it("accepts ecdsa-sha2-* keys", () => { | |
| 67 | expect( | |
| 68 | isValidSshKeyFormat("ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlz") | |
| 69 | ).toBe(true); | |
| 70 | }); | |
| 71 | ||
| 72 | it("rejects malformed / unsupported key prefixes", () => { | |
| 73 | expect(isValidSshKeyFormat("")).toBe(false); | |
| 74 | expect(isValidSshKeyFormat("not-a-key")).toBe(false); | |
| 75 | expect(isValidSshKeyFormat("ssh-dss AAAAB3NzaC1kc3M=")).toBe(false); | |
| 76 | // Case-sensitive prefix check — uppercase should be rejected. | |
| 77 | expect(isValidSshKeyFormat("SSH-RSA AAAA")).toBe(false); | |
| 78 | }); | |
| 79 | ||
| 80 | it("rejects keys with leading whitespace (contract preserves strict prefix)", () => { | |
| 81 | expect(isValidSshKeyFormat(" ssh-ed25519 AAAA")).toBe(false); | |
| 82 | }); | |
| 83 | }); | |
| 84 | ||
| 85 | describe("ssh keys — fingerprint shape", () => { | |
| 86 | it("produces a SHA256:… fingerprint without padding", async () => { | |
| 87 | const fp = await computeFingerprint( | |
| 88 | "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLEBYTESHERE" | |
| 89 | ); | |
| 90 | expect(fp.startsWith("SHA256:")).toBe(true); | |
| 91 | expect(fp.endsWith("=")).toBe(false); | |
| 92 | }); | |
| 93 | ||
| 94 | it("is deterministic for the same key body", async () => { | |
| 95 | const k = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEDETERMINISTICBYTES"; | |
| 96 | expect(await computeFingerprint(k)).toBe(await computeFingerprint(k)); | |
| 97 | }); | |
| 98 | ||
| 99 | it("differs across distinct key bodies", async () => { | |
| 100 | const a = await computeFingerprint("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAA"); | |
| 101 | const b = await computeFingerprint("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5BBBBB"); | |
| 102 | expect(a).not.toBe(b); | |
| 103 | }); | |
| 104 | }); | |
| 105 | ||
| 106 | // --------------------------------------------------------------------------- | |
| 107 | // Route auth contract (no session cookie) | |
| 108 | // --------------------------------------------------------------------------- | |
| 109 | ||
| 110 | describe("ssh keys — /settings/keys auth guard", () => { | |
| 111 | it("GET /settings/keys without a session → redirect to /login", async () => { | |
| 112 | const res = await app.request("/settings/keys"); | |
| 113 | expect(res.status).toBe(302); | |
| 114 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 115 | }); | |
| 116 | ||
| 117 | it("POST /settings/keys (add) without a session → redirect to /login", async () => { | |
| 118 | const res = await app.request("/settings/keys", { | |
| 119 | method: "POST", | |
| 120 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 121 | body: new URLSearchParams({ | |
| 122 | title: "laptop", | |
| 123 | public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE alice", | |
| 124 | }), | |
| 125 | }); | |
| 126 | expect(res.status).toBe(302); | |
| 127 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 128 | }); | |
| 129 | ||
| 130 | it("POST /settings/keys/:id/delete without a session → redirect to /login", async () => { | |
| 131 | const res = await app.request( | |
| 132 | "/settings/keys/00000000-0000-0000-0000-000000000000/delete", | |
| 133 | { method: "POST" } | |
| 134 | ); | |
| 135 | expect(res.status).toBe(302); | |
| 136 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 137 | }); | |
| 138 | }); | |
| 139 | ||
| 140 | // --------------------------------------------------------------------------- | |
| 141 | // JSON API (Authorization-less) — same contract, but these live under | |
| 142 | // /api/user/keys where the redirect target is still /login. | |
| 143 | // --------------------------------------------------------------------------- | |
| 144 | ||
| 145 | describe("ssh keys — /api/user/keys auth guard", () => { | |
| 146 | it("GET /api/user/keys without a session → redirect to /login (302)", async () => { | |
| 147 | const res = await app.request("/api/user/keys"); | |
| 148 | expect(res.status).toBe(302); | |
| 149 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 150 | }); | |
| 151 | ||
| 152 | it("POST /api/user/keys without a session → redirect to /login (302)", async () => { | |
| 153 | const res = await app.request("/api/user/keys", { | |
| 154 | method: "POST", | |
| 155 | headers: { "content-type": "application/json" }, | |
| 156 | body: JSON.stringify({ | |
| 157 | title: "laptop", | |
| 158 | public_key: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAI alice", | |
| 159 | }), | |
| 160 | }); | |
| 161 | expect(res.status).toBe(302); | |
| 162 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 163 | }); | |
| 164 | ||
| 165 | it("DELETE /api/user/keys/:id without a session → redirect to /login (302)", async () => { | |
| 166 | const res = await app.request( | |
| 167 | "/api/user/keys/00000000-0000-0000-0000-000000000000", | |
| 168 | { method: "DELETE" } | |
| 169 | ); | |
| 170 | expect(res.status).toBe(302); | |
| 171 | expect(res.headers.get("location") || "").toContain("/login"); | |
| 172 | }); | |
| 173 | ||
| 174 | it("POST /api/user/keys rejects an invalid PAT bearer with 401 JSON", async () => { | |
| 175 | // `requireAuth` must 401 for Bearer tokens (JSON), NOT redirect. | |
| 176 | const res = await app.request("/api/user/keys", { | |
| 177 | method: "POST", | |
| 178 | headers: { | |
| 179 | "content-type": "application/json", | |
| 180 | authorization: "Bearer glc_notarealtoken1234567890", | |
| 181 | }, | |
| 182 | body: JSON.stringify({ | |
| 183 | title: "ci", | |
| 184 | public_key: "ssh-ed25519 AAAA", | |
| 185 | }), | |
| 186 | }); | |
| 187 | if (HAS_DB) { | |
| 188 | expect(res.status).toBe(401); | |
| 189 | const body = await res.json().catch(() => null); | |
| 190 | expect(body && body.error).toBeTruthy(); | |
| 191 | } else { | |
| 192 | // Without a DB the PAT loader catches and returns null; requireAuth | |
| 193 | // then returns 401 JSON from its invalid-bearer branch. | |
| 194 | expect(res.status).toBe(401); | |
| 195 | } | |
| 196 | }); | |
| 197 | }); |