Merge pull request 'test: meaningful unit tests for server models/controllers + client logic' (#86) from test/coverage-meaningful-gaps into main
Reviewed-on: #86
This commit is contained in:
@@ -52,6 +52,9 @@ jobs:
|
|||||||
cache-dependency-path: client/package-lock.json
|
cache-dependency-path: client/package-lock.json
|
||||||
- name: Install client deps
|
- name: Install client deps
|
||||||
run: npm ci --prefix client
|
run: npm ci --prefix client
|
||||||
|
- name: Run client tests
|
||||||
|
# Pure-logic unit tests on Node's built-in runner (no browser/DOM).
|
||||||
|
run: npm test --prefix client
|
||||||
- name: Build client
|
- name: Build client
|
||||||
run: npm run build --prefix client
|
run: npm run build --prefix client
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,18 @@ jobs:
|
|||||||
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
--test-reporter=lcov --test-reporter-destination=server/coverage/lcov.info \
|
||||||
server/test/*.test.js
|
server/test/*.test.js
|
||||||
|
|
||||||
|
- name: Generate client test coverage (LCOV)
|
||||||
|
# The client's pure-logic modules (lib/, api/, data/) are plain ESM with no
|
||||||
|
# browser/DOM deps, so they run on the same built-in runner. Run from the
|
||||||
|
# repo root so the `SF:` paths come out as `client/src/...`. No `npm ci`:
|
||||||
|
# the tested modules import only relative files + Node built-ins.
|
||||||
|
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 \
|
||||||
|
client/test/*.test.js
|
||||||
|
|
||||||
- name: Run SonarQube scan
|
- name: Run SonarQube scan
|
||||||
uses: sonarsource/sonarqube-scan-action@v4
|
uses: sonarsource/sonarqube-scan-action@v4
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tiptap/extension-image": "^2.27.2",
|
"@tiptap/extension-image": "^2.27.2",
|
||||||
|
|||||||
142
client/test/apiClient.test.js
Normal file
142
client/test/apiClient.test.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { test, beforeEach, afterEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { api, ApiError } from '../src/api/client.js'
|
||||||
|
|
||||||
|
// Unit-test the fetch wrapper that every API call flows through. The behaviors
|
||||||
|
// that matter to the whole app:
|
||||||
|
// - it always sends the session cookie (credentials: 'include');
|
||||||
|
// - a non-2xx response becomes a thrown ApiError carrying status + a message
|
||||||
|
// (server body.message → statusText → generic), never a silent bad value;
|
||||||
|
// - an empty body resolves to null (not a JSON parse throw);
|
||||||
|
// - JSON bodies get a Content-Type, but a raw FormData upload does NOT (so the
|
||||||
|
// browser can set the multipart boundary);
|
||||||
|
// - query strings and path params are built/encoded correctly.
|
||||||
|
// We drive the real req() by mocking global.fetch and inspecting what it received.
|
||||||
|
|
||||||
|
let calls
|
||||||
|
const realFetch = global.fetch
|
||||||
|
|
||||||
|
// Build a fake Response-ish object req() understands (ok/status/statusText/text()).
|
||||||
|
function reply({ status = 200, statusText = 'OK', body = '' } = {}) {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
statusText,
|
||||||
|
text: async () => (typeof body === 'string' ? body : JSON.stringify(body)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = []
|
||||||
|
global.fetch = async (url, opts) => {
|
||||||
|
calls.push({ url, opts })
|
||||||
|
return calls.nextReply || reply({ body: { ok: true } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = realFetch
|
||||||
|
})
|
||||||
|
|
||||||
|
// helper to queue the next response
|
||||||
|
function willReply(r) {
|
||||||
|
global.fetch = async (url, opts) => {
|
||||||
|
calls.push({ url, opts })
|
||||||
|
return reply(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── happy path + cookie + base path ─────────────────────────────────────
|
||||||
|
test('a GET hits the same-origin /api/v1 base, sends cookies, and returns parsed JSON', async () => {
|
||||||
|
willReply({ body: { user: { id: 1 } } })
|
||||||
|
const out = await api.me()
|
||||||
|
assert.equal(calls[0].url, '/api/v1/auth/me')
|
||||||
|
assert.equal(calls[0].opts.credentials, 'include')
|
||||||
|
assert.equal(calls[0].opts.method, 'GET')
|
||||||
|
assert.deepEqual(out, { user: { id: 1 } })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── error mapping ───────────────────────────────────────────────────────
|
||||||
|
test('a non-ok response throws an ApiError with status and the server message', async () => {
|
||||||
|
willReply({ status: 401, statusText: 'Unauthorized', body: { message: 'Incorrect username or password.' } })
|
||||||
|
await assert.rejects(
|
||||||
|
() => api.login('u', 'bad'),
|
||||||
|
(err) => {
|
||||||
|
assert.ok(err instanceof ApiError)
|
||||||
|
assert.equal(err.status, 401)
|
||||||
|
assert.equal(err.message, 'Incorrect username or password.')
|
||||||
|
assert.deepEqual(err.body, { message: 'Incorrect username or password.' })
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an error with no JSON message falls back to statusText', async () => {
|
||||||
|
willReply({ status: 503, statusText: 'Service Unavailable', body: '' })
|
||||||
|
await assert.rejects(
|
||||||
|
() => api.status(),
|
||||||
|
(err) => err instanceof ApiError && err.status === 503 && err.message === 'Service Unavailable',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── empty body ──────────────────────────────────────────────────────────
|
||||||
|
test('an empty 200 body resolves to null instead of throwing on JSON.parse', async () => {
|
||||||
|
willReply({ status: 200, body: '' })
|
||||||
|
const out = await api.logout()
|
||||||
|
assert.equal(out, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a non-JSON body is returned as the raw text (safeParse tolerates it)', async () => {
|
||||||
|
willReply({ status: 200, body: 'plain text' })
|
||||||
|
const out = await api.me()
|
||||||
|
assert.equal(out, 'plain text')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── request body encoding ───────────────────────────────────────────────
|
||||||
|
test('a JSON POST serializes the body and sets Content-Type', async () => {
|
||||||
|
willReply({ body: { user: { id: 9 } } })
|
||||||
|
await api.register('newbie', 'pw', { company: '' })
|
||||||
|
const { opts } = calls[0]
|
||||||
|
assert.equal(opts.method, 'POST')
|
||||||
|
assert.equal(opts.headers['Content-Type'], 'application/json')
|
||||||
|
assert.deepEqual(JSON.parse(opts.body), { username: 'newbie', password: 'pw', company: '' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a raw FormData upload does NOT set Content-Type and passes the body untouched', async () => {
|
||||||
|
willReply({ body: { url: '/uploads/x.png' } })
|
||||||
|
const fakeFile = { name: 'x.png' }
|
||||||
|
await api.admin.upload(fakeFile)
|
||||||
|
const { opts } = calls[0]
|
||||||
|
assert.equal(opts.method, 'POST')
|
||||||
|
assert.equal(opts.headers['Content-Type'], undefined) // browser sets the multipart boundary
|
||||||
|
assert.ok(opts.body instanceof FormData)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── query strings + path param encoding ─────────────────────────────────
|
||||||
|
test('wiki() builds a query string only from the params that are set', async () => {
|
||||||
|
willReply({ body: [] })
|
||||||
|
await api.wiki({ category: 'lore', q: 'dragon slayer' })
|
||||||
|
const url = new URL(calls[0].url, 'http://x')
|
||||||
|
assert.equal(url.pathname, '/api/v1/public/wiki')
|
||||||
|
assert.equal(url.searchParams.get('category'), 'lore')
|
||||||
|
assert.equal(url.searchParams.get('q'), 'dragon slayer')
|
||||||
|
assert.equal(url.searchParams.get('tag'), null) // omitted when unset
|
||||||
|
})
|
||||||
|
|
||||||
|
test('wiki() with no options sends no query string at all', async () => {
|
||||||
|
willReply({ body: [] })
|
||||||
|
await api.wiki()
|
||||||
|
assert.equal(calls[0].url, '/api/v1/public/wiki')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('path params are URL-encoded (a token/city with unsafe characters is escaped)', async () => {
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.shard.governorHistory('Serpent’s Hold', 5)
|
||||||
|
assert.match(calls[0].url, /\/governors\/Serpent%E2%80%99s%20Hold\/history\?limit=5/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('DELETE self-service session revoke encodes the id and uses the DELETE method', async () => {
|
||||||
|
willReply({ body: {} })
|
||||||
|
await api.revokeMySession('a b/c')
|
||||||
|
assert.equal(calls[0].opts.method, 'DELETE')
|
||||||
|
assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/)
|
||||||
|
})
|
||||||
55
client/test/format.test.js
Normal file
55
client/test/format.test.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { longDate, shortDate, dateTime, monthTile, ago, categoryLabel } from '../src/lib/format.js'
|
||||||
|
|
||||||
|
// Unit-test the shared date/label formatters. Date strings are given with an
|
||||||
|
// explicit local time (no trailing Z) so getMonth/getDate read the same value in
|
||||||
|
// any timezone the test runs in — otherwise a date-only UTC string could shift a
|
||||||
|
// day. The point is to lock the human-facing formats the whole site renders.
|
||||||
|
|
||||||
|
test('longDate renders "Month D, YYYY"', () => {
|
||||||
|
assert.equal(longDate('2026-06-24T12:00:00'), 'June 24, 2026')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('shortDate renders "Mon D"', () => {
|
||||||
|
assert.equal(shortDate('2026-06-24T12:00:00'), 'Jun 24')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('dateTime renders "Mon D HH:MM" with zero-padded time', () => {
|
||||||
|
assert.equal(dateTime('2026-06-24T08:05:00'), 'Jun 24 08:05')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('monthTile returns the uppercased 3-letter month and 2-digit year', () => {
|
||||||
|
assert.deepEqual(monthTile('2026-06-24T12:00:00'), { mon: 'JUN', num: "'26" })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every formatter returns an empty/placeholder value for a missing or invalid date', () => {
|
||||||
|
for (const bad of [null, undefined, '', 'not-a-date']) {
|
||||||
|
assert.equal(longDate(bad), '')
|
||||||
|
assert.equal(shortDate(bad), '')
|
||||||
|
assert.equal(dateTime(bad), '')
|
||||||
|
assert.equal(ago(bad), '')
|
||||||
|
assert.deepEqual(monthTile(bad), { mon: '—', num: '' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── ago(): relative-time buckets ────────────────────────────────────────
|
||||||
|
test('ago picks the right unit as the gap widens', () => {
|
||||||
|
const now = Date.now()
|
||||||
|
assert.equal(ago(new Date(now - 5 * 1000)), '5s ago')
|
||||||
|
assert.equal(ago(new Date(now - 5 * 60 * 1000)), '5m ago')
|
||||||
|
assert.equal(ago(new Date(now - 3 * 60 * 60 * 1000)), '3h ago')
|
||||||
|
assert.equal(ago(new Date(now - 2 * 24 * 60 * 60 * 1000)), '2d ago')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ago floors to at least 1s (never "0s ago" or a negative)', () => {
|
||||||
|
assert.equal(ago(new Date(Date.now())), '1s ago')
|
||||||
|
assert.equal(ago(new Date(Date.now() + 5000)), '1s ago') // a slightly-future timestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── categoryLabel(): known map + passthrough ────────────────────────────
|
||||||
|
test('categoryLabel maps known db categories and passes unknown ones through', () => {
|
||||||
|
assert.equal(categoryLabel('five_on_friday'), 'Five on Friday')
|
||||||
|
assert.equal(categoryLabel('newsletter'), 'Newsletter')
|
||||||
|
assert.equal(categoryLabel('mystery_category'), 'mystery_category') // unknown → itself
|
||||||
|
})
|
||||||
81
client/test/heroLayout.test.js
Normal file
81
client/test/heroLayout.test.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import {
|
||||||
|
heroBgStack,
|
||||||
|
buildOverlay,
|
||||||
|
heroBackground,
|
||||||
|
parseLayout,
|
||||||
|
defaultLayout,
|
||||||
|
DEFAULT_HERO_IMAGE,
|
||||||
|
} from '../src/lib/heroLayout.js'
|
||||||
|
|
||||||
|
// Unit-test the hero-layout helpers shared by the public portal and the admin
|
||||||
|
// editor. The high-value logic: parseLayout must reject anything malformed or of
|
||||||
|
// the wrong version (a bad stored value must never render as a broken hero), and
|
||||||
|
// heroBackground must take the untouched-default branch only when there is no
|
||||||
|
// custom image.
|
||||||
|
|
||||||
|
// ── parseLayout: the version/shape guard ────────────────────────────────
|
||||||
|
test('parseLayout accepts a well-formed v1 layout', () => {
|
||||||
|
const layout = { version: 1, elements: [] }
|
||||||
|
assert.deepEqual(parseLayout(JSON.stringify(layout)), layout)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('parseLayout returns null for missing, malformed, wrong-version, or wrong-shape input', () => {
|
||||||
|
assert.equal(parseLayout(null), null)
|
||||||
|
assert.equal(parseLayout(''), null)
|
||||||
|
assert.equal(parseLayout('{ not json'), null)
|
||||||
|
assert.equal(parseLayout(JSON.stringify({ version: 2, elements: [] })), null) // wrong version
|
||||||
|
assert.equal(parseLayout(JSON.stringify({ version: 1, elements: 'nope' })), null) // elements not an array
|
||||||
|
assert.equal(parseLayout(JSON.stringify({ version: 1 })), null) // no elements
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── heroBackground: default vs custom branch ────────────────────────────
|
||||||
|
test('heroBackground uses the contained-emblem default stack only when default AND no custom image', () => {
|
||||||
|
const style = heroBackground({ background: {} }, { isDefault: true })
|
||||||
|
assert.match(style.backgroundImage, /runic-emblem\.png/)
|
||||||
|
assert.equal(style.backgroundSize, 'cover, min(74vh, 640px, 86vw)') // the two-layer default size
|
||||||
|
})
|
||||||
|
|
||||||
|
test('heroBackground threads a per-instance defaultImage into the default stack', () => {
|
||||||
|
const style = heroBackground({ background: {} }, { isDefault: true, defaultImage: '/brand/hero.jpg' })
|
||||||
|
assert.match(style.backgroundImage, /\/brand\/hero\.jpg/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('heroBackground composes overlay + custom image once a custom image is set', () => {
|
||||||
|
const style = heroBackground(
|
||||||
|
{ background: { image_url: '/uploads/hero.png', position_x: 'right', position_y: 'top', size: 'contain' }, overlay: { opacity: 0.5 } },
|
||||||
|
{ isDefault: true }, // still leaves the default branch because a custom image_url is present
|
||||||
|
)
|
||||||
|
assert.match(style.backgroundImage, /\/uploads\/hero\.png/)
|
||||||
|
assert.match(style.backgroundImage, /rgba\(11,15,20,0\.5\)/) // overlay opacity threaded in
|
||||||
|
assert.equal(style.backgroundPosition, 'right top')
|
||||||
|
assert.equal(style.backgroundSize, 'contain')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('heroBackground defaults the overlay opacity to 0.72 when unset', () => {
|
||||||
|
const style = heroBackground({ background: { image_url: '/x.png' } }, {})
|
||||||
|
assert.match(style.backgroundImage, /rgba\(11,15,20,0\.72\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── smaller helpers ─────────────────────────────────────────────────────
|
||||||
|
test('heroBgStack falls back to the default emblem when no image is given', () => {
|
||||||
|
assert.match(heroBgStack(), new RegExp(DEFAULT_HERO_IMAGE.replace(/[/.]/g, '\\$&')))
|
||||||
|
assert.match(heroBgStack('/custom.png'), /\/custom\.png/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('buildOverlay scales both gradient stops from the opacity', () => {
|
||||||
|
const overlay = buildOverlay(0.8)
|
||||||
|
assert.match(overlay, /rgba\(11,15,20,0.12\)/) // 0.8 * 0.15 top stop
|
||||||
|
assert.match(overlay, /rgba\(11,15,20,0.8\)/) // bottom stop
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── defaultLayout: the fallback hero ────────────────────────────────────
|
||||||
|
test('defaultLayout builds a valid v1 layout that threads the teaser and shard name', () => {
|
||||||
|
const layout = defaultLayout('Come play with us', 'My Shard')
|
||||||
|
assert.equal(parseLayout(JSON.stringify(layout)) !== null, true, 'the default is itself a valid layout')
|
||||||
|
assert.equal(layout.version, 1)
|
||||||
|
const textLines = layout.elements.find((e) => e.id === 'default-text').props.lines
|
||||||
|
assert.ok(textLines.some((l) => l.text === 'My Shard'), 'shard name rendered as the h1')
|
||||||
|
assert.ok(textLines.some((l) => l.text === 'Come play with us'), 'teaser threaded in')
|
||||||
|
})
|
||||||
60
client/test/regionBuckets.test.js
Normal file
60
client/test/regionBuckets.test.js
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { bucketize, BUCKETS } from '../src/data/regionBuckets.js'
|
||||||
|
|
||||||
|
// Unit-test the presence.online region roll-up for the "Players Online" widget.
|
||||||
|
// The load-bearing invariant: the bucket counts ALWAYS reconcile to the true
|
||||||
|
// total — anything unmatched lands in Wilderness — so the widget can never show
|
||||||
|
// a sum that disagrees with the headline online count.
|
||||||
|
|
||||||
|
test('bucketize groups named regions into their buckets', () => {
|
||||||
|
const { rows, total } = bucketize({
|
||||||
|
'Britain': 4,
|
||||||
|
'Moonglow': 2,
|
||||||
|
'Despise': 3,
|
||||||
|
'Green Acres House 12': 1, // not a town/dungeon name → Housing
|
||||||
|
})
|
||||||
|
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||||
|
assert.equal(byId.britain, 4)
|
||||||
|
assert.equal(byId.towns, 2)
|
||||||
|
assert.equal(byId.dungeons, 3)
|
||||||
|
assert.equal(byId.housing, 1)
|
||||||
|
assert.equal(total, 10)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('first match wins by BUCKETS order: a town-named house region counts as Towns, not Housing', () => {
|
||||||
|
// The towns regex is ^-anchored and towns is checked BEFORE housing, so a house
|
||||||
|
// region whose name starts with a town name is bucketed as Towns. Pinning this
|
||||||
|
// documents the ordering dependency for anyone retuning BUCKETS.
|
||||||
|
const { rows } = bucketize({ 'Trinsic House 12': 1 })
|
||||||
|
const byId = Object.fromEntries(rows.map((r) => [r.id, r.count]))
|
||||||
|
assert.equal(byId.towns, 1)
|
||||||
|
assert.equal(byId.housing, undefined) // empty bucket dropped
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unmatched region falls through to Wilderness so counts always reconcile', () => {
|
||||||
|
const { rows, total } = bucketize({ 'Some Unnamed Field': 5, 'Wilderness': 2 })
|
||||||
|
const wilderness = rows.find((r) => r.id === 'wilderness')
|
||||||
|
assert.equal(wilderness.count, 7)
|
||||||
|
assert.equal(total, 7)
|
||||||
|
// The reconciliation guarantee: the buckets sum to the total, exactly.
|
||||||
|
assert.equal(rows.reduce((s, r) => s + r.count, 0), total)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bucketize returns rows in BUCKETS order and drops empty buckets', () => {
|
||||||
|
const { rows } = bucketize({ 'Despise': 1, 'Britain': 1 })
|
||||||
|
assert.deepEqual(rows.map((r) => r.id), ['britain', 'dungeons']) // BUCKETS order, no empty towns/housing/wilderness
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bucketize coerces non-numeric counts and tolerates empty/nullish input', () => {
|
||||||
|
assert.deepEqual(bucketize({}), { rows: [], total: 0 })
|
||||||
|
assert.deepEqual(bucketize(), { rows: [], total: 0 })
|
||||||
|
const { total } = bucketize({ 'Britain': '3', 'Minoc': 'oops' })
|
||||||
|
assert.equal(total, 3) // '3' → 3, 'oops' → 0
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the last bucket is the catch-all (its match accepts anything)', () => {
|
||||||
|
const last = BUCKETS[BUCKETS.length - 1]
|
||||||
|
assert.equal(last.id, 'wilderness')
|
||||||
|
assert.equal(last.match('literally anything'), true)
|
||||||
|
})
|
||||||
74
client/test/shardEvents.test.js
Normal file
74
client/test/shardEvents.test.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { describe, categoryOf, kindLabel, CATEGORIES } from '../src/lib/shardEvents.js'
|
||||||
|
|
||||||
|
// Unit-test the shared shard-event formatter — the single place that decides how
|
||||||
|
// each event kind reads and which filter category it belongs to. These strings
|
||||||
|
// are user-facing on the public Shard page, the Activity feed, and the admin
|
||||||
|
// live feed, so a regression here is visible everywhere at once.
|
||||||
|
|
||||||
|
// ── describe(): works on both stored (.payload) and live (top-level) frames ──
|
||||||
|
test('describe reads fields from .payload when present, else the top level', () => {
|
||||||
|
const stored = { kind: 'quest.complete', payload: { who: { name: 'Ada' }, quest: 'The Cavern' } }
|
||||||
|
const live = { kind: 'quest.complete', who: { name: 'Ada' }, quest: 'The Cavern' }
|
||||||
|
assert.equal(describe(stored), 'Ada completed “The Cavern”')
|
||||||
|
assert.equal(describe(live), 'Ada completed “The Cavern”')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe resolves an actor from name → acct → "Someone"', () => {
|
||||||
|
assert.equal(describe({ kind: 'mob.login', who: { name: 'Bob' } }), 'Bob entered the world')
|
||||||
|
assert.equal(describe({ kind: 'mob.login', who: { acct: 'acct7' } }), 'acct7 entered the world')
|
||||||
|
assert.equal(describe({ kind: 'mob.login', who: null }), 'Someone entered the world')
|
||||||
|
assert.equal(describe({ kind: 'mob.login', who: 'RawString' }), 'RawString entered the world')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe pluralizes a vendor sale only when amount > 1 and formats the price', () => {
|
||||||
|
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Katana', amount: 1, price: 1200 }), 'Katana sold for 1,200gp')
|
||||||
|
assert.equal(describe({ kind: 'vendor.sale', itemType: 'Arrow', amount: 40, price: 80 }), 'Arrow ×40 sold for 80gp')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe includes the killer only when present (optional clause)', () => {
|
||||||
|
assert.equal(describe({ kind: 'player.death', who: { name: 'Ada' } }), 'Ada was slain')
|
||||||
|
assert.equal(
|
||||||
|
describe({ kind: 'player.death', who: { name: 'Ada' }, killer: { name: 'Orc' } }),
|
||||||
|
'Ada was slain by Orc',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe champ.update branches on status and boss state', () => {
|
||||||
|
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', bossUp: true }), 'Rikktor: boss is up')
|
||||||
|
assert.equal(
|
||||||
|
describe({ kind: 'champ.update', name: 'Rikktor', status: 'active', level: 3 }),
|
||||||
|
'Rikktor is active — level 3',
|
||||||
|
)
|
||||||
|
assert.equal(describe({ kind: 'champ.update', name: 'Rikktor', status: 'cooldown' }), 'Rikktor is on cooldown')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('describe falls back to the raw kind for an unknown event', () => {
|
||||||
|
assert.equal(describe({ kind: 'some.future.kind' }), 'some.future.kind')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── categoryOf(): membership + catch-all ────────────────────────────────
|
||||||
|
test('categoryOf groups kinds per the CATEGORIES table, and unknowns are "other"', () => {
|
||||||
|
assert.equal(categoryOf('player.death'), 'pvp')
|
||||||
|
assert.equal(categoryOf('skill.gain'), 'progress')
|
||||||
|
assert.equal(categoryOf('house.decay'), 'world')
|
||||||
|
assert.equal(categoryOf('vendor.sale'), 'other') // deliberately not a public category
|
||||||
|
assert.equal(categoryOf('totally.unknown'), 'other')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every kind listed in CATEGORIES maps back to that category (table stays consistent)', () => {
|
||||||
|
for (const cat of CATEGORIES) {
|
||||||
|
if (!cat.kinds) continue
|
||||||
|
for (const kind of cat.kinds) {
|
||||||
|
assert.equal(categoryOf(kind), cat.id, `${kind} should be in ${cat.id}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── kindLabel(): badge text ─────────────────────────────────────────────
|
||||||
|
test('kindLabel turns dots/underscores into spaces and tolerates empty input', () => {
|
||||||
|
assert.equal(kindLabel('player.death'), 'player death')
|
||||||
|
assert.equal(kindLabel('account.login.attempt'), 'account login attempt')
|
||||||
|
assert.equal(kindLabel(null), '')
|
||||||
|
})
|
||||||
285
server/test/authController.test.js
Normal file
285
server/test/authController.test.js
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
// Point the DB at a closed port BEFORE requiring the controller (its models build
|
||||||
|
// the pool). Every model/service call is monkeypatched, so no query runs;
|
||||||
|
// db.close() at the end releases the pool so the process exits cleanly.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, after, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Unit-test the auth controller's security-sensitive decision logic. These are
|
||||||
|
// the rules a regression must never silently break:
|
||||||
|
// - a tripped honeypot fails generically and is never issued a session;
|
||||||
|
// - every credential failure returns the SAME generic message (no user
|
||||||
|
// enumeration, no "which field was wrong");
|
||||||
|
// - correct password on an inactive account is refused (no session, no TOTP);
|
||||||
|
// - a TOTP-enabled user gets a challenge, NOT a session, until the code checks;
|
||||||
|
// - registration is gated by the registration mode; a dup username is a 409;
|
||||||
|
// - logout always clears the cookie and revokes the session when present.
|
||||||
|
const ctrl = require('../src/router/v1/auth/auth.controller')
|
||||||
|
const users = require('../src/model/users/users.model')
|
||||||
|
const activity = require('../src/model/activity/activity.model')
|
||||||
|
const settings = require('../src/model/settings/settings.model')
|
||||||
|
const sessionService = require('../src/auth/session.service')
|
||||||
|
const totp = require('../src/utils/totp')
|
||||||
|
const botScore = require('../src/middleware/botScore')
|
||||||
|
const loginProtection = require('../src/middleware/loginProtection')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
cookies: {},
|
||||||
|
cleared: [],
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
cookie(name, val) {
|
||||||
|
this.cookies[name] = val
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
clearCookie(name) {
|
||||||
|
this.cleared.push(name)
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spies / stubs for the collaborators the controller drives.
|
||||||
|
let sessionsCreated
|
||||||
|
const orig = {}
|
||||||
|
beforeEach(() => {
|
||||||
|
sessionsCreated = []
|
||||||
|
for (const [mod, name] of [
|
||||||
|
[users, 'getRawByUsername'], [users, 'validatePassword'], [users, 'recordLogin'],
|
||||||
|
[users, 'getRawById'], [users, 'getById'], [users, 'createUser'], [users, 'isDuplicateUsername'],
|
||||||
|
[activity, 'log'], [settings, 'getRegistrationMode'],
|
||||||
|
[sessionService, 'createSession'], [sessionService, 'createPartialSession'], [sessionService, 'upgradeSessionAfterTotp'], [sessionService, 'revokeSession'],
|
||||||
|
[totp, 'verifyCode'],
|
||||||
|
[botScore, 'recordHoneypot'], [botScore, 'recordLoginFailure'],
|
||||||
|
[loginProtection, 'recordFailure'], [loginProtection, 'recordSuccess'],
|
||||||
|
]) {
|
||||||
|
orig[`${name}`] = orig[`${name}`] || { mod, val: mod[name] }
|
||||||
|
}
|
||||||
|
// Safe defaults; individual tests override.
|
||||||
|
users.recordLogin = async () => {}
|
||||||
|
users.getById = async (id) => ({ id, username: 'u', role: 'player' })
|
||||||
|
activity.log = async () => {}
|
||||||
|
botScore.recordHoneypot = () => {}
|
||||||
|
botScore.recordLoginFailure = () => {}
|
||||||
|
loginProtection.recordFailure = () => {}
|
||||||
|
loginProtection.recordSuccess = () => {}
|
||||||
|
sessionService.createSession = (user) => {
|
||||||
|
sessionsCreated.push(user)
|
||||||
|
return { token: 'session-token' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of Object.keys(orig)) {
|
||||||
|
orig[key].mod[key] = orig[key].val
|
||||||
|
delete orig[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const baseReq = (body = {}) => ({ body, ip: '10.0.0.1', headers: {}, session: null })
|
||||||
|
|
||||||
|
// ── honeypot ────────────────────────────────────────────────────────────
|
||||||
|
test('login: a filled honeypot field fails generically and issues no session', async () => {
|
||||||
|
let scored = false
|
||||||
|
botScore.recordHoneypot = () => {
|
||||||
|
scored = true
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'x', password: 'y', company: 'ACME Bot' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.equal(res.body.message, 'Incorrect username or password.')
|
||||||
|
assert.equal(scored, true)
|
||||||
|
assert.equal(sessionsCreated.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── credential failures are indistinguishable ───────────────────────────
|
||||||
|
test('login: an unknown username and a wrong password return the identical generic failure', async () => {
|
||||||
|
users.getRawByUsername = async () => null // unknown user
|
||||||
|
users.validatePassword = async () => false
|
||||||
|
const res1 = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'ghost', password: 'z' }), res1)
|
||||||
|
|
||||||
|
users.getRawByUsername = async () => ({ id: 1, username: 'real', status: 'active' })
|
||||||
|
users.validatePassword = async () => false // wrong password
|
||||||
|
const res2 = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'real', password: 'wrong' }), res2)
|
||||||
|
|
||||||
|
assert.equal(res1.statusCode, 401)
|
||||||
|
assert.equal(res2.statusCode, 401)
|
||||||
|
assert.deepEqual(res1.body, res2.body) // no enumeration: same message either way
|
||||||
|
assert.equal(sessionsCreated.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── inactive account: correct password, still refused ───────────────────
|
||||||
|
test('login: correct password on a non-active account is a 403 with no session', async () => {
|
||||||
|
users.getRawByUsername = async () => ({ id: 1, username: 'banned', status: 'banned' })
|
||||||
|
users.validatePassword = async () => true
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'banned', password: 'right' }), res)
|
||||||
|
assert.equal(res.statusCode, 403)
|
||||||
|
assert.equal(sessionsCreated.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── TOTP gate: password ok but a second factor is required ──────────────
|
||||||
|
test('login: a TOTP-enabled user gets a challenge, not a session', async () => {
|
||||||
|
users.getRawByUsername = async () => ({ id: 5, username: 'safe', status: 'active', totp_enabled: 1 })
|
||||||
|
users.validatePassword = async () => true
|
||||||
|
sessionService.createPartialSession = () => 'challenge-jwt'
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'safe', password: 'right' }), res)
|
||||||
|
assert.equal(res.body.totpRequired, true)
|
||||||
|
assert.equal(res.body.challenge, 'challenge-jwt')
|
||||||
|
assert.equal(sessionsCreated.length, 0, 'no real session until the code is verified')
|
||||||
|
assert.equal(res.cookies['auth_token'] || res.cookies.token, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('login: a user without TOTP is logged straight in (session + cookie)', async () => {
|
||||||
|
users.getRawByUsername = async () => ({ id: 9, username: 'plain', role: 'player', status: 'active', totp_enabled: 0 })
|
||||||
|
users.validatePassword = async () => true
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.login(baseReq({ username: 'plain', password: 'right' }), res)
|
||||||
|
assert.equal(sessionsCreated.length, 1)
|
||||||
|
assert.equal(sessionsCreated[0].id, 9)
|
||||||
|
assert.equal(res.body.user.id, 9)
|
||||||
|
assert.equal(Object.keys(res.cookies).length, 1, 'a session cookie was set')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── loginTotp second step ───────────────────────────────────────────────
|
||||||
|
test('loginTotp: an expired/invalid challenge is a 401 (no user lookup)', async () => {
|
||||||
|
sessionService.upgradeSessionAfterTotp = () => null
|
||||||
|
let lookedUp = false
|
||||||
|
users.getRawById = async () => {
|
||||||
|
lookedUp = true
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.loginTotp(baseReq({ challenge: 'stale', code: '000000' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.equal(lookedUp, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loginTotp: a wrong code is a 401 and issues no session', async () => {
|
||||||
|
sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
|
||||||
|
users.getRawById = async () => ({ id: 5, totp_enabled: 1, totp_secret: 'S' })
|
||||||
|
totp.verifyCode = () => false
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '123456' }), res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
assert.equal(sessionsCreated.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('loginTotp: a valid code issues the session with authMethod totp', async () => {
|
||||||
|
sessionService.upgradeSessionAfterTotp = () => ({ id: 5 })
|
||||||
|
users.getRawById = async () => ({ id: 5, username: 'safe', role: 'admin', totp_enabled: 1, totp_secret: 'S' })
|
||||||
|
totp.verifyCode = () => true
|
||||||
|
let method
|
||||||
|
sessionService.createSession = (user, authMethod) => {
|
||||||
|
method = authMethod
|
||||||
|
sessionsCreated.push(user)
|
||||||
|
return { token: 't' }
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.loginTotp(baseReq({ challenge: 'ok', code: '654321' }), res)
|
||||||
|
assert.equal(sessionsCreated.length, 1)
|
||||||
|
assert.equal(method, 'totp')
|
||||||
|
assert.equal(res.body.user.id, 5)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── register gating ─────────────────────────────────────────────────────
|
||||||
|
test('register: refused (403) when the registration mode excludes passwords', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'sso' // password path closed
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.register(baseReq({ username: 'newbie', password: 'pw' }), res)
|
||||||
|
assert.equal(res.statusCode, 403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('register: an invalid username is a 400 before any user is created', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'password'
|
||||||
|
let created = false
|
||||||
|
users.createUser = async () => {
|
||||||
|
created = true
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.register(baseReq({ username: 'x', password: 'pw' }), res) // too short
|
||||||
|
assert.equal(res.statusCode, 400)
|
||||||
|
assert.equal(created, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('register: a duplicate username surfaces as a 409', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'both'
|
||||||
|
users.createUser = async () => {
|
||||||
|
throw new Error('dup')
|
||||||
|
}
|
||||||
|
users.isDuplicateUsername = () => true
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.register(baseReq({ username: 'takenname', password: 'pw' }), res)
|
||||||
|
assert.equal(res.statusCode, 409)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('register: a valid new player is created and auto-logged-in', async () => {
|
||||||
|
settings.getRegistrationMode = async () => 'password'
|
||||||
|
users.createUser = async ({ username, role }) => ({ id: 30, username, role })
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.register(baseReq({ username: 'freshplayer', password: 'pw', email: ' a@b.c ' }), res)
|
||||||
|
assert.equal(res.body.user.id, 30)
|
||||||
|
assert.equal(res.body.user.role, 'player')
|
||||||
|
assert.equal(sessionsCreated.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── logout always clears, revokes when a session exists ─────────────────
|
||||||
|
test('logout: clears the cookie and revokes the server-side session when present', async () => {
|
||||||
|
let revoked = null
|
||||||
|
sessionService.revokeSession = async (id) => {
|
||||||
|
revoked = id
|
||||||
|
}
|
||||||
|
const req = { ...baseReq(), session: { sessionId: 'jti-1', userId: 7, expiresAt: 123 } }
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.logout(req, res)
|
||||||
|
assert.equal(res.cleared.length, 1, 'cookie cleared')
|
||||||
|
assert.equal(revoked, 'jti-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('logout: still succeeds (cookie cleared) when there is no session to revoke', async () => {
|
||||||
|
let revokeCalled = false
|
||||||
|
sessionService.revokeSession = async () => {
|
||||||
|
revokeCalled = true
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.logout(baseReq(), res)
|
||||||
|
assert.equal(res.cleared.length, 1)
|
||||||
|
assert.equal(revokeCalled, false)
|
||||||
|
assert.match(res.body.message, /logged out/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('logout: a revocation error never fails the logout', async () => {
|
||||||
|
sessionService.revokeSession = async () => {
|
||||||
|
throw new Error('store unreachable')
|
||||||
|
}
|
||||||
|
const req = { ...baseReq(), session: { sessionId: 'jti', userId: 1, expiresAt: 1 } }
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.logout(req, res) // must resolve
|
||||||
|
assert.equal(res.cleared.length, 1)
|
||||||
|
assert.match(res.body.message, /logged out/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── me ──────────────────────────────────────────────────────────────────
|
||||||
|
test('me: returns 401 when the backing user no longer exists', async () => {
|
||||||
|
users.getById = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.me({ ...baseReq(), user: { id: 99 } }, res)
|
||||||
|
assert.equal(res.statusCode, 401)
|
||||||
|
})
|
||||||
157
server/test/inviteController.test.js
Normal file
157
server/test/inviteController.test.js
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
// Point the DB at a closed port BEFORE requiring the controller (its models build
|
||||||
|
// the pool). Every model/service call is monkeypatched, so no query runs;
|
||||||
|
// db.close() at the end releases the pool so the process exits cleanly.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, after, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Unit-test invite acceptance. The two invariants that matter most:
|
||||||
|
// - the new user is created at the invite's PRESET role (an invite is its own
|
||||||
|
// authority — it bypasses the registration gate but not the role);
|
||||||
|
// - a lost double-accept race rolls back the just-created user, so a spent
|
||||||
|
// invite can never yield two accounts.
|
||||||
|
// Plus the usual guards: honeypot, invalid token, invalid username, dup username.
|
||||||
|
const ctrl = require('../src/router/v1/auth/invite.controller')
|
||||||
|
const authCtrl = require('../src/router/v1/auth/auth.controller')
|
||||||
|
const invites = require('../src/model/invites/invites.model')
|
||||||
|
const users = require('../src/model/users/users.model')
|
||||||
|
const activity = require('../src/model/activity/activity.model')
|
||||||
|
const sessionService = require('../src/auth/session.service')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
cookies: {},
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
cookie(name, val) {
|
||||||
|
this.cookies[name] = val
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orig = {}
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const [mod, name] of [
|
||||||
|
[invites, 'findValidByToken'], [invites, 'accept'],
|
||||||
|
[users, 'createUser'], [users, 'isDuplicateUsername'], [users, 'remove'], [users, 'recordLogin'],
|
||||||
|
[activity, 'log'], [sessionService, 'createSession'],
|
||||||
|
]) {
|
||||||
|
orig[name] = { mod, val: mod[name] }
|
||||||
|
}
|
||||||
|
activity.log = async () => {}
|
||||||
|
users.recordLogin = async () => {}
|
||||||
|
sessionService.createSession = () => ({ token: 'session-token' })
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of Object.keys(orig)) {
|
||||||
|
orig[key].mod[key] = orig[key].val
|
||||||
|
delete orig[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const HONEYPOT = authCtrl.HONEYPOT_FIELD
|
||||||
|
const req = (body = {}, token = 'tok') => ({ body, ip: '10.0.0.1', headers: {}, params: { token } })
|
||||||
|
|
||||||
|
// ── getInvite ───────────────────────────────────────────────────────────
|
||||||
|
test('getInvite 404s an invalid token and otherwise returns only email + role', async () => {
|
||||||
|
invites.findValidByToken = async () => null
|
||||||
|
const res404 = mockRes()
|
||||||
|
await ctrl.getInvite(req({}, 'bad'), res404)
|
||||||
|
assert.equal(res404.statusCode, 404)
|
||||||
|
|
||||||
|
invites.findValidByToken = async () => ({ id: 1, email: 'invitee@x.io', role: 'moderator', token_hash: 'SECRET' })
|
||||||
|
const resOk = mockRes()
|
||||||
|
await ctrl.getInvite(req({}, 'good'), resOk)
|
||||||
|
assert.deepEqual(resOk.body, { email: 'invitee@x.io', role: 'moderator' }) // no id/token/hash
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── acceptInvite guards ─────────────────────────────────────────────────
|
||||||
|
test('acceptInvite rejects a tripped honeypot with a 400 before any lookup', async () => {
|
||||||
|
let lookedUp = false
|
||||||
|
invites.findValidByToken = async () => {
|
||||||
|
lookedUp = true
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.acceptInvite(req({ username: 'x', password: 'p', [HONEYPOT]: 'bot' }), res)
|
||||||
|
assert.equal(res.statusCode, 400)
|
||||||
|
assert.equal(lookedUp, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('acceptInvite 404s an invalid/expired invite token', async () => {
|
||||||
|
invites.findValidByToken = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.acceptInvite(req({ username: 'validname', password: 'pw' }), res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('acceptInvite 400s an invalid username without creating a user', async () => {
|
||||||
|
invites.findValidByToken = async () => ({ id: 1, email: 'a@x.io', role: 'editor' })
|
||||||
|
let created = false
|
||||||
|
users.createUser = async () => {
|
||||||
|
created = true
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.acceptInvite(req({ username: 'x', password: 'pw' }), res) // too short
|
||||||
|
assert.equal(res.statusCode, 400)
|
||||||
|
assert.equal(created, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the preset-role invariant ───────────────────────────────────────────
|
||||||
|
test('acceptInvite creates the user at the invite role (not player) and logs them in', async () => {
|
||||||
|
invites.findValidByToken = async () => ({ id: 2, email: 'mod@x.io', role: 'moderator' })
|
||||||
|
let createArgs
|
||||||
|
users.createUser = async (args) => {
|
||||||
|
createArgs = args
|
||||||
|
return { id: 50, username: args.username, role: args.role }
|
||||||
|
}
|
||||||
|
invites.accept = async () => true
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.acceptInvite(req({ username: 'newmod', password: 'pw' }), res)
|
||||||
|
assert.equal(createArgs.role, 'moderator') // preset role carried through
|
||||||
|
assert.equal(createArgs.email, 'mod@x.io') // email comes from the invite, not the body
|
||||||
|
assert.equal(createArgs.emailVerified, true) // using the link proves control of the address
|
||||||
|
assert.equal(res.body.user.id, 50)
|
||||||
|
assert.equal(Object.keys(res.cookies).length, 1, 'a session cookie was set')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── dup username ────────────────────────────────────────────────────────
|
||||||
|
test('acceptInvite surfaces a duplicate username as a 409', async () => {
|
||||||
|
invites.findValidByToken = async () => ({ id: 3, email: 'a@x.io', role: 'player' })
|
||||||
|
users.createUser = async () => {
|
||||||
|
throw new Error('dup')
|
||||||
|
}
|
||||||
|
users.isDuplicateUsername = () => true
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.acceptInvite(req({ username: 'takenname', password: 'pw' }), res)
|
||||||
|
assert.equal(res.statusCode, 409)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the double-accept race rolls back the created user ──────────────────
|
||||||
|
test('acceptInvite rolls back the new user and 409s when it loses the accept race', async () => {
|
||||||
|
invites.findValidByToken = async () => ({ id: 4, email: 'a@x.io', role: 'player' })
|
||||||
|
users.createUser = async () => ({ id: 77, username: 'racer', role: 'player' })
|
||||||
|
invites.accept = async () => false // someone else consumed the invite first
|
||||||
|
let removed = null
|
||||||
|
users.remove = async (id) => {
|
||||||
|
removed = id
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.acceptInvite(req({ username: 'racername', password: 'pw' }), res)
|
||||||
|
assert.equal(res.statusCode, 409)
|
||||||
|
assert.equal(removed, 77, 'the orphaned user is deleted — a spent invite never yields two accounts')
|
||||||
|
})
|
||||||
110
server/test/moderation.model.test.js
Normal file
110
server/test/moderation.model.test.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
// Point the DB pool at a dead port before it's built; the db modules are
|
||||||
|
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const pool = require('../src/utils/db')
|
||||||
|
after(() => pool.close())
|
||||||
|
|
||||||
|
// Unit-test the moderation MODEL's orchestration (the pure helpers are covered
|
||||||
|
// separately in moderation.test.js). What the model adds on top:
|
||||||
|
// - summary() merges five independent window feeds (mod actions + member
|
||||||
|
// joins/leaves + invite joins + filter/spam hits) into one windows object;
|
||||||
|
// - userSummary() zero-fills per-type counts but total_actions sums EVERY row,
|
||||||
|
// including action types not in the fixed count set;
|
||||||
|
// - a missing/failing bot config degrades to "nothing automated", never throws.
|
||||||
|
const moderationDb = require('../src/model/moderation/moderation.db')
|
||||||
|
const botConfigDb = require('../src/model/botConfig/botConfig.db')
|
||||||
|
const moderation = require('../src/model/moderation/moderation.model')
|
||||||
|
|
||||||
|
const saved = {}
|
||||||
|
function patch(mod, name, fn) {
|
||||||
|
saved[`${mod === moderationDb ? 'm' : 'b'}:${name}`] = mod[name]
|
||||||
|
mod[name] = fn
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of Object.keys(saved)) {
|
||||||
|
const [tag, name] = key.split(':')
|
||||||
|
const mod = tag === 'm' ? moderationDb : botConfigDb
|
||||||
|
mod[name] = saved[key]
|
||||||
|
delete saved[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── summary() merges every feed into the three windows ──────────────────
|
||||||
|
test('summary folds mod-action counts and all four event feeds into each window', async () => {
|
||||||
|
patch(moderationDb, 'countsByWindow', async () => [{ action_type: 'ban', d1: 1, d7: 2, d30: 3 }])
|
||||||
|
patch(moderationDb, 'memberCountsByWindow', async () => [
|
||||||
|
{ event_type: 'join', d1: 5, d7: 6, d30: 7 },
|
||||||
|
{ event_type: 'leave', d1: 1, d7: 1, d30: 1 },
|
||||||
|
])
|
||||||
|
patch(moderationDb, 'inviteJoinCountsByWindow', async () => ({ d1: 2, d7: 2, d30: 2 }))
|
||||||
|
patch(moderationDb, 'tableCountsByWindow', async (table) =>
|
||||||
|
table === 'filter_hits' ? { d1: 9, d7: 9, d30: 9 } : { d1: 4, d7: 4, d30: 4 },
|
||||||
|
)
|
||||||
|
|
||||||
|
const { windows } = await moderation.summary()
|
||||||
|
assert.equal(windows['24h'].ban, 1)
|
||||||
|
assert.equal(windows['7d'].ban, 2)
|
||||||
|
assert.equal(windows['24h'].joins, 5)
|
||||||
|
assert.equal(windows['24h'].leaves, 1)
|
||||||
|
assert.equal(windows['24h'].invite_joins, 2)
|
||||||
|
assert.equal(windows['24h'].filter_hits, 9)
|
||||||
|
assert.equal(windows['24h'].spam_hits, 4)
|
||||||
|
assert.equal(windows['30d'].joins, 7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('summary zero-fills a feed that returned no rows for a window', async () => {
|
||||||
|
patch(moderationDb, 'countsByWindow', async () => [])
|
||||||
|
patch(moderationDb, 'memberCountsByWindow', async () => []) // no join/leave rows
|
||||||
|
patch(moderationDb, 'inviteJoinCountsByWindow', async () => null)
|
||||||
|
patch(moderationDb, 'tableCountsByWindow', async () => null)
|
||||||
|
|
||||||
|
const { windows } = await moderation.summary()
|
||||||
|
assert.equal(windows['24h'].joins, 0)
|
||||||
|
assert.equal(windows['7d'].filter_hits, 0)
|
||||||
|
assert.equal(windows['30d'].ban, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── userSummary() count/total semantics ─────────────────────────────────
|
||||||
|
test('userSummary zero-fills known types but total_actions sums every row', async () => {
|
||||||
|
patch(moderationDb, 'userCounts', async () => [
|
||||||
|
{ action_type: 'ban', c: '2' },
|
||||||
|
{ action_type: 'warn', c: 3 },
|
||||||
|
{ action_type: 'note', c: 5 }, // NOT in zeroCounts — excluded from counts, still in total
|
||||||
|
])
|
||||||
|
patch(moderationDb, 'latestTag', async () => 'Griefer#1')
|
||||||
|
patch(moderationDb, 'linkedAccount', async () => ({ id: 9, username: 'griefer' }))
|
||||||
|
|
||||||
|
const out = await moderation.userSummary('123')
|
||||||
|
assert.equal(out.counts.ban, 2)
|
||||||
|
assert.equal(out.counts.warn, 3)
|
||||||
|
assert.equal(out.counts.kick, 0) // zero-filled
|
||||||
|
assert.equal(out.counts.note, undefined) // unknown type not surfaced as a count
|
||||||
|
assert.equal(out.total_actions, 10) // 2 + 3 + 5 — total includes the unknown type
|
||||||
|
assert.equal(out.tag, 'Griefer#1')
|
||||||
|
assert.deepEqual(out.linked_account, { id: 9, username: 'griefer' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── automated-action annotation depends on bot config, which may be absent ──
|
||||||
|
test('recent flags actions taken by the bot application id as automated', async () => {
|
||||||
|
patch(botConfigDb, 'get', async () => ({ application_id: '999' }))
|
||||||
|
patch(moderationDb, 'recentActions', async () => [
|
||||||
|
{ id: 1, staff_user_id: '999' }, // the bot
|
||||||
|
{ id: 2, staff_user_id: '42' }, // a human mod
|
||||||
|
])
|
||||||
|
const rows = await moderation.recent({})
|
||||||
|
assert.equal(rows[0].is_automated, true)
|
||||||
|
assert.equal(rows[1].is_automated, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recent degrades to nothing-automated when bot config lookup throws', async () => {
|
||||||
|
patch(botConfigDb, 'get', async () => {
|
||||||
|
throw new Error('bot config table missing')
|
||||||
|
})
|
||||||
|
patch(moderationDb, 'recentActions', async () => [{ id: 1, staff_user_id: '999' }])
|
||||||
|
const rows = await moderation.recent({})
|
||||||
|
assert.equal(rows[0].is_automated, false) // appId resolved to null, not a crash
|
||||||
|
})
|
||||||
242
server/test/pages.model.test.js
Normal file
242
server/test/pages.model.test.js
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
// Point the DB pool at a dead port before it's built; every pages.db method is
|
||||||
|
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const pool = require('../src/utils/db')
|
||||||
|
after(() => pool.close())
|
||||||
|
|
||||||
|
// Unit-test the CMS pages model against an in-memory fake by monkeypatching
|
||||||
|
// pages.db (no DB). The point is to lock the rules the model owns and the API
|
||||||
|
// surface must not be able to bypass:
|
||||||
|
// - slug validation + reserved-name guard, and slug immutability after create;
|
||||||
|
// - block validation/sanitization on every write (the authoritative gate);
|
||||||
|
// - the `protected` asymmetry: ON via a normal update, OFF only via unprotect();
|
||||||
|
// - published_at stamped once, on the first publish;
|
||||||
|
// - draft pages invisible to public (getBySlug) reads;
|
||||||
|
// - a duplicate slug surfaces as a 409, not a raw DB error.
|
||||||
|
const pagesDb = require('../src/model/pages/pages.db')
|
||||||
|
const pages = require('../src/model/pages/pages.model')
|
||||||
|
|
||||||
|
let rows
|
||||||
|
let nextId
|
||||||
|
const saved = {}
|
||||||
|
|
||||||
|
// A row as pages.db would return it (snake_case columns). blocks stored as JSON.
|
||||||
|
function seed(row) {
|
||||||
|
const full = {
|
||||||
|
id: nextId++,
|
||||||
|
slug: 'seed',
|
||||||
|
title: 'Seed',
|
||||||
|
status: 'draft',
|
||||||
|
blocks: '[]',
|
||||||
|
seo_title: null,
|
||||||
|
meta_description: null,
|
||||||
|
og_image: null,
|
||||||
|
canonical_url: null,
|
||||||
|
robots: null,
|
||||||
|
layout: 'default',
|
||||||
|
show_in_nav: 0,
|
||||||
|
nav_group: null,
|
||||||
|
nav_order: null,
|
||||||
|
protected: 0,
|
||||||
|
author_id: 1,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
published_at: null,
|
||||||
|
...row,
|
||||||
|
}
|
||||||
|
rows.push(full)
|
||||||
|
return full
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rows = []
|
||||||
|
nextId = 1
|
||||||
|
for (const k of ['listSummaries', 'findById', 'findBySlug', 'insert', 'update', 'remove']) saved[k] = pagesDb[k]
|
||||||
|
|
||||||
|
pagesDb.listSummaries = async () => rows.slice()
|
||||||
|
pagesDb.findById = async (id) => rows.find((r) => r.id === id) || null
|
||||||
|
pagesDb.findBySlug = async (slug) => rows.find((r) => r.slug === slug) || null
|
||||||
|
pagesDb.insert = async (row) => {
|
||||||
|
if (rows.some((r) => r.slug === row.slug)) {
|
||||||
|
const err = new Error('dup')
|
||||||
|
err.code = 'ER_DUP_ENTRY'
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
const id = nextId++
|
||||||
|
rows.push({ id, created_at: new Date(), updated_at: new Date(), published_at: null, ...row })
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
pagesDb.update = async (id, fields) => {
|
||||||
|
const row = rows.find((r) => r.id === id)
|
||||||
|
if (row) Object.assign(row, fields)
|
||||||
|
}
|
||||||
|
pagesDb.remove = async (id) => {
|
||||||
|
const i = rows.findIndex((r) => r.id === id)
|
||||||
|
if (i >= 0) rows.splice(i, 1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const k of Object.keys(saved)) pagesDb[k] = saved[k]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── create: slug rules ─────────────────────────────────────────────────
|
||||||
|
test('create rejects a slug with illegal characters', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => pages.create({ slug: 'Not A Slug', title: 'T' }, 1),
|
||||||
|
(e) => e.code === 'invalid_slug' && e.status === 400,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('create rejects a reserved slug that would shadow a named route', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => pages.create({ slug: 'admin', title: 'T' }, 1),
|
||||||
|
(e) => e.code === 'reserved_slug' && e.status === 400,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('create requires a non-empty title within the length limit', async () => {
|
||||||
|
await assert.rejects(() => pages.create({ slug: 'ok', title: ' ' }, 1), (e) => e.code === 'invalid_title')
|
||||||
|
await assert.rejects(() => pages.create({ slug: 'ok', title: 'x'.repeat(201) }, 1), (e) => e.code === 'invalid_title')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('create trims the title and defaults status to draft (no published_at)', async () => {
|
||||||
|
const page = await pages.create({ slug: 'welcome', title: ' Welcome ' }, 7)
|
||||||
|
assert.equal(page.title, 'Welcome')
|
||||||
|
assert.equal(page.status, 'draft')
|
||||||
|
assert.equal(page.publishedAt, null)
|
||||||
|
assert.equal(page.authorId, 7)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('creating with status=published stamps published_at', async () => {
|
||||||
|
const page = await pages.create({ slug: 'live', title: 'Live', status: 'published' }, 1)
|
||||||
|
assert.equal(page.status, 'published')
|
||||||
|
assert.ok(page.publishedAt instanceof Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a duplicate slug surfaces as a 409 slug_taken, not a raw DB error', async () => {
|
||||||
|
await pages.create({ slug: 'dup', title: 'First' }, 1)
|
||||||
|
await assert.rejects(
|
||||||
|
() => pages.create({ slug: 'dup', title: 'Second' }, 1),
|
||||||
|
(e) => e.code === 'slug_taken' && e.status === 409,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── create: block gate ──────────────────────────────────────────────────
|
||||||
|
test('create rejects invalid blocks (the authoritative validation gate)', async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
() => pages.create({ slug: 'bad', title: 'T', blocks: [{ type: 'does-not-exist' }] }, 1),
|
||||||
|
(e) => e.code === 'invalid_blocks' && Array.isArray(e.errors) && e.errors.length > 0,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── update: slug immutability ───────────────────────────────────────────
|
||||||
|
test('update rejects changing the slug after creation', async () => {
|
||||||
|
const p = seed({ slug: 'fixed' })
|
||||||
|
await assert.rejects(
|
||||||
|
() => pages.update(p.id, { slug: 'renamed' }),
|
||||||
|
(e) => e.code === 'slug_immutable' && e.status === 400,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('update tolerates the same slug being echoed back (no-op, not a rejection)', async () => {
|
||||||
|
const p = seed({ slug: 'same' })
|
||||||
|
const out = await pages.update(p.id, { slug: 'same', title: 'Updated' })
|
||||||
|
assert.equal(out.title, 'Updated')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('update on a missing page is a 404', async () => {
|
||||||
|
await assert.rejects(() => pages.update(999, { title: 'x' }), (e) => e.code === 'not_found' && e.status === 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── update: publish stamping is once-only ───────────────────────────────
|
||||||
|
test('publishing stamps published_at once and does not re-stamp on a later edit', async () => {
|
||||||
|
const p = seed({ slug: 'draft-first' })
|
||||||
|
const published = await pages.update(p.id, { status: 'published' })
|
||||||
|
const firstStamp = published.publishedAt
|
||||||
|
assert.ok(firstStamp instanceof Date)
|
||||||
|
|
||||||
|
// A later edit that keeps it published must not move published_at.
|
||||||
|
await pages.update(p.id, { title: 'Edited' })
|
||||||
|
const again = await pages.getById(p.id)
|
||||||
|
assert.deepEqual(again.publishedAt, firstStamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── the protected asymmetry (a security boundary) ───────────────────────
|
||||||
|
test('update can turn protection ON', async () => {
|
||||||
|
const p = seed({ slug: 'guard', protected: 0 })
|
||||||
|
const out = await pages.update(p.id, { settings: { protected: true } })
|
||||||
|
assert.equal(out.settings.protected, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('update CANNOT turn protection OFF — that requires the unprotect endpoint', async () => {
|
||||||
|
const p = seed({ slug: 'guarded', protected: 1 })
|
||||||
|
await assert.rejects(
|
||||||
|
() => pages.update(p.id, { settings: { protected: false } }),
|
||||||
|
(e) => e.code === 'unprotect_required' && e.status === 403,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('setting protected=false on an already-unprotected page is a harmless no-op', async () => {
|
||||||
|
const p = seed({ slug: 'open', protected: 0 })
|
||||||
|
const out = await pages.update(p.id, { settings: { protected: false } })
|
||||||
|
assert.equal(out.settings.protected, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unprotect() is the only path that clears protection', async () => {
|
||||||
|
const p = seed({ slug: 'locked', protected: 1 })
|
||||||
|
const out = await pages.unprotect(p.id)
|
||||||
|
assert.equal(out.settings.protected, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── delete guard ────────────────────────────────────────────────────────
|
||||||
|
test('a protected page cannot be deleted', async () => {
|
||||||
|
const p = seed({ slug: 'keep', protected: 1 })
|
||||||
|
await assert.rejects(() => pages.remove(p.id), (e) => e.code === 'page_protected' && e.status === 403)
|
||||||
|
assert.ok(rows.find((r) => r.id === p.id), 'row still present')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an unprotected page deletes', async () => {
|
||||||
|
const p = seed({ slug: 'trash', protected: 0 })
|
||||||
|
const out = await pages.remove(p.id)
|
||||||
|
assert.equal(out.id, p.id)
|
||||||
|
assert.equal(rows.find((r) => r.id === p.id), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── public read hides drafts ────────────────────────────────────────────
|
||||||
|
test('getBySlug hides a draft from the public but an admin can include it', async () => {
|
||||||
|
seed({ slug: 'hidden', status: 'draft' })
|
||||||
|
assert.equal(await pages.getBySlug('hidden'), null) // public: indistinguishable from missing
|
||||||
|
const asAdmin = await pages.getBySlug('hidden', { includeUnpublished: true })
|
||||||
|
assert.equal(asAdmin.slug, 'hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getBySlug returns a published page to the public', async () => {
|
||||||
|
seed({ slug: 'shown', status: 'published' })
|
||||||
|
const out = await pages.getBySlug('shown')
|
||||||
|
assert.equal(out.slug, 'shown')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── field-mapping validation ────────────────────────────────────────────
|
||||||
|
test('update rejects an unknown layout and an out-of-range metadata string', async () => {
|
||||||
|
const p = seed({ slug: 'meta' })
|
||||||
|
await assert.rejects(() => pages.update(p.id, { settings: { layout: 'fancy' } }), (e) => e.code === 'invalid_settings')
|
||||||
|
await assert.rejects(
|
||||||
|
() => pages.update(p.id, { metadata: { seoTitle: 'x'.repeat(201) } }),
|
||||||
|
(e) => e.code === 'invalid_metadata',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('serialize maps DB columns to the grouped API shape and coerces flags to booleans', async () => {
|
||||||
|
const p = seed({ slug: 'shape', show_in_nav: 1, protected: 1, nav_group: 'main', nav_order: 3 })
|
||||||
|
const out = await pages.getById(p.id)
|
||||||
|
assert.equal(out.settings.showInNav, true)
|
||||||
|
assert.equal(out.settings.protected, true)
|
||||||
|
assert.equal(out.settings.navGroup, 'main')
|
||||||
|
assert.equal(out.settings.navOrder, 3)
|
||||||
|
assert.equal(out.metadata.seoTitle, null)
|
||||||
|
})
|
||||||
191
server/test/passwordResetController.test.js
Normal file
191
server/test/passwordResetController.test.js
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
// Point the DB at a closed port BEFORE requiring the controller (its models build
|
||||||
|
// the pool). Every model/mailer call is monkeypatched, so no query runs;
|
||||||
|
// db.close() at the end releases the pool so the process exits cleanly.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, after, beforeEach, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Unit-test the self-service password reset controller. The security invariants:
|
||||||
|
// - requestReset NEVER reveals whether an email exists — empty, unmatched,
|
||||||
|
// matched, and even an internal error all return the same generic 200;
|
||||||
|
// - one account's mail failure does not abort the others or change the answer;
|
||||||
|
// - confirmReset consumes the token atomically (a lost double-submit race is a
|
||||||
|
// 404) and, on success, rotates the password AND revokes mobile sessions,
|
||||||
|
// without auto-logging the user in.
|
||||||
|
const ctrl = require('../src/router/v1/auth/passwordReset.controller')
|
||||||
|
const passwordResets = require('../src/model/passwordResets/passwordResets.model')
|
||||||
|
const users = require('../src/model/users/users.model')
|
||||||
|
const mobileSessions = require('../src/model/mobileSessions/mobileSessions.model')
|
||||||
|
const activity = require('../src/model/activity/activity.model')
|
||||||
|
const mailer = require('../src/utils/mailer')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GENERIC_MATCH = /if an account exists/i
|
||||||
|
|
||||||
|
const orig = {}
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const [mod, name] of [
|
||||||
|
[users, 'getActiveByEmail'], [users, 'getById'], [users, 'update'],
|
||||||
|
[passwordResets, 'create'], [passwordResets, 'findValidByToken'], [passwordResets, 'consume'], [passwordResets, 'invalidatePendingForUser'],
|
||||||
|
[mobileSessions, 'revokeAllForUser'], [activity, 'log'], [mailer, 'sendPasswordReset'],
|
||||||
|
]) {
|
||||||
|
orig[name] = { mod, val: mod[name] }
|
||||||
|
}
|
||||||
|
activity.log = async () => {}
|
||||||
|
mailer.sendPasswordReset = async () => ({ sent: true })
|
||||||
|
passwordResets.create = async () => ({ token: 'opaque-token' })
|
||||||
|
passwordResets.invalidatePendingForUser = async () => {}
|
||||||
|
mobileSessions.revokeAllForUser = async () => {}
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
for (const key of Object.keys(orig)) {
|
||||||
|
orig[key].mod[key] = orig[key].val
|
||||||
|
delete orig[key]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const req = (body = {}) => ({ body, ip: '10.0.0.1' })
|
||||||
|
|
||||||
|
// ── requestReset never enumerates ───────────────────────────────────────
|
||||||
|
test('requestReset returns the generic OK for an empty email without any lookup', async () => {
|
||||||
|
let lookedUp = false
|
||||||
|
users.getActiveByEmail = async () => {
|
||||||
|
lookedUp = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: ' ' }), res)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
assert.match(res.body.message, GENERIC_MATCH)
|
||||||
|
assert.equal(lookedUp, false, 'a blank email is short-circuited before the DB')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requestReset returns the SAME generic OK whether or not the email matched', async () => {
|
||||||
|
users.getActiveByEmail = async () => [] // no account
|
||||||
|
const resNone = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: 'ghost@x.io' }), resNone)
|
||||||
|
|
||||||
|
users.getActiveByEmail = async () => [{ id: 1, email: 'real@x.io', username: 'real' }]
|
||||||
|
const resHit = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: 'real@x.io' }), resHit)
|
||||||
|
|
||||||
|
assert.deepEqual(resNone.body, resHit.body) // indistinguishable
|
||||||
|
assert.equal(resHit.statusCode, 200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requestReset emails every account matching a (non-unique) address', async () => {
|
||||||
|
users.getActiveByEmail = async () => [
|
||||||
|
{ id: 1, email: 'shared@x.io', username: 'alpha' },
|
||||||
|
{ id: 2, email: 'shared@x.io', username: 'beta' },
|
||||||
|
]
|
||||||
|
const sent = []
|
||||||
|
mailer.sendPasswordReset = async ({ username }) => {
|
||||||
|
sent.push(username)
|
||||||
|
return { sent: true }
|
||||||
|
}
|
||||||
|
await ctrl.requestReset(req({ email: 'shared@x.io' }), mockRes())
|
||||||
|
assert.deepEqual(sent.sort(), ['alpha', 'beta'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("requestReset: one account's mail failure does not abort the others or change the response", async () => {
|
||||||
|
users.getActiveByEmail = async () => [
|
||||||
|
{ id: 1, email: 'a@x.io', username: 'alpha' },
|
||||||
|
{ id: 2, email: 'b@x.io', username: 'beta' },
|
||||||
|
]
|
||||||
|
const sent = []
|
||||||
|
mailer.sendPasswordReset = async ({ username }) => {
|
||||||
|
if (username === 'alpha') throw new Error('smtp reject')
|
||||||
|
sent.push(username)
|
||||||
|
return { sent: true }
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: 'a@x.io' }), res)
|
||||||
|
assert.deepEqual(sent, ['beta'], 'beta still got its link after alpha failed')
|
||||||
|
assert.match(res.body.message, GENERIC_MATCH)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requestReset stays generic even when the account lookup itself throws', async () => {
|
||||||
|
users.getActiveByEmail = async () => {
|
||||||
|
throw new Error('pool down')
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.requestReset(req({ email: 'x@x.io' }), res)
|
||||||
|
assert.equal(res.statusCode, 200) // internal error is not an enumeration oracle
|
||||||
|
assert.match(res.body.message, GENERIC_MATCH)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── lookupReset ─────────────────────────────────────────────────────────
|
||||||
|
test('lookupReset 404s an invalid/expired token and returns only the username on success', async () => {
|
||||||
|
passwordResets.findValidByToken = async () => null
|
||||||
|
const res404 = mockRes()
|
||||||
|
await ctrl.lookupReset({ params: { token: 'bad' } }, res404)
|
||||||
|
assert.equal(res404.statusCode, 404)
|
||||||
|
|
||||||
|
passwordResets.findValidByToken = async () => ({ user_id: 7 })
|
||||||
|
users.getById = async () => ({ id: 7, username: 'target', email: 'secret@x.io' })
|
||||||
|
const resOk = mockRes()
|
||||||
|
await ctrl.lookupReset({ params: { token: 'good' } }, resOk)
|
||||||
|
assert.deepEqual(resOk.body, { username: 'target' }) // email/token never surfaced
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── confirmReset consume race + revoke-everywhere ───────────────────────
|
||||||
|
test('confirmReset 404s when the token is not valid', async () => {
|
||||||
|
passwordResets.findValidByToken = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.confirmReset({ params: { token: 'bad' }, body: { password: 'new' } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirmReset 404s the loser of a double-submit race and never rotates the password', async () => {
|
||||||
|
passwordResets.findValidByToken = async () => ({ id: 3, user_id: 7 })
|
||||||
|
passwordResets.consume = async () => false // lost the race
|
||||||
|
let rotated = false
|
||||||
|
users.update = async () => {
|
||||||
|
rotated = true
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.confirmReset({ params: { token: 't' }, body: { password: 'new' } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
assert.equal(rotated, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('confirmReset rotates the password, revokes mobile sessions, and retires other links — no auto-login', async () => {
|
||||||
|
passwordResets.findValidByToken = async () => ({ id: 3, user_id: 7 })
|
||||||
|
passwordResets.consume = async () => true
|
||||||
|
const calls = { update: null, revoke: null, invalidate: null }
|
||||||
|
users.update = async (id, patch) => {
|
||||||
|
calls.update = { id, patch }
|
||||||
|
}
|
||||||
|
mobileSessions.revokeAllForUser = async (id) => {
|
||||||
|
calls.revoke = id
|
||||||
|
}
|
||||||
|
passwordResets.invalidatePendingForUser = async (id) => {
|
||||||
|
calls.invalidate = id
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.confirmReset({ params: { token: 't' }, body: { password: 'brand-new' } }, res)
|
||||||
|
assert.deepEqual(calls.update, { id: 7, patch: { password: 'brand-new' } })
|
||||||
|
assert.equal(calls.revoke, 7)
|
||||||
|
assert.equal(calls.invalidate, 7)
|
||||||
|
assert.equal(res.body.ok, true)
|
||||||
|
assert.equal(res.body.user, undefined, 'no session/user is returned — the user signs in fresh')
|
||||||
|
})
|
||||||
215
server/test/publicController.test.js
Normal file
215
server/test/publicController.test.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// Point the DB at a closed port BEFORE requiring the controller (its models build
|
||||||
|
// the pool). Every model call is monkeypatched, so no query runs; db.close() at
|
||||||
|
// the end releases the pool so the process exits cleanly.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, after, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Unit-test the public CMS/wiki controller's decision logic:
|
||||||
|
// - getPage: staff see drafts (live preview); the public gets a 404 for a draft,
|
||||||
|
// indistinguishable from a missing page (draft visibility is a boundary);
|
||||||
|
// - getPagePreview: only a valid, matching preview token unlocks a draft;
|
||||||
|
// - getWikiList: full-text search takes precedence, and an unknown category/tag
|
||||||
|
// yields [] rather than an error;
|
||||||
|
// - getPost(s): an unknown category is a 404;
|
||||||
|
// - contact: a mailer failure surfaces as a 502, not a 500 or a throw.
|
||||||
|
const ctrl = require('../src/router/v1/public/public.controller')
|
||||||
|
const posts = require('../src/model/posts/posts.model')
|
||||||
|
const wiki = require('../src/model/wiki/wiki.model')
|
||||||
|
const pages = require('../src/model/pages/pages.model')
|
||||||
|
const mailer = require('../src/utils/mailer')
|
||||||
|
const token = require('../src/auth/token')
|
||||||
|
const sessionService = require('../src/auth/session.service')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getUserFromRequest (destructured into the controller) delegates to
|
||||||
|
// sessionService.validateSession — drive staff/public from there.
|
||||||
|
function asStaff(role = 'admin') {
|
||||||
|
sessionService.validateSession = () => ({ userId: 1, username: 'boss', role })
|
||||||
|
}
|
||||||
|
function asPublic() {
|
||||||
|
sessionService.validateSession = () => null
|
||||||
|
}
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
validateSession: sessionService.validateSession,
|
||||||
|
getBySlug: pages.getBySlug,
|
||||||
|
getById: pages.getById,
|
||||||
|
isValidUrlCategory: posts.isValidUrlCategory,
|
||||||
|
listPublished: posts.listPublished,
|
||||||
|
getPublished: posts.getPublished,
|
||||||
|
wikiSearch: wiki.search,
|
||||||
|
getCategoryBySlug: wiki.getCategoryBySlug,
|
||||||
|
getTagBySlug: wiki.getTagBySlug,
|
||||||
|
wikiListPublished: wiki.listPublished,
|
||||||
|
sendContactMessage: mailer.sendContactMessage,
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
sessionService.validateSession = originals.validateSession
|
||||||
|
pages.getBySlug = originals.getBySlug
|
||||||
|
pages.getById = originals.getById
|
||||||
|
posts.isValidUrlCategory = originals.isValidUrlCategory
|
||||||
|
posts.listPublished = originals.listPublished
|
||||||
|
posts.getPublished = originals.getPublished
|
||||||
|
wiki.search = originals.wikiSearch
|
||||||
|
wiki.getCategoryBySlug = originals.getCategoryBySlug
|
||||||
|
wiki.getTagBySlug = originals.getTagBySlug
|
||||||
|
wiki.listPublished = originals.wikiListPublished
|
||||||
|
mailer.sendContactMessage = originals.sendContactMessage
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getPage draft visibility ────────────────────────────────────────────
|
||||||
|
test('getPage lets staff include unpublished drafts', async () => {
|
||||||
|
asStaff('editor')
|
||||||
|
let sawOpts
|
||||||
|
pages.getBySlug = async (slug, opts) => {
|
||||||
|
sawOpts = opts
|
||||||
|
return { slug, status: 'draft' }
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getPage({ params: { slug: 'wip' } }, res)
|
||||||
|
assert.equal(sawOpts.includeUnpublished, true)
|
||||||
|
assert.equal(res.body.slug, 'wip')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getPage hides drafts from the public and 404s (model returns null)', async () => {
|
||||||
|
asPublic()
|
||||||
|
let sawOpts
|
||||||
|
pages.getBySlug = async (slug, opts) => {
|
||||||
|
sawOpts = opts
|
||||||
|
return null // model already filtered the draft out for a public caller
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getPage({ params: { slug: 'wip' } }, res)
|
||||||
|
assert.equal(sawOpts.includeUnpublished, false)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getPage treats a player role as non-staff (no draft access)', async () => {
|
||||||
|
asStaff('player') // a player is NOT in STAFF_ROLES
|
||||||
|
let sawOpts
|
||||||
|
pages.getBySlug = async (slug, opts) => {
|
||||||
|
sawOpts = opts
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
await ctrl.getPage({ params: { slug: 'wip' } }, mockRes())
|
||||||
|
assert.equal(sawOpts.includeUnpublished, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getPagePreview token gate ───────────────────────────────────────────
|
||||||
|
test('getPagePreview unlocks a draft with a valid, matching preview token', async () => {
|
||||||
|
asPublic()
|
||||||
|
const validToken = token.signPagePreview(42)
|
||||||
|
pages.getById = async (id) => ({ id, status: 'draft' })
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getPagePreview({ params: { id: '42', token: validToken } }, res)
|
||||||
|
assert.equal(res.statusCode, 200)
|
||||||
|
assert.equal(res.body.id, 42)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getPagePreview 404s when the token is for a different page', async () => {
|
||||||
|
const tokenForOther = token.signPagePreview(7)
|
||||||
|
let loaded = false
|
||||||
|
pages.getById = async () => {
|
||||||
|
loaded = true
|
||||||
|
return { id: 42 }
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getPagePreview({ params: { id: '42', token: tokenForOther } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
assert.equal(loaded, false, 'a mismatched token never loads the page')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getPagePreview 404s on a garbage token', async () => {
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getPagePreview({ params: { id: '42', token: 'not-a-jwt' } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getWikiList precedence + unknown filters ────────────────────────────
|
||||||
|
test('getWikiList runs a full-text search when q is present, ignoring filters', async () => {
|
||||||
|
let searched
|
||||||
|
wiki.search = async (q, opts) => {
|
||||||
|
searched = { q, opts }
|
||||||
|
return [{ slug: 'hit' }]
|
||||||
|
}
|
||||||
|
wiki.listPublished = async () => {
|
||||||
|
throw new Error('listPublished should not run when q is set')
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getWikiList({ query: { q: ' dragon ', category: 'bestiary' } }, res)
|
||||||
|
assert.equal(searched.q, 'dragon') // trimmed
|
||||||
|
assert.equal(searched.opts.publishedOnly, true)
|
||||||
|
assert.equal(res.body[0].slug, 'hit')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getWikiList returns [] for an unknown category filter without listing pages', async () => {
|
||||||
|
wiki.getCategoryBySlug = async () => null
|
||||||
|
let listed = false
|
||||||
|
wiki.listPublished = async () => {
|
||||||
|
listed = true
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getWikiList({ query: { category: 'ghosts' } }, res)
|
||||||
|
assert.deepEqual(res.body, [])
|
||||||
|
assert.equal(listed, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getWikiList combines a known category and tag into the list filter', async () => {
|
||||||
|
wiki.getCategoryBySlug = async () => ({ id: 3 })
|
||||||
|
wiki.getTagBySlug = async () => ({ id: 9 })
|
||||||
|
let filters
|
||||||
|
wiki.listPublished = async (f) => {
|
||||||
|
filters = f
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
await ctrl.getWikiList({ query: { category: 'lore', tag: 'undead' } }, mockRes())
|
||||||
|
assert.deepEqual(filters, { categoryId: 3, tagId: 9 })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── posts category validation ───────────────────────────────────────────
|
||||||
|
test('getPosts 404s an unknown url category', async () => {
|
||||||
|
posts.isValidUrlCategory = () => false
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getPosts({ params: { category: 'nope' } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getPost 404s a valid category with no matching post', async () => {
|
||||||
|
posts.isValidUrlCategory = () => true
|
||||||
|
posts.getPublished = async () => null
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getPost({ params: { category: 'news', idOrSlug: 'missing' } }, res)
|
||||||
|
assert.equal(res.statusCode, 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── contact failure path ────────────────────────────────────────────────
|
||||||
|
test('contact surfaces a mailer failure as a 502 (not a 500 or a throw)', async () => {
|
||||||
|
mailer.sendContactMessage = async () => {
|
||||||
|
throw new Error('smtp down')
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.contact({ body: { name: 'A', email: 'a@b.c', message: 'hi' } }, res)
|
||||||
|
assert.equal(res.statusCode, 502)
|
||||||
|
assert.match(res.body.message, /send/i)
|
||||||
|
})
|
||||||
150
server/test/shardControllerPublic.test.js
Normal file
150
server/test/shardControllerPublic.test.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
// Point the DB at a closed port BEFORE requiring the controller (its models build
|
||||||
|
// the pool). Every model call is monkeypatched, so no query runs; db.close() at
|
||||||
|
// the end releases the pool so the process exits cleanly.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, after, afterEach } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
// Unit-test the public shard controller's SECURITY BOUNDARIES and shaping — the
|
||||||
|
// bits that decide what the anonymous public may and may not see:
|
||||||
|
// - getFeed serves only kinds on the public allowlist (staff audit / cheat /
|
||||||
|
// login events are stored for the admin channel and must never leak here);
|
||||||
|
// - getHouses exposes only IDOC houses and only their location — owner, price,
|
||||||
|
// co-owners and decay detail are staff-only and must be stripped;
|
||||||
|
// - getStatus assembles the connection/economy summary;
|
||||||
|
// - a model failure degrades to a 500, never a thrown/uncaught error.
|
||||||
|
const ctrl = require('../src/router/v1/public/shard.controller')
|
||||||
|
const shardEvents = require('../src/model/shardEvents/shardEvents.model')
|
||||||
|
const shardState = require('../src/model/shardState/shardState.model')
|
||||||
|
const uoLinkConfig = require('../src/model/uoLinkConfig/uoLinkConfig.model')
|
||||||
|
const broadcast = require('../src/utils/shardBroadcast')
|
||||||
|
const db = require('../src/utils/db')
|
||||||
|
|
||||||
|
after(() => db.close())
|
||||||
|
|
||||||
|
function mockRes() {
|
||||||
|
return {
|
||||||
|
statusCode: 200,
|
||||||
|
body: null,
|
||||||
|
status(c) {
|
||||||
|
this.statusCode = c
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
json(b) {
|
||||||
|
this.body = b
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const originals = {
|
||||||
|
eventsList: shardEvents.list,
|
||||||
|
listIdoc: shardState.listIdoc,
|
||||||
|
onlineCount: shardState.onlineCount,
|
||||||
|
latestEconomy: shardState.latestEconomy,
|
||||||
|
getSafe: uoLinkConfig.getSafe,
|
||||||
|
}
|
||||||
|
afterEach(() => {
|
||||||
|
shardEvents.list = originals.eventsList
|
||||||
|
shardState.listIdoc = originals.listIdoc
|
||||||
|
shardState.onlineCount = originals.onlineCount
|
||||||
|
shardState.latestEconomy = originals.latestEconomy
|
||||||
|
uoLinkConfig.getSafe = originals.getSafe
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getFeed: the public-safe allowlist is a security boundary ───────────
|
||||||
|
test('getFeed refuses a kind that is not on the public allowlist (returns [], no query)', async () => {
|
||||||
|
let queried = false
|
||||||
|
shardEvents.list = async () => {
|
||||||
|
queried = true
|
||||||
|
return [{ kind: 'staff.audit' }]
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getFeed({ query: { kind: 'staff.audit' } }, res) // an admin-only kind
|
||||||
|
assert.deepEqual(res.body, [])
|
||||||
|
assert.equal(queried, false, 'a disallowed kind is rejected before any DB read')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getFeed serves a specific kind when it IS public-safe', async () => {
|
||||||
|
const publicKind = [...broadcast.PUBLIC_KINDS][0]
|
||||||
|
let seen
|
||||||
|
shardEvents.list = async (opts) => {
|
||||||
|
seen = opts
|
||||||
|
return [{ kind: publicKind }]
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getFeed({ query: { kind: publicKind, limit: 5 } }, res)
|
||||||
|
assert.equal(seen.kind, publicKind)
|
||||||
|
assert.equal(seen.limit, 5)
|
||||||
|
assert.equal(res.body[0].kind, publicKind)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getFeed with no kind restricts the query to the whole public allowlist', async () => {
|
||||||
|
let seen
|
||||||
|
shardEvents.list = async (opts) => {
|
||||||
|
seen = opts
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
await ctrl.getFeed({ query: {} }, mockRes())
|
||||||
|
assert.deepEqual(new Set(seen.kinds), broadcast.PUBLIC_KINDS)
|
||||||
|
// Sanity: a known admin-only kind is absent from what the public feed queries.
|
||||||
|
assert.ok(!seen.kinds.includes('staff.audit'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getHouses: the public house view must strip owner/price ─────────────
|
||||||
|
test('getHouses exposes only IDOC location fields and strips owner/price/decay', async () => {
|
||||||
|
shardState.listIdoc = async () => [
|
||||||
|
{
|
||||||
|
serial: 1,
|
||||||
|
name: 'Keep',
|
||||||
|
region: 'Britain',
|
||||||
|
map: 'Felucca',
|
||||||
|
x: 1,
|
||||||
|
y: 2,
|
||||||
|
z: 3,
|
||||||
|
// The following are staff-only and must NOT appear in the public payload:
|
||||||
|
ownerName: 'Lord British',
|
||||||
|
ownerAcct: 'secret',
|
||||||
|
price: 999999,
|
||||||
|
coOwners: 'a,b',
|
||||||
|
decay: 'IDOC',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getHouses({}, res)
|
||||||
|
const [h] = res.body
|
||||||
|
assert.deepEqual(Object.keys(h).sort(), ['isIdoc', 'map', 'name', 'region', 'serial', 'x', 'y', 'z'])
|
||||||
|
assert.equal(h.isIdoc, true)
|
||||||
|
assert.equal(h.ownerName, undefined)
|
||||||
|
assert.equal(h.price, undefined)
|
||||||
|
assert.equal(h.coOwners, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── getStatus assembles the summary ─────────────────────────────────────
|
||||||
|
test('getStatus merges the sidecar config with the online count and latest economy', async () => {
|
||||||
|
uoLinkConfig.getSafe = async () => ({
|
||||||
|
enabled: true,
|
||||||
|
status: 'connected',
|
||||||
|
pluginConnected: true,
|
||||||
|
lastEventAt: 'ts',
|
||||||
|
})
|
||||||
|
shardState.onlineCount = async () => 12
|
||||||
|
shardState.latestEconomy = async () => ({ gold: 100, accounts: 3, t: 1 })
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getStatus({}, res)
|
||||||
|
assert.equal(res.body.enabled, true)
|
||||||
|
assert.equal(res.body.onlineCount, 12)
|
||||||
|
assert.equal(res.body.economy.gold, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('getStatus degrades to a 500 when a model call fails, without throwing', async () => {
|
||||||
|
uoLinkConfig.getSafe = async () => {
|
||||||
|
throw new Error('pool down')
|
||||||
|
}
|
||||||
|
const res = mockRes()
|
||||||
|
await ctrl.getStatus({}, res) // must resolve, not reject
|
||||||
|
assert.equal(res.statusCode, 500)
|
||||||
|
assert.equal(res.body.message, 'Internal Server Error')
|
||||||
|
})
|
||||||
253
server/test/shardState.model.test.js
Normal file
253
server/test/shardState.model.test.js
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
// Point the DB pool at a dead port before it's built; every db method is
|
||||||
|
// monkeypatched below, and pool.close() at the end lets the process exit cleanly.
|
||||||
|
process.env.DB_HOST = '127.0.0.1'
|
||||||
|
process.env.DB_PORT = '59999'
|
||||||
|
|
||||||
|
const { test, beforeEach, afterEach, after } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const pool = require('../src/utils/db')
|
||||||
|
after(() => pool.close())
|
||||||
|
|
||||||
|
// Unit-test the shard-state model's mapping/derivation rules against a fake
|
||||||
|
// shardState.db (no DB). These are the transforms the ingest dispatcher and the
|
||||||
|
// public read endpoints both depend on:
|
||||||
|
// - a partial online refresh (char.vitals) only writes the keys it carries, so
|
||||||
|
// it never clobbers login-only fields with undefined;
|
||||||
|
// - is_idoc is DERIVED from the decay stage, not trusted from the wire;
|
||||||
|
// - the economy series is clamped, returned oldest→newest, and gold coerced to
|
||||||
|
// a JS number (mariadb hands back BigInt-ish strings for large gold totals);
|
||||||
|
// - an empty presence table reads as a well-formed zero snapshot, not null;
|
||||||
|
// - champ/guild/governor rows fall back to hoisted columns when payload is absent;
|
||||||
|
// - remove/upsert guard against missing identifiers instead of hitting the DB.
|
||||||
|
const db = require('../src/model/shardState/shardState.db')
|
||||||
|
const shardState = require('../src/model/shardState/shardState.model')
|
||||||
|
|
||||||
|
// Records the (id, fields) the model hands to each db write, and serves canned
|
||||||
|
// rows back for reads.
|
||||||
|
let calls
|
||||||
|
const saved = {}
|
||||||
|
const DB_KEYS = [
|
||||||
|
'upsertOnline', 'removeOnline', 'clearOnline', 'insertEconomy', 'listEconomy', 'latestEconomy',
|
||||||
|
'upsertHouse', 'removeHouse', 'listIdocHouses', 'listRegistryHouses', 'setPresence', 'latestPresence',
|
||||||
|
'upsertChamp', 'removeChamp', 'listChamps', 'upsertGuild', 'removeGuild', 'listGuilds',
|
||||||
|
'upsertGovernor', 'listGovernors', 'listGovernorTerms', 'listOnline', 'listOnlineLinked', 'listPages',
|
||||||
|
]
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
calls = {}
|
||||||
|
for (const k of DB_KEYS) {
|
||||||
|
saved[k] = db[k]
|
||||||
|
calls[k] = []
|
||||||
|
db[k] = async (...args) => {
|
||||||
|
calls[k].push(args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const k of DB_KEYS) db[k] = saved[k]
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── partial online refresh must not clobber ─────────────────────────────
|
||||||
|
test('upsertOnline drops undefined keys so a vitals refresh keeps login fields', async () => {
|
||||||
|
// A char.vitals event carries hits but not name/acct — those must not be sent as
|
||||||
|
// undefined columns (which would overwrite the login row).
|
||||||
|
await shardState.upsertOnline({ serial: 5, hits: 40, hitsMax: 100 })
|
||||||
|
const [serial, fields] = calls.upsertOnline[0]
|
||||||
|
assert.equal(serial, 5)
|
||||||
|
assert.deepEqual(fields, { hits: 40, hits_max: 100 })
|
||||||
|
assert.ok(!('name' in fields), 'name not written when absent from the event')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertOnline maps camelCase vitals to snake_case columns', async () => {
|
||||||
|
await shardState.upsertOnline({ serial: 9, name: 'Bob', webId: 3, hitsMax: 90, manaMax: 50, stamMax: 70 })
|
||||||
|
const [, fields] = calls.upsertOnline[0]
|
||||||
|
assert.equal(fields.web_id, 3)
|
||||||
|
assert.equal(fields.hits_max, 90)
|
||||||
|
assert.equal(fields.mana_max, 50)
|
||||||
|
assert.equal(fields.stam_max, 70)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertOnline ignores an event with no serial (never touches the DB)', async () => {
|
||||||
|
await shardState.upsertOnline({ name: 'Nobody' })
|
||||||
|
await shardState.upsertOnline(null)
|
||||||
|
assert.equal(calls.upsertOnline.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── is_idoc is derived, not trusted ─────────────────────────────────────
|
||||||
|
test('upsertHouse derives is_idoc=1 only for the IDOC stage (case-insensitive)', async () => {
|
||||||
|
await shardState.upsertHouse({ serial: 1, stage: 'IDOC' })
|
||||||
|
await shardState.upsertHouse({ serial: 2, stage: 'idoc' })
|
||||||
|
await shardState.upsertHouse({ serial: 3, stage: 'Slightly' })
|
||||||
|
assert.equal(calls.upsertHouse[0][1].is_idoc, 1)
|
||||||
|
assert.equal(calls.upsertHouse[1][1].is_idoc, 1)
|
||||||
|
assert.equal(calls.upsertHouse[2][1].is_idoc, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertHouseRegistry writes in_registry=1 and flattens the owner actor', async () => {
|
||||||
|
await shardState.upsertHouseRegistry({ serial: 7, name: 'Keep', owner: { serial: 20, acct: 'a', name: 'Liege' } })
|
||||||
|
const [serial, fields] = calls.upsertHouse[0]
|
||||||
|
assert.equal(serial, 7)
|
||||||
|
assert.equal(fields.in_registry, 1)
|
||||||
|
assert.equal(fields.owner_serial, 20)
|
||||||
|
assert.equal(fields.owner_name, 'Liege')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertHouseRegistry tolerates an abandoned house (null owner)', async () => {
|
||||||
|
await shardState.upsertHouseRegistry({ serial: 8, name: 'Ruin', owner: null })
|
||||||
|
const [, fields] = calls.upsertHouse[0]
|
||||||
|
assert.equal(fields.owner_serial, null)
|
||||||
|
assert.equal(fields.owner_name, null)
|
||||||
|
assert.equal(fields.in_registry, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── economy series shaping ──────────────────────────────────────────────
|
||||||
|
test('listEconomy clamps the limit, reverses to oldest→newest, and coerces gold to Number', async () => {
|
||||||
|
// db.listEconomy returns newest-first; the model reverses for charting.
|
||||||
|
db.listEconomy = async (n) => {
|
||||||
|
assert.equal(n, 1000, 'limit is clamped to MAX_ECONOMY')
|
||||||
|
return [
|
||||||
|
{ accounts: 3, gold: '9000000000', t: 30 },
|
||||||
|
{ accounts: 2, gold: '20', t: 20 },
|
||||||
|
{ accounts: 1, gold: null, t: 10 },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const out = await shardState.listEconomy(999999)
|
||||||
|
assert.deepEqual(out.map((r) => r.t), [10, 20, 30], 'oldest first')
|
||||||
|
assert.equal(out[2].gold, 9000000000)
|
||||||
|
assert.equal(typeof out[2].gold, 'number')
|
||||||
|
assert.equal(out[0].gold, null, 'null gold stays null, not 0')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listEconomy floors a non-positive limit to the default', async () => {
|
||||||
|
let seen
|
||||||
|
db.listEconomy = async (n) => {
|
||||||
|
seen = n
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
await shardState.listEconomy(0)
|
||||||
|
assert.equal(seen, 100)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── presence defaults ───────────────────────────────────────────────────
|
||||||
|
test('latestPresence returns a well-formed zero snapshot when nothing is stored', async () => {
|
||||||
|
db.latestPresence = async () => null
|
||||||
|
const out = await shardState.latestPresence()
|
||||||
|
assert.deepEqual(out, { count: 0, byFacet: {}, byRegion: {}, t: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('latestPresence parses JSON string columns from the DB', async () => {
|
||||||
|
db.latestPresence = async () => ({ count: '12', by_facet: '{"felucca":5}', by_region: '{"Britain":3}', t: '99' })
|
||||||
|
const out = await shardState.latestPresence()
|
||||||
|
assert.equal(out.count, 12)
|
||||||
|
assert.deepEqual(out.byFacet, { felucca: 5 })
|
||||||
|
assert.equal(out.t, 99)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── payload fallback shaping ────────────────────────────────────────────
|
||||||
|
test('listChamps returns the stored payload verbatim when present', async () => {
|
||||||
|
const payload = { kind: 'champ.update', serial: 1, name: 'Barracoon', custom: 'field' }
|
||||||
|
db.listChamps = async () => [{ serial: 1, payload: JSON.stringify(payload) }]
|
||||||
|
const out = await shardState.listChamps()
|
||||||
|
assert.deepEqual(out[0], payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listChamps falls back to hoisted columns for a legacy row with no payload', async () => {
|
||||||
|
db.listChamps = async () => [{ serial: 2, name: 'Rikktor', active: 1, boss_up: 0, payload: null }]
|
||||||
|
const out = await shardState.listChamps()
|
||||||
|
assert.equal(out[0].kind, 'champ.update')
|
||||||
|
assert.equal(out[0].name, 'Rikktor')
|
||||||
|
assert.equal(out[0].active, true)
|
||||||
|
assert.equal(out[0].bossUp, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listGuilds falls back to a shaped leader object when payload is absent', async () => {
|
||||||
|
db.listGuilds = async () => [{ id: 1, name: 'Order', leader_serial: 5, leader_name: 'Cap', payload: null }]
|
||||||
|
const out = await shardState.listGuilds()
|
||||||
|
assert.equal(out[0].leader.serial, 5)
|
||||||
|
assert.equal(out[0].leader.name, 'Cap')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── guards against missing identifiers ──────────────────────────────────
|
||||||
|
test('remove helpers are no-ops on a falsy id (never call the DB)', async () => {
|
||||||
|
await shardState.removeChamp(undefined)
|
||||||
|
await shardState.removeHouse('')
|
||||||
|
await shardState.removeGuild(null)
|
||||||
|
assert.equal(calls.removeChamp.length, 0)
|
||||||
|
assert.equal(calls.removeHouse.length, 0)
|
||||||
|
assert.equal(calls.removeGuild.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('removeGuild treats id 0 as a real id (0 != null) but skips null/undefined', async () => {
|
||||||
|
await shardState.removeGuild(0)
|
||||||
|
assert.equal(calls.removeGuild.length, 1, 'guild id 0 is valid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('upsertChamp/upsertGuild/upsertGovernor ignore events missing their key', async () => {
|
||||||
|
await shardState.upsertChamp({ name: 'no serial' })
|
||||||
|
await shardState.upsertGuild({ name: 'no id' })
|
||||||
|
await shardState.upsertGovernor({ governor: {} }) // no city
|
||||||
|
assert.equal(calls.upsertChamp.length, 0)
|
||||||
|
assert.equal(calls.upsertGuild.length, 0)
|
||||||
|
assert.equal(calls.upsertGovernor.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── read-shaping locks the camelCase API/app contract ───────────────────
|
||||||
|
// A field-name regression in these serializers silently breaks the public site
|
||||||
|
// and the Android client, so pin the shapes the read endpoints emit.
|
||||||
|
test('listOnline maps snake_case columns to the camelCase player shape', async () => {
|
||||||
|
db.listOnline = async () => [
|
||||||
|
{ serial: 1, name: 'A', acct: 'acc', web_id: 7, hits: 10, hits_max: 100, mana_max: 50, stam_max: 60, updated_at: 'ts' },
|
||||||
|
]
|
||||||
|
const [p] = await shardState.listOnline()
|
||||||
|
assert.equal(p.webId, 7)
|
||||||
|
assert.equal(p.hitsMax, 100)
|
||||||
|
assert.equal(p.manaMax, 50)
|
||||||
|
assert.equal(p.stamMax, 60)
|
||||||
|
assert.equal(p.updatedAt, 'ts')
|
||||||
|
assert.ok(!('web_id' in p), 'no snake_case leaks into the API shape')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listIdoc shapes houses and coerces isIdoc/price', async () => {
|
||||||
|
db.listIdocHouses = async () => [{ serial: 3, is_idoc: 1, price: '5000', in_registry: 1, owner_serial: 2 }]
|
||||||
|
const [h] = await shardState.listIdoc()
|
||||||
|
assert.equal(h.isIdoc, true)
|
||||||
|
assert.equal(h.price, 5000)
|
||||||
|
assert.equal(typeof h.price, 'number')
|
||||||
|
assert.equal(h.inRegistry, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listPages folds the sender columns into a nested actor and coerces flags', async () => {
|
||||||
|
db.listPages = async () => [
|
||||||
|
{ page_id: 42, type: 'gm', sender_name: 'Help', sender_acct: 'x', web_id: 9, handled: 0, sent_ms: '1234', payload: null },
|
||||||
|
]
|
||||||
|
const [pg] = await shardState.listPages()
|
||||||
|
assert.equal(pg.pageId, 42)
|
||||||
|
assert.deepEqual(pg.sender, { serial: 42, name: 'Help', acct: 'x', webId: 9 })
|
||||||
|
assert.equal(pg.handled, false)
|
||||||
|
assert.equal(pg.sentMs, 1234)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listGovernors falls back to a shaped governor object when payload is absent', async () => {
|
||||||
|
db.listGovernors = async () => [
|
||||||
|
{ city: 'Britain', governor_serial: 5, governor_name: 'Lord', governor_acct: 'a', election_phase: 'none', payload: null },
|
||||||
|
]
|
||||||
|
const [g] = await shardState.listGovernors()
|
||||||
|
assert.equal(g.kind, 'city.update')
|
||||||
|
assert.equal(g.city, 'Britain')
|
||||||
|
assert.equal(g.governor.name, 'Lord')
|
||||||
|
assert.equal(g.governorElect, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('listGovernorHistory coerces started/ended timestamps to numbers and clamps the limit', async () => {
|
||||||
|
let seenLimit
|
||||||
|
db.listGovernorTerms = async (city, n) => {
|
||||||
|
seenLimit = n
|
||||||
|
return [{ city, governor_serial: 1, governor_name: 'X', started_at: '100', ended_at: null, votes: 3 }]
|
||||||
|
}
|
||||||
|
const out = await shardState.listGovernorHistory('Trinsic', 99999)
|
||||||
|
assert.equal(seenLimit, 500) // clamped to the 500 max
|
||||||
|
assert.equal(out[0].startedAt, 100)
|
||||||
|
assert.equal(typeof out[0].startedAt, 'number')
|
||||||
|
assert.equal(out[0].endedAt, null) // an open term stays null, not coerced to 0
|
||||||
|
})
|
||||||
@@ -10,18 +10,19 @@ sonar.projectName=runic gateway website
|
|||||||
sonar.sources=server/src,client/src,bot/src
|
sonar.sources=server/src,client/src,bot/src
|
||||||
|
|
||||||
# Test code is analysed separately from sources so coverage/metrics attribute
|
# Test code is analysed separately from sources so coverage/metrics attribute
|
||||||
# correctly. Only the server has a test suite today.
|
# correctly. The server suite and the client's pure-logic unit tests both run on
|
||||||
sonar.tests=server/test
|
# Node's built-in test runner (no browser/DOM), so they live side by side here.
|
||||||
sonar.test.inclusions=server/test/**/*.test.js
|
sonar.tests=server/test,client/test
|
||||||
|
sonar.test.inclusions=server/test/**/*.test.js,client/test/**/*.test.js
|
||||||
|
|
||||||
# Coverage. The sonarqube.yml workflow runs the server suite with Node's built-in
|
# Coverage. The sonarqube.yml workflow runs both suites with Node's built-in
|
||||||
# test-coverage and writes an LCOV report here BEFORE the scan runs; without it
|
# test-coverage and writes an LCOV report for each BEFORE the scan runs; without
|
||||||
# the dashboard shows 0% (the scanner never executes tests itself). The SF: paths
|
# them the dashboard shows 0% (the scanner never executes tests itself). The SF:
|
||||||
# in the report are repo-root-relative (server/src/...) so the scanner resolves
|
# paths in each report are repo-root-relative (server/src/... , client/src/...) so
|
||||||
# them against the project base dir.
|
# the scanner resolves them against the project base dir.
|
||||||
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info
|
sonar.javascript.lcov.reportPaths=server/coverage/lcov.info,client/coverage/lcov.info
|
||||||
|
|
||||||
# Never analyse dependencies, build output, generated specs, or runtime dirs.
|
# Never analyse dependencies, build output, generated specs, or runtime dirs.
|
||||||
sonar.exclusions=**/node_modules/**,client/dist/**,client/public/**,server/swagger/**,server/logs/**,server/uploads/**,server/coverage/**,**/*.min.js
|
sonar.exclusions=**/node_modules/**,client/dist/**,client/public/**,server/swagger/**,server/logs/**,server/uploads/**,server/coverage/**,client/coverage/**,**/*.min.js
|
||||||
|
|
||||||
sonar.sourceEncoding=UTF-8
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|||||||
Reference in New Issue
Block a user