feat(auth): add opaque bearer sessions
This commit is contained in:
@@ -108,6 +108,11 @@ test('budget center is visible to platform admin, budget monitor, and executive
|
||||
assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'budget'), false)
|
||||
})
|
||||
|
||||
test('system settings are visible to platform admin instead of business managers', () => {
|
||||
assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'settings'), true)
|
||||
assert.equal(canAccessAppView({ roleCodes: ['manager'] }, 'settings'), false)
|
||||
})
|
||||
|
||||
test('budget edit and department switching are limited to admin and senior finance', () => {
|
||||
assert.equal(canEditBudgetCenter({ username: 'admin', roleCodes: ['manager'] }), true)
|
||||
assert.equal(canSwitchBudgetDepartments({ username: 'admin', roleCodes: ['manager'] }), true)
|
||||
|
||||
@@ -45,9 +45,11 @@ async function testSupportsBlobResponses() {
|
||||
assert.equal(payload, blob)
|
||||
}
|
||||
|
||||
async function testInjectsAuthenticatedUserHeaders() {
|
||||
const sessionStorage = new Map([
|
||||
[
|
||||
async function testInjectsBearerTokenWithoutUserControlledIdentityHeaders() {
|
||||
const sessionStorage = new Map([
|
||||
['x-financial-auth-access-token', 'opaque-access-token'],
|
||||
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z'],
|
||||
[
|
||||
'x-financial-auth-user',
|
||||
JSON.stringify({
|
||||
username: 'admin',
|
||||
@@ -84,27 +86,16 @@ async function testInjectsAuthenticatedUserHeaders() {
|
||||
|
||||
await apiRequest('/knowledge/library')
|
||||
|
||||
assert.equal(capturedOptions.headers['x-auth-username'], 'admin')
|
||||
assert.equal(capturedOptions.headers['x-auth-name'], 'Admin User')
|
||||
assert.equal(capturedOptions.headers['x-auth-position'], 'System Manager')
|
||||
assert.equal(capturedOptions.headers['x-auth-grade'], 'M5')
|
||||
assert.equal(capturedOptions.headers['x-auth-employee-no'], 'E-001')
|
||||
assert.equal(capturedOptions.headers['x-auth-manager-name'], 'Approver User')
|
||||
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
|
||||
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
||||
assert.equal(capturedOptions.headers.Authorization, 'Bearer opaque-access-token')
|
||||
assert.equal(capturedOptions.headers['x-auth-username'], undefined)
|
||||
assert.equal(capturedOptions.headers['x-auth-role-codes'], undefined)
|
||||
assert.equal(capturedOptions.headers['x-auth-is-admin'], undefined)
|
||||
}
|
||||
|
||||
async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() {
|
||||
async function testLoginCanDisableBearerInjection() {
|
||||
const sessionStorage = new Map([
|
||||
[
|
||||
'x-financial-auth-user',
|
||||
JSON.stringify({
|
||||
username: 'superadmin',
|
||||
name: 'superadmin',
|
||||
roleCodes: ['manager'],
|
||||
is_admin: true
|
||||
})
|
||||
]
|
||||
['x-financial-auth-access-token', 'stale-token'],
|
||||
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
|
||||
])
|
||||
|
||||
global.window = {
|
||||
@@ -127,11 +118,74 @@ async function testInjectsLegacyAdminHeaderFromSnakeCaseFlag() {
|
||||
}
|
||||
}
|
||||
|
||||
await apiRequest('/reimbursements/claims/demo', { method: 'DELETE' })
|
||||
await apiRequest('/auth/login', {
|
||||
auth: false,
|
||||
handleUnauthorized: false,
|
||||
method: 'POST',
|
||||
body: '{}'
|
||||
})
|
||||
|
||||
assert.equal(capturedOptions.headers['x-auth-username'], 'superadmin')
|
||||
assert.equal(capturedOptions.headers['x-auth-role-codes'], 'manager')
|
||||
assert.equal(capturedOptions.headers['x-auth-is-admin'], 'true')
|
||||
assert.equal(capturedOptions.headers.Authorization, undefined)
|
||||
}
|
||||
|
||||
async function testRejectsCustomAuthorizationOverride() {
|
||||
const sessionStorage = new Map([
|
||||
['x-financial-auth-access-token', 'server-issued-token'],
|
||||
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
|
||||
])
|
||||
global.window = {
|
||||
sessionStorage: {
|
||||
getItem(key) {
|
||||
return sessionStorage.get(key) ?? null
|
||||
}
|
||||
}
|
||||
}
|
||||
let capturedOptions = null
|
||||
global.fetch = async (_url, options) => {
|
||||
capturedOptions = options
|
||||
return { ok: true, async json() { return { ok: true } } }
|
||||
}
|
||||
|
||||
await apiRequest('/knowledge/library', {
|
||||
headers: { Authorization: 'Bearer attacker-token' }
|
||||
})
|
||||
|
||||
assert.equal(capturedOptions.headers.Authorization, 'Bearer server-issued-token')
|
||||
}
|
||||
|
||||
async function testUnauthorizedResponsePublishesSessionExpiredEvent() {
|
||||
const sessionStorage = new Map([
|
||||
['x-financial-auth-access-token', 'expired-token'],
|
||||
['x-financial-auth-expires-at', '2099-01-01T00:00:00.000Z']
|
||||
])
|
||||
const events = []
|
||||
global.window = {
|
||||
sessionStorage: {
|
||||
getItem(key) {
|
||||
return sessionStorage.get(key) ?? null
|
||||
}
|
||||
},
|
||||
dispatchEvent(event) {
|
||||
events.push(event.type)
|
||||
}
|
||||
}
|
||||
global.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
async json() {
|
||||
return { detail: '登录会话已失效。' }
|
||||
}
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
() => apiRequest('/auth/me'),
|
||||
(error) => {
|
||||
assert.equal(error.status, 401)
|
||||
assert.equal(error.code, 'AUTH_SESSION_EXPIRED')
|
||||
return true
|
||||
}
|
||||
)
|
||||
assert.deepEqual(events, ['x-financial:auth-expired'])
|
||||
}
|
||||
|
||||
async function testFormatsValidationErrors() {
|
||||
@@ -192,8 +246,10 @@ async function testRejectsWithCustomTimeoutMessage() {
|
||||
async function run() {
|
||||
await testUsesCustomContentTypeHeader()
|
||||
await testSupportsBlobResponses()
|
||||
await testInjectsAuthenticatedUserHeaders()
|
||||
await testInjectsLegacyAdminHeaderFromSnakeCaseFlag()
|
||||
await testInjectsBearerTokenWithoutUserControlledIdentityHeaders()
|
||||
await testLoginCanDisableBearerInjection()
|
||||
await testRejectsCustomAuthorizationOverride()
|
||||
await testUnauthorizedResponsePublishesSessionExpiredEvent()
|
||||
await testFormatsValidationErrors()
|
||||
await testRejectsWithCustomTimeoutMessage()
|
||||
console.log('api-request tests passed')
|
||||
|
||||
51
web/tests/auth-session-storage.test.mjs
Normal file
51
web/tests/auth-session-storage.test.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
AUTH_ACCESS_TOKEN_KEY,
|
||||
AUTH_EXPIRES_AT_KEY,
|
||||
buildBearerHeaders,
|
||||
clearAuthCredentials,
|
||||
hasValidAuthSession,
|
||||
persistAuthCredentials,
|
||||
readAuthAccessToken,
|
||||
readAuthExpiresAt
|
||||
} from '../src/utils/authSessionStorage.js'
|
||||
|
||||
const storage = new Map()
|
||||
global.window = {
|
||||
sessionStorage: {
|
||||
getItem(key) {
|
||||
return storage.get(key) ?? null
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, String(value))
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expiresAt = '2099-01-01T00:00:00.000Z'
|
||||
persistAuthCredentials('opaque-token', expiresAt)
|
||||
assert.equal(readAuthAccessToken(), 'opaque-token')
|
||||
assert.equal(readAuthExpiresAt(), Date.parse(expiresAt))
|
||||
assert.equal(hasValidAuthSession(Date.parse('2098-01-01T00:00:00.000Z')), true)
|
||||
assert.deepEqual(buildBearerHeaders(), { Authorization: 'Bearer opaque-token' })
|
||||
|
||||
storage.set(AUTH_EXPIRES_AT_KEY, '2020-01-01T00:00:00.000Z')
|
||||
assert.equal(hasValidAuthSession(Date.parse('2021-01-01T00:00:00.000Z')), false)
|
||||
|
||||
storage.set(AUTH_ACCESS_TOKEN_KEY, '')
|
||||
assert.deepEqual(buildBearerHeaders(), {})
|
||||
|
||||
assert.throws(
|
||||
() => persistAuthCredentials('', expiresAt),
|
||||
/未返回有效的认证凭证/
|
||||
)
|
||||
|
||||
clearAuthCredentials()
|
||||
assert.equal(storage.has(AUTH_ACCESS_TOKEN_KEY), false)
|
||||
assert.equal(storage.has(AUTH_EXPIRES_AT_KEY), false)
|
||||
|
||||
console.log('auth session storage tests passed')
|
||||
35
web/tests/vite-setup-lock.test.mjs
Normal file
35
web/tests/vite-setup-lock.test.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { isSetupCompletedState, normalizeState } from '../vite.config.js'
|
||||
|
||||
const completedEnv = {
|
||||
SETUP_COMPLETED: 'true',
|
||||
POSTGRES_HOST: 'database.internal',
|
||||
POSTGRES_PORT: '5432',
|
||||
POSTGRES_DB: 'x_financial',
|
||||
POSTGRES_USER: 'postgres-admin',
|
||||
POSTGRES_PASSWORD: 'secret',
|
||||
REDIS_URL: 'redis://redis.internal:6379/0'
|
||||
}
|
||||
|
||||
assert.equal(isSetupCompletedState(completedEnv, true), true)
|
||||
assert.equal(isSetupCompletedState(completedEnv, false), false)
|
||||
assert.equal(isSetupCompletedState({ SETUP_COMPLETED: 'false' }, true), false)
|
||||
|
||||
const publicState = normalizeState(completedEnv, { adminConfigured: true })
|
||||
assert.equal(publicState.initialized, true)
|
||||
assert.equal(publicState.database.host, '')
|
||||
assert.equal(publicState.database.username, '')
|
||||
assert.equal(publicState.redis.url, '')
|
||||
assert.equal(publicState.database.password_configured, true)
|
||||
|
||||
const setupState = normalizeState(
|
||||
{ ...completedEnv, SETUP_COMPLETED: 'false' },
|
||||
{ adminConfigured: false }
|
||||
)
|
||||
assert.equal(setupState.initialized, false)
|
||||
assert.equal(setupState.database.host, 'database.internal')
|
||||
assert.equal(setupState.database.username, 'postgres-admin')
|
||||
assert.equal(setupState.redis.url, 'redis://redis.internal:6379/0')
|
||||
|
||||
console.log('vite setup lock tests passed')
|
||||
Reference in New Issue
Block a user