refactor(modules)!: move the UO server half out to module-uo (phase 3, slice 1) #137
@@ -4,62 +4,12 @@ const assert = require('node:assert/strict')
|
||||
const logic = require('../src/model/announceJobs/announceJobs.logic')
|
||||
// Each leg owns its own text-building and result classification since PR 4 — a
|
||||
// leg is a registration now, not a branch in the worker (MODULE_SYSTEM.md §1.8).
|
||||
const townCrier = require('../src/utils/shardAnnounce')
|
||||
const discord = require('../src/utils/discordAnnounce')
|
||||
|
||||
// ── buildTownCrierText ───────────────────────────────────────────────────────
|
||||
test('buildTownCrierText produces title, excerpt, and URL lines', () => {
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 7, title: 'Server Update', excerpt: 'Big things afoot.', body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
assert.deepEqual(lines, ['Server Update', 'Big things afoot.', 'https://uom.example/site/news'])
|
||||
})
|
||||
|
||||
test('buildTownCrierText falls back to a stripped body when excerpt is empty', () => {
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: 'T', excerpt: '', body: '<p>Hello <b>world</b></p>' },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
assert.equal(lines[1], 'Hello world')
|
||||
})
|
||||
|
||||
test('buildTownCrierText clamps each line to the sidecar per-line cap', () => {
|
||||
const longTitle = 'x'.repeat(500)
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: longTitle, excerpt: 'y'.repeat(500), body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
for (const line of lines) assert.ok(line.length <= townCrier.MAX_LINE_LEN, `line too long: ${line.length}`)
|
||||
assert.ok(lines[0].endsWith('…'))
|
||||
assert.ok(lines.length <= townCrier.MAX_LINES)
|
||||
})
|
||||
|
||||
test('buildTownCrierText omits the excerpt line when there is no excerpt or body', () => {
|
||||
const lines = townCrier.buildTownCrierText(
|
||||
{ id: 1, title: 'Only a title', excerpt: null, body: null },
|
||||
{ baseUrl: 'https://uom.example' },
|
||||
)
|
||||
assert.deepEqual(lines, ['Only a title', 'https://uom.example/site/news'])
|
||||
})
|
||||
|
||||
// ── classifyTownCrier ────────────────────────────────────────────────────────
|
||||
test('town crier classify: 2xx is done', () => {
|
||||
assert.equal(townCrier.classify({ ok: true, status: 200 }).outcome, 'done')
|
||||
})
|
||||
|
||||
test('town crier classify: over-cap / auth / protocol errors are terminal (no retry)', () => {
|
||||
for (const status of [400, 401, 409]) {
|
||||
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'terminal', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('town crier classify: shard-transient and network errors retry', () => {
|
||||
for (const status of [503, 504, 500, 0]) {
|
||||
assert.equal(townCrier.classify({ ok: false, status }).outcome, 'retry', `status ${status}`)
|
||||
}
|
||||
})
|
||||
|
||||
// The town-crier leg's tests left with module-uo in Phase 3 — it is a MODULE's
|
||||
// leg, and its text building and status classification are its business. What
|
||||
// stays here is core's announce pipeline: the backoff schedule every leg shares,
|
||||
// the parent-status rollup over all of a job's legs, and core's own Discord leg.
|
||||
// ── classifyDiscord ──────────────────────────────────────────────────────────
|
||||
test('discord classify: ok is done, every failure retries', () => {
|
||||
assert.equal(discord.classify({ ok: true, status: 200 }).outcome, 'done')
|
||||
|
||||
@@ -503,11 +503,22 @@ test('ctx exposes exactly the documented surface, and is frozen', () => {
|
||||
assert.equal(stateOf(freshLoader(tmpRoot), 'probe').state, 'registered')
|
||||
|
||||
const probe = JSON.parse(fs.readFileSync(seen, 'utf8'))
|
||||
// API 1.1.0 added activity, users and site — each because module-uo's
|
||||
// extraction needed it and none could be vendored: an admin action a module
|
||||
// performs belongs in core's one audit log, the extension slot needs the user
|
||||
// its prefix names, and §2.7 forbids a module reading core's APP_BASE_URL.
|
||||
assert.deepEqual(probe.keys, [
|
||||
'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'posts', 'push', 'secretBox', 'settings', 'uploads', 'validator',
|
||||
'activity', 'auth', 'db', 'express', 'log', 'middleware', 'moduleId', 'paths',
|
||||
'posts', 'push', 'secretBox', 'settings', 'site', 'uploads', 'users', 'validator',
|
||||
])
|
||||
// is core's limiter FACTORY, not a limiter: a module states its own
|
||||
// window and cap and takes the plumbing, so there is one express-rate-limit in
|
||||
// the process and one place a breach is logged. is
|
||||
// handed over whole because it is shared policy — core's /auth/me and
|
||||
// /player/account sit behind the same counter.
|
||||
assert.deepEqual(probe.middleware, [
|
||||
'accountChangeLimiter', 'noindex', 'rateLimit', 'requireAuth', 'requireRole', 'siteMode', 'validate',
|
||||
])
|
||||
assert.deepEqual(probe.middleware, ['noindex', 'requireAuth', 'requireRole', 'siteMode', 'validate'])
|
||||
assert.equal(probe.moduleId, 'probe')
|
||||
assert.equal(probe.mutable, false, 'ctx members must be frozen')
|
||||
})
|
||||
|
||||
@@ -41,17 +41,16 @@ function tryApply(owner, build) {
|
||||
|
||||
// ── Core goes through the same door ────────────────────────────────────────
|
||||
|
||||
test('registerCore registers core AND the not-yet-extracted shard content', () => {
|
||||
test('registerCore registers exactly what core owns, and nothing else', () => {
|
||||
registries.registerCore()
|
||||
|
||||
const ids = registries.allStreams().map((s) => s.id)
|
||||
// Core's one stream first, then the seven that leave with module-uo.
|
||||
assert.equal(ids[0], 'news.post')
|
||||
assert.equal(ids.length, 8)
|
||||
assert.ok(ids.includes('vendor.sale'))
|
||||
|
||||
assert.deepEqual(registries.announceLegIds(), ['discord', 'towncrier'])
|
||||
assert.equal(registries.slotFilledBy('admin.users.detail'), 'core')
|
||||
// One stream, one leg, no filled slot. Before Phase 3 this was eight streams,
|
||||
// two legs and a core-filled `admin.users.detail` — core was holding shard
|
||||
// CONTENT so the seam would be exercised on every boot before a module first
|
||||
// used it. module-uo registers all of it now, through the same door.
|
||||
assert.deepEqual(registries.allStreams().map((s) => s.id), ['news.post'])
|
||||
assert.deepEqual(registries.announceLegIds(), ['discord'])
|
||||
assert.equal(registries.slotFilledBy('admin.users.detail'), null)
|
||||
assert.equal(registries.isCoreRegistered(), true)
|
||||
})
|
||||
|
||||
@@ -63,15 +62,26 @@ test('registerCore is idempotent — a second call registers nothing twice', ()
|
||||
})
|
||||
|
||||
test('the wire shape of a stream survives registration', () => {
|
||||
registries.registerCore()
|
||||
const personal = registries.allStreams().find((s) => s.id === 'vendor.sale')
|
||||
// Asserted against a REGISTERED stream rather than a core one, because the
|
||||
// shape is what a module hands over and core republishes. `vendor.sale` used
|
||||
// to be the subject here and is module-uo's now; a synthetic registration
|
||||
// tests the same contract without core needing a personal stream of its own.
|
||||
assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([{
|
||||
id: 'uo.vendorsale',
|
||||
label: 'Vendor sales',
|
||||
description: 'One of your vendors sold something.',
|
||||
personal: true,
|
||||
requiresLinkedAccount: true,
|
||||
}])), null)
|
||||
|
||||
const personal = registries.allStreams().find((s) => s.id === 'uo.vendorsale')
|
||||
// Two booleans, not the contract's single `scope`: this object is the body of
|
||||
// GET /auth/me/notifications/streams and a shipped Android client reads both.
|
||||
assert.equal(personal.personal, true)
|
||||
assert.equal(personal.requiresLinkedAccount, true)
|
||||
assert.ok(personal.description.length > 0)
|
||||
assert.ok(registries.personalStreams().has('vendor.sale'))
|
||||
assert.equal(registries.isValidStream('vendor.sale'), true)
|
||||
assert.ok(registries.personalStreams().has('uo.vendorsale'))
|
||||
assert.equal(registries.isValidStream('uo.vendorsale'), true)
|
||||
assert.equal(registries.isValidStream('nope.nope'), false)
|
||||
})
|
||||
|
||||
@@ -185,10 +195,12 @@ test('only a declared slot can be filled, and only once', () => {
|
||||
assert.throws(() => api.registerExtension('admin.invented', router), /unknown extension slot/)
|
||||
assert.throws(() => api.registerExtension('admin.users.detail', 'not a router'), /is not a router/)
|
||||
|
||||
registries.registerCore() // core fills it
|
||||
// Core no longer fills it — module-uo does. Two registrants racing for the
|
||||
// same slot is still the case worth testing, so the first fill is a module's.
|
||||
assert.equal(tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)), null)
|
||||
assert.match(
|
||||
tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)),
|
||||
/already filled by "core"/,
|
||||
tryApply('rust', (a) => a.registerExtension('admin.users.detail', router)),
|
||||
/already filled by "uo"/,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -208,9 +220,11 @@ test('the filled slot’s router is findable in the live app, at the resource pa
|
||||
const { findMountPrefix } = require('../swagger/slotSpecs')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const [slot] = registries.filledSlots()
|
||||
assert.ok(slot, 'app.js must have registered core, filling the slot')
|
||||
assert.equal(slot.slot, 'admin.users.detail')
|
||||
assert.ok(slot.specFile, 'core names the file its slot router is generated from')
|
||||
assert.equal(findMountPrefix(app._router.stack, slot.router), '/api/v1/admin/users/:id')
|
||||
// The slot is DECLARED by core and filled by whichever module is installed —
|
||||
// none, in core's own test run. What must keep working regardless is the
|
||||
// recovery of its mount prefix from the live stack, because that is what
|
||||
// slotSpecs.js needs and what fails silently when it breaks.
|
||||
const router = registries.declaredSlotRouter('admin.users.detail')
|
||||
assert.ok(router, 'core declares the slot at require time, filled or not')
|
||||
assert.equal(findMountPrefix(app._router.stack, router), '/api/v1/admin/users/:id')
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Point the DB at a closed port BEFORE requiring anything that builds the pool,
|
||||
// so any stray query fails fast instead of holding the process open. The session
|
||||
// service, users model, and shardLinks model are all stubbed, so no query runs.
|
||||
// service, users model, and appeals model are all stubbed, so no query runs.
|
||||
process.env.DB_HOST = '127.0.0.1'
|
||||
process.env.DB_PORT = '59999'
|
||||
|
||||
@@ -11,7 +11,7 @@ const { startApp } = require('./_helper')
|
||||
const playerRouter = require('../src/router/v1/player')
|
||||
const sessionService = require('../src/auth/session.service')
|
||||
const users = require('../src/model/users/users.model')
|
||||
const shardLinks = require('../src/model/shardLinks/shardLinks.model')
|
||||
const appeals = require('../src/model/appeals/appeals.model')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -21,14 +21,14 @@ const originals = {
|
||||
isSessionRevoked: sessionService.isSessionRevoked,
|
||||
sessionMeta: sessionService.sessionMeta,
|
||||
getById: users.getById,
|
||||
listForUser: shardLinks.listForUser,
|
||||
listMine: appeals.listMine,
|
||||
}
|
||||
afterEach(() => {
|
||||
sessionService.validateSession = originals.validateSession
|
||||
sessionService.isSessionRevoked = originals.isSessionRevoked
|
||||
sessionService.sessionMeta = originals.sessionMeta
|
||||
users.getById = originals.getById
|
||||
shardLinks.listForUser = originals.listForUser
|
||||
appeals.listMine = originals.listMine
|
||||
})
|
||||
|
||||
// Sign every request in as the given DB user (role decides the gate outcome).
|
||||
@@ -44,28 +44,28 @@ function signInAs(user) {
|
||||
// handlers. Regression guard for the fix that dropped requireRole('player') so a
|
||||
// staff/admin account is no longer 403'd out of its own characters.
|
||||
for (const role of ['player', 'admin', 'editor', 'moderator']) {
|
||||
test(`GET /player/shard/accounts is reachable by an authenticated ${role}`, async () => {
|
||||
test(`GET /player/appeals is reachable by an authenticated ${role}`, async () => {
|
||||
signInAs({ id: 7, username: 'u', role, status: 'active' })
|
||||
shardLinks.listForUser = async (id) => {
|
||||
appeals.listMine = async (id) => {
|
||||
assert.equal(id, 7) // self-scoped to the caller regardless of role
|
||||
return [{ account: 'acctA' }]
|
||||
return [{ id: 1 }]
|
||||
}
|
||||
const app = await startApp((a) => a.use('/api/v1/player', playerRouter))
|
||||
try {
|
||||
const res = await fetch(app.url + '/api/v1/player/shard/accounts')
|
||||
const res = await fetch(app.url + '/api/v1/player/appeals')
|
||||
assert.equal(res.status, 200, `${role} should reach the handler, got ${res.status}`)
|
||||
assert.deepEqual(await res.json(), [{ account: 'acctA' }])
|
||||
assert.deepEqual(await res.json(), [{ id: 1 }])
|
||||
} finally {
|
||||
await app.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('GET /player/shard/accounts still rejects an unauthenticated caller with 401', async () => {
|
||||
test('GET /player/appeals still rejects an unauthenticated caller with 401', async () => {
|
||||
sessionService.validateSession = () => null
|
||||
const app = await startApp((a) => a.use('/api/v1/player', playerRouter))
|
||||
try {
|
||||
const res = await fetch(app.url + '/api/v1/player/shard/accounts')
|
||||
const res = await fetch(app.url + '/api/v1/player/appeals')
|
||||
assert.equal(res.status, 401)
|
||||
} finally {
|
||||
await app.close()
|
||||
@@ -76,7 +76,7 @@ test('a disabled account is still rejected with 403 (status gate, not role)', as
|
||||
signInAs({ id: 8, username: 'banned', role: 'player', status: 'disabled' })
|
||||
const app = await startApp((a) => a.use('/api/v1/player', playerRouter))
|
||||
try {
|
||||
const res = await fetch(app.url + '/api/v1/player/shard/accounts')
|
||||
const res = await fetch(app.url + '/api/v1/player/appeals')
|
||||
assert.equal(res.status, 403)
|
||||
} finally {
|
||||
await app.close()
|
||||
|
||||
@@ -6,11 +6,9 @@ process.env.DB_PORT = '59999'
|
||||
const { test, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { mapShardEvent, createTracker } = require('../src/config/shardStreams')
|
||||
const pushDispatch = require('../src/utils/pushDispatch')
|
||||
// fromShardEvent moved out of pushDispatch in PR 4: core publishes, the shard
|
||||
// side resolves an event to a stream and an owner (MODULE_SYSTEM.md §1.8).
|
||||
const shardPush = require('../src/utils/shardPush')
|
||||
const db = require('../src/utils/db')
|
||||
|
||||
after(() => db.close())
|
||||
@@ -33,71 +31,11 @@ function withEnv(vars, fn) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── mapShardEvent ───────────────────────────────────────────────────────────
|
||||
|
||||
test('server.hello / shutdown / crashed map to the public server.status stream', () => {
|
||||
const t = createTracker()
|
||||
assert.deepEqual(mapShardEvent({ kind: 'server.hello', bootId: 'b1' }, t), [
|
||||
{ streamId: 'server.status', ref: 'up:b1' },
|
||||
])
|
||||
assert.deepEqual(mapShardEvent({ kind: 'server.shutdown' }, t), [{ streamId: 'server.status', ref: 'down' }])
|
||||
assert.deepEqual(mapShardEvent({ kind: 'server.crashed' }, t), [{ streamId: 'server.status', ref: 'down' }])
|
||||
})
|
||||
|
||||
test('house.decay INTO idoc yields the public idoc.warning AND the owner-keyed house.idoc', () => {
|
||||
const t = createTracker()
|
||||
const out = mapShardEvent({ kind: 'house.decay', to: 'IDOC', serial: '0x40', ownerAcct: 'bob' }, t)
|
||||
assert.deepEqual(out, [
|
||||
{ streamId: 'idoc.warning', ref: '0x40' },
|
||||
{ streamId: 'house.idoc', ref: '0x40', ownerAccount: 'bob' },
|
||||
])
|
||||
// A non-IDOC decay stage produces nothing.
|
||||
assert.deepEqual(mapShardEvent({ kind: 'house.decay', to: 'Fairly', serial: '0x41' }, t), [])
|
||||
})
|
||||
|
||||
test('champ.update fires champ.start only on the inactive→active transition', () => {
|
||||
const t = createTracker()
|
||||
// First sight active → start.
|
||||
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
|
||||
{ streamId: 'champ.start', ref: 'c1' },
|
||||
])
|
||||
// Still active → no re-fire.
|
||||
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [])
|
||||
// Goes inactive, then active again → fires again.
|
||||
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: false }, t), [])
|
||||
assert.deepEqual(mapShardEvent({ kind: 'champ.update', serial: 'c1', active: true }, t), [
|
||||
{ streamId: 'champ.start', ref: 'c1' },
|
||||
])
|
||||
})
|
||||
|
||||
test('city.update fires governor.election only on a real governor change, never on first sight', () => {
|
||||
const t = createTracker()
|
||||
// First sight of the city → no election (could be a reconnect snapshot).
|
||||
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
|
||||
// Same governor → nothing.
|
||||
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x1' } }, t), [])
|
||||
// New governor → election.
|
||||
assert.deepEqual(mapShardEvent({ kind: 'city.update', city: 'Britain', governor: { serial: '0x2' } }, t), [
|
||||
{ streamId: 'governor.election', ref: 'Britain' },
|
||||
])
|
||||
})
|
||||
|
||||
test('personal streams are owner-keyed and sensitive kinds never yield a public target', () => {
|
||||
const t = createTracker()
|
||||
const sale = mapShardEvent({ kind: 'vendor.sale', ownerAcct: 'bob', t: 7 }, t)
|
||||
assert.deepEqual(sale, [{ streamId: 'vendor.sale', ref: '7', ownerAccount: 'bob' }])
|
||||
|
||||
const login = mapShardEvent({ kind: 'account.login.attempt', acct: 'bob', ip: '1.2.3.4', t: 9 }, t)
|
||||
assert.deepEqual(login, [{ streamId: 'account.login', ref: '9', ownerAccount: 'bob' }])
|
||||
|
||||
// Every personal target carries an ownerAccount (never a bare public push).
|
||||
for (const target of [...sale, ...login]) assert.ok(target.ownerAccount, 'personal target must be owner-keyed')
|
||||
|
||||
// A truly sensitive, unmapped kind produces nothing at all.
|
||||
assert.deepEqual(mapShardEvent({ kind: 'cheat.fastwalk', acct: 'bob' }, t), [])
|
||||
assert.deepEqual(mapShardEvent({ kind: 'admin.audit', actor: 'staff' }, t), [])
|
||||
})
|
||||
|
||||
// `mapShardEvent` and `fromShardEvent` left with module-uo in Phase 3: which
|
||||
// shard event becomes which stream is the module's catalog, not core's
|
||||
// infrastructure. What stays here is what core owns — the SSRF guard on the
|
||||
// configured ntfy endpoint, and `publish()` delivering a content-free tickle to
|
||||
// the right subscribers.
|
||||
// ── isAllowedEndpoint (SSRF guard) ──────────────────────────────────────────
|
||||
|
||||
test('isAllowedEndpoint pins the configured ntfy origin and rejects everything else', () => {
|
||||
@@ -188,51 +126,3 @@ test('publish skips endpoints that fail the SSRF guard', async () => {
|
||||
assert.equal(calls[0].url, 'https://relay.test/ok')
|
||||
})
|
||||
})
|
||||
|
||||
// ── fromShardEvent (owner resolution) ───────────────────────────────────────
|
||||
|
||||
test('fromShardEvent resolves a personal event to the owning user, or drops it if unlinked', async () => {
|
||||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||||
const { calls, fetchImpl } = captureFetch()
|
||||
const userStreamCalls = []
|
||||
const shardLinks = { getByAccount: async (acct) => (acct === 'mine' ? { userId: 42 } : null) }
|
||||
const pushDevices = {
|
||||
endpointsForStream: async () => [],
|
||||
endpointsForUserStream: async (userId, s) => {
|
||||
userStreamCalls.push([userId, s])
|
||||
return [{ endpoint: 'https://relay.test/UPc' }]
|
||||
},
|
||||
}
|
||||
const deps = { shardLinks, pushDevices, fetchImpl, tracker: createTracker() }
|
||||
|
||||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'mine', t: 1 }, deps)
|
||||
assert.deepEqual(userStreamCalls, [[42, 'vendor.sale']])
|
||||
assert.equal(calls.length, 1)
|
||||
|
||||
// Unlinked account → nobody to notify → no publish.
|
||||
userStreamCalls.length = 0
|
||||
calls.length = 0
|
||||
await shardPush.fromShardEvent({ kind: 'vendor.sale', ownerAcct: 'stranger', t: 2 }, deps)
|
||||
assert.equal(userStreamCalls.length, 0)
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
})
|
||||
|
||||
test('fromShardEvent fans a public shard event to the stream’s subscribers', async () => {
|
||||
await withEnv({ NTFY_BASE_URL: undefined, NTFY_ALLOWED_ORIGINS: undefined }, async () => {
|
||||
const { fetchImpl } = captureFetch()
|
||||
const publicCalls = []
|
||||
const pushDevices = {
|
||||
endpointsForStream: async (s) => {
|
||||
publicCalls.push(s)
|
||||
return []
|
||||
},
|
||||
endpointsForUserStream: async () => [],
|
||||
}
|
||||
await shardPush.fromShardEvent(
|
||||
{ kind: 'server.hello', bootId: 'b1' },
|
||||
{ pushDevices, fetchImpl, tracker: createTracker() },
|
||||
)
|
||||
assert.deepEqual(publicCalls, ['server.status'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,7 +59,11 @@ test('every /admin, /player and /settings route still sits behind the shared aut
|
||||
// unambiguous.
|
||||
const AUTHENTICATED_GROUPS = ['/api/v1/admin/', '/api/v1/player/', '/api/v1/settings/']
|
||||
const gated = collected.public.filter((r) => AUTHENTICATED_GROUPS.some((p) => r.path.startsWith(p)))
|
||||
assert.ok(gated.length > 100, 'expected the gated surface to be found')
|
||||
// A floor, not a count: it exists so a filter that silently matches nothing
|
||||
// fails loudly rather than passing vacuously. It was >100 before Phase 3 moved
|
||||
// 70 UO routes into module-uo, and there is no value in tracking core's exact
|
||||
// total here — the per-route assertion below is what actually guards the gate.
|
||||
assert.ok(gated.length > 50, 'expected the gated surface to be found')
|
||||
for (const route of gated) {
|
||||
assert.ok(
|
||||
route.gates.includes('requireAuth'),
|
||||
|
||||
Reference in New Issue
Block a user