fix: harden identity, live access, drafts and setup (review only)
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.
This commit is contained in:
115
web/lib/api/session-isolation.test.mjs
Normal file
115
web/lib/api/session-isolation.test.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user