/**
* UI-token (ui.exposeToken) tests.
*
* exposeToken lets the relay embed its management token into the served
* dashboard/wizard HTML so the browser UI can authenticate when it reaches
* the relay through a trusted authenticating reverse proxy (e.g. Umbrel's
* app_proxy), where the localhost check can never apply. Off by default —
* direct/localhost and existing fleet deployments are entirely unaffected.
*
* Also covers the dashboard-asset path resolver: the git-tracked source
* lives at the repo root, which is what a fresh clone and the Docker image
* have — so serving must not depend on the legacy packages/core/dashboard
* copy that only lingers on long-lived installs.
*/
import test from 'brittle'
import { createHmac } from 'crypto'
import { RelayAPI } from 'p2p-hiverelay/core/relay-node/api.js'
import { deriveTokenFromSeed } from 'p2p-hiverelay/config/loader.js'
function makeApi (opts = {}) {
// Minimal node stub; set auth fields deterministically (ignore env).
const api = new RelayAPI({ emit () {} }, { apiPort: 0, apiHost: '127.0.0.1' })
api._apiKey = opts.apiKey || null
api._uiExposeToken = opts.uiExposeToken || false
api.trustProxy = opts.trustProxy || false
return api
}
function fakeRes () {
return {
headers: {},
statusCode: null,
body: null,
setHeader (k, v) { this.headers[k.toLowerCase()] = v },
writeHead (s) { this.statusCode = s },
end (b) { this.body = b }
}
}
const reqFrom = (remoteAddress, headers = {}) => ({ socket: { remoteAddress }, headers })
// ─── deriveTokenFromSeed ───────────────────────────────────────────
test('deriveTokenFromSeed: stable, hex, distinct per seed', (t) => {
const seed = 'a'.repeat(48)
const tok = deriveTokenFromSeed(seed)
t.is(typeof tok, 'string', 'returns a string')
t.is(tok.length, 64, '32-byte token rendered as hex')
t.is(tok, deriveTokenFromSeed(seed), 'same seed -> same token (reinstall-safe)')
t.not(deriveTokenFromSeed('b'.repeat(48)), tok, 'different seed -> different token')
})
test('deriveTokenFromSeed: guards missing/short/non-string input', (t) => {
t.is(deriveTokenFromSeed(null), null, 'null -> null')
t.is(deriveTokenFromSeed(undefined), null, 'undefined -> null')
t.is(deriveTokenFromSeed(''), null, 'empty -> null')
t.is(deriveTokenFromSeed('short'), null, 'too short -> null')
t.is(deriveTokenFromSeed(12345), null, 'non-string -> null')
})
test('deriveTokenFromSeed: domain-separated from wizard identity key', (t) => {
const seed = 'c'.repeat(48)
const wizardKey = createHmac('sha256', 'hiverelay/wizard/v1')
.update(Buffer.from(seed, 'utf8')).digest('hex')
t.not(deriveTokenFromSeed(seed), wizardKey, 'ui-token salt differs from wizard salt')
})
// ─── token injection into served HTML ──────────────────────────────
test('exposeToken OFF: no token injected (default / fleet path)', async (t) => {
const api = makeApi() // no key, no exposeToken
const res = fakeRes()
await api._serveDashboard(res, '_t_off', 'index.html')
t.is(res.statusCode, 200, 'served ok')
// The page's inert fetch-shim mentions the meta NAME, so assert on the
// injected <meta ... content=> tag specifically, not the bare string.
t.absent(res.body.includes('<meta name="hiverelay-ui-token" content='), 'no injected token meta')
t.absent(res.headers['cache-control'], 'no no-store header when not exposing')
t.is(res.headers['content-type'], 'text/html; charset=utf-8', 'dashboard HTML is explicitly typed')
t.is(res.headers['x-content-type-options'], 'nosniff', 'dashboard responses disable content sniffing')
t.is(res.headers['referrer-policy'], 'no-referrer', 'dashboard responses do not leak referrers')
t.ok(res.headers['permissions-policy'].includes('camera=()'), 'dashboard disables browser device permissions')
t.ok(res.headers['content-security-policy'].includes("default-src 'self'"), 'dashboard sets a same-origin CSP')
t.ok(res.headers['content-security-policy'].includes("object-src 'none'"), 'dashboard blocks plugin/object loads')
t.ok(res.headers['content-security-policy'].includes("connect-src 'self' ws: wss:"), 'dashboard CSP keeps live API/WebSocket connects explicit')
})
test('exposeToken ON + key: token meta embedded + no-store', async (t) => {
const api = makeApi({ apiKey: 'deadbeefcafe', uiExposeToken: true })
const res = fakeRes()
await api._serveDashboard(res, '_t_on', 'index.html')
t.ok(res.body.includes('<meta name="hiverelay-ui-token" content="deadbeefcafe">'), 'token meta injected into head')
t.is(res.headers['cache-control'], 'no-store, max-age=0', 'token response is not cacheable')
})
test('exposeToken ON but no key: nothing injected (start() would disable)', async (t) => {
const api = makeApi({ uiExposeToken: true }) // _apiKey null
const res = fakeRes()
await api._serveDashboard(res, '_t_nokey', 'index.html')
t.absent(res.body.includes('<meta name="hiverelay-ui-token" content='), 'no token to embed -> no injected meta')
})
test('token value is HTML-attribute-escaped (no breakout)', async (t) => {
const api = makeApi({ apiKey: 'a"><script>x', uiExposeToken: true })
const res = fakeRes()
await api._serveDashboard(res, '_t_esc', 'index.html')
t.ok(res.body.includes('content="a"><script>x"'), 'special chars escaped')
t.absent(res.body.includes('content="a"><script>x"'), 'no raw attribute breakout')
})
test('wizard.html is served + token-injected in exposeToken mode', async (t) => {
const api = makeApi({ apiKey: 'feed', uiExposeToken: true })
const res = fakeRes()
await api._serveDashboard(res, '_t_wiz', 'wizard.html')
t.is(res.statusCode, 200, 'wizard served (resolved from repo-root dashboard)')
t.ok(res.body.includes('<meta name="hiverelay-ui-token" content="feed">'), 'wizard carries the injected token')
})
// ─── auth: wizard/management endpoints honor the bearer token ──────
test('_checkAuth: bearer token accepted behind proxy (exposeToken)', (t) => {
const api = makeApi({ apiKey: 'tok', uiExposeToken: true, trustProxy: true })
t.ok(api._checkAuth(reqFrom('10.0.0.5', { authorization: 'Bearer tok' })), 'proxy request with token authorizes')
t.absent(api._checkAuth(reqFrom('10.0.0.5', {})), 'proxy request without token rejected')
})
test('no key + no exposeToken: localhost still authorizes (unchanged)', (t) => {
const api = makeApi()
t.ok(api._checkAuth(reqFrom('127.0.0.1')), 'localhost dev path unchanged')
})