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.
| 36cc17a | 1 | /** |
| 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 | ||
| 33 | import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test"; | |
| 34 | ||
| 35 | const _real_db = await import("../db"); | |
| 36 | const _real_cache = await import("../lib/cache"); | |
| 37 | ||
| 38 | // --- per-test row hooks ---------------------------------------------------- | |
| 39 | let _nextSessionRow: any = null; | |
| 40 | let _nextUserRow: any = null; | |
| 41 | let _lastSelectFrom: any = null; | |
| 42 | ||
| 43 | const 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 | ||
| 51 | const _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 | ||
| 74 | const _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 | ||
| 91 | mock.module("../db", () => ({ ..._real_db, ..._fakeDb })); | |
| 92 | ||
| 93 | // Import AFTER the mock is installed so the app graph picks up our fake db. | |
| 94 | const { default: app } = await import("../app"); | |
| 95 | const { sessionCache } = await import("../lib/cache"); | |
| 96 | ||
| 97 | // --- fixtures -------------------------------------------------------------- | |
| 98 | const USER_ID = "44444444-4444-4444-4444-444444444444"; | |
| 99 | const SESSION_TOKEN = "test-session-token-layout-userprop"; | |
| 100 | ||
| 101 | const 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 | ||
| 112 | const TEST_SESSION = { | |
| 113 | userId: USER_ID, | |
| 114 | token: SESSION_TOKEN, | |
| 115 | expiresAt: new Date(Date.now() + 60 * 60 * 1000), | |
| 116 | requires2fa: false, | |
| 117 | }; | |
| 118 | ||
| 119 | function authedHeaders(): HeadersInit { | |
| 120 | return { cookie: `session=${SESSION_TOKEN}` }; | |
| 121 | } | |
| 122 | ||
| 123 | beforeEach(() => { | |
| 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 | ||
| 131 | afterAll(() => { | |
| 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 --------------------------------------------------------------- | |
| 141 | const LOGGED_OUT_NAV_MARKER = `href="/login" class="nav-link"`; | |
| 142 | ||
| 143 | function 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 ----------------------------------------------------------------- | |
| 153 | describe("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 | ||
| 191 | describe("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 | }); |