// ── Slash commands: registration, the actor, and the dispatcher ──────────── // // TEAMS.md §7.1, phase 7. Three things are worth a test here and none of them // can be exercised by hand without a Discord guild: // // 1. **Registration rejects a definition Discord would reject as a batch.** The // bot registers the whole set in one PUT, so one bad option type costs every // command — including the bot's own. That has to fail at `register()`. // 2. **The dispatcher is the access boundary**, not the Discord-side permission // default, which cannot express "has a linked account" at all. // 3. **A handler's failure is its own.** A throw, a hang, and a disabled owner // each produce an answer the bot can render, and never an exception. process.env.DB_HOST = '127.0.0.1' process.env.DB_PORT = '59999' const { test, beforeEach, after } = require('node:test') const assert = require('node:assert/strict') const registries = require('../src/modules/registries') const slash = require('../src/utils/slashCommands') const loader = require('../src/modules/loader') const authProviders = require('../src/model/authProviders/authProviders.model') const userIdentities = require('../src/model/userIdentities/userIdentities.model') const users = require('../src/model/users/users.model') const db = require('../src/utils/db') after(() => db.close()) const originals = { isLoaded: loader.isLoaded, list: loader.list, version: loader.version, providerList: authProviders.list, findIdentity: userIdentities.findByProviderSubject, getUser: users.getById, } // The loader is stubbed rather than driven: `load()` needs a modules directory // on disk, and every property below is about what the registry and the // dispatcher do with a state, not about how the state was reached. function loaded(state = 'started', id = 'uo') { loader.isLoaded = () => true loader.list = () => [{ id, state }] loader.version = () => 42 } beforeEach(() => { registries._reset() Object.assign(loader, { isLoaded: originals.isLoaded, list: originals.list, version: originals.version }) Object.assign(authProviders, { list: originals.providerList }) Object.assign(userIdentities, { findByProviderSubject: originals.findIdentity }) Object.assign(users, { getById: originals.getUser }) }) const cmd = (over = {}) => ({ name: 'guild', description: 'Show a guild', handler: async () => ({ text: 'ok' }), ...over, }) /** Register a batch as `owner`; returns the error message, or null on success. */ function tryRegister(owner, commands) { const api = registries.stage(owner) try { api.registerSlashCommands(commands) registries.apply(api.staged) return null } catch (err) { return err.message } } // ── Registration ─────────────────────────────────────────────────────────── test('a registered command is served without its handler', () => { assert.equal(tryRegister('uo', [cmd()]), null) const [definition] = registries.slashCommandDefinitions() assert.equal(definition.name, 'guild') assert.equal(definition.owner, 'uo') assert.equal(definition.access, 'everyone', 'access defaults rather than being undefined on the wire') assert.equal(definition.handler, undefined, 'the handler never crosses the process boundary') assert.equal(typeof registries.slashCommand('guild').handler, 'function') }) // Discord's name grammar has no dot in it, so the owner-prefix rule every other // id in registries.js follows cannot be expressed here. Collisions are the // substitute, and they have to name the holder. test('two registrants cannot hold the same command name', () => { tryRegister('uo', [cmd()]) assert.match(tryRegister('rust', [cmd()]), /already registered by "uo"/) // The same name twice inside ONE batch, which the held-by check above cannot // catch — nothing is committed yet when the second entry is validated. registries._reset() assert.match(tryRegister('uo', [cmd(), cmd()]), /registered twice/) }) test('a name Discord would reject is refused at registration', () => { assert.match(tryRegister('uo', [cmd({ name: 'uo.guild' })]), /bad command name/) assert.match(tryRegister('uo', [cmd({ name: 'Guild' })]), /bad command name/) assert.match(tryRegister('uo', [cmd({ name: 'g'.repeat(33) })]), /bad command name/) assert.match(tryRegister('uo', [cmd({ description: '' })]), /description of 1-100/) assert.match(tryRegister('uo', [cmd({ handler: 'nope' })]), /has no handler/) assert.match(tryRegister('uo', [cmd({ access: 'members' })]), /unknown access "members"/) }) // §7.1.1 keeps the option schema small on purpose: subcommand groups, // autocomplete, attachments and modals are the features whose semantics do not // survive a second platform. test('only the four option types survive registration', () => { const opt = (over) => cmd({ options: [{ name: 'x', type: 'string', description: 'd', ...over }] }) assert.equal(tryRegister('uo', [opt({})]), null) registries._reset() assert.match(tryRegister('uo', [opt({ type: 'attachment' })]), /unsupported type "attachment"/) assert.match(tryRegister('uo', [opt({ type: 'boolean', choices: [{ name: 'a', value: 1 }] })]), /choices need string or integer/) assert.match(tryRegister('uo', [opt({ choices: [] })]), /empty choices list/) }) // Discord rejects the whole batch for this, so it cannot be left to be // discovered at the next `ready`. test('a required option after an optional one is refused', () => { const options = [ { name: 'a', type: 'string', description: 'd', required: false }, { name: 'b', type: 'string', description: 'd', required: true }, ] assert.match(tryRegister('uo', [cmd({ options })]), /required option after an optional one/) }) // The validate-then-commit rule the rest of registries.js follows: a batch that // fails leaves nothing behind, or the bot would pull a half-registered set. test('a batch that fails registers none of it', () => { tryRegister('uo', [cmd(), cmd({ name: 'bad name' })]) assert.deepEqual(registries.slashCommandDefinitions(), []) }) // ── Liveness ─────────────────────────────────────────────────────────────── // The registries have no removal path — nothing takes a registration back — so a // module an operator disables at runtime would otherwise keep answering. test('a disabled module’s command disappears from the pull and stops dispatching', async () => { tryRegister('uo', [cmd()]) loaded('started') assert.equal(slash.definitions().commands.length, 1) assert.equal(slash.definitions().version, 42) loaded('disabled') assert.deepEqual(slash.definitions().commands, []) assert.deepEqual(await slash.dispatch({ command: 'guild' }), { ok: false, reason: 'unknown' }) }) test('before load() there is nothing registered and that is an answer, not an error', () => { loader.isLoaded = () => false assert.deepEqual(slash.definitions(), { version: 0, commands: [] }) }) // ── The actor ────────────────────────────────────────────────────────────── function identity({ providerId = 'discord', kind = 'discord', role = 'player', status = 'active' } = {}) { authProviders.list = async () => [{ id: providerId, kind }] userIdentities.findByProviderSubject = async (provider, subject) => (provider === providerId && subject === '555' ? { user_id: 9 } : null) users.getById = async (id) => (id === 9 ? { id: 9, role, status } : null) } // `auth_providers.id` is an operator-chosen slug; `kind` is the enum. Resolving // by id would report "not linked" for every user on a deployment that named its // Discord provider anything else. test('the Discord provider is found by kind, whatever the operator named it', async () => { identity({ providerId: 'our-discord' }) const actor = await slash.resolveActor({ platform: 'discord', platformUserId: '555' }) assert.deepEqual(actor, { platform: 'discord', platformUserId: '555', guildId: null, userId: 9, role: 'player', isLinked: true, isStaff: false, }) }) test('a non-Discord kind on a provider named "discord" does not link anyone', async () => { identity({ kind: 'oidc' }) const actor = await slash.resolveActor({ platform: 'discord', platformUserId: '555' }) assert.equal(actor.isLinked, false) }) // Otherwise Discord would be the one surface a ban does not reach. test('a banned or disabled account resolves as unlinked', async () => { identity({ status: 'banned' }) const actor = await slash.resolveActor({ platform: 'discord', platformUserId: '555' }) assert.equal(actor.isLinked, false) assert.equal(actor.userId, null) }) test('staff is the same two roles every other Team surface means by it', async () => { identity({ role: 'moderator' }) assert.equal((await slash.resolveActor({ platform: 'discord', platformUserId: '555' })).isStaff, true) identity({ role: 'editor' }) assert.equal((await slash.resolveActor({ platform: 'discord', platformUserId: '555' })).isStaff, false) }) // ── Dispatch ─────────────────────────────────────────────────────────────── test('access is enforced by the dispatcher, not by Discord’s permission model', async () => { identity({ role: 'player' }) loaded() tryRegister('uo', [cmd({ name: 'linked-only', access: 'linked' }), cmd({ name: 'staff-only', access: 'staff' })]) const unlinked = { command: 'linked-only', platformUserId: 'nobody' } assert.deepEqual(await slash.dispatch(unlinked), { ok: false, reason: 'forbidden', access: 'linked', isLinked: false, }) // A linked player clears `linked` and not `staff` — the distinction Discord's // default_member_permissions cannot express at all. assert.equal((await slash.dispatch({ command: 'linked-only', platformUserId: '555' })).ok, true) assert.equal((await slash.dispatch({ command: 'staff-only', platformUserId: '555' })).reason, 'forbidden') }) test('a handler that throws costs its own command and answers renderably', async () => { loaded() tryRegister('uo', [cmd({ handler: async () => { throw new Error('boom') } })]) assert.deepEqual(await slash.dispatch({ command: 'guild' }), { ok: false, reason: 'error' }) }) test('a handler that hangs is bounded, not waited on', async () => { loaded() tryRegister('uo', [cmd({ handler: () => new Promise(() => {}) })]) const started = Date.now() const res = await slash.dispatch({ command: 'guild' }) assert.deepEqual(res, { ok: false, reason: 'error' }) assert.ok(Date.now() - started < slash.HANDLER_TIMEOUT_MS + 500) }) // `ok` is core's verdict on whether the handler produced an answer, so it lives // outside the envelope where a handler cannot write it. test('a handler cannot forge the success flag', async () => { loaded() tryRegister('uo', [cmd({ handler: async () => ({ ok: false, text: 'hi' }) })]) const res = await slash.dispatch({ command: 'guild' }) assert.equal(res.ok, true) assert.equal(res.response.ok, undefined) assert.equal(res.response.text, 'hi') }) // An oversized reply fails inside the bot's editReply, where the module that // produced it cannot be seen — the caller would get "that command failed" for a // handler that worked. test('the envelope is clamped to what Discord will render', () => { const out = slash.envelope({ text: 'x'.repeat(3000), fields: Array.from({ length: 30 }, (_, i) => ({ name: `n${i}`, value: 'v'.repeat(2000) })), url: 'javascript:alert(1)', notice: 'link your account', }, 'guild') assert.equal(out.text.length, 2000) assert.equal(out.fields.length, 25) assert.equal(out.fields[0].value.length, 1024) assert.equal(out.url, undefined, 'only absolute http(s) survives') assert.equal(out.notice, 'link your account') }) test('a handler that returns nothing has simply said nothing', async () => { loaded() tryRegister('uo', [cmd({ handler: async () => undefined })]) assert.deepEqual(await slash.dispatch({ command: 'guild' }), { ok: true, response: { ephemeral: false } }) })