mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-13 08:23:18 +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>
353 lines
22 KiB
JavaScript
353 lines
22 KiB
JavaScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { mkdtemp, readFile, writeFile, mkdir, rm, readdir, lstat } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { RolesStore, ROLES } from '../lib/roles.js'
|
|
import { hashPasswordAsync, derivePasswordKey } from '../lib/utils/password-kdf.js'
|
|
import { sealLocalAdminBox, openLocalAdminBoxAsync } from '../lib/local-admin.js'
|
|
import { QrChallengeStore, qrLoginCredentials } from '../lib/qr-challenge.js'
|
|
import { TaskCapabilityStore } from '../lib/task-capability.js'
|
|
import { createTaskEnvironment } from '../lib/task-environment.js'
|
|
import { SessionBridgeStore } from '../lib/session-bridge.js'
|
|
import { extractSessionBearer } from '../lib/session/request-auth.js'
|
|
import { migrateState } from '../lib/state-migration.js'
|
|
import { atomicWriteFile } from '../lib/utils/atomic-write.js'
|
|
import { checkRequestOrigin } from '../lib/utils/origin-guard.js'
|
|
import { protectProfileDirectory } from '../lib/profile-security.js'
|
|
import { runInNewContext } from 'node:vm'
|
|
import { installGatewayPolicy } from '../lib/gateway-policy.js'
|
|
import { installGatewayEvents } from '../lib/gateway-events.js'
|
|
|
|
test('Gateway guards new namespaces and lookup owners before invocation, including live streams', async () => {
|
|
let principal = { empNo: 'u' }, active = true, viewAll = false, prepared = 0
|
|
const descriptors = {
|
|
global: { namespace: 'newPlugin', method: 'global', parameters: [], invocation: { kind: 'direct' } },
|
|
read: { namespace: 'newPlugin', method: 'read', parameters: [{ name: 'workspaceFileScope', source: 'lookup', wire: 'workspaceFileScopeId' }], invocation: { kind: 'direct' } },
|
|
stream: { namespace: 'newPlugin', method: 'stream', parameters: [{ name: 'sessionId', wire: 'sessionId' }], invocation: { kind: 'direct' }, mode: 'stream' },
|
|
}
|
|
const gateway = {
|
|
async *openRemoteEvents() {}, receiveRemoteEventResult() {},
|
|
resolveDescriptor(ns, method) { return descriptors[method] },
|
|
async prepareInvocation(request) {
|
|
prepared++
|
|
return { method: request.method === 'stream' ? async function* () { yield 'first'; yield 'second' } : () => 'ok' }
|
|
},
|
|
}
|
|
installGatewayPolicy(gateway, { identity: () => principal,
|
|
assertPrincipal: p => { if (!p || !active) throw new Error('login_required') },
|
|
permissions: () => ({ canAccessSettings: viewAll }),
|
|
assertSession: sid => { if (sid !== 'own') throw new Error('session_forbidden') },
|
|
deny: code => { throw new Error(code) } })
|
|
await assert.rejects(gateway.prepareInvocation({ namespace: 'newPlugin', method: 'global', args: {} }), /forbidden_settings/)
|
|
await assert.rejects(gateway.prepareInvocation({ namespace: 'newPlugin', method: 'read', args: { workspaceFileScopeId: 'foreign' } }), /session_forbidden/)
|
|
assert.equal(prepared, 0, 'authorization precedes providers and lookups')
|
|
const invocation = await gateway.prepareInvocation({ namespace: 'newPlugin', method: 'read', args: { workspaceFileScopeId: 'own' } })
|
|
assert.equal(invocation.method(), 'ok')
|
|
const stream = (await gateway.prepareInvocation({ namespace: 'newPlugin', method: 'stream', args: { sessionId: 'own' } })).method()
|
|
assert.equal((await stream.next()).value, 'first')
|
|
active = false; await assert.rejects(stream.next(), /login_required/)
|
|
principal = undefined
|
|
assert.equal((await gateway.prepareInvocation({ namespace: 'newPlugin', method: 'global', args: {} })).method(), 'ok')
|
|
principal = null; await assert.rejects(gateway.prepareInvocation({ namespace: 'newPlugin', method: 'global', args: {} }), /login_required/)
|
|
assert.throws(() => installGatewayPolicy({}, {}), /unsupported_gateway_acl_contract/)
|
|
})
|
|
|
|
test('Gateway event stream hides foreign events and binds responses to the issuing local session', async () => {
|
|
let principal = { empNo: 'u', sessionId: 'login1' }, active = true
|
|
let finish
|
|
const gate = new Promise(resolve => { finish = resolve })
|
|
const gateway = { pendingRemoteEvents: new Map([['event-own', { frame: { agentId: 'own' } }]]),
|
|
async *openRemoteEvents() {
|
|
yield { type: 'ready', clientId: 'client1', host: { home: 'private-home' } }
|
|
yield { type: 'emit', event: 'api-session/added', args: [{ id: 'foreign', title: 'secret' }] }
|
|
yield { type: 'emit', event: 'api-session/status', args: ['own', true] }
|
|
yield { type: 'emit', event: 'credentials/record-updated', args: ['global-secret'] }
|
|
yield { type: 'waterfall', eventId: 'event-foreign', agentId: 'foreign', request: { secret: 'hidden' } }
|
|
yield { type: 'waterfall', eventId: 'event-own', agentId: 'own', request: {} }
|
|
await gate
|
|
yield { type: 'emit', event: 'api-session/status', args: ['own', false] }
|
|
}, receiveRemoteEventResult() { return 'accepted' } }
|
|
installGatewayEvents(gateway, { identity: () => principal,
|
|
assertPrincipal: p => { if (!p || !active) throw new Error('login_required') },
|
|
permissions: () => ({ canAccessSettings: false }), assertSession: sid => { if (sid !== 'own') throw new Error('session_forbidden') },
|
|
deny: code => { throw new Error(code) } })
|
|
const stream = gateway.openRemoteEvents()
|
|
assert.deepEqual((await stream.next()).value.host, { home: '' })
|
|
assert.equal((await stream.next()).value.event, 'api-session/status')
|
|
assert.equal((await stream.next()).value.eventId, 'event-own')
|
|
const result = { clientId: 'client1', eventId: 'event-own' }
|
|
assert.equal(gateway.receiveRemoteEventResult({}, result), 'accepted')
|
|
principal = { empNo: 'u', sessionId: 'login2' }
|
|
assert.throws(() => gateway.receiveRemoteEventResult({}, result), /event_client_forbidden/)
|
|
active = false; finish(); await assert.rejects(stream.next(), /login_required/)
|
|
})
|
|
|
|
test('background shell capability survives foreground completion and ends with its own job', () => {
|
|
const caps = new TaskCapabilityStore(), roles = new RolesStore({}), controller = new AbortController()
|
|
const env = createTaskEnvironment({ getOwner: () => 'u', rolesStore: roles, capabilities: caps, getWebServer: () => ({ port: 12345 }) })
|
|
const execution = { token: {}, agent: { session: { header: { id: 'own' } } }, signal: controller.signal }
|
|
const values = env.contributor.resolve(execution)
|
|
let listener, unsubscribed = false
|
|
const jobs = { list: () => [{ id: 'job1', status: 'running' }], events: { subscribe(scope, fn) { assert.deepEqual(scope, { owner: 'own' }); listener = fn; return () => { unsubscribed = true } } } }
|
|
env.finish(execution, { value: { kind: 'background', jobId: 'job1' } }, jobs)
|
|
controller.abort()
|
|
assert.ok(caps.verify(values.DSH_UDS_TASK_CAPABILITY))
|
|
listener({ type: 'settled', job: { id: 'other' } }); assert.ok(caps.verify(values.DSH_UDS_TASK_CAPABILITY))
|
|
listener({ type: 'settled', job: { id: 'job1' } }); assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null)
|
|
assert.equal(unsubscribed, true)
|
|
})
|
|
|
|
async function temporary(t) {
|
|
const dir = await mkdtemp(join(tmpdir(), 'uds-auth-0310-'))
|
|
t.after(() => rm(dir, { recursive: true, force: true }))
|
|
return dir
|
|
}
|
|
|
|
test('password transactions queue set/set/clear and persist a restart-safe generation', async t => {
|
|
const dir = await temporary(t), file = join(dir, 'roles.json')
|
|
const store = new RolesStore({ rolesFile: file })
|
|
await store.init()
|
|
await Promise.all([
|
|
store.setFallbackPassword('password-first-0310', ROLES.SUPER_ADMIN),
|
|
store.setFallbackPassword('password-second-0310', ROLES.SUPER_ADMIN),
|
|
store.clearFallbackPassword(ROLES.SUPER_ADMIN),
|
|
])
|
|
assert.equal(store.isFallbackEnabled(), false)
|
|
assert.equal(store.getFallbackCredVersion(), 3)
|
|
const reopened = new RolesStore({ rolesFile: file }); await reopened.init()
|
|
assert.equal(reopened.isFallbackEnabled(), false)
|
|
assert.equal(reopened.getFallbackCredVersion(), 3)
|
|
})
|
|
|
|
test('queued admin writes recheck the actor after a preceding demotion commits', async t => {
|
|
const store = new RolesStore({ rolesFile: join(await temporary(t), 'roles.json') }); await store.init()
|
|
await store.ensureUser('actor', ROLES.SUPER_ADMIN); await store.setRole('actor', ROLES.ADMIN, ROLES.SUPER_ADMIN)
|
|
await store.ensureUser('target', ROLES.SUPER_ADMIN)
|
|
const flush = store.flush.bind(store)
|
|
let start, finish
|
|
const started = new Promise(resolve => { start = resolve }), gate = new Promise(resolve => { finish = resolve })
|
|
store.flush = async () => { start(); await gate; return flush() }
|
|
const demotion = store.setRole('actor', ROLES.USER, ROLES.SUPER_ADMIN)
|
|
await started
|
|
const stale = store.setRole('target', ROLES.ADMIN, () => store.getRole('actor'))
|
|
const denied = assert.rejects(stale, { code: 'forbidden_set_role' })
|
|
finish(); await demotion; await denied
|
|
assert.equal(store.getRole('target'), ROLES.USER)
|
|
})
|
|
|
|
test('failed credential write cannot resurrect an uncommitted peer; next queued write commits', async t => {
|
|
const file = join(await temporary(t), 'roles.json')
|
|
const store = new RolesStore({ rolesFile: file }); await store.init()
|
|
await store.setFallbackPassword('original-password-0310', ROLES.SUPER_ADMIN)
|
|
const originalFlush = store.flush.bind(store)
|
|
let start, finish
|
|
const started = new Promise(r => start = r), gate = new Promise(r => finish = r)
|
|
let count = 0
|
|
store.flush = async () => {
|
|
if (++count === 1) { start(); await gate; throw new Error('injected-write-failure') }
|
|
await originalFlush()
|
|
}
|
|
const first = store.setFallbackPassword('uncommitted-password-0310', ROLES.SUPER_ADMIN)
|
|
const failure = assert.rejects(first, /injected-write-failure/)
|
|
await started
|
|
const second = store.setFallbackPassword('committed-second-0310', ROLES.SUPER_ADMIN)
|
|
assert.equal(store.getFallbackCredVersion(), 1, 'readers see only the durable generation')
|
|
finish(); await failure; await second
|
|
assert.equal(await store.verifyFallbackAsync('uncommitted-password-0310', 'ip1'), false)
|
|
assert.equal(await store.verifyFallbackAsync('committed-second-0310', 'ip2'), true)
|
|
const again = new RolesStore({ rolesFile: file }); await again.init()
|
|
assert.equal(again.getFallbackCredVersion(), 2)
|
|
assert.equal(await again.verifyFallbackAsync('committed-second-0310', 'ip3'), true)
|
|
})
|
|
|
|
test('staged role promotion is invisible before commit; failed preferences restore durable state', async t => {
|
|
const store = new RolesStore({ rolesFile: join(await temporary(t), 'roles.json') }); await store.init()
|
|
await store.ensureUser('u', ROLES.SUPER_ADMIN); await store.ensureUser('boss', ROLES.SUPER_ADMIN)
|
|
await store.setRole('boss', ROLES.SUPER_ADMIN, ROLES.SUPER_ADMIN)
|
|
let start, finish
|
|
const started = new Promise(r => start = r), gate = new Promise(r => finish = r)
|
|
store.flush = async () => { start(); await gate; throw new Error('disk-offline') }
|
|
const promotion = store.setRole('u', ROLES.ADMIN, ROLES.SUPER_ADMIN)
|
|
const failed = assert.rejects(promotion, /disk-offline/)
|
|
await started
|
|
assert.equal(store.resolvePermissions('u').canAccessSettings, false)
|
|
finish(); await failed
|
|
assert.equal(store.getRole('u'), ROLES.USER)
|
|
await assert.rejects(store.setViewAllSessionsDurable('boss', false), /disk-offline/)
|
|
assert.equal(store.resolvePermissions('boss').canViewAllSessions, true)
|
|
})
|
|
|
|
test('runtime KDF leaves the event loop responsive and rejects queue overload', async () => {
|
|
let heartbeat = false
|
|
const timer = setTimeout(() => { heartbeat = true }, 0)
|
|
await hashPasswordAsync('responsive-password-0310')
|
|
clearTimeout(timer)
|
|
assert.equal(heartbeat, true)
|
|
const results = await Promise.allSettled(Array.from({ length: 12 }, () => derivePasswordKey('load-password', Buffer.alloc(16))))
|
|
assert.equal(results.filter(r => r.status === 'fulfilled').length, 10)
|
|
assert.equal(results.filter(r => r.status === 'rejected' && r.reason.code === 'kdf_busy').length, 2)
|
|
})
|
|
|
|
test('async sealed-box unlock uses the existing wire format and rejects wrong passphrase', async () => {
|
|
const box = sealLocalAdminBox('offline-passphrase-0310')
|
|
assert.equal((await openLocalAdminBoxAsync(box, 'offline-passphrase-0310')).ok, true)
|
|
assert.equal((await openLocalAdminBoxAsync(box, 'wrong-passphrase-0310')).ok, false)
|
|
})
|
|
|
|
test('QR pending survives; public scan coordinates cannot poll or consume on another browser', () => {
|
|
const store = new QrChallengeStore(), c = store.create({ loginSystemCode: 'sys', originSystemCode: '' })
|
|
assert.equal(c.qrCodeStr.includes(c.browserBinding), false)
|
|
assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, '').reason, 'qr_binding_required')
|
|
assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, 'f'.repeat(64)).ok, false)
|
|
assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, c.browserBinding).ok, true)
|
|
assert.equal(store.peek(c.qrCodeKey, c.qrCodeValue, c.browserBinding).ok, true)
|
|
const results = Array.from({ length: 20 }, () => store.consume(c.qrCodeKey, c.qrCodeValue, c.browserBinding))
|
|
assert.equal(results.filter(r => r.ok).length, 1)
|
|
assert.equal(results.filter(r => r.reason === 'qr_replay').length, 19)
|
|
})
|
|
|
|
test('QR expiry and missing-credential success fail closed', () => {
|
|
const store = new QrChallengeStore(), c = store.create({ loginSystemCode: 'sys', originSystemCode: '' })
|
|
store.rows.get(c.qrCodeKey).expiresAt = Date.now() - 1
|
|
assert.equal(store.consume(c.qrCodeKey, c.qrCodeValue, c.browserBinding).reason, 'qr_expired')
|
|
assert.equal(qrLoginCredentials({ code: { code: '0000' }, bo: { code: '0000' } }), null)
|
|
assert.equal(qrLoginCredentials({ code: '0000', bo: { code: '4002' }, other: { account: 'u', token: 't' } }), null)
|
|
assert.deepEqual(qrLoginCredentials({ code: '0000', bo: { code: '0000' }, other: { account: 'u', token: 't' } }), { empNo: 'u', token: 't' })
|
|
})
|
|
|
|
test('shell/cron environment derives the owner and revokes on tool result/abort/disposal', () => {
|
|
const roles = new RolesStore({}), caps = new TaskCapabilityStore()
|
|
const owners = new Map([['s1', 'u']])
|
|
const env = createTaskEnvironment({ getOwner: sid => owners.get(sid), rolesStore: roles,
|
|
capabilities: caps, getWebServer: () => ({ port: 43210 }) })
|
|
const controller = new AbortController()
|
|
const execution = { token: {}, agent: { session: { header: { id: 's1' } } }, signal: controller.signal,
|
|
arguments: { empNo: 'other', env: { UDS_TASK_CAPABILITY: 'forged' } } }
|
|
let values = env.contributor.resolve(execution)
|
|
let cap = caps.verify(values.DSH_UDS_TASK_CAPABILITY, { dshSessionId: 's1' })
|
|
assert.equal(cap.empNo, 'u'); assert.deepEqual(cap.scopes, ['outbound'])
|
|
assert.equal(values.DSH_UDS_AUTH_BASE, 'http://127.0.0.1:43210/uds-auth')
|
|
assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY, { dshSessionId: 's2' }), null)
|
|
env.release({ token: execution.token }); assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null)
|
|
values = env.contributor.resolve(execution); controller.abort()
|
|
assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null)
|
|
const next = { token: {}, agent: execution.agent, signal: new AbortController().signal }
|
|
values = env.contributor.resolve(next); env.dispose()
|
|
assert.equal(caps.verify(values.DSH_UDS_TASK_CAPABILITY), null)
|
|
assert.equal(env.contributor.resolve({ token: {}, agent: { session: { header: { id: 'missing' } } } }).DSH_UDS_TASK_CAPABILITY, '')
|
|
})
|
|
|
|
test('WS tickets authorize only GET with a real upgrade on the Gateway mux entry', () => {
|
|
const bridge = new SessionBridgeStore({}), cap = bridge.mintWsTicket({ empNo: 'u', bearer: 'b'.repeat(32) })
|
|
const headers = { upgrade: 'websocket', connection: 'Upgrade' }
|
|
const good = { method: 'GET', url: '/api/remote.mux?udsWsTicket=' + cap.ticket, headers }
|
|
// Wrong path / method must not consume. Missing Connection is allowed (0.3.9 Desktop).
|
|
for (const bad of [
|
|
{ ...good, url: '/foreign?udsWsTicket=' + cap.ticket },
|
|
{ ...good, method: 'POST' },
|
|
{ ...good, headers: { connection: 'Upgrade' } },
|
|
]) {
|
|
assert.equal(extractSessionBearer(bad, { sessionBridge: bridge }), null)
|
|
}
|
|
assert.equal(
|
|
extractSessionBearer(
|
|
{ ...good, headers: { upgrade: 'websocket' } },
|
|
{ sessionBridge: bridge },
|
|
)?.via,
|
|
'ws_ticket',
|
|
)
|
|
const cap2 = bridge.mintWsTicket({ empNo: 'u', bearer: 'b'.repeat(32) })
|
|
const good2 = { method: 'GET', url: '/api/remote.mux?udsWsTicket=' + cap2.ticket, headers }
|
|
assert.equal(extractSessionBearer(good2, { sessionBridge: bridge })?.via, 'ws_ticket')
|
|
assert.equal(extractSessionBearer(good2, { sessionBridge: bridge }), null)
|
|
})
|
|
|
|
test('Host/CSRF/upgrade matrix rejects hostile authority, Origin and browser cross-site signals', () => {
|
|
const req = { method: 'POST', headers: { host: 'localhost:3000', origin: 'http://localhost:3000' }, socket: { remoteAddress: '127.0.0.1' } }
|
|
assert.equal(checkRequestOrigin(req).ok, true)
|
|
for (const headers of [{ ...req.headers, host: 'evil.invalid:3000' }, { ...req.headers, origin: 'http://evil.invalid:3000' },
|
|
{ ...req.headers, origin: 'file://localhost:3000' }, { ...req.headers, origin: 'http://localhost:3000/path' },
|
|
{ ...req.headers, host: 'localhost:3000/evil' }, { ...req.headers, 'sec-fetch-site': 'cross-site' }]) {
|
|
assert.equal(checkRequestOrigin({ ...req, method: 'GET', headers }, { upgrade: true }).ok, false)
|
|
}
|
|
assert.equal(checkRequestOrigin({ ...req, headers: { host: '[::1]:3000', origin: 'null' }, socket: { remoteAddress: '::1' } }).ok, true)
|
|
assert.equal(checkRequestOrigin({ ...req, headers: { host: 'localhost:3000', origin: 'dsh-app://app' } }).ok, true)
|
|
assert.equal(checkRequestOrigin({ ...req, headers: { host: 'localhost:3000', origin: 'dsh-app://foreign' } }).ok, false)
|
|
})
|
|
|
|
test('legacy migration rolls back partial writes, resumes identical files and never overwrites conflicts', async t => {
|
|
const root = await temporary(t), src = join(root, 'src'), dst = join(root, 'dst')
|
|
await mkdir(src); await mkdir(dst)
|
|
await writeFile(join(src, 'roles.json'), '{"roles":{"boss":"super_admin"}}')
|
|
await writeFile(join(src, 'session-owners.json'), '{"owners":{"s":"boss"}}')
|
|
let calls = 0
|
|
const { copyFile } = await import('node:fs/promises')
|
|
await assert.rejects(migrateState(dst, src, { error() {} }, { copyFile: async (...args) => {
|
|
if (++calls === 2) throw Object.assign(new Error('disk error'), { code: 'EIO' })
|
|
return copyFile(...args)
|
|
} }), /disk error/)
|
|
assert.deepEqual(await readdir(dst), [])
|
|
await copyFile(join(src, 'roles.json'), join(dst, 'roles.json'))
|
|
assert.equal((await migrateState(dst, src, { info() {} })).migrated, true)
|
|
assert.equal((await migrateState(dst, src)).reason, 'already')
|
|
const conflict = join(root, 'conflict'); await mkdir(conflict)
|
|
await writeFile(join(conflict, 'roles.json'), '{"roles":{}}')
|
|
await assert.rejects(migrateState(conflict, src), { code: 'legacy_migrate_conflict' })
|
|
assert.deepEqual(await readdir(conflict), ['roles.json'])
|
|
})
|
|
|
|
test('failed atomic rename cleans temporary secret buffers from disk', async t => {
|
|
const dir = await temporary(t), dest = join(dir, 'directory'); await mkdir(dest)
|
|
await assert.rejects(atomicWriteFile(dest, 'secret'))
|
|
assert.deepEqual(await readdir(dir), ['directory'])
|
|
})
|
|
|
|
test('profile applies and verifies actual platform permissions on a disposable directory', async t => {
|
|
const dir = await temporary(t); await writeFile(join(dir, 'roles.json'), '{}')
|
|
const result = await protectProfileDirectory(dir)
|
|
if (process.platform === 'win32') {
|
|
assert.equal(result.protected, true); assert.deepEqual(result.principals, ['current-user', 'SYSTEM'])
|
|
} else {
|
|
assert.equal((await lstat(dir)).mode & 0o777, 0o700)
|
|
assert.equal((await lstat(join(dir, 'roles.json'))).mode & 0o777, 0o600)
|
|
}
|
|
})
|
|
|
|
test('actual Desktop fetch/WS wrapper preserves headers and only attaches credentials to the owned authority', async () => {
|
|
const src = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
|
|
const calls = [], sockets = [], bridge = { empNo: 'administrator', token: 'b'.repeat(32), kind: 'sealed_box' }
|
|
const window = { fetch: async (input, opts) => { calls.push({ input, opts }); return {} },
|
|
WebSocket: function(url) { sockets.push(url) }, localStorage: { getItem: () => JSON.stringify(bridge) },
|
|
__ModuleLoader__: { load({ factory }) { factory(() => ({ createElement() {} })) } } }
|
|
const location = { protocol: 'dsh-app:', hostname: 'app', port: '', href: 'dsh-app://app/' }
|
|
const ticketOpens = []
|
|
const context = { window, location, URL, Headers, console, XMLHttpRequest: function() {
|
|
this.open = (method, url) => { ticketOpens.push(String(url)) }
|
|
this.setRequestHeader = () => {}; this.send = () => {}
|
|
this.status = 200; this.responseText = '{"ticket":"once"}'
|
|
} }
|
|
runInNewContext(src, context)
|
|
// 0.3.9: without streamBaseUrl, only loopback is trusted (not arbitrary hosts).
|
|
await window.fetch('http://example.com/api/remote')
|
|
assert.equal(calls.at(-1).opts, undefined, 'boot not ready must not trust non-loopback')
|
|
await window.fetch('http://127.0.0.1:1111/api/remote')
|
|
assert.equal(calls.at(-1).opts.headers['X-UDS-Bridge-Token'], bridge.token, 'loopback Gateway trusted before transport ready')
|
|
context.__DSH_TRANSPORT__ = { streamBaseUrl: 'http://127.0.0.1:43210/' }
|
|
await window.fetch('dsh-app://foreign/api/remote'); assert.equal(calls.at(-1).opts, undefined)
|
|
await window.fetch('http://127.0.0.1:43211/api/remote'); assert.equal(calls.at(-1).opts, undefined)
|
|
await window.fetch({ url: 'http://127.0.0.1:43210/api/remote', headers: new Headers({ 'X-Original': 'keep' }) })
|
|
assert.equal(calls.at(-1).opts.headers['X-UDS-Bridge-Token'], bridge.token)
|
|
assert.equal(calls.at(-1).opts.headers['x-original'], 'keep')
|
|
assert.equal(calls.at(-1).opts.redirect, 'error')
|
|
new window.WebSocket('ws://127.0.0.1:43210/api/remote.mux')
|
|
assert.equal(new URL(sockets.at(-1)).searchParams.get('udsWsTicket'), 'once')
|
|
assert.equal(sockets.at(-1).includes(bridge.token), false)
|
|
assert.ok(
|
|
ticketOpens[0] === '/uds-auth/api/ws-ticket'
|
|
|| ticketOpens.some((u) => u.startsWith('http://127.0.0.1:43210/uds-auth/api/ws-ticket')),
|
|
'Desktop ticket mint must hit relative /uds-auth or Host streamBaseUrl',
|
|
)
|
|
new window.WebSocket('ws://127.0.0.1:43210/foreign')
|
|
assert.equal(new URL(sockets.at(-1)).search, '')
|
|
})
|