oclaw/uds-auth/test/acceptance-batch1.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

170 lines
5.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-1 regressions (R01–R05).
*/
import assert from 'node:assert/strict'
import test from 'node:test'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { MemoryStore } from '../lib/session/memory-store.js'
import { createAgentAuthHandlers } from '../lib/agent-auth.js'
import { RolesStore, ROLES } from '../lib/roles.js'
import { identityFromSessionRecord } from '../lib/dsh-acl.js'
import { runWithUserContext } from '../lib/context.js'
const root = join(dirname(fileURLToPath(import.meta.url)), '..')
test('R01/A05: client attaches bridge only to same-host http↔ws / https↔wss', () => {
const src = readFileSync(join(root, 'lib/client.js'), 'utf8')
assert.match(src, /isTrustedHostUrl/)
assert.match(src, /locSecure !== urlSecure/)
assert.match(src, /!isTrustedHostUrl\(url\)/)
assert.match(src, /!isTrustedHostUrl\(input\)/)
})
test('R03: revoked session fails getBySessionIdSync', async () => {
const store = new MemoryStore()
const { sessionId, bearer } = await store.create({ empNo: 'A001', displayName: 'A' }, 600)
assert.ok(await store.getByBearer(bearer))
assert.ok(store.getBySessionIdSync(sessionId))
await store.revokeSession(sessionId)
assert.equal(store.getBySessionIdSync(sessionId), null)
assert.equal(await store.getByBearer(bearer), null)
})
test('R03/Q10: fallback identity rejected when emergency password disabled', () => {
const roles = new RolesStore({})
roles._roles.set('administrator', ROLES.FALLBACK_ADMIN || 'fallback_admin')
roles._fallbackPasswordHash = null
assert.equal(roles.isFallbackEnabled(), false)
const id = identityFromSessionRecord({
sessionId: 's1',
empNo: 'administrator',
kind: 'fallback',
userData: { empNo: 'administrator' },
}, roles)
assert.equal(id, null)
})
test('R03/Q10: fallback identity accepted when emergency password enabled', () => {
const roles = new RolesStore({})
roles._roles.set('administrator', 'fallback_admin')
roles._fallbackPasswordHash = 'abc'
assert.equal(roles.isFallbackEnabled(), true)
const id = identityFromSessionRecord({
sessionId: 's1',
empNo: 'administrator',
kind: 'fallback',
userData: { empNo: 'administrator' },
}, roles)
assert.equal(id?.empNo, 'administrator')
assert.equal(id?.kind, 'fallback')
})
test('R04: create/follow use live permissions; no admin follow bypass', () => {
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(src, /assertCreateTargetAllowed[\s\S]*?livePermissions\(identity\)/)
assert.match(src, /never bypass projection for admin-at-subscribe/)
assert.equal(/if \(canSeeAllWorkspaces\(identity\)\) \{\s*yield\* origFollow/.test(src), false)
})
test('R05: closing allowRawAgentToken takes effect immediately', async () => {
let allowRaw = true
const handlers = createAgentAuthHandlers({
allowRawTokenResponse: () => allowRaw,
outboundHosts: () => ['example.com'],
resolveCallerSession: async () => ({ empNo: 'A', sessionId: 's' }),
getSessionOwner: () => 'A',
resolveCredentialsForSession: async () => ({ empNo: 'A', token: 'secret' }),
})
allowRaw = false
const chunks = []
const res = {
statusCode: 200,
setHeader() {},
end(s) { chunks.push(String(s)) },
}
await handlers.handleAgentCredentials({
method: 'GET',
headers: {},
socket: { remoteAddress: '127.0.0.1' },
}, res)
assert.equal(res.statusCode, 403)
assert.match(chunks.join(''), /raw_token_disabled/)
})
test('R05: empty outbound list does not revive defaults', async () => {
const handlers = createAgentAuthHandlers({
allowRawTokenResponse: () => false,
outboundHosts: () => [],
resolveCallerSession: async () => ({ empNo: 'A', sessionId: 's' }),
getSessionOwner: () => 'A',
resolveCredentialsForSession: async () => ({ empNo: 'A', token: 'secret' }),
})
const chunks = []
const res = {
statusCode: 200,
setHeader() {},
end(s) { chunks.push(String(s)) },
}
const body = JSON.stringify({
sessionId: 's',
url: 'https://icenterapi.zte.com.cn/x',
method: 'GET',
})
const req = {
method: 'POST',
headers: { 'content-type': 'application/json' },
socket: { remoteAddress: '127.0.0.1' },
async *[Symbol.asyncIterator]() {
yield Buffer.from(body)
},
}
await handlers.handleOutbound(req, res)
const text = chunks.join('')
assert.ok(
/host_not_allowed|outbound_denied|forbidden|not_allowed/i.test(text)
|| res.statusCode >= 400,
text,
)
})
test('R05: live config getters wired in index', () => {
const src = readFileSync(join(root, 'lib/index.js'), 'utf8')
assert.match(src, /getAllowOpenRegistration/)
assert.match(src, /allowRawTokenResponse: \(\) =>/)
assert.match(src, /empty string \/ \[\] means deny-all|deny-all/)
})
test('R02: job/terminal/fs ACL wraps and principal live check', () => {
const src = readFileSync(join(root, 'lib/dsh-acl.js'), 'utf8')
assert.match(src, /jobController/)
assert.match(src, /terminalController/)
assert.match(src, /file_forbidden/)
assert.match(src, /assertPrincipalLive/)
assert.match(src, /getSessionStore/)
})
test('R03: assertCanAccess rejects revoked principal via session store', async () => {
const store = new MemoryStore()
const { sessionId } = await store.create({ empNo: 'A001' }, 600)
const roles = new RolesStore({})
roles._roles.set('A001', 'user')
const identity = identityFromSessionRecord({
sessionId,
empNo: 'A001',
kind: 'uds',
userData: { empNo: 'A001' },
}, roles)
assert.ok(identity)
await store.revokeSession(sessionId)
assert.equal(store.getBySessionIdSync(sessionId), null)
// Simulate installDshAcl assertPrincipalLive gate
const sid = identity.sessionId
assert.ok(sid)
assert.equal(store.getBySessionIdSync(sid), null)
})
// silence unused in some runners
void runWithUserContext