Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

ssh-keys.test.tsBlame197 lines · 1 contributor
34e87e4Claude1/**
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
17import { describe, it, expect } from "bun:test";
18import app from "../app";
19
20const 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
27function isValidSshKeyFormat(publicKey: string): boolean {
28 return (
29 publicKey.startsWith("ssh-rsa ") ||
30 publicKey.startsWith("ssh-ed25519 ") ||
31 publicKey.startsWith("ecdsa-sha2-")
32 );
33}
34
35async 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
51describe("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
85describe("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
110describe("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
145describe("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});