Compare commits
4 Commits
v0.3.0
...
e93361aa48
| Author | SHA1 | Date | |
|---|---|---|---|
| e93361aa48 | |||
| 2fa4d87a40 | |||
| 97e2fddfcd | |||
| 62c8ee68b4 |
@@ -61,12 +61,19 @@
|
||||
#
|
||||
# Runner: the shared self-hosted `ubuntu-latest` runner. These jobs need only
|
||||
# Node — no Docker socket, no database.
|
||||
#
|
||||
# Scope note: `edge` is gated as well as `main`. Multi-phase work lands there
|
||||
# first, so gating only the `main` hop would run these checks for the first time
|
||||
# at the cutover — the one moment a red build is most expensive to discover. This
|
||||
# is the same call `RunicGateway/installer` made for the same reason, and it was
|
||||
# taken here after a nine-PR Android workstream landed on an ungated `edge` with
|
||||
# no CI at all. Adding a branch to the `branches:` list is the whole change.
|
||||
|
||||
name: PR Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, edge]
|
||||
|
||||
# A newer push to the same PR cancels the in-flight run.
|
||||
concurrency:
|
||||
|
||||
103
.gitea/workflows/sonarqube.yml
Normal file
103
.gitea/workflows/sonarqube.yml
Normal file
@@ -0,0 +1,103 @@
|
||||
# Run SonarQube static analysis against the code that just landed on `main` and
|
||||
# report the results to the self-hosted SonarQube server for review. This is
|
||||
# intentionally NON-BLOCKING: it triggers on push to main (i.e. AFTER merge),
|
||||
# not on pull_request, so it never gates a PR. It complements pr-checks.yml
|
||||
# (which gates PRs) and release.yml (which publishes the bundle) — this one only
|
||||
# feeds the dashboard.
|
||||
#
|
||||
# Mirrors RunicGateway/website's sonarqube.yml, for the same reason pr-checks.yml
|
||||
# does: this module is two npm packages shaped like that repo's `server/` and
|
||||
# `client/`, and it is loaded into that repo's process. Until now it was the one
|
||||
# part of the platform that had never been scanned — 75 files that arrived in the
|
||||
# Phase 3 extraction with core's Sonar history left behind in core's project.
|
||||
#
|
||||
# Prerequisites (one-time, in the Gitea UI — Repo → Settings → Actions):
|
||||
# • Secret SONAR_TOKEN — a SonarQube "Analysis" token generated at
|
||||
# My Account → Security in SonarQube for the
|
||||
# Module-uo project (or a global one).
|
||||
# • Variable SONAR_HOST_URL — the SonarQube base URL on your LAN, e.g.
|
||||
# http://192.168.0.56:9000
|
||||
# (kept as a variable, not committed, so the internal address stays out of git.)
|
||||
#
|
||||
# The runner (self-hosted `ubuntu-latest`, same as the other workflows) must be
|
||||
# able to reach SONAR_HOST_URL on your network. Nothing here waits on the
|
||||
# SonarQube Quality Gate, so a failing gate does not fail this job — check the
|
||||
# dashboard when you want to.
|
||||
|
||||
name: SonarQube
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
# Allow re-running the analysis on demand from the Actions tab.
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: sonarqube-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
analysis:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out (full history for accurate new-code + blame)
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# SonarQube uses git history to attribute issues to authors and to
|
||||
# compute "new code". A shallow clone degrades both.
|
||||
fetch-depth: 0
|
||||
|
||||
# Node 22, where pr-checks.yml pins 20: the built-in `lcov` coverage
|
||||
# reporter this job depends on needs >= 22. The version that matters for
|
||||
# correctness is the one in pr-checks.yml, which matches the core process
|
||||
# this module is loaded into; nothing here ships.
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install deps for both halves
|
||||
run: |
|
||||
npm ci --prefix server
|
||||
npm ci --prefix client
|
||||
|
||||
# The chunk has to exist before the client suite runs: build.test.js and
|
||||
# registration.test.js read `client/dist/entry.js`, and both SKIP when
|
||||
# there is no build. Run the other way round they skip silently and this
|
||||
# job reports coverage for a suite that quietly asked less than it looks
|
||||
# like it did — the same ordering pr-checks.yml calls load-bearing.
|
||||
- name: Build the client chunk
|
||||
run: npm run build --prefix client
|
||||
|
||||
# SonarQube runs static analysis only — it never executes the test suite,
|
||||
# so we must produce the coverage report ourselves and hand it to the
|
||||
# scanner (see sonar.javascript.lcov.reportPaths in sonar-project.properties).
|
||||
#
|
||||
# Both suites are invoked from the REPO ROOT rather than with `--prefix`,
|
||||
# so the LCOV `SF:` paths come out repo-root-relative (`server/router/...`,
|
||||
# `client/src/...`) and resolve against sonar.sources. That is also why the
|
||||
# server suite's `--require` is spelled out here instead of reusing
|
||||
# `npm test --prefix server`, whose path is relative to `server/`.
|
||||
- name: Generate server test coverage (LCOV)
|
||||
run: |
|
||||
mkdir -p server/coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--require ./server/test/_setup.js \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=server/coverage/test-execution.xml \
|
||||
server/test/*.test.js
|
||||
|
||||
- name: Generate client test coverage (LCOV)
|
||||
run: |
|
||||
mkdir -p client/coverage
|
||||
node --test --experimental-test-coverage \
|
||||
--test-reporter=spec --test-reporter-destination=stdout \
|
||||
--test-reporter=lcov --test-reporter-destination=client/coverage/lcov.info \
|
||||
--test-reporter=./scripts/sonar-test-reporter.mjs --test-reporter-destination=client/coverage/test-execution.xml \
|
||||
client/test/*.test.js
|
||||
|
||||
- name: Run SonarQube scan
|
||||
uses: sonarsource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ vars.SONAR_HOST_URL }}
|
||||
@@ -96,6 +96,19 @@ when someone builds on the server is not shippable.
|
||||
branch and no cutover, unlike `website`, whose module work accumulates on `edge`
|
||||
and reaches `main` once.
|
||||
|
||||
### Static analysis runs after the merge, not on the PR
|
||||
|
||||
`.gitea/workflows/sonarqube.yml` scans `main` on push and reports to the
|
||||
self-hosted SonarQube instance under the project key **`Module-uo`**. It is
|
||||
deliberately non-blocking: it never gates a pull request, and a failing quality
|
||||
gate does not fail the job. Check the dashboard when you want to; the things
|
||||
that must not reach `main` are gated by `pr-checks.yml` instead.
|
||||
|
||||
It runs both suites from the repo root to produce coverage, and builds the
|
||||
client chunk first — two of the client tests read `dist/entry.js` and skip
|
||||
without it, which would leave this job reporting on a suite that quietly asked
|
||||
less than it appears to.
|
||||
|
||||
### Commit messages
|
||||
|
||||
We use [Conventional Commits](https://www.conventionalcommits.org/) —
|
||||
|
||||
64
scripts/sonar-test-reporter.mjs
Normal file
64
scripts/sonar-test-reporter.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
// Custom node:test reporter that emits SonarQube's Generic Test Execution XML.
|
||||
//
|
||||
// Node's built-in reporters give us coverage (`lcov`) and pass/fail output
|
||||
// (`spec`/`tap`/`junit`), but SonarQube's "Unit Tests" measure is fed by a
|
||||
// SEPARATE report in *its own* format via `sonar.testExecutionReportPaths` — the
|
||||
// lcov report only populates Coverage, which is why the dashboard shows coverage
|
||||
// while the Unit Tests tile stays "-". This reporter produces that missing report.
|
||||
//
|
||||
// Format: https://docs.sonarsource.com/sonarqube/latest/analyzing-source-code/test-coverage/generic-test-data/
|
||||
// <testExecutions version="1">
|
||||
// <file path="server/test/foo.test.js">
|
||||
// <testCase name="..." duration="12"/> <!-- duration = integer ms -->
|
||||
// </file>
|
||||
// </testExecutions>
|
||||
//
|
||||
// Paths are emitted repo-root-relative (POSIX separators) so they match the
|
||||
// `sonar.tests` roots; the workflow runs `node --test` from the repo root, so the
|
||||
// absolute `file` on each event strips cleanly against process.cwd().
|
||||
import path from 'node:path'
|
||||
|
||||
function xmlEscape(s) {
|
||||
return String(s).replace(/[<>&"']/g, (c) => ({
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'&': '&',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
})[c])
|
||||
}
|
||||
|
||||
export default async function* sonarTestReporter(source) {
|
||||
const byFile = new Map()
|
||||
const cwd = process.cwd()
|
||||
|
||||
for await (const event of source) {
|
||||
if (event.type !== 'test:pass' && event.type !== 'test:fail') continue
|
||||
const d = event.data
|
||||
// Skip the container events (a `describe` suite) and anything without a file
|
||||
// — only real test cases go in the report, so the count matches the runner's.
|
||||
if (!d.file || (d.details && d.details.type === 'suite')) continue
|
||||
|
||||
const rel = path.relative(cwd, d.file).split(path.sep).join('/')
|
||||
if (!byFile.has(rel)) byFile.set(rel, [])
|
||||
byFile.get(rel).push({
|
||||
name: d.name,
|
||||
duration: Math.max(0, Math.round(d.details?.duration_ms ?? 0)),
|
||||
failed: event.type === 'test:fail',
|
||||
skipped: Boolean(d.skip || d.todo),
|
||||
})
|
||||
}
|
||||
|
||||
yield '<?xml version="1.0" encoding="UTF-8"?>\n<testExecutions version="1">\n'
|
||||
for (const [file, cases] of byFile) {
|
||||
yield ` <file path="${xmlEscape(file)}">\n`
|
||||
for (const c of cases) {
|
||||
const attrs = `name="${xmlEscape(c.name)}" duration="${c.duration}"`
|
||||
if (c.failed) yield ` <testCase ${attrs}><failure message="test failed"/></testCase>\n`
|
||||
else if (c.skipped) yield ` <testCase ${attrs}><skipped/></testCase>\n`
|
||||
else yield ` <testCase ${attrs}/>\n`
|
||||
}
|
||||
yield ' </file>\n'
|
||||
}
|
||||
yield '</testExecutions>\n'
|
||||
}
|
||||
@@ -45,6 +45,7 @@ DROP TABLE IF EXISTS `shard_ruleset`;
|
||||
DROP TABLE IF EXISTS `shard_presence`;
|
||||
DROP TABLE IF EXISTS `shard_governor_terms`;
|
||||
DROP TABLE IF EXISTS `shard_governors`;
|
||||
DROP TABLE IF EXISTS `shard_guild_members`;
|
||||
DROP TABLE IF EXISTS `shard_guilds`;
|
||||
DROP TABLE IF EXISTS `shard_pages`;
|
||||
DROP TABLE IF EXISTS `shard_champs`;
|
||||
|
||||
@@ -220,6 +220,34 @@ CREATE TABLE IF NOT EXISTS shard_guilds (
|
||||
INDEX idx_shard_guilds_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Guild membership (Protocol 4). One row per member per guild, replaced on
|
||||
-- guild.roster and thinned by guild.leave. Protocol 2 could only say HOW MANY
|
||||
-- members a guild had, so this table has no pre-4 equivalent and the Guilds page
|
||||
-- could show a count but never a roster.
|
||||
--
|
||||
-- `acct` / `web_id` are the site-identity fields and are stored because the
|
||||
-- sidecar forwards them; they are NOT public. shardVisibility locks any key that
|
||||
-- is or ends in acct/webId to `admin` and recurses into arrays, so a projected
|
||||
-- roster loses them below that rung — storing them here is what lets a linked
|
||||
-- member be matched to a site user at all.
|
||||
--
|
||||
-- A roster over the shard's per-frame cap arrives in several frames, so rows are
|
||||
-- keyed on (guild_id, serial) and the frame carrying seq 0 clears the guild first;
|
||||
-- see upsertGuildRoster.
|
||||
CREATE TABLE IF NOT EXISTS shard_guild_members (
|
||||
guild_id INT NOT NULL,
|
||||
serial VARCHAR(20) NOT NULL, -- in-game mobile serial, "0x1F5"
|
||||
name VARCHAR(120) NULL,
|
||||
acct VARCHAR(120) NULL, -- absent for a mobile with no account
|
||||
web_id INT NULL, -- set only when the account is linked
|
||||
is_player TINYINT(1) NOT NULL DEFAULT 1,
|
||||
t BIGINT NULL, -- roster event time, epoch ms
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (guild_id, serial),
|
||||
INDEX idx_shard_guild_members_acct (acct),
|
||||
INDEX idx_shard_guild_members_web (web_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Town-governor board (Protocol 2.0, City Loyalty). One row per city, upserted on
|
||||
-- city.update (full-state, emitted only on change; there is no remove event since
|
||||
-- the set of cities is fixed). governor / governorElect are actor objects
|
||||
|
||||
@@ -171,6 +171,37 @@ const removeGuild = (id) => query('DELETE FROM shard_guilds WHERE id = ?', [id])
|
||||
const clearGuilds = () => query('DELETE FROM shard_guilds')
|
||||
const listGuilds = () => query(`SELECT ${GUILD_COLS} FROM shard_guilds ORDER BY name ASC`)
|
||||
|
||||
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
|
||||
const MEMBER_COLS = 'guild_id, serial, name, acct, web_id, is_player, t'
|
||||
|
||||
// Upsert rather than plain insert: a roster frame can be redelivered (the /history
|
||||
// backfill replays stored frames on every reconnect), and a redelivery must be a
|
||||
// no-op rather than a duplicate-key error.
|
||||
const upsertGuildMembers = (rows) => {
|
||||
if (!rows.length) return Promise.resolve()
|
||||
const values = rows.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')
|
||||
const params = rows.flatMap((r) => [r.guild_id, r.serial, r.name, r.acct, r.web_id, r.is_player, r.t])
|
||||
return query(
|
||||
`INSERT INTO shard_guild_members (${MEMBER_COLS}) VALUES ${values}
|
||||
ON DUPLICATE KEY UPDATE name = VALUES(name), acct = VALUES(acct),
|
||||
web_id = VALUES(web_id), is_player = VALUES(is_player), t = VALUES(t)`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
const clearGuildMembers = (guildId) =>
|
||||
query('DELETE FROM shard_guild_members WHERE guild_id = ?', [guildId])
|
||||
|
||||
const removeGuildMember = (guildId, serial) =>
|
||||
query('DELETE FROM shard_guild_members WHERE guild_id = ? AND serial = ?', [guildId, serial])
|
||||
|
||||
const clearAllGuildMembers = () => query('DELETE FROM shard_guild_members')
|
||||
|
||||
const listGuildMembers = (guildId) =>
|
||||
query(`SELECT ${MEMBER_COLS} FROM shard_guild_members WHERE guild_id = ? ORDER BY name ASC`, [
|
||||
guildId,
|
||||
])
|
||||
|
||||
// The guild an actor LEADS — matched on the current board (leader_serial or the
|
||||
// linked leader_acct), so it reflects live state. Guild MEMBERSHIP for non-leaders
|
||||
// is not modelled (the board carries only counts + leader), so we don't guess it.
|
||||
@@ -342,6 +373,11 @@ module.exports = {
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
upsertGuildMembers,
|
||||
clearGuildMembers,
|
||||
removeGuildMember,
|
||||
clearAllGuildMembers,
|
||||
listGuildMembers,
|
||||
findGuildLedByActor,
|
||||
listGuildsLedByAccounts,
|
||||
upsertGovernor,
|
||||
|
||||
@@ -340,8 +340,80 @@ async function upsertGuild(ev) {
|
||||
})
|
||||
}
|
||||
|
||||
const removeGuild = (id) => (id == null ? Promise.resolve() : db.removeGuild(id))
|
||||
const clearGuilds = () => db.clearGuilds()
|
||||
const removeGuild = async (id) => {
|
||||
if (id == null) return
|
||||
await db.removeGuild(id)
|
||||
await db.clearGuildMembers(id)
|
||||
}
|
||||
const clearGuilds = async () => {
|
||||
await db.clearGuilds()
|
||||
await db.clearAllGuildMembers()
|
||||
}
|
||||
|
||||
// ── Guild membership (Protocol 4) ──────────────────────────────────────────
|
||||
// Apply one guild.roster frame.
|
||||
//
|
||||
// A roster larger than the shard's per-frame cap arrives as several frames
|
||||
// carrying seq/more/total. The sidecar reassembles them for its OWN board, but the
|
||||
// live WebSocket feed and the /history backfill both carry the individual frames,
|
||||
// so this ingest sees them unreassembled and has to cope.
|
||||
//
|
||||
// It copes without buffering, because a table can express what a single JSON column
|
||||
// could not: the frame carrying seq 0 clears the guild first and every frame then
|
||||
// upserts its own rows. Rows are keyed on (guild_id, serial), so a redelivered frame
|
||||
// — the /history backfill replays stored frames on every reconnect — is idempotent
|
||||
// rather than a duplicate-key error.
|
||||
//
|
||||
// The cost is a brief window during a multi-frame update where the table holds part
|
||||
// of a roster. That is acceptable for a projection that is already only as fresh as
|
||||
// a 60s sweep, and the frames arrive back-to-back in one burst; buffering to close
|
||||
// it would duplicate the sidecar's reassembly for a sub-second inconsistency.
|
||||
async function upsertGuildRoster(ev) {
|
||||
if (!ev || ev.id == null) return
|
||||
|
||||
const seq = Number.isFinite(ev.seq) ? ev.seq : 0
|
||||
const members = Array.isArray(ev.members) ? ev.members : []
|
||||
|
||||
// seq 0 begins a roster and supersedes whatever was held for this guild.
|
||||
if (seq === 0) await db.clearGuildMembers(ev.id)
|
||||
|
||||
const rows = members
|
||||
.filter((m) => m && m.serial)
|
||||
.map((m) => ({
|
||||
guild_id: ev.id,
|
||||
serial: m.serial,
|
||||
name: m.name ?? null,
|
||||
acct: m.acct ?? null,
|
||||
web_id: Number.isFinite(m.webId) ? m.webId : null,
|
||||
is_player: m.player ? 1 : 0,
|
||||
t: Number.isFinite(ev.t) ? ev.t : null,
|
||||
}))
|
||||
|
||||
await db.upsertGuildMembers(rows)
|
||||
}
|
||||
|
||||
// A single departure (guild.leave). Advisory: the shard re-emits the full roster
|
||||
// whenever the member set changes, so the table would converge on the next frame
|
||||
// even if this were dropped. Applying it makes the change visible immediately
|
||||
// instead of at the end of the sweep that produced it.
|
||||
async function removeGuildMember(ev) {
|
||||
if (!ev || ev.id == null || !ev.who) return
|
||||
await db.removeGuildMember(ev.id, ev.who)
|
||||
}
|
||||
|
||||
// The membership roster for one guild, in the wire shape the projection expects
|
||||
// (an array of actor objects), so shardVisibility strips acct/webId by the same
|
||||
// rule it applies to guild.leader.
|
||||
async function listGuildMembers(guildId) {
|
||||
const rows = await db.listGuildMembers(guildId)
|
||||
return rows.map((r) => ({
|
||||
serial: r.serial,
|
||||
name: r.name,
|
||||
...(r.acct == null ? {} : { acct: r.acct }),
|
||||
...(r.web_id == null ? {} : { webId: r.web_id }),
|
||||
player: !!r.is_player,
|
||||
}))
|
||||
}
|
||||
|
||||
function shapeGuild(r) {
|
||||
const payload = typeof r.payload === 'string' ? safeJson(r.payload) : r.payload
|
||||
@@ -620,6 +692,9 @@ module.exports = {
|
||||
removeGuild,
|
||||
clearGuilds,
|
||||
listGuilds,
|
||||
upsertGuildRoster,
|
||||
removeGuildMember,
|
||||
listGuildMembers,
|
||||
replaceGuilds,
|
||||
findGuildForActor,
|
||||
listGuildsLedForAccounts,
|
||||
|
||||
75
server/test/shardIngest.guildRoster.test.js
Normal file
75
server/test/shardIngest.guildRoster.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
// Protocol 4 membership routing: guild.roster (board state, possibly chunked) and
|
||||
// guild.leave (a real-time departure, logged like its guild.join counterpart).
|
||||
const { test, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const shardIngest = require('../utils/shardIngest')
|
||||
|
||||
function makeDeps() {
|
||||
const calls = { roster: [], memberRemove: [], appended: [], broadcast: [] }
|
||||
const noop = async () => {}
|
||||
return {
|
||||
calls,
|
||||
shardEvents: { append: async (row) => { calls.appended.push(row); return true } },
|
||||
shardState: {
|
||||
upsertGuildRoster: async (ev) => { calls.roster.push(ev) },
|
||||
removeGuildMember: async (ev) => { calls.memberRemove.push(ev) },
|
||||
upsertGuild: noop, removeGuild: noop,
|
||||
clearOnline: noop, upsertOnline: noop, setOffline: noop,
|
||||
addEconomySample: noop,
|
||||
},
|
||||
shardLinks: { removeByAccount: noop },
|
||||
uoLinkConfig: { recordStatus: noop },
|
||||
broadcast: (ev) => { calls.broadcast.push(ev) },
|
||||
pushDispatch: async () => {},
|
||||
log: { warn() {}, info() {}, error() {} },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => shardIngest.reset())
|
||||
|
||||
test('guild.roster routes to upsertGuildRoster and is NOT logged', async () => {
|
||||
// It is board state like guild.update, and the one fat frame on the wire —
|
||||
// logging it would put a full membership snapshot in shard_events on every
|
||||
// membership change.
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'guild.roster', id: 7, seq: 0, more: false, total: 2,
|
||||
members: [{ serial: '0x1', name: 'Ada' }, { serial: '0x2', name: 'Bo' }], t: 1 },
|
||||
deps,
|
||||
)
|
||||
|
||||
assert.equal(deps.calls.roster.length, 1)
|
||||
assert.equal(deps.calls.roster[0].id, 7)
|
||||
assert.equal(deps.calls.roster[0].members.length, 2)
|
||||
assert.equal(r.logged, false)
|
||||
})
|
||||
|
||||
test('every frame of a chunked roster reaches the model, seq intact', async () => {
|
||||
// The sidecar reassembles for its own board, but the live feed and the /history
|
||||
// backfill both carry individual frames — so the model must see each one with its
|
||||
// seq, which is what tells it whether to clear the guild first.
|
||||
const deps = makeDeps()
|
||||
|
||||
for (const [seq, more, serial] of [[0, true, '0x1'], [1, true, '0x2'], [2, false, '0x3']]) {
|
||||
await shardIngest.ingest(
|
||||
{ kind: 'guild.roster', id: 7, seq, more, total: 3, members: [{ serial, name: serial }], t: 1 },
|
||||
deps,
|
||||
)
|
||||
}
|
||||
|
||||
assert.deepEqual(deps.calls.roster.map((e) => e.seq), [0, 1, 2])
|
||||
assert.deepEqual(deps.calls.roster.map((e) => e.more), [true, true, false])
|
||||
})
|
||||
|
||||
test('guild.leave is logged and broadcast, like guild.join', async () => {
|
||||
const deps = makeDeps()
|
||||
const r = await shardIngest.ingest(
|
||||
{ kind: 'guild.leave', id: 7, name: 'The Cartographers', who: '0x2', t: 2 }, deps)
|
||||
|
||||
assert.equal(r.logged, true)
|
||||
assert.equal(deps.calls.appended.length, 1)
|
||||
assert.equal(deps.calls.appended[0].kind, 'guild.leave')
|
||||
assert.equal(deps.calls.broadcast.length, 1)
|
||||
assert.deepEqual(deps.calls.memberRemove.map((e) => e.who), ['0x2'])
|
||||
})
|
||||
@@ -116,6 +116,52 @@ test('acct and webId are stripped below admin regardless of feature config', ()
|
||||
assert.equal(asAdmin.leader.webId, '42')
|
||||
})
|
||||
|
||||
test('acct and webId are stripped from every member of a guild roster (Protocol 4)', () => {
|
||||
// A roster is the first frame where the locked fields appear inside an ARRAY of
|
||||
// actors rather than one nested actor. The walker recurses into arrays, so this
|
||||
// should already hold — this test is here because it is the difference between a
|
||||
// public Guilds page listing character names and one publishing 150 account names.
|
||||
const config = visibility.compileDefaults()
|
||||
const frame = {
|
||||
kind: 'guild.roster',
|
||||
id: 7,
|
||||
total: 3,
|
||||
seq: 0,
|
||||
more: false,
|
||||
members: [
|
||||
{ serial: '0x1', name: 'Ada', acct: 'ada_acct', webId: '11', player: true },
|
||||
{ serial: '0x2', name: 'Bo', acct: 'bo_acct', player: true },
|
||||
{ serial: '0x3', name: 'Cy', player: true }, // a mobile with no account at all
|
||||
],
|
||||
}
|
||||
|
||||
for (const level of ['anonymous', 'logged_in', 'player', 'staff']) {
|
||||
const out = visibility.projectFeature('guilds', frame, level, config)
|
||||
assert.equal(out.members.length, 3, `${level} still sees every member`)
|
||||
assert.deepEqual(out.members.map((m) => m.name), ['Ada', 'Bo', 'Cy'])
|
||||
for (const m of out.members) {
|
||||
assert.equal('acct' in m, false, `${level} must not see a member's acct`)
|
||||
assert.equal('webId' in m, false, `${level} must not see a member's webId`)
|
||||
}
|
||||
}
|
||||
|
||||
const asAdmin = visibility.projectFeature('guilds', frame, 'admin', config)
|
||||
assert.equal(asAdmin.members[0].acct, 'ada_acct')
|
||||
assert.equal(asAdmin.members[0].webId, '11')
|
||||
})
|
||||
|
||||
test('guild.roster and guild.leave are mapped, so neither falls closed to admin-only', () => {
|
||||
// Rule 2 fails an unmapped kind closed. That is the right default, but for these
|
||||
// two it would silently keep the public Guilds page from ever seeing a roster.
|
||||
const config = visibility.compileDefaults()
|
||||
for (const kind of ['guild.roster', 'guild.leave']) {
|
||||
assert.equal(
|
||||
visibility.kindVisibleTo(kind, 'anonymous', config), true,
|
||||
`${kind} should reach an anonymous viewer under the default guilds config`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('a stored rule trying to loosen a locked field is ignored', async () => {
|
||||
withRows([
|
||||
{ feature: 'guilds', enabled: true, audience: 'anonymous', stream: true, fieldRules: { acct: 'anonymous', webId: 'anonymous' } },
|
||||
@@ -308,10 +354,16 @@ const PRE_V3_PUBLIC_KINDS = [
|
||||
// pointedly not among them (its feature ships with stream off).
|
||||
const V3_ADDED_PUBLIC_KINDS = ['world.ruleset', 'points.board']
|
||||
|
||||
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 additions', () => {
|
||||
// v4 adds guild membership. Both ride the existing `guilds` feature, which is
|
||||
// already anonymous, so they join the public set — carrying character names and
|
||||
// serials, never acct/webId, which the locked-field rules strip by suffix even
|
||||
// inside the roster's member array (see the roster test above).
|
||||
const V4_ADDED_PUBLIC_KINDS = ['guild.roster', 'guild.leave']
|
||||
|
||||
test('derived PUBLIC_KINDS is exactly the pre-v3 allowlist plus the v3 and v4 additions', () => {
|
||||
assert.deepEqual(
|
||||
[...visibility.PUBLIC_KINDS].sort(),
|
||||
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS].sort(),
|
||||
[...PRE_V3_PUBLIC_KINDS, ...V3_ADDED_PUBLIC_KINDS, ...V4_ADDED_PUBLIC_KINDS].sort(),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@ const LOGGED_KINDS = new Set([
|
||||
'server.crashed',
|
||||
// Protocol 2.0: a real-time guild join (the board itself is state, not logged).
|
||||
'guild.join',
|
||||
// Protocol 4: the departure counterpart to guild.join, and logged for the same
|
||||
// reason — it is what a "so-and-so left" feed reads. `guild.roster` deliberately
|
||||
// stays out: it is board state like guild.update, and it is the one fat frame on
|
||||
// the wire (~69 bytes per member), so logging it would bloat shard_events with
|
||||
// a full membership snapshot on every membership change.
|
||||
'guild.leave',
|
||||
// Protocol 2.0 provisioning audit (admin channel only — not in PUBLIC_KINDS).
|
||||
'account.audit',
|
||||
'account.unlinked',
|
||||
@@ -187,6 +193,16 @@ async function applyStateChange(event, deps) {
|
||||
case 'guild.remove':
|
||||
await shardState.removeGuild(event.id)
|
||||
return
|
||||
// Protocol 4: membership. A roster arrives in one frame for any realistic
|
||||
// guild and in several for one over the shard's cap — upsertGuildRoster
|
||||
// handles both. guild.leave is advisory; the next roster would converge
|
||||
// anyway, but applying it shows the departure at once.
|
||||
case 'guild.roster':
|
||||
await shardState.upsertGuildRoster(event)
|
||||
return
|
||||
case 'guild.leave':
|
||||
await shardState.removeGuildMember(event)
|
||||
return
|
||||
case 'city.update':
|
||||
// Upserts the board AND captures term history (idempotent).
|
||||
await shardState.upsertGovernor(event)
|
||||
|
||||
@@ -163,6 +163,13 @@ const KIND_FEATURE = new Map(
|
||||
'guild.update': 'guilds',
|
||||
'guild.remove': 'guilds',
|
||||
'guild.join': 'guilds',
|
||||
// Protocol 4. Both carry actor data — a roster is an array of actor objects
|
||||
// and guild.leave names a serial — so they ride the same `guilds` feature and
|
||||
// the same locked-field rules: `acct`/`webId` inside a roster member are
|
||||
// stripped below admin by suffix, exactly as `guild.leader.acct` already is.
|
||||
// Without these two lines rule 2 would fail them closed to admin-only.
|
||||
'guild.roster': 'guilds',
|
||||
'guild.leave': 'guilds',
|
||||
'city.update': 'governors',
|
||||
'presence.online': 'presence',
|
||||
'region.enter': 'presence',
|
||||
|
||||
54
sonar-project.properties
Normal file
54
sonar-project.properties
Normal file
@@ -0,0 +1,54 @@
|
||||
# SonarQube analysis config for module-uo.
|
||||
# Consumed by the scanner in .gitea/workflows/sonarqube.yml on push to main.
|
||||
# The project key must match the one created in SonarQube (dashboard URL
|
||||
# ?id=Module-uo).
|
||||
|
||||
sonar.projectKey=Module-uo
|
||||
sonar.projectName=Module-uo
|
||||
|
||||
# Analysed application code.
|
||||
#
|
||||
# Unlike core's repo there is no `src/` directory to point at: the server half
|
||||
# keeps its code at `server/` root (boot.js, core.js, index.js) beside its
|
||||
# subdirectories, so the whole tree is included and the non-source parts are
|
||||
# excluded below. That direction is deliberate — a new top-level server
|
||||
# directory is scanned by default rather than silently unscanned, which is the
|
||||
# safer way for this list to be wrong.
|
||||
#
|
||||
# `client/scripts` and `server/scripts` are in, not out: checkExternals.js and
|
||||
# checkImports.js *are* the enforcement of MODULE_API.md §3.6 and §5.1, they
|
||||
# each carry their own test suite, and both have already shipped defects that a
|
||||
# reviewer missed (see MODULE_SYSTEM.md §2.7.1). Build code that decides whether
|
||||
# a release is allowed out is not throwaway code.
|
||||
sonar.sources=server,client/src,client/scripts
|
||||
|
||||
# Test code is analysed separately from sources so coverage/metrics attribute
|
||||
# correctly. Both halves run on Node's built-in test runner (no browser/DOM):
|
||||
# the server suite is CommonJS behind test/_setup.js, the client's is ESM.
|
||||
sonar.tests=server/test,client/test
|
||||
sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js
|
||||
|
||||
# Coverage. The sonarqube.yml workflow runs both suites with Node's built-in
|
||||
# test-coverage and writes an LCOV report for each BEFORE the scan runs; without
|
||||
# them the dashboard shows 0% (the scanner never executes tests itself). Both
|
||||
# suites are invoked from the repo root so the `SF:` paths come out
|
||||
# repo-root-relative (server/..., client/src/...) and the scanner resolves them
|
||||
# against the project base dir.
|
||||
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info
|
||||
|
||||
# Test execution ("Unit Tests" measure). A SEPARATE report from coverage: the
|
||||
# lcov files above only populate Coverage, so without this the dashboard shows a
|
||||
# coverage % but an empty "Unit Tests" tile. Written by scripts/sonar-test-reporter.mjs,
|
||||
# a copy of core's — a pure leaf build helper, which is the side of the vendoring
|
||||
# line that may be copied (MODULE_SYSTEM.md §2.7.1).
|
||||
sonar.testExecutionReportPaths=server/coverage/test-execution.xml,client/coverage/test-execution.xml
|
||||
|
||||
# Never analyse dependencies, build output, generated artifacts, or fixtures.
|
||||
#
|
||||
# `client/dist` is the built chunk (gitignored, but the workflow builds it before
|
||||
# scanning because client/test/{build,registration}.test.js import it).
|
||||
# `server/swagger/doc.js` and the two committed generated artifacts at the repo
|
||||
# root are inputs to and outputs of swagger-autogen, not hand-written code.
|
||||
sonar.exclusions=**/node_modules/**,server/test/**,client/test/**,client/dist/**,server/swagger/**,server/data/**,server/coverage/**,client/coverage/**,**/*.min.js
|
||||
|
||||
sonar.sourceEncoding=UTF-8
|
||||
Reference in New Issue
Block a user