Blame · Line-by-line history
token-expiry.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.
| b9b9f22 | 1 | /** |
| 2 | * PAT expiration. | |
| 3 | * | |
| 4 | * The api_tokens.expires_at column has existed since the table was created, | |
| 5 | * but nothing ever wrote to it: the create form had no expiry control and the | |
| 6 | * POST handler never set the field, so every token ever issued was stored with | |
| 7 | * expires_at = NULL. All three auth paths read that as "never expires", so | |
| 8 | * PATs were effectively immortal and could not be rotated on a schedule. | |
| 9 | * | |
| 10 | * These cover the parsing rules and, crucially, that a rejected expiry never | |
| 11 | * degrades into "no expiration". | |
| 12 | */ | |
| 13 | ||
| 14 | import { describe, expect, it } from "bun:test"; | |
| 15 | import { parseExpiry, INVALID_EXPIRY } from "../routes/tokens"; | |
| 16 | ||
| 17 | const NOW = new Date("2026-07-27T09:00:00.000Z"); | |
| 18 | const DAY = 86_400_000; | |
| 19 | ||
| 20 | describe("parseExpiry — presets", () => { | |
| 21 | for (const days of [7, 30, 60, 90]) { | |
| 22 | it(`"${days}" → ${days} days from now`, () => { | |
| 23 | const got = parseExpiry(String(days), "", NOW); | |
| 24 | expect(got).toBeInstanceOf(Date); | |
| 25 | expect((got as Date).getTime()).toBe(NOW.getTime() + days * DAY); | |
| 26 | }); | |
| 27 | } | |
| 28 | ||
| 29 | it('"never" → null (no expiration)', () => { | |
| 30 | expect(parseExpiry("never", "", NOW)).toBeNull(); | |
| 31 | }); | |
| 32 | ||
| 33 | it("a blank choice defaults to 30 days, NOT to no-expiration", () => { | |
| 34 | const got = parseExpiry("", "", NOW); | |
| 35 | expect(got).toBeInstanceOf(Date); | |
| 36 | expect((got as Date).getTime()).toBe(NOW.getTime() + 30 * DAY); | |
| 37 | }); | |
| 38 | }); | |
| 39 | ||
| 40 | describe("parseExpiry — custom date", () => { | |
| 41 | it("accepts a future yyyy-mm-dd and anchors to end-of-day UTC", () => { | |
| 42 | const got = parseExpiry("custom", "2026-08-15", NOW); | |
| 43 | expect(got).toBeInstanceOf(Date); | |
| 44 | expect((got as Date).toISOString()).toBe("2026-08-15T23:59:59.999Z"); | |
| 45 | }); | |
| 46 | ||
| 47 | it("end-of-day anchoring keeps today's date usable, not instantly dead", () => { | |
| 48 | // A user picking "today" should get the rest of the day, not a token that | |
| 49 | // expired at 00:00 this morning. | |
| 50 | const got = parseExpiry("custom", "2026-07-27", NOW); | |
| 51 | expect(got).toBeInstanceOf(Date); | |
| 52 | expect((got as Date).getTime()).toBeGreaterThan(NOW.getTime()); | |
| 53 | }); | |
| 54 | ||
| 55 | it("rejects a past date", () => { | |
| 56 | expect(parseExpiry("custom", "2026-07-01", NOW)).toBe(INVALID_EXPIRY); | |
| 57 | }); | |
| 58 | ||
| 59 | it("rejects a blank custom date", () => { | |
| 60 | expect(parseExpiry("custom", "", NOW)).toBe(INVALID_EXPIRY); | |
| 61 | }); | |
| 62 | ||
| 63 | it("rejects garbage", () => { | |
| 64 | expect(parseExpiry("custom", "not-a-date", NOW)).toBe(INVALID_EXPIRY); | |
| 65 | }); | |
| 66 | }); | |
| 67 | ||
| 68 | describe("parseExpiry — rejected input never becomes 'no expiration'", () => { | |
| 69 | // The whole point of the INVALID_EXPIRY sentinel. If these returned null the | |
| 70 | // caller would happily mint an immortal token from input it had rejected. | |
| 71 | for (const bad of ["0", "-30", "1.5", "abc"]) { | |
| 72 | it(`"${bad}" is rejected, not treated as never-expires`, () => { | |
| 73 | const got = parseExpiry(bad, "", NOW); | |
| 74 | expect(got).toBe(INVALID_EXPIRY); | |
| 75 | expect(got).not.toBeNull(); | |
| 76 | }); | |
| 77 | } | |
| 78 | }); | |
| 79 | ||
| 80 | describe("expiry enforcement contract", () => { | |
| 81 | it("all three auth paths null-guard expiresAt before comparing", async () => { | |
| 82 | // Guards against a regression where someone drops the `row.expiresAt &&` | |
| 83 | // check: `new Date(null) < new Date()` is 1970 < now === true, which would | |
| 84 | // instantly reject every no-expiration token in existence. | |
| 85 | const { readFileSync } = await import("fs"); | |
| 86 | const paths = [ | |
| 87 | "src/middleware/api-auth.ts", | |
| 88 | "src/middleware/auth.ts", | |
| 89 | "src/lib/git-push-auth.ts", | |
| 90 | ]; | |
| 91 | for (const p of paths) { | |
| 92 | const src = readFileSync(p, "utf8"); | |
| 93 | expect(src).toContain("expiresAt &&"); | |
| 94 | } | |
| 95 | }); | |
| 96 | }); |