228 lines
11 KiB
JavaScript
228 lines
11 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createSession, normalizeUsername, validPassword } from '../src/session.mjs'
|
|
const admin = { id: 1, username: 'fixture.admin', role: 'admin' }
|
|
const learner = { id: 2, username: 'fixture.learner', role: 'learner' }
|
|
const response = (data, status = 200) => ({ ok: status < 400, status, json: async () => ({ code: status, data, msg: '请求失败' }) })
|
|
function setup(fetch) {
|
|
const data = new Map()
|
|
const storage = { getItem: key => data.get(key), setItem: (key, value) => data.set(key, value), removeItem: key => data.delete(key) }
|
|
return { session: createSession({ fetch, storage }), data }
|
|
}
|
|
test('normalizes account names and enforces UTF-8 password byte limits', () => {
|
|
assert.equal(normalizeUsername(' Fixture.Admin '), 'fixture.admin')
|
|
assert.equal(validPassword('虚构测试密码'), true)
|
|
assert.equal(validPassword('短'), false)
|
|
assert.equal(validPassword('虚'.repeat(25)), false)
|
|
})
|
|
test('creation and reset accept six bytes but reject five and over 72', async () => {
|
|
const writes = []
|
|
const { session } = setup(async (url, options) => {
|
|
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
|
|
writes.push(JSON.parse(options.body))
|
|
return response(learner)
|
|
})
|
|
await session.login(admin.username, 'fictional-password')
|
|
await assert.rejects(session.createAccount('fixture.new', 'z'.repeat(5)))
|
|
await assert.rejects(session.createAccount('fixture.new', 'z'.repeat(73)))
|
|
await session.createAccount('fixture.new', 'z'.repeat(6))
|
|
await session.createAccount('fixture.unicode', '虚构')
|
|
await session.createAccount('fixture.maximum', 'z'.repeat(72))
|
|
assert.equal(writes.length, 3)
|
|
await assert.rejects(session.updateAccount(learner.id, { password: 'z'.repeat(5) }))
|
|
await assert.rejects(session.updateAccount(learner.id, { password: 'z'.repeat(73) }))
|
|
for (const password of ['z'.repeat(6), '虚构', 'z'.repeat(72)]) {
|
|
await session.updateAccount(learner.id, { password })
|
|
assert.equal(writes.at(-1).password, password)
|
|
}
|
|
})
|
|
|
|
test('learner login is rejected, revoked, and never stored in admin session', async () => {
|
|
const requests = []
|
|
const { session, data } = setup(async (url, options) => {
|
|
requests.push([url, options])
|
|
return response(url.endsWith('/login') ? { token: 'fictional-learner-token', user: learner } : null)
|
|
})
|
|
await assert.rejects(session.login('fixture.learner', 'fictional-password'), /管理权限/)
|
|
assert.equal(session.state.user, null)
|
|
assert.equal(data.size, 0)
|
|
assert.equal(requests[1][0], '/api/v1/logout')
|
|
})
|
|
test('admin account list sends bearer and clears private data on 401', async () => {
|
|
let expire = false
|
|
const { session, data } = setup(async (url, options) => {
|
|
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
|
|
assert.equal(options.headers.Authorization, 'Bearer fictional-admin-token')
|
|
return expire ? response(null, 401) : response({ items: [learner] })
|
|
})
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await session.loadAccounts()
|
|
assert.deepEqual(session.state.accounts, [learner])
|
|
expire = true
|
|
await assert.rejects(session.loadAccounts())
|
|
assert.equal(session.state.user, null)
|
|
assert.deepEqual(session.state.accounts, [])
|
|
assert.equal(data.size, 0)
|
|
})
|
|
test('logout discards an in-flight account response', async () => {
|
|
let complete
|
|
const { session } = setup(async url => {
|
|
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
|
|
if (url.endsWith('/accounts')) return new Promise(resolve => { complete = resolve })
|
|
return response(null)
|
|
})
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
const pending = session.loadAccounts()
|
|
await session.logout()
|
|
complete(response({ items: [learner] }))
|
|
await assert.rejects(pending, /会话已变化/)
|
|
assert.deepEqual(session.state.accounts, [])
|
|
})
|
|
test('account creation and state/password update use bounded API contract', async () => {
|
|
const writes = []
|
|
const { session } = setup(async (url, options) => {
|
|
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
|
|
writes.push([url, options.method, JSON.parse(options.body)])
|
|
return response(learner)
|
|
})
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await session.createAccount(' Fixture.Learner ', 'fictional-password')
|
|
await session.updateAccount(2, { disabled: true })
|
|
await session.updateAccount(2, { password: 'fictional-new-password' })
|
|
assert.deepEqual(writes, [
|
|
['/api/v1/accounts', 'POST', { username: 'fixture.learner', password: 'fictional-password' }],
|
|
['/api/v1/accounts/2', 'PATCH', { disabled: true }],
|
|
['/api/v1/accounts/2', 'PATCH', { password: 'fictional-new-password' }]
|
|
])
|
|
})
|
|
test('unauthenticated account access does not reach the API', async () => {
|
|
const { session } = setup(async () => assert.fail('must not call API'))
|
|
await assert.rejects(session.loadAccounts(), /请先登录/)
|
|
})
|
|
|
|
test('old restore failure cannot clear a newly logged in admin', async () => {
|
|
let complete
|
|
const { session, data } = setup(async url => {
|
|
if (url.endsWith('/me')) return new Promise(resolve => { complete = resolve })
|
|
if (url.endsWith('/login')) return response({ token: 'fictional-new-token', user: admin })
|
|
return response(null)
|
|
})
|
|
session.state.token = 'fictional-old-token'
|
|
const pending = session.restore()
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
complete(response(null, 401))
|
|
await assert.rejects(pending)
|
|
assert.equal(session.state.user?.id, admin.id)
|
|
assert.equal(data.get('lexgo-admin-token'), 'fictional-new-token')
|
|
})
|
|
|
|
test('restored learner session is revoked and removed', async () => {
|
|
let revoked = false
|
|
const { session } = setup(async url => {
|
|
if (url.endsWith('/logout')) { revoked = true; return response(null) }
|
|
return response(learner)
|
|
})
|
|
session.state.token = 'fictional-learner-token'
|
|
await assert.rejects(session.restore(), /管理权限/)
|
|
assert.equal(session.state.user, null)
|
|
assert.equal(session.state.token, '')
|
|
assert.equal(revoked, true)
|
|
})
|
|
test('duplicate account error remains actionable without ending the admin session', async () => {
|
|
const { session } = setup(async url => url.endsWith('/login')
|
|
? response({ token: 'fictional-admin-token', user: admin })
|
|
: { ok: false, status: 409, json: async () => ({ code: 409, msg: '账号已存在' }) })
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await assert.rejects(session.createAccount('fixture.learner', 'fictional-password'), /账号已存在/)
|
|
assert.equal(session.state.user.id, admin.id)
|
|
})
|
|
test('admin self-disable is blocked before issuing a request', async () => {
|
|
const { session } = setup(async url => {
|
|
assert.equal(url, '/api/v1/login')
|
|
return response({ token: 'fictional-admin-token', user: admin })
|
|
})
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await assert.rejects(session.updateAccount(1, { disabled: true }), /不能停用当前管理员/)
|
|
})
|
|
|
|
test('logout at the response-to-store handoff cannot restore account data', async () => {
|
|
const { session } = setup(async url => {
|
|
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
|
|
return { ok: true, status: 200, json: async () => {
|
|
queueMicrotask(() => queueMicrotask(() => session.clear()))
|
|
return { code: 200, data: { items: [learner] } }
|
|
} }
|
|
})
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await assert.rejects(session.loadAccounts(), /会话已变化/)
|
|
assert.deepEqual(session.state.accounts, [])
|
|
})
|
|
|
|
test('administrator password reset is blocked before issuing a write', async () => {
|
|
const { session } = setup(async url => {
|
|
assert.equal(url, '/api/v1/login')
|
|
return response({ token: 'fictional-admin-token', user: admin })
|
|
})
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await assert.rejects(session.updateAccount(1, { password: 'fictional-new-password' }), /管理员/)
|
|
})
|
|
test('another administrator cannot be enabled, disabled, or reset from account management', async () => {
|
|
const otherAdmin = { id: 3, username: 'fixture.otheradmin', role: 'admin', disabled: false }
|
|
const { session } = setup(async url => {
|
|
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
|
|
assert.equal(url, '/api/v1/accounts')
|
|
return response({ items: [otherAdmin] })
|
|
})
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await session.loadAccounts()
|
|
for (const patch of [{ disabled: true }, { disabled: false }, { password: 'fictional-new-password' }]) {
|
|
await assert.rejects(session.updateAccount(3, patch), /管理员/)
|
|
}
|
|
})
|
|
|
|
test('logout HTTP 503 clears local private state but reports unconfirmed server revocation', async () => {
|
|
const { session, data } = setup(async url => {
|
|
if (url.endsWith('/login')) return response({ token: 'fictional-admin-token', user: admin })
|
|
if (url.endsWith('/accounts')) return response({ items: [learner] })
|
|
return response(null, 503)
|
|
})
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await session.loadAccounts()
|
|
await assert.rejects(session.logout(), /服务器尚未确认退出/)
|
|
assert.equal(session.state.user, null)
|
|
assert.deepEqual(session.state.accounts, [])
|
|
assert.equal(data.size, 0)
|
|
})
|
|
test('account switch stops on unconfirmed revocation of the previous session', async () => {
|
|
let loginCount = 0
|
|
const { session, data } = setup(async url => {
|
|
if (url.endsWith('/login')) {
|
|
loginCount++
|
|
return response({ token: 'fictional-admin-token', user: admin })
|
|
}
|
|
return response(null, 503)
|
|
})
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await assert.rejects(session.login('fixture.otheradmin', 'fictional-password'), /服务器尚未确认退出/)
|
|
assert.equal(loginCount, 1)
|
|
assert.equal(session.state.user, null)
|
|
assert.equal(data.size, 0)
|
|
})
|
|
test('logout HTTP 401 is accepted because the server session is already invalid', async () => {
|
|
const { session } = setup(async url => url.endsWith('/login')
|
|
? response({ token: 'fictional-admin-token', user: admin })
|
|
: response(null, 401))
|
|
await session.login('fixture.admin', 'fictional-password')
|
|
await session.logout()
|
|
assert.equal(session.state.user, null)
|
|
})
|
|
|
|
test('rejected learner login reports failed server revocation without storing credentials', async () => {
|
|
const { session, data } = setup(async url => url.endsWith('/login')
|
|
? response({ token: 'fictional-learner-token', user: learner })
|
|
: response(null, 503))
|
|
await assert.rejects(session.login('fixture.learner', 'fictional-password'), /没有管理权限.*服务器尚未确认退出/)
|
|
assert.equal(session.state.user, null)
|
|
assert.equal(data.size, 0)
|
|
})
|