The one place untrusted game data becomes a public page (docs/website/TEAMS.md
§2.8), and the gate on releasing it (§2.9).
A Team's name is written by a player, in the game, with no review, and this
platform turns it into a public page, a URL and eventually a Discord channel
name. Someone naming their guild "Admin" or "<Brand> Staff" gets an
official-looking page on the operator's own site for free.
Hide, never reject. Core cannot refuse a name -- the guild already exists in the
game and core is a mirror of it, not an authority over it. A match hides the Team
from public surfaces and files it in a review queue, and it keeps working
completely for its own members: their forum, their grants, their notifications.
The people in it are not being punished for a name their leader chose.
That asymmetry -- a false positive costs a human glance, a false negative costs
an impersonated staff page -- is what lets the matcher be conservative. It is not
licence to be sloppy the other way: a check that fires on "Badminton" gets
switched off, and then the real cost is paid in full. So matching is whole WORDS
after normalisation, never substrings, following the precedent
scripts/checkModuleIdentifiers.js set for exactly this reason.
Three matcher gaps found by writing the tests, all real impersonation vectors:
- "Guild of Moderators" did not match `moderator`. Only a trailing s off the
WHOLE term is stripped, so "Nomads" still does not match `mod`.
- "G.M." normalises to two single-letter words and matched nothing. A run of
two or more single-letter words is now also offered joined. Deliberately not
a whole-name condensation, which would re-admit substring matching.
- The multi-word condensed form was already handled and is what makes
"RunicGateway" match the two-word term -- the form an impersonator would
reach for, since it is what the Gitea org and every URL use.
Terms resolve at CHECK time, never baked in, so renaming a deployment protects
the new name without a redeploy. A failed settings read falls back to the static
role and project terms rather than to an empty list: screening fewer terms is
bad, screening none is the whole hole.
Re-screening runs on every reconcile, over names no human has ruled on. Names are
immutable per row, so it only ever changes an outcome when the TERM LIST changed
-- an operator adding one, or a rename -- which is exactly what a create-time-only
check would miss forever. `name_reviewed_at` is what makes a staff decision
sticky; without it an override would be undone every fifteen minutes.
The gate is scoped to three actions because they publish untrusted game-sourced
strings, and to nothing else. Ordinary forum grants, leadership overrides,
archives and forum moderation still apply immediately and are audited. A
moderator initiating one files a pending request; an admin applies at once.
Never four-eyes on admins: users.role defaults to admin and `npm run seed`
creates exactly one, so most deployments have precisely one and a second-approver
rule would wedge them with no way out.
Hiding is deliberately NOT gated. Publishing untrusted data needs a second pair
of eyes; withdrawing it needs to be possible at once, by whoever is on duty.
Two concurrency details worth the review: a decision moves the row out of
`pending` under a guard and applies its effect only if the row actually moved,
so two admins clicking approve cannot double-apply or overwrite each other's
record; and a JSON payload is parsed defensively, because the driver returns
JSON columns already parsed on some versions and as a string on others.
Screening is stubbed in the reconciler's own tests -- it is a separate unit, and
the real call reads settings, which this suite must never do against a live
database. That was caught the hard way: the suite went from 11s to hanging, and
the cause was the reconciler reaching a dead pool through the new call.
44 tests in the reconciler file (up from 39), 19 for the matcher, 25 for the
gate. Full suite 877 passed, 0 failed.
Refs docs/website/TEAMS.md §2.8, §2.9, Part 12 phase 2
Co-Authored-By: Claude <noreply@anthropic.com>
805 lines
32 KiB
JavaScript
805 lines
32 KiB
JavaScript
// The reconciler and its four refusal gates (docs/website/TEAMS.md §2.4).
|
||
//
|
||
// The db layer is stubbed and an in-memory projection stands in for the tables,
|
||
// so these are assertions about the ALGORITHM: which answers are applied, which
|
||
// are refused, and what is left untouched when one is refused. The gates are the
|
||
// reason the file exists — every one of them is invariant 1 in a different
|
||
// costume, and each is easy to "simplify" away by someone who has not seen what
|
||
// an empty answer during a cold start does to a site full of rosters.
|
||
const { test, beforeEach, afterEach } = require('node:test')
|
||
const assert = require('node:assert/strict')
|
||
|
||
const registries = require('../src/modules/registries')
|
||
const teamsDb = require('../src/model/teams/teams.db')
|
||
const moderation = require('../src/model/teams/teamModeration.model')
|
||
const settings = require('../src/model/settings/settings.model')
|
||
const teamSync = require('../src/model/teams/teamSync.model')
|
||
|
||
// ── An in-memory stand-in for the four tables ──────────────────────────────
|
||
|
||
let store
|
||
const saved = new Map()
|
||
|
||
function patch(mod, name, fn) {
|
||
if (!saved.has(mod)) saved.set(mod, new Map())
|
||
if (!saved.get(mod).has(name)) saved.get(mod).set(name, mod[name])
|
||
mod[name] = fn
|
||
}
|
||
|
||
function restore() {
|
||
for (const [mod, names] of saved) for (const [name, fn] of names) mod[name] = fn
|
||
saved.clear()
|
||
}
|
||
|
||
function freshStore() {
|
||
return {
|
||
teams: [], // { id, module_id, external_id, name, abbr, slug, status, ... }
|
||
members: new Map(), // teamId -> Map(memberKey -> row)
|
||
sync: new Map(), // moduleId -> row
|
||
nextId: 1,
|
||
}
|
||
}
|
||
|
||
function membersOf(teamId) {
|
||
if (!store.members.has(teamId)) store.members.set(teamId, new Map())
|
||
return store.members.get(teamId)
|
||
}
|
||
|
||
function stubDb() {
|
||
patch(teamsDb, 'activeByModule', async (moduleId) =>
|
||
store.teams.filter((t) => t.module_id === moduleId && t.status === 'active'))
|
||
|
||
patch(teamsDb, 'findActive', async (moduleId, externalId) =>
|
||
store.teams.find((t) => t.module_id === moduleId && t.external_id === externalId && t.status === 'active'))
|
||
|
||
patch(teamsDb, 'findById', async (id) => store.teams.find((t) => t.id === id))
|
||
|
||
patch(teamsDb, 'slugsLike', async (base) =>
|
||
store.teams.filter((t) => t.slug === base || t.slug.startsWith(`${base}-`)).map((t) => t.slug))
|
||
|
||
patch(teamsDb, 'insertTeam', async (row) => {
|
||
const id = store.nextId++
|
||
store.teams.push({
|
||
id,
|
||
module_id: row.moduleId,
|
||
external_id: row.externalId,
|
||
name: row.name,
|
||
abbr: row.abbr ?? null,
|
||
slug: row.slug,
|
||
meta: row.meta ?? null,
|
||
status: 'active',
|
||
hidden: row.hidden ? 1 : 0,
|
||
hidden_reason: row.hiddenReason || null,
|
||
hidden_term: row.hiddenTerm || null,
|
||
members_empty_since: null,
|
||
roster_synced_at: null,
|
||
succeeded_by: null,
|
||
member_count: 0,
|
||
linked_count: 0,
|
||
online_count: 0,
|
||
})
|
||
return id
|
||
})
|
||
|
||
patch(teamsDb, 'updateTeam', async (id, { abbr, meta }) => {
|
||
const t = store.teams.find((x) => x.id === id)
|
||
if (t) Object.assign(t, { abbr, meta })
|
||
})
|
||
|
||
patch(teamsDb, 'archiveTeam', async (id, reason, succeededBy = null) => {
|
||
const t = store.teams.find((x) => x.id === id && x.status === 'active')
|
||
if (t) Object.assign(t, { status: 'archived', archived_reason: reason, succeeded_by: succeededBy })
|
||
})
|
||
|
||
patch(teamsDb, 'recount', async (teamId) => {
|
||
const t = store.teams.find((x) => x.id === teamId)
|
||
if (!t) return
|
||
const rows = [...membersOf(teamId).values()].filter((m) => m.status === 'active')
|
||
t.member_count = rows.length
|
||
t.linked_count = rows.filter((m) => m.user_id != null).length
|
||
t.online_count = rows.filter((m) => m.online).length
|
||
})
|
||
|
||
patch(teamsDb, 'markRosterSynced', async (teamId) => {
|
||
const t = store.teams.find((x) => x.id === teamId)
|
||
if (t) t.roster_synced_at = new Date()
|
||
})
|
||
|
||
patch(teamsDb, 'setMembersEmptySince', async (teamId, since) => {
|
||
const t = store.teams.find((x) => x.id === teamId)
|
||
if (t) t.members_empty_since = since
|
||
})
|
||
|
||
patch(teamsDb, 'memberKeys', async (teamId) =>
|
||
[...membersOf(teamId).values()].filter((m) => m.status === 'active').map((m) => m.member_key))
|
||
|
||
patch(teamsDb, 'upsertMember', async (m) => {
|
||
const existing = membersOf(m.teamId).get(m.memberKey)
|
||
membersOf(m.teamId).set(m.memberKey, {
|
||
team_id: m.teamId,
|
||
member_key: m.memberKey,
|
||
display_name: m.displayName ?? null,
|
||
user_id: m.userId ?? null,
|
||
// Insert-only, mirroring the ON DUPLICATE KEY UPDATE clause that omits it:
|
||
// leadership is getTeamLeaders()'s answer, not the roster's.
|
||
is_leader: existing ? existing.is_leader : (m.isLeader ? 1 : 0),
|
||
rank_label: m.rankLabel ?? null,
|
||
online: m.online ? 1 : 0,
|
||
status: 'active',
|
||
first_seen_at: existing ? existing.first_seen_at : 'first',
|
||
})
|
||
})
|
||
|
||
patch(teamsDb, 'markDeparted', async (teamId, keys) => {
|
||
for (const key of keys) {
|
||
const row = membersOf(teamId).get(key)
|
||
if (row && row.status === 'active') Object.assign(row, { status: 'departed', online: 0 })
|
||
}
|
||
})
|
||
|
||
patch(teamsDb, 'setLeaders', async (teamId, leaderKeys) => {
|
||
for (const row of membersOf(teamId).values()) row.is_leader = leaderKeys.includes(row.member_key) ? 1 : 0
|
||
})
|
||
|
||
patch(teamsDb, 'setMemberLeader', async (teamId, key, isLeader) => {
|
||
const row = membersOf(teamId).get(key)
|
||
if (row) row.is_leader = isLeader ? 1 : 0
|
||
})
|
||
|
||
patch(teamsDb, 'syncState', async (moduleId) => store.sync.get(moduleId))
|
||
patch(teamsDb, 'recordAttempt', async (moduleId) => {
|
||
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
|
||
s.last_attempt_at = new Date()
|
||
store.sync.set(moduleId, s)
|
||
})
|
||
patch(teamsDb, 'recordFailure', async (moduleId, error) => {
|
||
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
|
||
s.consecutive_failures += 1
|
||
s.last_error = error
|
||
store.sync.set(moduleId, s)
|
||
})
|
||
patch(teamsDb, 'recordSuccess', async (moduleId) => {
|
||
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
|
||
s.consecutive_failures = 0
|
||
s.last_error = null
|
||
s.last_success_at = new Date()
|
||
store.sync.set(moduleId, s)
|
||
})
|
||
patch(teamsDb, 'setPendingEmpty', async (moduleId, since) => {
|
||
const s = store.sync.get(moduleId) || { module_id: moduleId, consecutive_failures: 0 }
|
||
s.pending_empty_since = since
|
||
store.sync.set(moduleId, s)
|
||
})
|
||
|
||
// Reserved-name screening is its own unit (teamModeration.test.js). Stubbed
|
||
// here so these tests stay about the reconciler — and because the real calls
|
||
// read settings, which means a live database connection this suite must never
|
||
// make. `screened` records that the reconciler asked, which is the integration
|
||
// point worth asserting from this side.
|
||
store.screened = []
|
||
patch(moderation, 'screenForCreate', async (name) => {
|
||
store.screened.push(name)
|
||
return { hidden: false }
|
||
})
|
||
patch(moderation, 'rescreen', async () => 0)
|
||
}
|
||
|
||
// A provider whose answers the test controls. Defaults are authoritative and
|
||
// well-formed, so each test only states the part it is about.
|
||
function provide(overrides = {}) {
|
||
const provider = {
|
||
getTeams: async () => ({ ok: true, teams: [] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [] }),
|
||
getTeamLeaders: async () => ({ ok: true, leaders: [] }),
|
||
...overrides,
|
||
}
|
||
const api = registries.stage('uo')
|
||
api.registerTeamProvider(provider)
|
||
registries.apply(api.staged)
|
||
return provider
|
||
}
|
||
|
||
const team = (externalId, name, extra = {}) => ({ externalId, name, abbr: null, meta: null, ...extra })
|
||
const member = (memberKey, extra = {}) => ({
|
||
memberKey, displayName: memberKey, rankLabel: null, leader: false, online: false, userId: null, ...extra,
|
||
})
|
||
|
||
const activeTeams = () => store.teams.filter((t) => t.status === 'active')
|
||
const activeMembers = (teamId) => [...membersOf(teamId).values()].filter((m) => m.status === 'active')
|
||
|
||
beforeEach(() => {
|
||
store = freshStore()
|
||
registries._reset()
|
||
teamSync._reset()
|
||
stubDb()
|
||
// The settings read is the only other database touch on this path.
|
||
patch(settings, 'get', async () => null)
|
||
})
|
||
|
||
afterEach(() => {
|
||
teamSync._reset()
|
||
registries._reset()
|
||
restore()
|
||
})
|
||
|
||
// ── Gate 1: a failed getTeams() touches nothing ────────────────────────────
|
||
|
||
test('gate 1 — a provider that cannot answer leaves every row untouched', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
// Compared as JSON on both sides: the rows carry Date objects, and a snapshot
|
||
// taken through JSON would otherwise "differ" from the live rows purely by
|
||
// having stringified them.
|
||
const before = JSON.stringify(store.teams)
|
||
assert.equal(store.teams.length, 1)
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => ({ ok: false, reason: 'sidecar unreachable' }) })
|
||
const result = await teamSync.reconcileNow('test')
|
||
|
||
assert.equal(result.ok, false)
|
||
assert.equal(JSON.stringify(store.teams), before, 'not a single row may change')
|
||
assert.equal(store.sync.get('uo').consecutive_failures, 1)
|
||
assert.equal(store.sync.get('uo').last_error, 'sidecar unreachable')
|
||
})
|
||
|
||
test('gate 1 — a hung or throwing provider is the same refusal, not an empty list', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => { throw new Error('EPIPE') } })
|
||
await teamSync.reconcileNow('test')
|
||
assert.equal(activeTeams().length, 1, 'a thrown error must never read as "no teams"')
|
||
})
|
||
|
||
test('failures accumulate and a success clears them', async () => {
|
||
provide({ getTeams: async () => ({ ok: false, reason: 'down' }) })
|
||
await teamSync.reconcileNow('a')
|
||
await teamSync.reconcileNow('b')
|
||
assert.equal(store.sync.get('uo').consecutive_failures, 2)
|
||
|
||
registries._reset()
|
||
provide()
|
||
await teamSync.reconcileNow('c')
|
||
assert.equal(store.sync.get('uo').consecutive_failures, 0)
|
||
assert.equal(store.sync.get('uo').last_error, null)
|
||
})
|
||
|
||
// ── Gate 2: an authoritative empty list is quarantined ─────────────────────
|
||
|
||
test('gate 2 — the first empty answer archives nothing', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
assert.equal(activeTeams().length, 2)
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||
const result = await teamSync.reconcileNow('test')
|
||
|
||
assert.equal(result.quarantined, true)
|
||
assert.equal(activeTeams().length, 2, 'a cold start must not empty the site')
|
||
assert.ok(store.sync.get('uo').pending_empty_since, 'the answer is remembered')
|
||
})
|
||
|
||
test('gate 2 — a second empty answer, an interval later, is applied', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||
await teamSync.reconcileNow('first empty')
|
||
// Age the quarantine past one full interval.
|
||
store.sync.get('uo').pending_empty_since = new Date(Date.now() - (teamSync.DEFAULT_INTERVAL_S + 1) * 1000)
|
||
|
||
const result = await teamSync.reconcileNow('second empty')
|
||
assert.equal(result.archived, 1, 'every team on the shard really did disband')
|
||
assert.equal(activeTeams().length, 0)
|
||
assert.equal(store.teams[0].archived_reason, 'disbanded')
|
||
})
|
||
|
||
test('gate 2 — a second empty answer TOO SOON is still quarantined', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||
await teamSync.reconcileNow('first')
|
||
const result = await teamSync.reconcileNow('second, immediately')
|
||
|
||
assert.equal(result.quarantined, true, 'two answers a second apart are one cold start, not two')
|
||
assert.equal(activeTeams().length, 1)
|
||
})
|
||
|
||
test('gate 2 — any non-empty answer clears the quarantine', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||
await teamSync.reconcileNow('empty')
|
||
assert.ok(store.sync.get('uo').pending_empty_since)
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('recovered')
|
||
assert.equal(store.sync.get('uo').pending_empty_since, null)
|
||
})
|
||
|
||
test('gate 2 — an empty list with nothing held is not a quarantine, just nothing to do', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [] }) })
|
||
const result = await teamSync.reconcileNow('test')
|
||
assert.equal(result.ok, true)
|
||
assert.notEqual(result.quarantined, true)
|
||
})
|
||
|
||
test('an incomplete answer never removes, so an empty partial list is harmless', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => ({ ok: true, complete: false, teams: [] }) })
|
||
const result = await teamSync.reconcileNow('partial')
|
||
assert.equal(result.archived, 0)
|
||
assert.equal(activeTeams().length, 1)
|
||
assert.ok(!store.sync.get('uo').pending_empty_since, 'no quarantine needed — nothing was at risk')
|
||
})
|
||
|
||
// ── Gate 3: one Team's unanswerable roster ─────────────────────────────────
|
||
|
||
test('gate 3 — a refused roster leaves that team alone and the others sync', async () => {
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }),
|
||
getTeamMembers: async (id) => (id === 'g1'
|
||
? { ok: true, members: [member('0x1'), member('0x2')] }
|
||
: { ok: true, members: [member('0x9')] }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
assert.equal(activeMembers(1).length, 2)
|
||
assert.equal(activeMembers(2).length, 1)
|
||
|
||
registries._reset()
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }),
|
||
getTeamMembers: async (id) => (id === 'g1'
|
||
? { ok: false, reason: 'roster unavailable' }
|
||
: { ok: true, members: [member('0x9'), member('0xA')] }),
|
||
})
|
||
const result = await teamSync.reconcileNow('test')
|
||
|
||
assert.equal(activeMembers(1).length, 2, "g1's roster is untouched, not emptied")
|
||
assert.equal(activeMembers(2).length, 2, "g2 syncs normally — one team's problem is its own")
|
||
assert.equal(result.rosters, 1, 'only one roster was applied')
|
||
})
|
||
|
||
test('gate 3 — a refused roster does not bump that team’s freshness stamp', async () => {
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
const syncedAt = store.teams[0].roster_synced_at
|
||
assert.ok(syncedAt)
|
||
|
||
registries._reset()
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: false, reason: 'nope' }),
|
||
})
|
||
await teamSync.reconcileNow('test')
|
||
assert.equal(store.teams[0].roster_synced_at, syncedAt, 'stale must show as stale, not as just-synced')
|
||
})
|
||
|
||
test('leadership is a separate answer — a refused one does not demote anybody', async () => {
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
|
||
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
assert.equal(membersOf(1).get('0x1').is_leader, 1)
|
||
|
||
registries._reset()
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
|
||
getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }),
|
||
})
|
||
await teamSync.reconcileNow('test')
|
||
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'an unanswerable question is not the answer "nobody"')
|
||
})
|
||
|
||
test('the roster seeds is_leader on a new row but never overwrites it afterwards', async () => {
|
||
// Two writers for one column is how a refused leadership answer becomes a
|
||
// silent demotion: the roster would write `leader: false` before the
|
||
// authoritative call was even made. Seeding on insert keeps a Team from being
|
||
// leaderless while getTeamLeaders() is failing.
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: true })] }),
|
||
getTeamLeaders: async () => ({ ok: false, reason: 'cannot say' }),
|
||
})
|
||
await teamSync.reconcileNow('first sync, leadership unanswerable')
|
||
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'seeded from the roster rather than left blank')
|
||
|
||
registries._reset()
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1', { leader: false })] }),
|
||
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
|
||
})
|
||
await teamSync.reconcileNow('roster disagrees with the authority')
|
||
assert.equal(membersOf(1).get('0x1').is_leader, 1, 'getTeamLeaders() is path 2, and the roster is not')
|
||
})
|
||
|
||
// ── Gate 4: an authoritative empty roster ──────────────────────────────────
|
||
|
||
test('gate 4 — the first empty roster departs nobody', async () => {
|
||
let members = [member('0x1'), member('0x2')]
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
assert.equal(activeMembers(1).length, 2)
|
||
|
||
members = []
|
||
await teamSync.reconcileNow('empty roster')
|
||
assert.equal(activeMembers(1).length, 2, 'a cold cache must not empty a roster')
|
||
assert.ok(store.teams[0].members_empty_since)
|
||
})
|
||
|
||
test('gate 4 — a second empty roster is applied', async () => {
|
||
let members = [member('0x1'), member('0x2')]
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
members = []
|
||
await teamSync.reconcileNow('first empty')
|
||
await teamSync.reconcileNow('second empty')
|
||
assert.equal(activeMembers(1).length, 0, 'the guild really was emptied')
|
||
assert.equal(store.teams[0].member_count, 0)
|
||
})
|
||
|
||
test('gate 4 — a non-empty roster clears the quarantine', async () => {
|
||
let members = [member('0x1')]
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
members = []
|
||
await teamSync.reconcileNow('empty')
|
||
assert.ok(store.teams[0].members_empty_since)
|
||
|
||
members = [member('0x1')]
|
||
await teamSync.reconcileNow('recovered')
|
||
assert.equal(store.teams[0].members_empty_since, null)
|
||
})
|
||
|
||
test('gate 4 — a team that never had members takes an empty roster at once', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
const result = await teamSync.reconcileNow('test')
|
||
assert.equal(result.rosters, 1, 'nothing is at risk, so nothing is quarantined')
|
||
assert.equal(store.teams[0].members_empty_since, null)
|
||
})
|
||
|
||
// ── Ordinary syncing ───────────────────────────────────────────────────────
|
||
|
||
test('a new team is created with a slug, and its roster and counts follow', async () => {
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand', { abbr: 'TSH' })] }),
|
||
getTeamMembers: async () => ({
|
||
ok: true,
|
||
members: [member('0x1', { userId: 7, online: true }), member('0x2')],
|
||
}),
|
||
getTeamLeaders: async () => ({ ok: true, leaders: ['0x1'] }),
|
||
})
|
||
const result = await teamSync.reconcileNow('test')
|
||
|
||
assert.equal(result.created, 1)
|
||
const row = store.teams[0]
|
||
assert.equal(row.slug, 'the-silver-hand')
|
||
assert.equal(row.member_count, 2)
|
||
assert.equal(row.linked_count, 1)
|
||
assert.equal(row.online_count, 1)
|
||
assert.equal(membersOf(1).get('0x1').is_leader, 1)
|
||
})
|
||
|
||
test('a member who disappears from a complete roster is departed, not deleted', async () => {
|
||
let members = [member('0x1'), member('0x2')]
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
members = [member('0x1')]
|
||
await teamSync.reconcileNow('test')
|
||
assert.equal(membersOf(1).get('0x2').status, 'departed', 'the row survives so history and rejoins do')
|
||
assert.equal(activeMembers(1).length, 1)
|
||
})
|
||
|
||
test('a rejoining member revives their row and keeps their first_seen_at', async () => {
|
||
let members = [member('0x1'), member('0x2')]
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
members = [member('0x1')]
|
||
await teamSync.reconcileNow('leaves')
|
||
members = [member('0x1'), member('0x2')]
|
||
await teamSync.reconcileNow('returns')
|
||
|
||
assert.equal(membersOf(1).get('0x2').status, 'active')
|
||
assert.equal(membersOf(1).get('0x2').first_seen_at, 'first', 'a rejoin is a revived row, not a second one')
|
||
})
|
||
|
||
test('an incomplete roster adds and updates but removes nothing', async () => {
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
registries._reset()
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, complete: false, members: [member('0x3')] }),
|
||
})
|
||
await teamSync.reconcileNow('partial')
|
||
assert.equal(activeMembers(1).length, 3, 'a partial answer is not a claim about who is absent')
|
||
})
|
||
|
||
test('a team absent from a complete list is archived as disbanded', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A'), team('g2', 'B')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('test')
|
||
|
||
assert.equal(activeTeams().length, 1)
|
||
const archived = store.teams.find((t) => t.external_id === 'g2')
|
||
assert.equal(archived.status, 'archived')
|
||
assert.equal(archived.archived_reason, 'disbanded')
|
||
})
|
||
|
||
// ── The rename rule (§2.2) ─────────────────────────────────────────────────
|
||
|
||
test('a renamed team is archived and succeeded, never edited in place', async () => {
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Silver Hand')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
registries._reset()
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'The Golden Hand')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
|
||
})
|
||
const result = await teamSync.reconcileNow('rename')
|
||
|
||
assert.equal(result.renamed, 1)
|
||
const [old_, next] = store.teams
|
||
assert.equal(old_.name, 'The Silver Hand', 'the name is immutable for the life of the row')
|
||
assert.equal(old_.status, 'archived')
|
||
assert.equal(old_.archived_reason, 'renamed')
|
||
assert.equal(old_.succeeded_by, next.id, 'the old slug can explain itself instead of 404ing')
|
||
assert.equal(next.name, 'The Golden Hand')
|
||
assert.equal(next.slug, 'the-golden-hand')
|
||
assert.equal(next.status, 'active')
|
||
})
|
||
|
||
test('a rename back to a previous name does not reuse the retired slug', async () => {
|
||
const names = ['Alpha', 'Beta', 'Alpha']
|
||
let i = 0
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', names[i])] }) })
|
||
await teamSync.reconcileNow('a')
|
||
i = 1
|
||
await teamSync.reconcileNow('b')
|
||
i = 2
|
||
await teamSync.reconcileNow('c')
|
||
|
||
const slugs = store.teams.map((t) => t.slug)
|
||
assert.deepEqual(slugs, ['alpha', 'beta', 'alpha-2'])
|
||
assert.equal(new Set(slugs).size, 3, 'an archived team stays readable at its own address')
|
||
})
|
||
|
||
test('two teams with the same name get distinct slugs', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Guard'), team('g2', 'Guard')] }) })
|
||
await teamSync.reconcileNow('test')
|
||
assert.deepEqual(store.teams.map((t) => t.slug), ['guard', 'guard-2'])
|
||
})
|
||
|
||
test('a name with nothing URL-safe in it still gets an address', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', '★☆★')] }) })
|
||
await teamSync.reconcileNow('test')
|
||
assert.equal(store.teams[0].slug, 'team')
|
||
assert.equal(store.teams[0].name, '★☆★', 'the identity keeps what the player typed')
|
||
})
|
||
|
||
// ── Screening is on the create path, and on every run ──────────────────────
|
||
|
||
test('every newly created team has its name screened', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin'), team('g2', 'The Silver Hand')] }) })
|
||
await teamSync.reconcileNow('test')
|
||
assert.deepEqual(store.screened, ['Admin', 'The Silver Hand'])
|
||
})
|
||
|
||
test('a renamed team is screened again under its new name', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Ordinary')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
registries._reset()
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }) })
|
||
await teamSync.reconcileNow('rename')
|
||
assert.deepEqual(store.screened, ['Ordinary', 'Admin'], 'a rename is a create, so it screens')
|
||
})
|
||
|
||
test('a hidden team is still created and still syncs its roster', async () => {
|
||
// Hide, never reject: the Team works completely for its own members. The people
|
||
// in it are not being punished for a name their leader chose.
|
||
patch(moderation, 'screenForCreate', async () => ({
|
||
hidden: true, hiddenReason: 'reserved_name', hiddenTerm: 'admin',
|
||
}))
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'Admin')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1'), member('0x2')] }),
|
||
})
|
||
await teamSync.reconcileNow('test')
|
||
|
||
assert.equal(store.teams[0].hidden, 1)
|
||
assert.equal(store.teams[0].hidden_term, 'admin')
|
||
assert.equal(activeMembers(1).length, 2, 'suppression is a public-surface rule, not a shutdown')
|
||
assert.equal(store.teams[0].member_count, 2)
|
||
})
|
||
|
||
test('a successful run re-screens the names no human has ruled on', async () => {
|
||
let called = 0
|
||
patch(moderation, 'rescreen', async () => { called += 1; return 0 })
|
||
provide()
|
||
await teamSync.reconcileNow('test')
|
||
assert.equal(called, 1)
|
||
})
|
||
|
||
test('a refused run does not re-screen — it does nothing at all', async () => {
|
||
let called = 0
|
||
patch(moderation, 'rescreen', async () => { called += 1; return 0 })
|
||
provide({ getTeams: async () => ({ ok: false, reason: 'down' }) })
|
||
await teamSync.reconcileNow('test')
|
||
assert.equal(called, 0)
|
||
})
|
||
|
||
// ── Events (§2.3) ──────────────────────────────────────────────────────────
|
||
|
||
test('an unknown event kind is rejected', async () => {
|
||
provide()
|
||
await assert.rejects(() => teamSync.publish({ kind: 'team.exploded', externalId: 'g1' }), /unknown event kind/)
|
||
})
|
||
|
||
test('an event with no externalId is rejected', async () => {
|
||
provide()
|
||
await assert.rejects(() => teamSync.publish({ kind: 'team.member.added' }), /no externalId/)
|
||
})
|
||
|
||
test('team.disbanded never archives — it asks for a reconciliation', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
await teamSync.publish({ kind: 'team.disbanded', externalId: 'g1' })
|
||
assert.equal(activeTeams().length, 1, 'destruction is never driven by a delta that may be a repeat')
|
||
})
|
||
|
||
test('team.created does not invent a team', async () => {
|
||
provide()
|
||
await teamSync.publish({ kind: 'team.created', externalId: 'brand-new' })
|
||
assert.equal(store.teams.length, 0, 'a team built from a delta has no name, roster or leaders')
|
||
})
|
||
|
||
test('a member delta applies at once for a known team and updates the counts', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
await teamSync.publish({
|
||
kind: 'team.member.added', externalId: 'g1', memberKey: '0x5', displayName: 'Ada', userId: 3,
|
||
})
|
||
assert.equal(activeMembers(1).length, 1)
|
||
assert.equal(membersOf(1).get('0x5').display_name, 'Ada')
|
||
assert.equal(store.teams[0].member_count, 1)
|
||
assert.equal(store.teams[0].linked_count, 1)
|
||
|
||
await teamSync.publish({ kind: 'team.member.removed', externalId: 'g1', memberKey: '0x5' })
|
||
assert.equal(activeMembers(1).length, 0)
|
||
assert.equal(store.teams[0].member_count, 0)
|
||
})
|
||
|
||
test('a leadership delta writes is_leader and nothing else', async () => {
|
||
provide({
|
||
getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }),
|
||
getTeamMembers: async () => ({ ok: true, members: [member('0x1')] }),
|
||
})
|
||
await teamSync.reconcileNow('setup')
|
||
|
||
await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0x1' })
|
||
assert.equal(membersOf(1).get('0x1').is_leader, 1)
|
||
assert.equal(activeMembers(1).length, 1, 'promotion is not a join')
|
||
|
||
await teamSync.publish({ kind: 'team.leader.removed', externalId: 'g1', memberKey: '0x1' })
|
||
assert.equal(membersOf(1).get('0x1').is_leader, 0)
|
||
assert.equal(activeMembers(1).length, 1, 'demotion is not a departure')
|
||
})
|
||
|
||
test('a leadership delta for an unknown member creates nobody', async () => {
|
||
provide({ getTeams: async () => ({ ok: true, teams: [team('g1', 'A')] }) })
|
||
await teamSync.reconcileNow('setup')
|
||
await teamSync.publish({ kind: 'team.leader.added', externalId: 'g1', memberKey: '0xdead' })
|
||
assert.equal(activeMembers(1).length, 0, 'a promotion is not evidence of membership')
|
||
})
|
||
|
||
test('an event for an unknown team asks for a reconciliation instead of guessing', async () => {
|
||
provide()
|
||
await teamSync.publish({ kind: 'team.member.added', externalId: 'nope', memberKey: '0x1' })
|
||
assert.equal(store.teams.length, 0)
|
||
})
|
||
|
||
test('publish is a no-op when no provider is registered', async () => {
|
||
await teamSync.publish({ kind: 'team.member.added', externalId: 'g1', memberKey: '0x1' })
|
||
assert.equal(store.teams.length, 0)
|
||
})
|
||
|
||
// ── Scheduling ─────────────────────────────────────────────────────────────
|
||
|
||
test('a run already in flight is joined rather than run twice', async () => {
|
||
let calls = 0
|
||
let release
|
||
const gate = new Promise((resolve) => { release = resolve })
|
||
provide({
|
||
getTeams: async () => {
|
||
calls += 1
|
||
await gate
|
||
return { ok: true, teams: [] }
|
||
},
|
||
})
|
||
|
||
const first = teamSync.reconcileNow('first')
|
||
const second = await teamSync.reconcileNow('second')
|
||
assert.equal(second.joined, true)
|
||
release()
|
||
await first
|
||
assert.equal(calls, 1, 'the lock is what stops two runs writing the same rows')
|
||
})
|
||
|
||
test('the poll interval falls back and is floored against a bad setting', async () => {
|
||
patch(settings, 'get', async () => 'not a number')
|
||
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S)
|
||
|
||
patch(settings, 'get', async () => '5')
|
||
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S, 'a hot loop is not a valid interval')
|
||
|
||
patch(settings, 'get', async () => '120')
|
||
assert.equal(await teamSync.intervalSeconds(), 120)
|
||
|
||
patch(settings, 'get', async () => { throw new Error('db down') })
|
||
assert.equal(await teamSync.intervalSeconds(), teamSync.DEFAULT_INTERVAL_S)
|
||
})
|
||
|
||
test('backoff grows with failures and is capped at the poll interval', async () => {
|
||
assert.equal(teamSync.backoffSeconds(0, 900), 900, 'no failures means the ordinary poll')
|
||
assert.equal(teamSync.backoffSeconds(1, 900), 30)
|
||
assert.equal(teamSync.backoffSeconds(2, 900), 60)
|
||
assert.ok(teamSync.backoffSeconds(4, 900) < 900)
|
||
assert.equal(teamSync.backoffSeconds(50, 900), 900, 'a module down for a day must recover promptly, not in weeks')
|
||
})
|
||
|
||
test('start() is inert with no provider registered', async () => {
|
||
await teamSync.start()
|
||
assert.equal(store.teams.length, 0)
|
||
})
|