also looking at this
test(auth): pin the browser session contract, and gate the deploy
#14
4 files
+400
-8
| @@ -48,6 +48,22 @@ jobs: | ||
| 48 | 48 | - name: Install dependencies |
| 49 | 49 | run: bun install --frozen-lockfile |
| 50 | 50 | |
| 51 | # The suite runs here, not as a `needs:` on the CI workflow: `needs:` | |
| 52 | # only reaches jobs in the same file, and lint/typecheck/test live in | |
| 53 | # ci.yml. Without this, a push whose tests fail still deploys - as | |
| 54 | # aac396460 did on 2026-09-24, where CI's test job failed and this | |
| 55 | # workflow succeeded on the same sha, in the same minute. The whole | |
| 56 | # suite costs a few seconds, so gating here is cheaper than a | |
| 57 | # workflow_run indirection. | |
| 58 | - name: Lint | |
| 59 | run: ./buddy lint | |
| 60 | ||
| 61 | - name: Typecheck | |
| 62 | run: ./buddy typecheck | |
| 63 | ||
| 64 | - name: Test | |
| 65 | run: bun run test | |
| 66 | ||
| 51 | 67 | - name: Build the production artifact |
| 52 | 68 | run: bun run build |
| 53 | 69 | |
| @@ -89,6 +105,14 @@ jobs: | ||
| 89 | 105 | DOTENV_PRIVATE_KEY_PRODUCTION: ${{ secrets.DOTENV_PRIVATE_KEY_PRODUCTION }} |
| 90 | 106 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 91 | 107 | |
| 108 | # SMOKE_EMAIL / SMOKE_PASSWORD turn on the sign-in leg: a real POST /login | |
| 109 | # followed by an authenticated GET /api/v1/workspace. Without them the | |
| 110 | # script still runs and says it skipped that leg, so this step does not | |
| 111 | # fail before the secrets exist. The account must belong to a team and | |
| 112 | # must not have two-factor enabled. | |
| 92 | 113 | - name: Smoke test production |
| 93 | 114 | if: github.event_name == 'push' || github.event.inputs.dry_run == 'false' |
| 94 | 115 | run: bun run deployment:smoke |
| 116 | env: | |
| 117 | SMOKE_EMAIL: ${{ secrets.SMOKE_EMAIL }} | |
| 118 | SMOKE_PASSWORD: ${{ secrets.SMOKE_PASSWORD }} | |
| @@ -50,14 +50,107 @@ async function readWithRetry(path: string): Promise<string> { | ||
| 50 | 50 | throw new Error(`${baseUrl}${path} failed after ${attempts} attempts: ${lastFailure}`) |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | const home = await readWithRetry('/') | |
| 54 | if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`) | |
| 53 | /** | |
| 54 | * The token out of a `Set-Cookie` header, or a failure that says which half of | |
| 55 | * the contract broke. | |
| 56 | * | |
| 57 | * Exported so the parsing can be tested without a network round trip; the live | |
| 58 | * checks below only run under `import.meta.main`. | |
| 59 | */ | |
| 60 | export function assertSessionCookie(setCookie: string | null | undefined): string { | |
| 61 | if (!setCookie) | |
| 62 | throw new Error('POST /login returned no Set-Cookie header, so the sign-in issued no session.') | |
| 55 | 63 | |
| 56 | const login = await readWithRetry('/login') | |
| 57 | if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.') | |
| 64 | const match = /(?:^|,\s*)auth-token=([^;,\s]*)/.exec(setCookie) | |
| 65 | if (!match) | |
| 66 | throw new Error(`POST /login set no auth-token cookie. Set-Cookie was: ${setCookie.slice(0, 200)}`) | |
| 67 | if (!match[1]) | |
| 68 | throw new Error('POST /login set an empty auth-token cookie.') | |
| 58 | 69 | |
| 59 | const robots = await readWithRetry('/robots.txt') | |
| 60 | if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.') | |
| 61 | if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`) | |
| 70 | return decodeURIComponent(match[1]) | |
| 71 | } | |
| 72 | ||
| 73 | /** | |
| 74 | * The attributes a deployed session cookie must carry. | |
| 75 | * | |
| 76 | * `Secure` is checked because the framework decides it from `config.app.url`, | |
| 77 | * which falls back to `commshq.localhost` — a loopback host, which drops the | |
| 78 | * flag. A misconfigured or undecryptable APP_URL would therefore ship a | |
| 79 | * long-lived session token in the clear, and nothing else would notice. | |
| 80 | */ | |
| 81 | export function assertSessionCookieAttributes(setCookie: string): void { | |
| 82 | const required = ['HttpOnly', 'Path=/', 'SameSite=Lax', 'Secure'] | |
| 83 | const missing = required.filter(attribute => !new RegExp(`(?:^|;\\s*)${attribute}`, 'i').test(setCookie)) | |
| 84 | ||
| 85 | if (missing.length) | |
| 86 | throw new Error(`The deployed auth-token cookie is missing ${missing.join(', ')}. Set-Cookie was: ${setCookie.slice(0, 200)}`) | |
| 87 | ||
| 88 | if (!/(?:^|;\s*)Max-Age=[1-9]\d*/i.test(setCookie)) | |
| 89 | throw new Error(`The deployed auth-token cookie carries no positive Max-Age. Set-Cookie was: ${setCookie.slice(0, 200)}`) | |
| 90 | } | |
| 91 | ||
| 92 | /** | |
| 93 | * Sign in for real, then spend the session on an authenticated route. | |
| 94 | * | |
| 95 | * Static page checks pass with sign-in completely broken, which is how a | |
| 96 | * schema or session regression reaches production unnoticed. `/api/v1/workspace` | |
| 97 | * is the only auth-guarded GET in routes/api.ts, so it is the one route that can | |
| 98 | * be exercised without a side effect. | |
| 99 | * | |
| 100 | * Deliberately not retried: POST /login is rate limited to 5/minute | |
| 101 | * (routes/auth.ts:23), and a retry loop would spend that budget and report a | |
| 102 | * 429 as a deploy failure. | |
| 103 | */ | |
| 104 | async function assertSignInWorks(): Promise<boolean> { | |
| 105 | const email = process.env.SMOKE_EMAIL | |
| 106 | const password = process.env.SMOKE_PASSWORD | |
| 107 | ||
| 108 | if (!email || !password) { | |
| 109 | console.log(' sign-in leg skipped: SMOKE_EMAIL and SMOKE_PASSWORD are not set.') | |
| 110 | return false | |
| 111 | } | |
| 112 | ||
| 113 | const signIn = await fetch(`${baseUrl}/login`, { | |
| 114 | method: 'POST', | |
| 115 | headers: { 'content-type': 'application/json', 'accept': 'application/json' }, | |
| 116 | body: JSON.stringify({ email, password }), | |
| 117 | redirect: 'manual', | |
| 118 | signal: AbortSignal.timeout(15_000), | |
| 119 | }) | |
| 120 | ||
| 121 | if (signIn.status !== 200) | |
| 122 | throw new Error(`POST /login returned ${signIn.status}, expected 200. A 422 usually means the smoke credentials are wrong; a 429 means the rate limit was already spent.`) | |
| 123 | ||
| 124 | const setCookie = signIn.headers.get('set-cookie') | |
| 125 | if (!setCookie && /twoFactor|two_factor|challenge/i.test(await signIn.clone().text())) | |
| 126 | throw new Error('POST /login answered with a two-factor challenge instead of a session. The smoke account must not have two-factor enabled.') | |
| 62 | 127 | |
| 63 | console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, and robots metadata are live.`) | |
| 128 | const token = assertSessionCookie(setCookie) | |
| 129 | assertSessionCookieAttributes(setCookie as string) | |
| 130 | ||
| 131 | const workspace = await fetch(`${baseUrl}/api/v1/workspace`, { | |
| 132 | headers: { accept: 'application/json', cookie: `auth-token=${encodeURIComponent(token)}` }, | |
| 133 | signal: AbortSignal.timeout(15_000), | |
| 134 | }) | |
| 135 | ||
| 136 | if (workspace.status !== 200) | |
| 137 | throw new Error(`GET /api/v1/workspace returned ${workspace.status} for a freshly issued session. A 401 means the cookie was not accepted; a 403 means the smoke account belongs to no team.`) | |
| 138 | ||
| 139 | return true | |
| 140 | } | |
| 141 | ||
| 142 | if (import.meta.main) { | |
| 143 | const home = await readWithRetry('/') | |
| 144 | if (!home.includes(contract.homeText)) throw new Error(`Homepage does not contain the expected release marker: ${contract.homeText}`) | |
| 145 | ||
| 146 | const login = await readWithRetry('/login') | |
| 147 | if (!/<(?:form|main)\b/i.test(login)) throw new Error('Login smoke response does not contain an application form or main region.') | |
| 148 | ||
| 149 | const robots = await readWithRetry('/robots.txt') | |
| 150 | if (robots.includes('http://localhost')) throw new Error('Deployed robots.txt contains a localhost URL.') | |
| 151 | if (!robots.includes(new URL(baseUrl).hostname)) throw new Error(`Deployed robots.txt does not name ${new URL(baseUrl).hostname}.`) | |
| 152 | ||
| 153 | const signedIn = await assertSignInWorks() | |
| 154 | ||
| 155 | console.log(`Deployment smoke passed for ${baseUrl}: homepage, login, ${signedIn ? 'a real sign-in, ' : ''}and robots metadata are live.`) | |
| 156 | } | |
| @@ -0,0 +1,209 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { | |
| 3 | authCookieForBrowserSession, | |
| 4 | browserSessionRemembered, | |
| 5 | resolveBrowserSessionPolicy, | |
| 6 | shouldSecureAuthCookie, | |
| 7 | } from '@stacksjs/auth' | |
| 8 | import { buildAuthCookie, sessionExpiryMinutes } from '../../app/Actions/Auth/authCookie' | |
| 9 | ||
| 10 | /** | |
| 11 | * Characterization tests for the browser session contract. | |
| 12 | * | |
| 13 | * These pin what a signed-in commshq user experiences TODAY, before the | |
| 14 | * hand-rolled auth overrides in `app/Actions/Auth/` are deleted in favour of | |
| 15 | * the framework's browser-session policy (stacksjs/stacks#2795). They are | |
| 16 | * written against both sides at once: the app's current helpers and the | |
| 17 | * framework functions that will replace them. Every assertion must keep | |
| 18 | * passing after the swap, which is the whole point - the moment the framework | |
| 19 | * would issue a different cookie or a different session length, one of these | |
| 20 | * fails instead of a user being silently signed out or silently downgraded. | |
| 21 | * | |
| 22 | * Nothing here touches the global config: every framework call is handed an | |
| 23 | * explicit config object, so the results do not depend on which .env the | |
| 24 | * suite happens to load. | |
| 25 | */ | |
| 26 | ||
| 27 | /** What `config/auth.ts` sets today. */ | |
| 28 | const WEEK_MS = 7 * 24 * 60 * 60 * 1000 | |
| 29 | const MONTH_MS = 30 * 24 * 60 * 60 * 1000 | |
| 30 | ||
| 31 | const WEEK_MINUTES = 7 * 24 * 60 // 10_080 | |
| 32 | const MONTH_MINUTES = 30 * 24 * 60 // 43_200 | |
| 33 | ||
| 34 | /** The config the app must carry for the framework to reproduce today's tiers. */ | |
| 35 | const TIERED_AUTH = { | |
| 36 | tokenExpiry: WEEK_MS, | |
| 37 | browserSession: { baselineLifetime: WEEK_MS, rememberedLifetime: MONTH_MS }, | |
| 38 | } | |
| 39 | ||
| 40 | describe('browser session: lifetime tiers', () => { | |
| 41 | it('is a week unchecked and a month when remembered, today', () => { | |
| 42 | expect(sessionExpiryMinutes(undefined)).toBe(WEEK_MINUTES) | |
| 43 | expect(sessionExpiryMinutes(true)).toBe(MONTH_MINUTES) | |
| 44 | }) | |
| 45 | ||
| 46 | it('reproduces both tiers through the framework policy, given the config', () => { | |
| 47 | expect(resolveBrowserSessionPolicy(undefined, TIERED_AUTH).expiresInMinutes).toBe(WEEK_MINUTES) | |
| 48 | expect(resolveBrowserSessionPolicy(true, TIERED_AUTH).expiresInMinutes).toBe(MONTH_MINUTES) | |
| 49 | }) | |
| 50 | ||
| 51 | /** | |
| 52 | * The regression this whole file exists for. `rememberedLifetime` defaults to | |
| 53 | * the baseline, so deleting the overrides WITHOUT adding the browserSession | |
| 54 | * block silently collapses "keep me signed in for 30 days" to 7 days. No | |
| 55 | * error, no test failure anywhere else, no user-visible signal until someone | |
| 56 | * is logged out 23 days early. | |
| 57 | */ | |
| 58 | it('collapses the remembered tier to the baseline when browserSession is absent', () => { | |
| 59 | const untiered = { tokenExpiry: WEEK_MS } | |
| 60 | ||
| 61 | expect(resolveBrowserSessionPolicy(true, untiered).expiresInMinutes).toBe(WEEK_MINUTES) | |
| 62 | expect(resolveBrowserSessionPolicy(true, untiered).expiresInMinutes).not.toBe(MONTH_MINUTES) | |
| 63 | }) | |
| 64 | }) | |
| 65 | ||
| 66 | describe('browser session: the remember accept-list', () => { | |
| 67 | // Everything `resources/views/login.stx` can put in the JSON body. It sends a | |
| 68 | // real boolean from `checkbox.checked` at step one and re-sends the stored | |
| 69 | // choice at step two, but the action has always accepted the string forms too. | |
| 70 | const remembered = [true, 1, '1', 'true', 'on', 'yes', 'TRUE', ' true '] | |
| 71 | const notRemembered = [false, 0, undefined, null, '', '0', 'false', 'off', 'no'] | |
| 72 | ||
| 73 | it('agrees with the framework on every value the login view can send', () => { | |
| 74 | for (const value of [true, false, undefined, '1', 'true', 'on', 'yes', '', '0']) | |
| 75 | expect(sessionExpiryMinutes(value) === MONTH_MINUTES).toBe(browserSessionRemembered(value)) | |
| 76 | }) | |
| 77 | ||
| 78 | it('treats the documented truthy forms as remembered', () => { | |
| 79 | for (const value of remembered) | |
| 80 | expect(resolveBrowserSessionPolicy(value, TIERED_AUTH).remembered).toBe(true) | |
| 81 | }) | |
| 82 | ||
| 83 | it('treats everything else as the baseline', () => { | |
| 84 | for (const value of notRemembered) | |
| 85 | expect(resolveBrowserSessionPolicy(value, TIERED_AUTH).remembered).toBe(false) | |
| 86 | }) | |
| 87 | }) | |
| 88 | ||
| 89 | describe('browser session: the cookie contract', () => { | |
| 90 | it('names, scopes and flags the cookie exactly as it does today', () => { | |
| 91 | const cookie = buildAuthCookie('a'.repeat(80), WEEK_MINUTES * 60) | |
| 92 | ||
| 93 | expect(cookie).toContain(`auth-token=${'a'.repeat(80)}`) | |
| 94 | expect(cookie).toContain('Path=/') | |
| 95 | expect(cookie).toContain('HttpOnly') | |
| 96 | expect(cookie).toContain('SameSite=Lax') | |
| 97 | expect(cookie).toContain('Max-Age=604800') | |
| 98 | }) | |
| 99 | ||
| 100 | /** | |
| 101 | * The cookie's Max-Age and the `oauth_access_tokens.expires_at` row have to | |
| 102 | * come from the SAME number, or the browser keeps sending a credential the | |
| 103 | * database has already expired (or drops one that is still valid). | |
| 104 | */ | |
| 105 | it('stamps Max-Age from the issued lifetime, not from a recomputed default', () => { | |
| 106 | const issued = 1234 | |
| 107 | expect(buildAuthCookie('token', issued)).toContain(`Max-Age=${issued}`) | |
| 108 | expect(authCookieForBrowserSession('token', issued)).toContain(`Max-Age=${issued}`) | |
| 109 | }) | |
| 110 | ||
| 111 | it('refuses to invent a lifetime when issuance did not report one', () => { | |
| 112 | expect(() => authCookieForBrowserSession('token', undefined)).toThrow(TypeError) | |
| 113 | expect(() => authCookieForBrowserSession('token', 0)).toThrow(TypeError) | |
| 114 | expect(() => authCookieForBrowserSession('token', -1)).toThrow(TypeError) | |
| 115 | }) | |
| 116 | ||
| 117 | it('leaves an 80-character hex token byte-identical through cookie encoding', () => { | |
| 118 | // Access tokens are randomBytes(40).toString('hex'), on which | |
| 119 | // encodeURIComponent is the identity - so no live session is invalidated | |
| 120 | // when issuance moves from the app helper to the framework one. | |
| 121 | const token = 'deadbeef'.repeat(10) | |
| 122 | expect(token).toHaveLength(80) | |
| 123 | expect(encodeURIComponent(token)).toBe(token) | |
| 124 | expect(authCookieForBrowserSession(token, 60)).toContain(`auth-token=${token}`) | |
| 125 | }) | |
| 126 | }) | |
| 127 | ||
| 128 | describe('browser session: two-factor keeps the tier', () => { | |
| 129 | /** | |
| 130 | * Step two must not downgrade a remembered session. Today `login.stx` stores | |
| 131 | * the checkbox at step one and re-sends it with the code, and | |
| 132 | * `VerifyTwoFactorLoginAction` runs it back through `sessionExpiryMinutes`. | |
| 133 | * The framework carries it on the challenge id instead. Either way the | |
| 134 | * property is the same, so this assertion survives the swap. | |
| 135 | */ | |
| 136 | it('resolves a remembered challenge to the month, not the week', () => { | |
| 137 | expect(sessionExpiryMinutes(true)).toBe(MONTH_MINUTES) | |
| 138 | expect(resolveBrowserSessionPolicy(true, TIERED_AUTH).lifetimeMs).toBe(MONTH_MS) | |
| 139 | }) | |
| 140 | ||
| 141 | it('resolves an unremembered challenge to the week', () => { | |
| 142 | expect(sessionExpiryMinutes(false)).toBe(WEEK_MINUTES) | |
| 143 | expect(resolveBrowserSessionPolicy(false, TIERED_AUTH).lifetimeMs).toBe(WEEK_MS) | |
| 144 | }) | |
| 145 | }) | |
| 146 | ||
| 147 | describe('browser session: refresh tokens', () => { | |
| 148 | /** | |
| 149 | * Browser sessions are fixed-lifetime: no refresh exchange. The framework | |
| 150 | * still defaults `withRefreshToken` to true, so turning it off is a | |
| 151 | * deliberate, separately reviewable change rather than a side effect of the | |
| 152 | * migration. Pinning the default here makes that flip visible. | |
| 153 | */ | |
| 154 | it('defaults to issuing a refresh token', () => { | |
| 155 | expect(resolveBrowserSessionPolicy(undefined, { tokenExpiry: WEEK_MS }).withRefreshToken).toBe(true) | |
| 156 | }) | |
| 157 | ||
| 158 | it('honours an explicit opt-out', () => { | |
| 159 | const fixed = { ...TIERED_AUTH, browserSession: { ...TIERED_AUTH.browserSession, withRefreshToken: false } } | |
| 160 | expect(resolveBrowserSessionPolicy(true, fixed).withRefreshToken).toBe(false) | |
| 161 | }) | |
| 162 | }) | |
| 163 | ||
| 164 | describe('browser session: the Secure flag', () => { | |
| 165 | /** | |
| 166 | * The one difference that is a security regression rather than a cosmetic | |
| 167 | * change. The app helper decides Secure from APP_ENV; the framework decides | |
| 168 | * it from `config.app.url`. commshq's url falls back to `commshq.localhost` | |
| 169 | * (config/app.ts:15), which the framework reads as a loopback host and | |
| 170 | * therefore serves WITHOUT Secure - a 7-to-30 day session cookie in the | |
| 171 | * clear if APP_URL is ever absent or fails to decrypt. | |
| 172 | * | |
| 173 | * These assertions document the trap. `config.auth.cookie.secure` must be set | |
| 174 | * explicitly in the migration so the inference never runs. | |
| 175 | */ | |
| 176 | it('secures an https app url', () => { | |
| 177 | expect(shouldSecureAuthCookie({ url: 'https://commshq.com' })).toBe(true) | |
| 178 | }) | |
| 179 | ||
| 180 | it('secures a non-loopback host even over plain http', () => { | |
| 181 | expect(shouldSecureAuthCookie({ url: 'http://commshq.com' })).toBe(true) | |
| 182 | }) | |
| 183 | ||
| 184 | it('DROPS Secure for the commshq.localhost fallback - the trap', () => { | |
| 185 | expect(shouldSecureAuthCookie({ url: 'commshq.localhost' })).toBe(false) | |
| 186 | expect(shouldSecureAuthCookie({ url: 'http://localhost:3000' })).toBe(false) | |
| 187 | }) | |
| 188 | ||
| 189 | it('still sets Secure today, because the app helper reads APP_ENV', () => { | |
| 190 | // Guards the current behavior: whatever the migration does, a production | |
| 191 | // cookie must keep this attribute. | |
| 192 | const production = buildAuthCookieUnder('production') | |
| 193 | expect(production).toContain('Secure') | |
| 194 | }) | |
| 195 | }) | |
| 196 | ||
| 197 | /** Build a cookie with APP_ENV temporarily set, then restore it. */ | |
| 198 | function buildAuthCookieUnder(appEnv: string): string { | |
| 199 | const previous = process.env.APP_ENV | |
| 200 | process.env.APP_ENV = appEnv | |
| 201 | try { | |
| 202 | return buildAuthCookie('token', 60) | |
| 203 | } | |
| 204 | finally { | |
| 205 | if (previous === undefined) | |
| 206 | delete process.env.APP_ENV | |
| 207 | else process.env.APP_ENV = previous | |
| 208 | } | |
| 209 | } | |
| @@ -0,0 +1,66 @@ | ||
| 1 | import { describe, expect, it } from 'bun:test' | |
| 2 | import { assertSessionCookie, assertSessionCookieAttributes } from '../../scripts/smoke-deployment' | |
| 3 | ||
| 4 | /** | |
| 5 | * The deploy smoke test signs in for real and spends the session on an | |
| 6 | * authenticated route. These cover the header parsing without a network round | |
| 7 | * trip, so a broken assertion is caught here rather than by a red deploy. | |
| 8 | */ | |
| 9 | ||
| 10 | const LIVE = 'auth-token=abc123; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800; Secure' | |
| 11 | ||
| 12 | describe('deployment smoke: session cookie parsing', () => { | |
| 13 | it('reads the token out of a live Set-Cookie header', () => { | |
| 14 | expect(assertSessionCookie(LIVE)).toBe('abc123') | |
| 15 | }) | |
| 16 | ||
| 17 | it('url-decodes the value, since the framework encodes it', () => { | |
| 18 | expect(assertSessionCookie('auth-token=a%2Bb; Path=/; HttpOnly')).toBe('a+b') | |
| 19 | }) | |
| 20 | ||
| 21 | it('finds the cookie when other cookies precede it', () => { | |
| 22 | expect(assertSessionCookie('other=1; Path=/, auth-token=xyz; Path=/; HttpOnly')).toBe('xyz') | |
| 23 | }) | |
| 24 | ||
| 25 | it('names the real failure when sign-in issued no cookie at all', () => { | |
| 26 | expect(() => assertSessionCookie(null)).toThrow('issued no session') | |
| 27 | expect(() => assertSessionCookie(undefined)).toThrow('issued no session') | |
| 28 | }) | |
| 29 | ||
| 30 | it('distinguishes a missing cookie from an empty one', () => { | |
| 31 | expect(() => assertSessionCookie('session=nope; Path=/')).toThrow('set no auth-token cookie') | |
| 32 | expect(() => assertSessionCookie('auth-token=; Path=/')).toThrow('empty auth-token cookie') | |
| 33 | }) | |
| 34 | }) | |
| 35 | ||
| 36 | describe('deployment smoke: session cookie attributes', () => { | |
| 37 | it('accepts the attributes a deployed session must carry', () => { | |
| 38 | expect(() => assertSessionCookieAttributes(LIVE)).not.toThrow() | |
| 39 | }) | |
| 40 | ||
| 41 | /** | |
| 42 | * The regression this guards. shouldSecureAuthCookie() reads config.app.url, | |
| 43 | * which falls back to commshq.localhost - a loopback host, so the flag is | |
| 44 | * dropped and a 7-to-30 day session token ships in the clear. | |
| 45 | */ | |
| 46 | it('rejects a production cookie served without Secure', () => { | |
| 47 | expect(() => assertSessionCookieAttributes('auth-token=abc; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800')) | |
| 48 | .toThrow('missing Secure') | |
| 49 | }) | |
| 50 | ||
| 51 | it('rejects a cookie readable by JavaScript', () => { | |
| 52 | expect(() => assertSessionCookieAttributes('auth-token=abc; Path=/; SameSite=Lax; Max-Age=604800; Secure')) | |
| 53 | .toThrow('missing HttpOnly') | |
| 54 | }) | |
| 55 | ||
| 56 | it('reports every missing attribute at once', () => { | |
| 57 | expect(() => assertSessionCookieAttributes('auth-token=abc; Max-Age=604800')) | |
| 58 | .toThrow('missing HttpOnly, Path=/, SameSite=Lax, Secure') | |
| 59 | }) | |
| 60 | ||
| 61 | it('rejects a session cookie with no positive Max-Age', () => { | |
| 62 | const base = 'auth-token=abc; Path=/; HttpOnly; SameSite=Lax; Secure' | |
| 63 | expect(() => assertSessionCookieAttributes(base)).toThrow('no positive Max-Age') | |
| 64 | expect(() => assertSessionCookieAttributes(`${base}; Max-Age=0`)).toThrow('no positive Max-Age') | |
| 65 | }) | |
| 66 | }) | |