All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
R1's identity flow, in the shape the UO bridge already proved: the player asks in game, the plugin mints a one-time code and hands it to them, and the website confirms it back through the sidecar. The site is the author of record; this plugin holds no link at all. **`/link` is chat, not console, and the reply is private** (D21). Both frameworks consume a `/` command rather than broadcasting it and `SendReply` addresses one player, so neither the request nor the code reaches anybody else — a code read off a stream is a code somebody else can spend. **Pending codes live in memory and nowhere else** (D22, matching module-uo): six characters from an alphabet with no O/0/I/1, five-minute TTL, a thirty-second cooldown per player, one outstanding code each, and a purge timer because an unconfirmed code is never looked up. A plugin reload drops them — phase 7b's config editor will do that routinely — and the cost is typing `/link` again, which is cheaper than a credential living in a second process. **The code is deliberately NOT in `account.link.requested`.** That event exists so an operator can see linking being used; the code travels through the player, which is what makes typing it proof they are the one who asked. `/unlink` emits rather than deletes, because there is nothing here to delete. It is the escape hatch that makes the site's refusal to move an already-linked Steam id safe: a player who linked the wrong account walks to a computer they are already sitting at. `link.confirm` consumes a code on the FIRST lookup whether or not it turns out to be expired, so a code cannot be probed twice. `unknown` and `expired` stay separate in the reply — an operator wants to know whether codes are being guessed or going stale — and the website says the same sentence for both. `checkPlugin.js` gains a check for the new surface, because a chat command binds by reflection exactly like a hook and fails the same silent way. It asserts the signature, the void return and the absence of duplicates, and — like the hook parser before it — a test reads the real plugin so a regex that matches nothing cannot pass. Verified by breaking it five ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
213 lines
7.7 KiB
JavaScript
213 lines
7.7 KiB
JavaScript
// A check is worth what it catches, so this breaks it thirteen ways.
|
|
//
|
|
// The case that matters most is the last one: `checkPlugin.js` finds hooks with a
|
|
// deliberately narrow regex, and a regex that silently matches NOTHING passes
|
|
// every check in this file and every check in CI while asserting nothing at all.
|
|
// So the real plugin source is read here too, and the parse is asserted against
|
|
// hooks that are known to be in it.
|
|
//
|
|
// node --test scripts/checkPlugin.test.js
|
|
//
|
|
// Named individually rather than `node --test scripts/`: directory mode is not
|
|
// portable across the Node versions this project runs on.
|
|
|
|
const test = require('node:test')
|
|
const assert = require('node:assert')
|
|
const fs = require('node:fs')
|
|
const path = require('node:path')
|
|
|
|
const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin')
|
|
|
|
/** A minimal plugin that passes, as the baseline every case below deviates from. */
|
|
function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) {
|
|
const body =
|
|
methods ??
|
|
` private void OnPlayerDeath(BasePlayer player, HitInfo info)
|
|
{
|
|
}`
|
|
|
|
return `namespace Oxide.Plugins
|
|
{
|
|
internal class RunicGateway : RustPlugin
|
|
{
|
|
private const int ProtocolVersion = ${version};
|
|
|
|
private static readonly string[] ExpectedHooks =
|
|
{
|
|
${expected.map((e) => `"${e}"`).join(', ')}
|
|
};
|
|
|
|
${body}
|
|
}
|
|
}`
|
|
}
|
|
|
|
const toml = (version = 2) => `protocol = ${version}\n`
|
|
|
|
test('a plugin that follows the rules passes', () => {
|
|
assert.deepEqual(check(source(), toml()), [])
|
|
})
|
|
|
|
test('a hook missing from ExpectedHooks is caught, because rg.hooks could not report it', () => {
|
|
const problems = check(source({ expected: [] }), toml())
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /OnPlayerDeath is implemented but missing from ExpectedHooks/)
|
|
})
|
|
|
|
test('a hook that can answer is caught — the rule the read path depends on', () => {
|
|
const methods = ` private object OnPlayerDeath(BasePlayer player, HitInfo info)
|
|
{
|
|
return null;
|
|
}`
|
|
|
|
const problems = check(source({ methods }), toml())
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /returns object, not void/)
|
|
})
|
|
|
|
test('returning null is not good enough — the signature is the rule', () => {
|
|
// `return null` today is one edit away from `return true` tomorrow, and the
|
|
// edit that breaks it looks harmless in a diff. A void method cannot be
|
|
// changed into a veto without changing its signature, which is visible.
|
|
const methods = ` private bool CanUserLogin(string name, string id, string ip)
|
|
{
|
|
return true;
|
|
}`
|
|
|
|
const problems = check(source({ expected: ['CanUserLogin'], methods }), toml())
|
|
assert.match(problems[0], /CanUserLogin returns bool, not void/)
|
|
})
|
|
|
|
test('a name listed but never implemented is caught, because it reports silent for ever', () => {
|
|
const problems = check(source({ expected: ['OnPlayerDeath', 'OnNewSave'] }), toml())
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /ExpectedHooks lists OnNewSave, but no method/)
|
|
})
|
|
|
|
test('a protocol version that disagrees with overlay.toml is caught', () => {
|
|
const problems = check(source({ version: 3 }), toml(2))
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /speaks protocol 3 and overlay\.toml declares 2/)
|
|
})
|
|
|
|
test('a method that is not shaped like a hook is left alone', () => {
|
|
// `Cadence`, `Frame`, `Flatten` and friends are ours, return real types, and
|
|
// must not be dragged into the void rule.
|
|
const methods = ` private Dictionary<string, object> Frame(string kind, string type)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
private static string Column(int index)
|
|
{
|
|
return null;
|
|
}`
|
|
|
|
assert.deepEqual(check(source({ expected: [], methods }), toml()), [])
|
|
assert.ok(!HOOK_NAME.test('Cadence'))
|
|
assert.ok(!HOOK_NAME.test('Frame'))
|
|
assert.ok(HOOK_NAME.test('OnPlayerDeath'))
|
|
assert.ok(HOOK_NAME.test('CanUserLogin'))
|
|
})
|
|
|
|
test('the parser actually reads the real plugin, rather than quietly matching nothing', () => {
|
|
const real = fs.readFileSync(
|
|
path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'),
|
|
'utf8'
|
|
)
|
|
|
|
const methods = readMethods(real)
|
|
const names = new Set(methods.map((m) => m.name))
|
|
|
|
// A narrow regex that matches nothing passes every other test in this file.
|
|
assert.ok(methods.length > 20, `only found ${methods.length} methods in the real plugin`)
|
|
for (const hook of ['OnPlayerDeath', 'OnPlayerConnected', 'CanUserLogin', 'OnNewSave']) {
|
|
assert.ok(names.has(hook), `${hook} was not found by the method parser`)
|
|
}
|
|
|
|
const expected = readExpectedHooks(real)
|
|
assert.ok(expected.length >= 15, `only found ${expected.length} entries in ExpectedHooks`)
|
|
})
|
|
|
|
// ── Chat commands (protocol 3) ─────────────────────────────────────────────
|
|
//
|
|
// A chat command binds by reflection on an exact signature, and a wrong one is
|
|
// never invoked and never logged: the player types `/link` and nothing happens.
|
|
// That is the same failure mode the hook checks exist for, one surface along.
|
|
|
|
/** A plugin whose only body is one chat command. */
|
|
function withChat(attribute, signature, returns = 'void') {
|
|
return source({
|
|
expected: [],
|
|
methods: ` [ChatCommand("${attribute}")]
|
|
private ${returns} CmdThing(${signature})
|
|
{
|
|
}`,
|
|
})
|
|
}
|
|
|
|
const GOOD_SIGNATURE = 'BasePlayer player, string command, string[] args'
|
|
|
|
test('a correctly shaped chat command passes', () => {
|
|
assert.deepEqual(check(withChat('link', GOOD_SIGNATURE), toml()), [])
|
|
})
|
|
|
|
test('a chat command taking the wrong player type is caught', () => {
|
|
// The plausible mistake: Covalence's `IPlayer` is what a Universal plugin
|
|
// takes, and this plugin is not one. It compiles and binds nothing.
|
|
const problems = check(withChat('link', 'IPlayer player, string command, string[] args'), toml())
|
|
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /not \(BasePlayer, string, string\[\]\)/)
|
|
})
|
|
|
|
test('a chat command missing the trailing args is caught', () => {
|
|
const problems = check(withChat('link', 'BasePlayer player, string command'), toml())
|
|
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /nothing happens at all/)
|
|
})
|
|
|
|
test('a chat command that returns something is caught', () => {
|
|
const problems = check(withChat('link', GOOD_SIGNATURE, 'object'), toml())
|
|
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /returns object, not void/)
|
|
})
|
|
|
|
test('two chat commands answering to one name is caught', () => {
|
|
const both = source({
|
|
expected: [],
|
|
methods: ` [ChatCommand("link")]
|
|
private void CmdOne(${GOOD_SIGNATURE})
|
|
{
|
|
}
|
|
|
|
[ChatCommand("link")]
|
|
private void CmdTwo(${GOOD_SIGNATURE})
|
|
{
|
|
}`,
|
|
})
|
|
|
|
const problems = check(both, toml())
|
|
|
|
assert.equal(problems.length, 1)
|
|
assert.match(problems[0], /declared twice/)
|
|
})
|
|
|
|
test('the chat-command parser reads the real plugin, rather than quietly matching nothing', () => {
|
|
// The same trap the method parser has, and the reason this file's last test
|
|
// exists: a regex that matches NOTHING passes every case above.
|
|
const real = fs.readFileSync(
|
|
path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'),
|
|
'utf8'
|
|
)
|
|
|
|
const commands = readChatCommands(real)
|
|
const names = new Set(commands.map((c) => c.command))
|
|
|
|
assert.ok(commands.length >= 2, `only found ${commands.length} chat commands in the real plugin`)
|
|
assert.ok(names.has('link'), '/link was not found by the chat-command parser')
|
|
assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser')
|
|
})
|