oclaw/uds-auth/test/acceptance-batch2.test.js
oliver df97c5e042
Some checks are pending
ci / test (push) Waiting to run
ci / test-postgresql (push) Waiting to run
fix(uds-auth): emergency login workspaces, Desktop key prompts, persistent sessions
Emergency (fallback / sealed-box) admins lost the workspace sidebar and saw
"sign-in failed / add API key" on DSH Desktop because the remote.mux opens
anonymously at boot and every early call was rejected terminally.

- gateway: anonymous workspace/follow reaches dsh-acl's empty baseline;
  anonymous streams are parked until the carrier aborts instead of failing;
  anonymous workspace/initializeDefault answers "nothing created"
- request-auth: a dead UDS_SESSION cookie no longer shadows a valid WS ticket
  or bridge (sync + async variants used by all callers)
- dsh-acl: /api/* waits (bounded 15s) for the gateway ACL during Host startup
  instead of 403, so the Desktop welcome read no longer fails
- desktop-bootstrap: key/account projection is best-effort per reference
- client: emit connection/reset after login so boot-time caches (settings
  describe mirror -> Settings > Models) re-read under the real principal
- sessions: absolute lifetime fixed at 7 days (was min(ttl, 7d) = 30 min);
  bridge expiry slides with its session; MemoryStore persisted to
  sessions.json (bearer hash only, userData AES-256-GCM sealed)
- README: session lifetime/persistence and DSH compatibility notes

Also includes previously uncommitted uds-auth 0.3.x work in this tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 02:11:09 +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.doesNotMatch(readme, /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/)
})