Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
116 lines
4.4 KiB
JavaScript
116 lines
4.4 KiB
JavaScript
import assert from "node:assert/strict"
|
|
import test from "node:test"
|
|
import { isSameSession } from "../session-request-guard.ts"
|
|
import { deferred, loadTypeScriptModule } from "../test-support/load-typescript-module.mjs"
|
|
|
|
function fixture() {
|
|
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
|
let expirations = 0
|
|
const response = deferred()
|
|
const client = loadTypeScriptModule(new URL("./client.ts", import.meta.url), {
|
|
"@/lib/auth": {
|
|
readSession: () => session,
|
|
expireSession: () => { session = null; expirations += 1 },
|
|
},
|
|
"@/lib/session-request-guard": { isSameSession },
|
|
"@/i18n/messages": { translateCurrentMessage: (key) => key },
|
|
}, { process: { env: {} }, Headers, FormData, fetch: () => response.promise })
|
|
return {
|
|
client, response,
|
|
get session() { return session },
|
|
set session(next) { session = next },
|
|
get expirations() { return expirations },
|
|
}
|
|
}
|
|
|
|
function expiredResponse() {
|
|
return new Response(JSON.stringify({ success: false, errorCode: 3002, message: "Expired" }), {
|
|
status: 401, headers: { "Content-Type": "application/json" },
|
|
})
|
|
}
|
|
|
|
for (const method of ["request", "requestBlob"]) {
|
|
test(`${method}: old auth failure cannot expire a newer login`, async () => {
|
|
const f = fixture()
|
|
const pending = f.client[method]("/test")
|
|
f.session = { user: { id: 2 }, accessToken: "test-session-b" }
|
|
f.response.resolve(expiredResponse())
|
|
await assert.rejects(pending, { errorCode: 3002 })
|
|
assert.equal(f.session.user.id, 2)
|
|
assert.equal(f.expirations, 0)
|
|
})
|
|
|
|
test(`${method}: matching auth failure expires only its own session`, async () => {
|
|
const f = fixture()
|
|
const pending = f.client[method]("/test")
|
|
f.response.resolve(expiredResponse())
|
|
await assert.rejects(pending, { errorCode: 3002 })
|
|
assert.equal(f.session, null)
|
|
assert.equal(f.expirations, 1)
|
|
})
|
|
|
|
test(`${method}: unauthenticated failures do not expire a dashboard session`, async () => {
|
|
const f = fixture()
|
|
const pending = f.client[method]("/test", { skipAuth: true })
|
|
f.response.resolve(expiredResponse())
|
|
await assert.rejects(pending, { errorCode: 3002 })
|
|
assert.equal(f.expirations, 0)
|
|
})
|
|
}
|
|
|
|
test("old logout and profile update cannot overwrite a new account", async () => {
|
|
for (const action of ["logout", "updateProfile"]) {
|
|
let session = { user: { id: 1 }, accessToken: "test-session-a" }
|
|
const response = deferred()
|
|
let writes = 0
|
|
const auth = loadTypeScriptModule(new URL("./auth.ts", import.meta.url), {
|
|
"@/lib/auth": {
|
|
readSession: () => session,
|
|
clearSession: () => { session = null; writes += 1 },
|
|
writeSession: (next) => { session = next; writes += 1 },
|
|
},
|
|
"@/lib/session-request-guard": { isSameSession },
|
|
"@/lib/api/client": { request: () => response.promise },
|
|
})
|
|
const pending = auth[action]({ nickname: "Updated" })
|
|
session = { user: { id: 2 }, accessToken: "test-session-b" }
|
|
response.resolve({ user: { id: 1 }, accessToken: "test-session-a" })
|
|
await pending
|
|
assert.equal(session.user.id, 2)
|
|
assert.equal(writes, 0)
|
|
}
|
|
})
|
|
|
|
for (const newerLogin of [true, false]) {
|
|
test(`logout navigation honors current session (newer login: ${newerLogin})`, async () => {
|
|
let session = null
|
|
const pendingLogout = deferred()
|
|
const redirects = []
|
|
const auth = loadTypeScriptModule(new URL("../../components/auth-provider.tsx", import.meta.url), {
|
|
react: {
|
|
createContext: () => ({ Provider: "context" }),
|
|
startTransition: (callback) => callback(),
|
|
useCallback: (callback) => callback,
|
|
useEffect: () => {},
|
|
},
|
|
"react/jsx-runtime": { jsx: (type, props) => ({ type, props }) },
|
|
"next/navigation": {
|
|
usePathname: () => "/dashboard",
|
|
useRouter: () => ({ replace: (url) => redirects.push(url) }),
|
|
},
|
|
"@/components/session-provider": {
|
|
SessionProvider: "session",
|
|
useSession: () => ({ session, ready: true, signOut: () => pendingLogout.promise }),
|
|
},
|
|
"@/lib/auth": { readSession: () => session },
|
|
})
|
|
const dashboardElement = auth.AuthProvider({ children: null }).props.children
|
|
const context = dashboardElement.type(dashboardElement.props)
|
|
const signOut = context.props.value.signOut()
|
|
if (newerLogin) session = { user: { id: 2 }, accessToken: "test-session-b" }
|
|
pendingLogout.resolve()
|
|
await signOut
|
|
assert.equal(redirects.length, newerLogin ? 0 : 1)
|
|
})
|
|
}
|