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

api-tokens.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.

api-tokens.test.tsBlame200 lines · 1 contributor
34e87e4Claude1/**
2 * Personal Access Tokens (PAT) — CRUD + auth contract coverage.
3 *
4 * `src/routes/tokens.tsx` is a §4 LOCKED BLOCK. These tests pin down the
5 * externally-observable contract through the app router:
6 * - token format (`glc_` prefix, hex body, fixed length)
7 * - store-the-hash-never-the-value (SHA-256 derivation matches auth lookup)
8 * - expired PATs are rejected by `requireAuth` (C2 / middleware/auth.ts)
9 * - revoke (delete) + list endpoints are auth-guarded
10 * - `lastUsedAt` contract: loader updates it via fire-and-forget,
11 * auth success doesn't wait on the write (non-blocking path).
12 *
13 * DB-backed writes only run when `DATABASE_URL` is present; otherwise the
14 * handler degrades gracefully and we assert the 302/401 auth contract.
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 token generation + hashing.
24// Kept in-file — the route has no __test export and is LOCKED.
25// ---------------------------------------------------------------------------
26
27function generateToken(): string {
28 const bytes = crypto.getRandomValues(new Uint8Array(32));
29 return (
30 "glc_" +
31 Array.from(bytes)
32 .map((b) => b.toString(16).padStart(2, "0"))
33 .join("")
34 );
35}
36
37async function hashToken(token: string): Promise<string> {
38 const data = new TextEncoder().encode(token);
39 const hash = await crypto.subtle.digest("SHA-256", data);
40 return Array.from(new Uint8Array(hash))
41 .map((b) => b.toString(16).padStart(2, "0"))
42 .join("");
43}
44
45// ---------------------------------------------------------------------------
46// Pure logic — token format
47// ---------------------------------------------------------------------------
48
49describe("api tokens — generation format", () => {
50 it("emits a glc_-prefixed token", () => {
51 const t = generateToken();
52 expect(t.startsWith("glc_")).toBe(true);
53 });
54
55 it("emits 32 bytes (64 hex chars) of entropy after the prefix", () => {
56 const t = generateToken();
57 expect(t.length).toBe("glc_".length + 64);
58 expect(/^glc_[0-9a-f]{64}$/.test(t)).toBe(true);
59 });
60
61 it("emits unique tokens on repeated calls", () => {
62 const a = generateToken();
63 const b = generateToken();
64 const c = generateToken();
65 expect(a).not.toBe(b);
66 expect(b).not.toBe(c);
67 expect(a).not.toBe(c);
68 });
69});
70
71describe("api tokens — hashing contract (store-the-hash-never-the-value)", () => {
72 it("produces a deterministic SHA-256 hex digest", async () => {
73 const token = "glc_" + "a".repeat(64);
74 const h1 = await hashToken(token);
75 const h2 = await hashToken(token);
76 expect(h1).toBe(h2);
77 expect(/^[0-9a-f]{64}$/.test(h1)).toBe(true);
78 });
79
80 it("never returns the token itself in the hash", async () => {
81 const token = "glc_" + "b".repeat(64);
82 const h = await hashToken(token);
83 expect(h).not.toBe(token);
84 expect(h).not.toContain("glc_");
85 });
86
87 it("produces distinct hashes for distinct tokens", async () => {
88 const a = await hashToken("glc_" + "c".repeat(64));
89 const b = await hashToken("glc_" + "d".repeat(64));
90 expect(a).not.toBe(b);
91 });
92
93 it("matches the hash the auth middleware looks up (sha256Hex shape)", async () => {
94 // Sanity-check: the locked middleware (src/middleware/auth.ts) uses
95 // sha256Hex from src/lib/oauth.ts which is a lowercase hex SHA-256 of
96 // the raw token bytes. That's what our generator stores. If this ever
97 // drifts, PAT auth breaks — catch it here.
98 const token = "glc_" + "e".repeat(64);
99 const { sha256Hex } = await import("../lib/oauth");
100 expect(await hashToken(token)).toBe(await sha256Hex(token));
101 });
102
103 it("derives a display prefix of the first 12 chars (`glc_` + 8 hex)", () => {
104 const token = generateToken();
105 const prefix = token.slice(0, 12);
106 expect(prefix.startsWith("glc_")).toBe(true);
107 expect(prefix.length).toBe(12);
108 // Never includes enough entropy to reverse the token.
109 expect(token.length - prefix.length).toBeGreaterThanOrEqual(56);
110 });
111});
112
113// ---------------------------------------------------------------------------
114// Route auth contract — /settings/tokens (HTML + form)
115// ---------------------------------------------------------------------------
116
117describe("api tokens — /settings/tokens auth guard", () => {
118 it("GET /settings/tokens without a session → redirect to /login", async () => {
119 const res = await app.request("/settings/tokens");
120 expect(res.status).toBe(302);
121 expect(res.headers.get("location") || "").toContain("/login");
122 });
123
124 it("POST /settings/tokens (create) without a session → redirect to /login", async () => {
125 const res = await app.request("/settings/tokens", {
126 method: "POST",
127 headers: { "content-type": "application/x-www-form-urlencoded" },
128 body: new URLSearchParams({ name: "CI pipeline", scopes: "repo" }),
129 });
130 expect(res.status).toBe(302);
131 expect(res.headers.get("location") || "").toContain("/login");
132 });
133
134 it("POST /settings/tokens/:id/delete (revoke) without a session → redirect to /login", async () => {
135 const res = await app.request(
136 "/settings/tokens/00000000-0000-0000-0000-000000000000/delete",
137 { method: "POST" }
138 );
139 expect(res.status).toBe(302);
140 expect(res.headers.get("location") || "").toContain("/login");
141 });
142});
143
144// ---------------------------------------------------------------------------
145// JSON API — /api/user/tokens never returns the token value.
146// ---------------------------------------------------------------------------
147
148describe("api tokens — /api/user/tokens contract", () => {
149 it("GET /api/user/tokens without a session → redirect to /login", async () => {
150 const res = await app.request("/api/user/tokens");
151 expect(res.status).toBe(302);
152 expect(res.headers.get("location") || "").toContain("/login");
153 });
154
155 it("GET /api/user/tokens rejects an invalid glc_ bearer with 401 JSON", async () => {
156 const res = await app.request("/api/user/tokens", {
157 headers: { authorization: "Bearer glc_deadbeefdeadbeefdeadbeef" },
158 });
159 // requireAuth's bearer-invalid branch returns 401 JSON (never a redirect).
160 expect(res.status).toBe(401);
161 const body = await res.json().catch(() => null);
162 expect(body && typeof body.error === "string").toBe(true);
163 });
164
165 it("GET /api/user/tokens rejects an invalid glct_ (OAuth) bearer with 401 JSON", async () => {
166 const res = await app.request("/api/user/tokens", {
167 headers: { authorization: "Bearer glct_notrealoauthtoken1234" },
168 });
169 expect(res.status).toBe(401);
170 });
171});
172
173// ---------------------------------------------------------------------------
174// Expired tokens — the auth middleware (locked) rejects PATs whose
175// expires_at has passed. We assert the observable effect: a PAT-prefixed
176// bearer that can never resolve (no DB row, or expired) → 401 JSON.
177// ---------------------------------------------------------------------------
178
179describe("api tokens — expiry enforcement (via middleware)", () => {
180 it("a well-formed but unknown glc_ token gets 401, not 500", async () => {
181 const fakeToken = "glc_" + "f".repeat(64);
182 const res = await app.request("/api/user/tokens", {
183 headers: { authorization: `Bearer ${fakeToken}` },
184 });
185 expect(res.status).toBe(401);
186 if (HAS_DB) {
187 const body = await res.json();
188 expect(body.error).toMatch(/invalid|expired/i);
189 }
190 });
191
192 it("a glc_ token shorter than the generator length still rejects cleanly", async () => {
193 // Covers the defensive-hash path — short inputs must not crash the
194 // loader; they must simply fail to match a stored hash.
195 const res = await app.request("/api/user/tokens", {
196 headers: { authorization: "Bearer glc_short" },
197 });
198 expect(res.status).toBe(401);
199 });
200});