import { afterEach, expect, test } from "vitest"; import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { stringify } from "yaml"; import { buildApp, type AppWithAuthSessionStore } from "../src/app.js"; import { loadAuthenticationConfig } from "../src/auth/config.js"; import { loadConfig } from "../src/config.js"; import { createFixtureAuthStorageBridge, prepareAuthStateRoot } from "./auth-test-fixtures.js"; const password = "correct horse battery staple"; const passwordHash = "$argon2id$v=19$m=65536,t=3,p=1$AAECAwQFBgcICQoLDA0ODw$DRo8ZSPI8G5OCvnFFapbVEjP69aDjy1Sw9i2743cPC4"; const userA = { id: "6ba7b810-9dad-4ed1-80b4-00c04fd430c8", username: "AdminA" }; const userB = { id: "6ba7b811-9dad-4ed1-80b4-00c04fd430c8", username: "AdminB" }; const publicUrl = "http://127.0.0.1:8787"; const cleanups: Array<() => Promise> = []; afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); function usersYaml(user: typeof userA): string { return [ "version: 1", "users:", ` - id: ${user.id}`, ` username: ${user.username}`, ` passwordHash: ${passwordHash}`, " roles:", " - admin", " enabled: true", " authRevision: 1", "", ].join("\n"); } function configYaml(usersFile: string) { return stringify({ version: 1, mode: "local", publicUrl, local: { usersFile } }); } function cookiePair(response: { headers: Record }): string { const header = response.headers["set-cookie"]; const first = Array.isArray(header) ? header[0] : header; return first?.split(";", 1)[0] ?? ""; } test("each login and session resolve uses the current config snapshot users file", async () => { const directory = mkdtempSync(join(realpathSync(tmpdir()), "thothii-auth-dynamic-")); chmodSync(directory, 0o700); const authFile = join(directory, "auth.yaml"); const usersAFile = join(directory, "users-a.yaml"); const usersBFile = join(directory, "users-bbbbb.yaml"); writeFileSync(usersAFile, usersYaml(userA), { encoding: "utf8", mode: 0o600 }); writeFileSync(usersBFile, usersYaml(userB), { encoding: "utf8", mode: 0o600 }); writeFileSync(authFile, configYaml("users-a.yaml"), { encoding: "utf8", mode: 0o600 }); chmodSync(authFile, 0o600); chmodSync(usersAFile, 0o600); chmodSync(usersBFile, 0o600); const authStateRoot = join(directory, "auth-state"); prepareAuthStateRoot(authStateRoot); const app = buildApp(loadConfig({ THT_AUTH_CONFIG_FILE: authFile, THT_AUTH_STATE_ROOT: authStateRoot, THT_HARNESS_DIR: "/tmp/h", }), { authStorageBridgeForTest: createFixtureAuthStorageBridge() }); cleanups.push(async () => { await app.close(); rmSync(directory, { recursive: true, force: true }); }); const signIn = (username: string) => app.inject({ method: "POST", url: "/auth/local/login", headers: { origin: publicUrl, "sec-fetch-site": "same-origin" }, payload: { username, password }, }); const first = await signIn(userA.username); expect(first.statusCode).toBe(200); const aCookie = cookiePair(first); expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: aCookie } })).json()) .toMatchObject({ subject: userA.id }); writeFileSync(authFile, configYaml("users-bbbbb.yaml"), { encoding: "utf8", mode: 0o600 }); chmodSync(authFile, 0o600); expect(readFileSync(usersAFile, "utf8")).toContain(userA.username); const removedUser = await signIn(userA.username); expect(removedUser.statusCode).toBe(401); expect(removedUser.json()).toEqual({ code: "invalid_credentials", error: "Invalid username or password" }); expect((await app.inject({ method: "GET", url: "/me", headers: { cookie: aCookie } })).statusCode).toBe(401); const second = await signIn(userB.username); expect(second.statusCode).toBe(200); const bCookie = cookiePair(second); expect(await app.inject({ method: "GET", url: "/me", headers: { cookie: bCookie } }).then((response) => response.json())) .toMatchObject({ subject: userB.id }); const token = bCookie.split("=", 2)[1] ?? ""; const record = await (app as AppWithAuthSessionStore).thothiiAuthSessionStore?.resolve(token); expect(record).toMatchObject({ subject: userB.id, authConfigRevision: loadAuthenticationConfig(authFile).revision }); });