oclaw/uds-auth/test/acceptance-batch2.test.js
oliver 07cfbefb6c
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
Enable default emergency account
2026-10-12 10:46:39 +08:00

193 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Acceptance batch-2/3 regressions (R06–R14 fragments).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { mkdtemp, writeFile, readFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { TaskCapabilityStore } from '../lib/task-capability.js'
import { createAgentAuthHandlers } from '../lib/agent-auth.js'
import { RolesStore, ROLES, ACCOUNT_STATUS, LEGACY_DEFAULT_SHA256, hashPasswordScrypt, verifyPasswordHash } from '../lib/roles.js'
import { loadOrCreateSecretKey, sealSecret } from '../lib/utils/secret-box.js'
import { checkRequestOrigin } from '../lib/utils/origin-guard.js'
import { SessionAclStore } from '../lib/session-acl.js'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('R06: task capability authorizes outbound without browser session', async () => {
const caps = new TaskCapabilityStore({ defaultTtlSeconds: 600 })
const minted = caps.mint({
empNo: 'A001',
dshSessionId: 'sess-a',
scopes: ['outbound', 'credentials'],
})
const handlers = createAgentAuthHandlers({
taskCapabilities: caps,
allowRawTokenResponse: () => false,
outboundHosts: () => ['example.com'],
getSessionOwner: () => 'A001',
resolveCredentialsForSession: async () => ({ empNo: 'A001', token: 'tok' }),
resolveCallerSession: async () => null,
})
const chunks = []
const res = { statusCode: 200, setHeader() {}, end(s) { chunks.push(String(s)) } }
// credentials still blocked when raw disabled
await handlers.handleAgentCredentials({
method: 'POST',
headers: {
'x-uds-task-capability': minted.token,
'x-dsh-session-id': 'sess-a',
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
async *[Symbol.asyncIterator]() {
yield Buffer.from(JSON.stringify({ sessionId: 'sess-a', taskCapability: minted.token }))
},
}, res)
assert.equal(res.statusCode, 403)
assert.match(chunks.join(''), /raw_token_disabled/)
// verify() binds empNo
const v = caps.verify(minted.token, { audience: 'uds-auth-agent', dshSessionId: 'sess-a' })
assert.equal(v?.empNo, 'A001')
assert.equal(caps.verify('deadbeefdeadbeefdeadbeefdeadbeef', {}), null)
})
test('R07: user-info response includes bo whitelist (source)', () => {
const src = readFileSync(join(root, 'lib/index.js'), 'utf8')
assert.match(src, /bo: \[safeRow\]/)
assert.match(src, /profile:\s*\{/)
})
test('R08: nested sessionIds and workspace remove projection (source)', () => {
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(src, /projectWorkspaceItem/)
assert.match(src, /frame\.type === 'remove'/)
assert.match(src, /projectMutationResult/)
})
test('R09: concurrent last-admin demotion keeps >=1 super_admin', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-roles-'))
const file = join(dir, 'roles.json')
const store = new RolesStore({ rolesFile: file })
await store.init()
store._roles.set('a', ROLES.SUPER_ADMIN)
store._roles.set('b', ROLES.SUPER_ADMIN)
store._dirty = true
await store.flush()
const results = await Promise.allSettled([
store.setRole('a', ROLES.USER, ROLES.SUPER_ADMIN),
store.setRole('b', ROLES.USER, ROLES.SUPER_ADMIN),
])
const fulfilled = results.filter((r) => r.status === 'fulfilled').length
const rejected = results.filter((r) => r.status === 'rejected').length
assert.equal(fulfilled + rejected, 2)
assert.ok(rejected >= 1, 'at least one demote must fail')
const supers = await store.countByRole(ROLES.SUPER_ADMIN)
assert.ok(supers >= 1, `remaining super_admins=${supers}`)
})
test('R09: corrupt session owners fail closed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-own-'))
const file = join(dir, 'owners.json')
await writeFile(file, '{not-json', 'utf8')
const store = new SessionAclStore({ ownersFile: file })
await store.init()
assert.equal(store.loadFailed, true)
})
test('R10: scrypt hash roundtrip; legacy default refused', () => {
const h = hashPasswordScrypt('CorrectHorseBattery')
assert.ok(h.startsWith('scrypt$'))
assert.equal(verifyPasswordHash('CorrectHorseBattery', h), true)
assert.equal(verifyPasswordHash('wrong', h), false)
assert.equal(verifyPasswordHash('Admin@123', LEGACY_DEFAULT_SHA256), false)
})
test('R10: setFallbackPassword rejects short and known default', async () => {
const store = new RolesStore({})
store._roles.set('admin', ROLES.SUPER_ADMIN)
await assert.rejects(() => store.setFallbackPassword('short', ROLES.SUPER_ADMIN), /password_too_short/)
await assert.rejects(() => store.setFallbackPassword('Admin@123', ROLES.SUPER_ADMIN), /password_too_common/)
await store.setFallbackPassword('LongEnoughPass1', ROLES.SUPER_ADMIN)
assert.equal(store.verifyFallback('LongEnoughPass1', '127.0.0.1'), true)
})
test('R11: invalid env key fails closed', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-key-'))
const prev = process.env.UDS_AUTH_SECRET_KEY
process.env.UDS_AUTH_SECRET_KEY = 'not-hex'
try {
await assert.rejects(() => loadOrCreateSecretKey(dir), (err) => err.code === 'invalid_secret_key_env')
} finally {
if (prev === undefined) delete process.env.UDS_AUTH_SECRET_KEY
else process.env.UDS_AUTH_SECRET_KEY = prev
}
})
test('R11: corrupt key file is not replaced', async () => {
const dir = await mkdtemp(join(tmpdir(), 'uds-key2-'))
await writeFile(join(dir, '.uds-auth-secret-key'), 'garbage\n', 'utf8')
const prev = process.env.UDS_AUTH_SECRET_KEY
delete process.env.UDS_AUTH_SECRET_KEY
try {
await assert.rejects(() => loadOrCreateSecretKey(dir), (err) => err.code === 'secret_key_corrupt')
const still = await readFile(join(dir, '.uds-auth-secret-key'), 'utf8')
assert.match(still, /garbage/)
} finally {
if (prev !== undefined) process.env.UDS_AUTH_SECRET_KEY = prev
}
})
test('R12: disabled account cannot be ensureUser / open-register revived', async () => {
const store = new RolesStore({})
store._roles.set('u1', ROLES.USER)
store._roles.set('admin', ROLES.SUPER_ADMIN)
await store.removeUser('u1', ROLES.SUPER_ADMIN)
assert.equal(store.isDisabled('u1'), true)
assert.equal(store.getAccountStatus('u1'), ACCOUNT_STATUS.DISABLED)
await assert.rejects(() => store.ensureUser('u1', ROLES.SUPER_ADMIN), /account_disabled/)
})
test('R13: trustedHosts rejects unlisted Host', () => {
const req = {
method: 'POST',
headers: {
host: 'evil.example:8787',
origin: 'https://evil.example:8787',
},
socket: { remoteAddress: '1.2.3.4' },
}
const denied = checkRequestOrigin(req, { trustedHosts: ['good.example:8787'] })
assert.equal(denied.ok, false)
assert.equal(denied.reason, 'host_not_trusted')
const ok = checkRequestOrigin({
method: 'POST',
headers: { host: 'good.example:8787', origin: 'https://good.example:8787' },
socket: { remoteAddress: '1.2.3.4' },
}, { trustedHosts: ['good.example:8787'] })
assert.equal(ok.ok, true)
})
test('R13/R14: QR challenge store + live identity patch + docs (source)', () => {
const index = readFileSync(join(root, 'lib/index.js'), 'utf8')
assert.match(index, /consumeQrChallenge/)
assert.match(index, /MAX_QR_UPSTREAM_BYTES/)
assert.match(index, /mintTaskCapability/)
const acl = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(acl, /__udsAuthIdentityLive/)
const readme = readFileSync(join(root, 'README.zh.md'), 'utf8')
assert.match(readme, /administrator.*Admin@123/)
assert.match(readme, /mintTaskCapability|UDS_TASK_CAPABILITY/)
})
test('R06 python helper mentions task capability', () => {
const py = readFileSync(join(root, 'skill-helpers/python/uds_skill_auth.py'), 'utf8')
assert.match(py, /UDS_TASK_CAPABILITY/)
assert.match(py, /X-UDS-Task-Capability/)
})