186 lines
4.8 KiB
JavaScript
186 lines
4.8 KiB
JavaScript
|
|
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))
|
||
|
|
}
|