Harden WebBFF and deployment release behavior
Model fail-closed cross-replica session renewal and implement owner-fenced refresh, transient observer handling, logout fencing, and graceful shutdown. Remove chart-owned Ingress while retaining qualification-owned external routing.
This commit is contained in:
parent
ffa65018ed
commit
414b380516
1 changed files with 980 additions and 136 deletions
186
web/server/sessions.test.mjs
Normal file
186
web/server/sessions.test.mjs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { SESSION_RENEWAL_PENDING, SessionStore } from './sessions.mjs'
|
||||
|
||||
const identifier = 'a'.repeat(43)
|
||||
const config = {
|
||||
sessionNamespace: 'remora:test:',
|
||||
sessionTtlSeconds: 300,
|
||||
}
|
||||
|
||||
test('serializes renewal across session store users', async () => {
|
||||
const client = new FakeRedis()
|
||||
const store = new SessionStore(config, client)
|
||||
const original = { kind: 'authenticated', accessToken: 'old' }
|
||||
await client.set(store.key(identifier), JSON.stringify(original))
|
||||
const blocked = deferred()
|
||||
let renewals = 0
|
||||
const renewal = async () => {
|
||||
renewals += 1
|
||||
await blocked.promise
|
||||
return { kind: 'authenticated', accessToken: 'new' }
|
||||
}
|
||||
const shouldRenew = (session) => session.accessToken === 'old'
|
||||
|
||||
const first = store.renew(identifier, shouldRenew, renewal)
|
||||
await waitFor(() => renewals === 1)
|
||||
const second = await store.renew(identifier, shouldRenew, renewal)
|
||||
assert.equal(second, SESSION_RENEWAL_PENDING)
|
||||
blocked.resolve()
|
||||
const left = await first
|
||||
|
||||
assert.deepEqual(left, { kind: 'authenticated', accessToken: 'new' })
|
||||
assert.equal(renewals, 1)
|
||||
assert.deepEqual(await store.read(identifier), left)
|
||||
assert.deepEqual(await store.renew(identifier, shouldRenew, renewal), left)
|
||||
assert.equal(renewals, 1)
|
||||
})
|
||||
|
||||
test('logout fences an in-flight renewal from restoring the session', async () => {
|
||||
const client = new FakeRedis()
|
||||
const store = new SessionStore(config, client)
|
||||
await client.set(
|
||||
store.key(identifier),
|
||||
JSON.stringify({ kind: 'authenticated', accessToken: 'old' }),
|
||||
)
|
||||
const blocked = deferred()
|
||||
let started = false
|
||||
const renewing = store.renew(
|
||||
identifier,
|
||||
() => true,
|
||||
async () => {
|
||||
started = true
|
||||
await blocked.promise
|
||||
return { kind: 'authenticated', accessToken: 'new' }
|
||||
},
|
||||
)
|
||||
await waitFor(() => started)
|
||||
|
||||
await store.destroy(identifier)
|
||||
blocked.resolve()
|
||||
|
||||
assert.equal(await renewing, null)
|
||||
assert.equal(await store.read(identifier), null)
|
||||
})
|
||||
|
||||
test('ambiguous commit retains renewal ownership', async () => {
|
||||
const client = new FakeRedis()
|
||||
const store = new SessionStore(config, client)
|
||||
await client.set(
|
||||
store.key(identifier),
|
||||
JSON.stringify({ accessToken: 'old' }),
|
||||
)
|
||||
client.failCommit = true
|
||||
|
||||
await assert.rejects(
|
||||
store.renew(
|
||||
identifier,
|
||||
() => true,
|
||||
async () => ({ accessToken: 'new' }),
|
||||
),
|
||||
/commit unavailable/,
|
||||
)
|
||||
|
||||
assert.equal(client.values.has(store.lockKey(identifier)), true)
|
||||
assert.equal(
|
||||
await store.renew(
|
||||
identifier,
|
||||
() => true,
|
||||
async () => {
|
||||
throw new Error('duplicate provider refresh')
|
||||
},
|
||||
),
|
||||
SESSION_RENEWAL_PENDING,
|
||||
)
|
||||
})
|
||||
|
||||
test('ambiguous destruction retains renewal ownership', async () => {
|
||||
const client = new FakeRedis()
|
||||
const store = new SessionStore(config, client)
|
||||
await client.set(
|
||||
store.key(identifier),
|
||||
JSON.stringify({ accessToken: 'old' }),
|
||||
)
|
||||
client.failDeleteKey = store.key(identifier)
|
||||
|
||||
await assert.rejects(
|
||||
store.renew(
|
||||
identifier,
|
||||
() => true,
|
||||
async () => null,
|
||||
),
|
||||
/deletion unavailable/,
|
||||
)
|
||||
|
||||
assert.equal(client.values.has(store.lockKey(identifier)), true)
|
||||
assert.equal(
|
||||
await store.renew(
|
||||
identifier,
|
||||
() => true,
|
||||
async () => {
|
||||
throw new Error('duplicate provider refresh')
|
||||
},
|
||||
),
|
||||
SESSION_RENEWAL_PENDING,
|
||||
)
|
||||
})
|
||||
|
||||
class FakeRedis {
|
||||
values = new Map()
|
||||
isOpen = true
|
||||
failCommit = false
|
||||
failDeleteKey = null
|
||||
|
||||
async get(key) {
|
||||
return this.values.get(key) ?? null
|
||||
}
|
||||
|
||||
async set(key, value, options = {}) {
|
||||
if (options.NX && this.values.has(key)) return null
|
||||
this.values.set(key, value)
|
||||
return 'OK'
|
||||
}
|
||||
|
||||
async del(...keys) {
|
||||
if (keys.includes(this.failDeleteKey))
|
||||
throw new Error('deletion unavailable')
|
||||
let deleted = 0
|
||||
for (const key of keys) deleted += Number(this.values.delete(key))
|
||||
return deleted
|
||||
}
|
||||
|
||||
async eval(_script, { keys, arguments: values }) {
|
||||
if (keys.length === 2 && values.length === 1) {
|
||||
if (!this.values.has(keys[1]) || this.values.has(keys[0])) return 0
|
||||
this.values.set(keys[0], values[0])
|
||||
return 1
|
||||
}
|
||||
if (keys.length === 2) {
|
||||
if (this.failCommit) throw new Error('commit unavailable')
|
||||
if (this.values.get(keys[0]) !== values[0] || !this.values.has(keys[1]))
|
||||
return 0
|
||||
this.values.set(keys[1], values[1])
|
||||
return 1
|
||||
}
|
||||
if (this.values.get(keys[0]) !== values[0]) return 0
|
||||
this.values.delete(keys[0])
|
||||
return 1
|
||||
}
|
||||
|
||||
async quit() {
|
||||
this.isOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve
|
||||
const promise = new Promise((complete) => {
|
||||
resolve = complete
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
async function waitFor(predicate) {
|
||||
while (!predicate()) await new Promise((resolve) => setTimeout(resolve, 1))
|
||||
}
|
||||
Loading…
Reference in a new issue