mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-13 04:40:44 +08:00
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>
170 lines
5.8 KiB
JavaScript
170 lines
5.8 KiB
JavaScript
/**
|
||
* 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
|