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

layout-user-prop.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.

layout-user-prop.test.tsBlame224 lines · 1 contributor
36cc17aClaude1/**
2 * Regression guard for the "29 routes render Layout without user= prop" bug.
3 *
4 * Before the fix, several Hono handlers built `<Layout title="..." />` without
5 * forwarding the `user` prop pulled from `c.get("user")`. The Layout's nav
6 * fell back to the logged-out shell ("Sign in" / "Register" links) even when
7 * the request was authenticated — so every authed user saw themselves as
8 * signed-out in the top-right nav. The owner caught this in a prod screenshot.
9 *
10 * This test hits a handful of authed routes with a fake session and asserts:
11 * 1. The response HTML contains the authed user's username/display name in
12 * the `.nav-user` slot (proves Layout received user=).
13 * 2. The response HTML does NOT contain the logged-out anchors
14 * (`href="/login" class="nav-link">Sign in`) — those only render when
15 * Layout's `user` prop is null/undefined.
16 *
17 * Plus two extras:
18 * 3. /login and /register, when hit with an active session, redirect away
19 * (302 -> /dashboard or the `redirect=` target) instead of rendering the
20 * sign-in shell over an authed session.
21 *
22 * Mock isolation
23 * --------------
24 * Bun's `mock.module()` is process-global. Like `mcp-write.test.ts`, we:
25 * - Capture the real module before mocking so afterAll can restore.
26 * - Mock ONLY `../db` (the one external resource), spreading the real
27 * export so any non-mocked surface stays untouched.
28 * - Restore the mock and clear `sessionCache` in afterAll.
29 * - Reset per-test row hooks in beforeEach so nothing leaks between tests
30 * in this file or downstream files.
31 */
32
33import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
34
35const _real_db = await import("../db");
36const _real_cache = await import("../lib/cache");
37
38// --- per-test row hooks ----------------------------------------------------
39let _nextSessionRow: any = null;
40let _nextUserRow: any = null;
41let _lastSelectFrom: any = null;
42
43const tableName = (t: any): string => {
44 if (!t || typeof t !== "object") return "?";
45 if ("token" in t && "expiresAt" in t && "userId" in t) return "sessions";
46 if ("passwordHash" in t && "username" in t) return "users";
47 if ("fingerprint" in t || "publicKey" in t) return "ssh_keys";
48 return "?";
49};
50
51const _selectChain: any = {
52 from: (t: any) => {
53 _lastSelectFrom = t;
54 return _selectChain;
55 },
56 innerJoin: () => _selectChain,
57 leftJoin: () => _selectChain,
58 where: () => _selectChain,
59 orderBy: () => _selectChain,
60 limit: async () => {
61 const name = tableName(_lastSelectFrom);
62 if (name === "sessions") return _nextSessionRow ? [_nextSessionRow] : [];
63 if (name === "users") return _nextUserRow ? [_nextUserRow] : [];
64 return [];
65 },
66 then: (resolve: (v: any) => void) => {
67 // Unbounded `await db.select()...` (no .limit()) — used by /settings/keys
68 // listing SSH keys for the user. Empty array renders the "No SSH keys
69 // yet." empty state, which is exactly what we want.
70 resolve([]);
71 },
72};
73
74const _fakeDb = {
75 db: {
76 select: () => _selectChain,
77 insert: () => ({
78 values: () => ({
79 returning: async () => [],
80 then: (r: (v: any) => void) => r(undefined),
81 }),
82 }),
83 update: () => ({
84 set: () => ({ where: () => Promise.resolve() }),
85 }),
86 delete: () => ({ where: () => Promise.resolve() }),
87 },
88 getDb: () => _fakeDb.db,
89};
90
91mock.module("../db", () => ({ ..._real_db, ..._fakeDb }));
92
93// Import AFTER the mock is installed so the app graph picks up our fake db.
94const { default: app } = await import("../app");
95const { sessionCache } = await import("../lib/cache");
96
97// --- fixtures --------------------------------------------------------------
98const USER_ID = "44444444-4444-4444-4444-444444444444";
99const SESSION_TOKEN = "test-session-token-layout-userprop";
100
101const TEST_USER = {
102 id: USER_ID,
103 username: "regression_user",
104 displayName: "Regression User",
105 email: "reg@example.com",
106 passwordHash: "x",
107 bio: null,
108 createdAt: new Date(),
109 updatedAt: new Date(),
110};
111
112const TEST_SESSION = {
113 userId: USER_ID,
114 token: SESSION_TOKEN,
115 expiresAt: new Date(Date.now() + 60 * 60 * 1000),
116 requires2fa: false,
117};
118
119function authedHeaders(): HeadersInit {
120 return { cookie: `session=${SESSION_TOKEN}` };
121}
122
123beforeEach(() => {
124 _nextSessionRow = TEST_SESSION;
125 _nextUserRow = TEST_USER;
126 // Pre-warm the soft-auth cache so handlers using `softAuth` skip the DB
127 // path entirely and pick up our user from in-memory state.
128 sessionCache.set(SESSION_TOKEN, TEST_USER as any);
129});
130
131afterAll(() => {
132 // Drop the cache entry so downstream test files don't inherit a "logged
133 // in" session token they didn't create.
134 sessionCache.invalidate(SESSION_TOKEN);
135 _nextSessionRow = null;
136 _nextUserRow = null;
137 mock.module("../db", () => _real_db);
138});
139
140// --- helpers ---------------------------------------------------------------
141const LOGGED_OUT_NAV_MARKER = `href="/login" class="nav-link"`;
142
143function assertAuthedNav(html: string) {
144 // The Layout nav renders `<a href={`/${user.username}`} class="nav-user">`
145 // with the user's display name when `user` is present. If the prop is
146 // missing the literal "Sign in" link shows up instead.
147 expect(html).toContain('class="nav-user"');
148 expect(html).toContain(TEST_USER.displayName);
149 expect(html).not.toContain(LOGGED_OUT_NAV_MARKER);
150}
151
152// --- tests -----------------------------------------------------------------
153describe("Layout user= prop is forwarded on authed routes", () => {
154 it("/settings renders the user nav (was missing user= before fix)", async () => {
155 const res = await app.request("/settings", { headers: authedHeaders() });
156 expect(res.status).toBe(200);
157 const body = await res.text();
158 assertAuthedNav(body);
159 });
160
161 it("/settings/keys renders the user nav", async () => {
162 const res = await app.request("/settings/keys", {
163 headers: authedHeaders(),
164 });
165 expect(res.status).toBe(200);
166 const body = await res.text();
167 assertAuthedNav(body);
168 });
169
170 it("/new (new repo form) renders the user nav", async () => {
171 const res = await app.request("/new", { headers: authedHeaders() });
172 expect(res.status).toBe(200);
173 const body = await res.text();
174 assertAuthedNav(body);
175 });
176
177 it("global 404 page renders the user nav when authed", async () => {
178 // Pick a path with enough segments that no route claims it, so we end
179 // up in app.notFound. The `/:owner` and `/:owner/:repo` patterns greedy-
180 // match shorter paths; deeper paths land in the 404 handler.
181 const res = await app.request(
182 "/__nope__/__nope__/__nope__/__nope__/__nope__",
183 { headers: authedHeaders() }
184 );
185 expect(res.status).toBe(404);
186 const body = await res.text();
187 assertAuthedNav(body);
188 });
189});
190
191describe("auth landing pages bounce already-authed users", () => {
192 // The owner's call: /login and /register should NOT render the logged-out
193 // sign-in shell while a valid session cookie is present. They redirect.
194
195 it("/login with a live session 302s to /dashboard", async () => {
196 const res = await app.request("/login", { headers: authedHeaders() });
197 expect(res.status).toBe(302);
198 expect(res.headers.get("location")).toBe("/dashboard");
199 });
200
201 it("/login?redirect=/foo honours the redirect target when authed", async () => {
202 const res = await app.request("/login?redirect=%2Ffoo%2Fbar", {
203 headers: authedHeaders(),
204 });
205 expect(res.status).toBe(302);
206 // Hono decodes query params; location is the decoded path.
207 expect(res.headers.get("location")).toBe("/foo/bar");
208 });
209
210 it("/register with a live session 302s to /dashboard", async () => {
211 const res = await app.request("/register", { headers: authedHeaders() });
212 expect(res.status).toBe(302);
213 expect(res.headers.get("location")).toBe("/dashboard");
214 });
215
216 it("/login WITHOUT a session still renders the sign-in shell", async () => {
217 // Empty cache + no cookie → softAuth sets user=null → page renders.
218 const res = await app.request("/login");
219 expect(res.status).toBe(200);
220 const body = await res.text();
221 // Logged-out nav literal is present on the actual sign-in page.
222 expect(body).toContain(LOGGED_OUT_NAV_MARKER);
223 });
224});