Files
website/server/test/slashCommands.test.js
Claude cecd72915f feat(teams): the slash-command seam, and the first command through it
Phase 7 of TEAMS.md. `api.registerSlashCommands` stops throwing: a module
registers a command's DEFINITION and its HANDLER together, the bot pulls the
definitions over the internal listener and runs none of our code, and the
handler executes here — forced by the bot container having no `modules` volume,
and the right boundary anyway.

Registration validates what Discord would reject as a batch (names, description
lengths, the four option types, required-before-optional), because the bot
registers the whole set in one PUT and a single bad entry costs every command
including the bot's own. Commands are not namespaced under their owner — there
is no dot in Discord's name grammar — so collisions are first-come with the
holder named.

The dispatcher is the access boundary: `linked` has no Discord equivalent, so
the platform-side permission default can only ever be advertising. It resolves
the actor by `auth_providers.kind` rather than the id slug, treats a banned
account as unlinked, bounds a handler under the bot's own timeout, and keeps
`ok` outside the envelope so a handler cannot forge it.

Liveness is asked at both the pull and the dispatch. The registries have no
removal path, so a module an operator disables at runtime would otherwise keep
a live handler behind a command Discord still advertises.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-18 18:53:34 -05:00

262 lines
12 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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 modules 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 Discords 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 } })
})