PLAN.md §30 as approved, plus D119/D120 from the build. Server: - rust_map_images (one row per server: picture as MEDIUMBLOB, geometry, monuments, DERIVATION_VERSION) and rust_map_overrides; purge.sql pair. - mapImages.js: D110. The board poll notices a new boot/wipe/seed/size and asks map.info; a new key or hash from the free Rust+ cache (or a render kept on disk) is fetched in slices, checked against its SHA-256 and stored in one statement. One fetch per server, a backoff on failure, `stale` abandons a fetch that straddles a map change. Render now (D109) is admin-only and watched to completion. - mapLive.js: D111. One map.live per server per 5 s whoever asks; positions are held in memory only. - model/map: four layers (world, events public; players, bases staff), a fleet default plus per-server override (D114), the players layer capped by presence (D113), own dot and online first-party clan mates for a linked viewer (D115, D117, D118). A layer the viewer may not see is absent from the answer, never sent and hidden. - Routes: public /servers/:id/map, /map/image (immutable under its hash), /map/live; admin /servers/:id/map/fetch and /render; the Map card on the visibility PUT. Swagger fragment and frozen manifest regenerated. Client: - A Map tab: Leaflet over the picture in CRS.Simple, the game's own grid (labels only when a cell is wide enough to hold one), a legend that lists hidden layers with who can see them, polled every 10 s while visible. - D120: Leaflet is a lazy split chunk beside entry.js, not in it. release.yml copies every dist/*.js; checkExternals and build.test.js hold both ends. - The Map card on Admin -> Rust visibility, with Fetch again and Render now. Capability `map` declared for the Android app (phase 15). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
495 lines
20 KiB
JavaScript
495 lines
20 KiB
JavaScript
// ── The live map (PLAN.md §30, protocol 11) ───────────────────────────────
|
||
//
|
||
// The map is a security boundary before it is a picture: player positions
|
||
// locate people, and base positions are where they sleep. Every test here is one
|
||
// of the ways that boundary could look right and be wrong:
|
||
//
|
||
// a fresh install shows the world and events, and nobody's position
|
||
// a word nobody recognises narrows — a layer to staff, the mates switch to off
|
||
// the players layer can never be wider than presence (D113)
|
||
// a hidden layer is ABSENT from the answer, not an empty array
|
||
// own dot and mates: own accounts (asleep too), ONLINE clan mates, nobody else
|
||
// the roster's audience has nothing to do with who sees positions (D117)
|
||
// a picture is stored only when it matches its own hash
|
||
// a fetch that straddles a map change is not spliced
|
||
// one fetch per server, and a plugin that predates the map is never asked
|
||
// any number of viewers cost one ask of the game (D111)
|
||
|
||
const test = require('node:test')
|
||
const assert = require('node:assert')
|
||
const crypto = require('crypto')
|
||
|
||
const { fakeCtx } = require('./_fakes')
|
||
|
||
require('../core')._reset()
|
||
require('../core').init(fakeCtx())
|
||
|
||
const client = require('../sidecarClient')
|
||
const mapDb = require('../model/map/map.db')
|
||
const map = require('../model/map/map.model')
|
||
const mapImages = require('../mapImages')
|
||
const mapLive = require('../mapLive')
|
||
const visibility = require('../model/visibility/visibility.model')
|
||
const visibilityDb = require('../model/visibility/visibility.db')
|
||
|
||
/**
|
||
* Stub every collaborator the model reads: the stored settings, the overrides,
|
||
* the viewer, the presence audience, the viewer's links and the clan query.
|
||
*/
|
||
function settings(t, { stored = {}, overrides = [], viewer = { level: 'public', userId: null }, presence = 'staff', links = [], mates = [] } = {}) {
|
||
const saved = {
|
||
getSetting: visibilityDb.getSetting,
|
||
getOverrides: mapDb.getOverrides,
|
||
viewer: visibility.viewer,
|
||
presenceFor: visibility.presenceFor,
|
||
steamIdsForUser: mapDb.steamIdsForUser,
|
||
clanMatesOn: mapDb.clanMatesOn,
|
||
}
|
||
const asked = { clans: 0 }
|
||
visibilityDb.getSetting = async (key) => (key in stored ? stored[key] : null)
|
||
mapDb.getOverrides = async () => overrides
|
||
visibility.viewer = async () => viewer
|
||
visibility.presenceFor = async () => presence
|
||
mapDb.steamIdsForUser = async () => links
|
||
mapDb.clanMatesOn = async () => {
|
||
asked.clans += 1
|
||
return mates
|
||
}
|
||
t.after(() => {
|
||
visibilityDb.getSetting = saved.getSetting
|
||
mapDb.getOverrides = saved.getOverrides
|
||
visibility.viewer = saved.viewer
|
||
visibility.presenceFor = saved.presenceFor
|
||
mapDb.steamIdsForUser = saved.steamIdsForUser
|
||
mapDb.clanMatesOn = saved.clanMatesOn
|
||
})
|
||
return asked
|
||
}
|
||
|
||
const LIVE = {
|
||
mapKey: '3000.1234.1',
|
||
world: [{ kind: 'cargo', x: 10, z: 20 }],
|
||
events: [{ kind: 'zone', runId: '4', x: 0, z: 0, radius: 50 }],
|
||
players: [
|
||
{ steamId: '7001', name: 'Me', x: 1, z: 1, sleeping: false, online: true },
|
||
{ steamId: '7002', name: 'Mate', x: 2, z: 2, sleeping: false, online: true },
|
||
{ steamId: '7003', name: 'Asleep mate', x: 3, z: 3, sleeping: true, online: false },
|
||
{ steamId: '7004', name: 'Stranger', x: 4, z: 4, sleeping: false, online: true },
|
||
{ steamId: '7005', name: 'My alt, asleep', x: 5, z: 5, sleeping: true, online: false },
|
||
],
|
||
bases: [{ kind: 'tc', x: 9, z: 9 }],
|
||
}
|
||
|
||
test('a fresh install shows the world and events to anybody, and positions to staff only', async (t) => {
|
||
settings(t)
|
||
assert.deepStrictEqual(await map.fleet(), { world: 'public', events: 'public', players: 'staff', bases: 'staff', mates: true })
|
||
|
||
const acc = await map.access({}, 'main')
|
||
assert.equal(acc.layers.world.visible, true)
|
||
assert.equal(acc.layers.events.visible, true)
|
||
assert.equal(acc.layers.players.visible, false)
|
||
assert.equal(acc.layers.bases.visible, false)
|
||
assert.equal(acc.mates.visible, false, 'an anonymous viewer has no accounts to be shown')
|
||
})
|
||
|
||
test('a stored word nobody recognises narrows: a layer to staff, the mates switch to off', async (t) => {
|
||
settings(t, { stored: { 'map.layer.world.audience': 'everyone', 'map.mates': 'yes' } })
|
||
const fleet = await map.fleet()
|
||
assert.equal(fleet.world, 'staff')
|
||
assert.equal(fleet.mates, false)
|
||
})
|
||
|
||
test('an override wins over the fleet for its server only', async (t) => {
|
||
settings(t, { overrides: [{ setting: 'map.layer.bases.audience', value: 'public' }, { setting: 'map.mates', value: 'off' }] })
|
||
const eff = await map.forServer('pve')
|
||
assert.equal(eff.bases, 'public')
|
||
assert.equal(eff.mates, false)
|
||
assert.equal(eff.players, 'staff', 'untouched layers inherit')
|
||
})
|
||
|
||
test('D113: widening the players layer alone shows an anonymous viewer nothing more', async (t) => {
|
||
settings(t, { stored: { 'map.layer.players.audience': 'public' }, presence: 'staff' })
|
||
const acc = await map.access({}, 'main')
|
||
assert.equal(acc.layers.players.visible, false)
|
||
assert.equal(acc.layers.players.audience, 'staff')
|
||
assert.equal(acc.layers.players.cappedByPresence, true)
|
||
assert.equal(map.project(LIVE, acc).players, undefined)
|
||
})
|
||
|
||
test('D113: widening both the layer and presence does show them', async (t) => {
|
||
settings(t, { stored: { 'map.layer.players.audience': 'public' }, presence: 'public' })
|
||
const acc = await map.access({}, 'main')
|
||
assert.equal(acc.layers.players.visible, true)
|
||
assert.equal(acc.layers.players.cappedByPresence, undefined)
|
||
assert.equal(map.project(LIVE, acc).players.length, LIVE.players.length)
|
||
})
|
||
|
||
test('a hidden layer is absent from the answer, not an empty array', async (t) => {
|
||
settings(t)
|
||
const out = map.project(LIVE, await map.access({}, 'main'))
|
||
assert.ok(Array.isArray(out.world))
|
||
assert.ok(Array.isArray(out.events))
|
||
for (const hidden of ['players', 'bases', 'mates', 'playersTruncated', 'basesTruncated']) {
|
||
assert.equal(Object.prototype.hasOwnProperty.call(out, hidden), false, `${hidden} must not be on the wire`)
|
||
}
|
||
})
|
||
|
||
test('staff get every layer', async (t) => {
|
||
settings(t, { viewer: { level: 'staff', userId: 1 } })
|
||
const out = map.project({ ...LIVE, basesTruncated: true }, await map.access({}, 'main'))
|
||
assert.equal(out.players.length, 5)
|
||
assert.equal(out.bases.length, 1)
|
||
assert.equal(out.basesTruncated, true)
|
||
})
|
||
|
||
test('own dot and mates: own accounts asleep or awake, ONLINE clan mates, and nobody else', async (t) => {
|
||
settings(t, {
|
||
viewer: { level: 'signed_in', userId: 9 },
|
||
links: ['7001', '7005'],
|
||
mates: ['7001', '7002', '7003', '7005'],
|
||
})
|
||
const acc = await map.access({}, 'main')
|
||
assert.equal(acc.layers.players.visible, false, 'a player is below the players layer')
|
||
assert.equal(acc.mates.visible, true)
|
||
|
||
const out = map.project(LIVE, acc, await map.mateIdsFor('main', acc))
|
||
const ids = out.mates.map((m) => m.steamId).sort()
|
||
assert.deepStrictEqual(ids, ['7001', '7002', '7005'])
|
||
assert.equal(out.mates.find((m) => m.steamId === '7005').self, true, 'the viewer’s own sleeper is theirs')
|
||
assert.equal(out.mates.find((m) => m.steamId === '7002').self, false)
|
||
assert.equal(out.players, undefined, 'the players layer itself stays absent')
|
||
})
|
||
|
||
test('the mates switch off removes own dot and mates alike', async (t) => {
|
||
const asked = settings(t, {
|
||
stored: { 'map.mates': 'off' },
|
||
viewer: { level: 'signed_in', userId: 9 },
|
||
links: ['7001'],
|
||
mates: ['7001', '7002'],
|
||
})
|
||
const acc = await map.access({}, 'main')
|
||
assert.equal(acc.mates.visible, false)
|
||
const out = map.project(LIVE, acc, await map.mateIdsFor('main', acc))
|
||
assert.equal(out.mates, undefined)
|
||
assert.equal(asked.clans, 0, 'nobody’s clan is even looked up')
|
||
})
|
||
|
||
test('D117: a viewer with nothing linked sees no positions, whatever the roster audience is', async (t) => {
|
||
settings(t, {
|
||
stored: { 'clans.roster.audience': 'public' },
|
||
viewer: { level: 'signed_in', userId: 9 },
|
||
links: [],
|
||
})
|
||
const acc = await map.access({}, 'main')
|
||
assert.equal(acc.mates.visible, false)
|
||
assert.equal(acc.mates.linked, false)
|
||
})
|
||
|
||
test('a setting that cannot be read hides every layer', async (t) => {
|
||
settings(t)
|
||
visibility.viewer = async () => {
|
||
throw new Error('pool exhausted')
|
||
}
|
||
const acc = await map.access({}, 'main')
|
||
for (const layer of map.LAYERS) assert.equal(acc.layers[layer].visible, false)
|
||
})
|
||
|
||
test('the admin write refuses a word it does not know, and writes nothing on a dry run', async (t) => {
|
||
settings(t)
|
||
const writes = []
|
||
const saved = { setSetting: visibilityDb.setSetting, setOverride: mapDb.setOverride }
|
||
visibilityDb.setSetting = async (...a) => writes.push(['fleet', ...a])
|
||
mapDb.setOverride = async (...a) => writes.push(['server', ...a])
|
||
t.after(() => Object.assign(visibilityDb, { setSetting: saved.setSetting }) && Object.assign(mapDb, { setOverride: saved.setOverride }))
|
||
|
||
assert.equal((await map.update({ fleet: { players: 'everyone' } })).status, 400)
|
||
assert.equal((await map.update({ fleet: { mates: 'on' } })).status, 400, 'mates is a boolean')
|
||
assert.equal((await map.update({ servers: { nope: { world: 'staff' } } }, null, async () => false)).status, 404)
|
||
assert.deepStrictEqual(await map.update({ fleet: { world: 'staff' } }, null, undefined, { dryRun: true }), { ok: true, changed: {} })
|
||
assert.equal(writes.length, 0)
|
||
|
||
const done = await map.update({ fleet: { players: 'signed_in', mates: false }, servers: { pve: { players: 'public', mates: null } } }, { id: 3 })
|
||
assert.equal(done.ok, true)
|
||
assert.deepStrictEqual(writes, [
|
||
['fleet', 'map.layer.players.audience', 'signed_in', 3],
|
||
['fleet', 'map.mates', 'off', 3],
|
||
['server', 'pve', 'map.layer.players.audience', 'public', 3],
|
||
['server', 'pve', 'map.mates', null, 3],
|
||
])
|
||
})
|
||
|
||
test('derive: a map with no picture is sized like the Rust+ cache would be', () => {
|
||
const row = map.derive('main', { mapKey: '4500.7.1', source: 'none', worldSize: 4500, oceanMargin: 500, gridCells: 30, gridCellSize: 150, monuments: [] })
|
||
assert.equal(row.width, 3250)
|
||
assert.equal(row.height, 3250)
|
||
assert.equal(row.sha256, null)
|
||
assert.equal(row.source, 'none')
|
||
assert.equal(row.derivation, map.DERIVATION_VERSION)
|
||
})
|
||
|
||
test('derive: the picture’s own size wins, and nothing unsafe reaches a style', () => {
|
||
const row = map.derive('main', {
|
||
mapKey: '3000.1234.1', source: 'companion', sha256: 'AB'.repeat(32), width: 2500, height: 2500,
|
||
worldSize: 3000, oceanMargin: 500, gridCells: 20, gridCellSize: 150, background: 'red;x:1',
|
||
monuments: [{ value: 'harbor_1#2', kind: 'harbor_1', label: 'Harbor', x: 1, z: 2 }, { kind: 'broken', x: 'nope', z: 1 }],
|
||
})
|
||
assert.equal(row.sha256, 'ab'.repeat(32), 'hashes are compared lower-case')
|
||
assert.equal(row.background, null)
|
||
assert.equal(row.monuments.length, 1)
|
||
})
|
||
|
||
test('a render’s stall is estimated from the measured one, scaled by area', () => {
|
||
assert.equal(map.renderStallSeconds(3000), 9)
|
||
assert.equal(map.renderStallSeconds(4500), 14)
|
||
assert.equal(map.renderStallSeconds(null), 9, 'unknown size estimates the measured map')
|
||
})
|
||
|
||
// ── The picture ───────────────────────────────────────────────────────────
|
||
|
||
const SERVER = { id: 'main', baseUrl: 'http://main:1', token: 't' }
|
||
const HELLO = { bootId: 'boot-1', wipeId: 'w-1', seed: 1234, worldSize: 3000, worldReady: true, protocol: 11 }
|
||
|
||
function picture(bytes, { source = 'companion', mapKey = '3000.1234.1', chunkBytes = 4 } = {}) {
|
||
const sha = crypto.createHash('sha256').update(bytes).digest('hex')
|
||
const info = {
|
||
kind: 'map.info', mapKey, source, sha256: sha, bytes: bytes.length, width: 2500, height: 2500,
|
||
worldSize: 3000, oceanMargin: 500, gridCells: 20, gridCellSize: 150, background: '#0B3B4A',
|
||
chunks: Math.ceil(bytes.length / chunkBytes), monuments: [],
|
||
}
|
||
const chunk = (n) => ({ kind: 'map.chunk', chunk: n, data: bytes.subarray(n * chunkBytes, (n + 1) * chunkBytes).toString('base64') })
|
||
return { info, chunk, sha }
|
||
}
|
||
|
||
function bridge(t, { info, chunk, stored = null } = {}) {
|
||
mapImages._reset()
|
||
const calls = { info: 0, chunks: [], put: [], geometry: [] }
|
||
const saved = { mapInfo: client.mapInfo, mapChunk: client.mapChunk, getMeta: mapDb.getMeta, putImage: mapDb.putImage, putGeometry: mapDb.putGeometry }
|
||
client.mapInfo = async () => {
|
||
calls.info += 1
|
||
return typeof info === 'function' ? info(calls.info) : { ok: true, status: 'ok', data: info }
|
||
}
|
||
client.mapChunk = async (server, q) => {
|
||
calls.chunks.push(q)
|
||
return { ok: true, status: 'ok', data: chunk(q.n, q) }
|
||
}
|
||
mapDb.getMeta = async () => stored
|
||
mapDb.putImage = async (row) => calls.put.push(row)
|
||
mapDb.putGeometry = async (row) => calls.geometry.push(row)
|
||
t.after(() => {
|
||
Object.assign(client, { mapInfo: saved.mapInfo, mapChunk: saved.mapChunk })
|
||
Object.assign(mapDb, { getMeta: saved.getMeta, putImage: saved.putImage, putGeometry: saved.putGeometry })
|
||
mapImages._reset()
|
||
})
|
||
return calls
|
||
}
|
||
|
||
test('a new map is fetched in slices, checked against its hash, and stored whole', async (t) => {
|
||
const p = picture(Buffer.from('a jpeg, honestly'))
|
||
const calls = bridge(t, { info: p.info, chunk: p.chunk })
|
||
|
||
const result = await mapImages.run(SERVER)
|
||
assert.equal(result.outcome, 'fetched')
|
||
assert.equal(calls.chunks.length, p.info.chunks)
|
||
assert.equal(calls.put.length, 1)
|
||
assert.equal(calls.put[0].bytes.toString(), 'a jpeg, honestly')
|
||
assert.equal(calls.put[0].sha256, p.sha)
|
||
})
|
||
|
||
test('bytes that do not match their hash are never stored', async (t) => {
|
||
const p = picture(Buffer.from('the real picture'))
|
||
const calls = bridge(t, { info: p.info, chunk: (n) => ({ ...p.chunk(n), data: Buffer.from('xxxx').toString('base64') }) })
|
||
|
||
const result = await mapImages.run(SERVER)
|
||
assert.equal(result.ok, false)
|
||
assert.equal(calls.put.length, 0)
|
||
})
|
||
|
||
test('a fetch that straddles a map change is abandoned whole and retried soon, not spliced', async (t) => {
|
||
const p = picture(Buffer.from('first map picture'))
|
||
const calls = bridge(t, {
|
||
info: p.info,
|
||
chunk: (n) => (n === 0 ? p.chunk(0) : { kind: 'map.error', reason: 'stale', message: 'moved' }),
|
||
})
|
||
|
||
const result = await mapImages.run(SERVER)
|
||
assert.equal(result.ok, false)
|
||
assert.equal(result.retrySoon, true)
|
||
assert.equal(calls.put.length, 0)
|
||
})
|
||
|
||
test('the stored picture of this map is not fetched again', async (t) => {
|
||
const p = picture(Buffer.from('same'))
|
||
const calls = bridge(t, {
|
||
info: p.info,
|
||
chunk: p.chunk,
|
||
stored: { mapKey: p.info.mapKey, sha256: p.sha, source: 'companion', derivation: map.DERIVATION_VERSION },
|
||
})
|
||
|
||
assert.equal((await mapImages.run(SERVER)).outcome, 'current')
|
||
assert.equal(calls.chunks.length, 0)
|
||
assert.equal(calls.geometry.length, 0)
|
||
})
|
||
|
||
test('a game with no picture keeps the one already stored for the same map', async (t) => {
|
||
const calls = bridge(t, {
|
||
info: { kind: 'map.info', mapKey: '3000.1234.1', source: 'none', worldSize: 3000, oceanMargin: 500, gridCells: 20, gridCellSize: 150, monuments: [] },
|
||
stored: { mapKey: '3000.1234.1', sha256: 'f'.repeat(64), source: 'rendered', width: 2500, height: 2500 },
|
||
})
|
||
assert.equal((await mapImages.run(SERVER)).outcome, 'current')
|
||
assert.equal(calls.put.length, 0, 'the picture row is not replaced')
|
||
assert.equal(calls.geometry[0].source, 'rendered')
|
||
})
|
||
|
||
test('a different map with no picture stores geometry so the layers can be drawn', async (t) => {
|
||
const calls = bridge(t, {
|
||
info: { kind: 'map.info', mapKey: '3500.9.1', source: 'none', worldSize: 3500, oceanMargin: 500, gridCells: 23, gridCellSize: 152.174, monuments: [] },
|
||
stored: { mapKey: '3000.1234.1', sha256: 'f'.repeat(64), source: 'companion' },
|
||
})
|
||
assert.equal((await mapImages.run(SERVER)).outcome, 'none')
|
||
assert.equal(calls.put.length, 1)
|
||
assert.equal(calls.put[0].bytes, undefined)
|
||
})
|
||
|
||
test('one fetch per server at a time', async (t) => {
|
||
const p = picture(Buffer.from('slow'))
|
||
let release
|
||
const gate = new Promise((resolve) => {
|
||
release = resolve
|
||
})
|
||
bridge(t, {
|
||
info: async () => {
|
||
await gate
|
||
return { ok: true, status: 'ok', data: p.info }
|
||
},
|
||
chunk: p.chunk,
|
||
})
|
||
|
||
const first = mapImages.run(SERVER)
|
||
assert.equal((await mapImages.run(SERVER)).outcome, 'busy')
|
||
release()
|
||
assert.equal((await first).outcome, 'fetched')
|
||
})
|
||
|
||
test('observe asks only a connected, ready plugin that knows the map verbs, and only when something moved', async (t) => {
|
||
const p = picture(Buffer.from('pic'))
|
||
const calls = bridge(t, { info: p.info, chunk: p.chunk })
|
||
|
||
mapImages.observe(SERVER, { ...HELLO, protocol: 10 })
|
||
mapImages.observe(SERVER, { ...HELLO, worldReady: false })
|
||
await new Promise((r) => setImmediate(r))
|
||
assert.equal(calls.info, 0)
|
||
|
||
mapImages.observe(SERVER, HELLO)
|
||
await new Promise((r) => setTimeout(r, 10))
|
||
assert.equal(calls.info, 1)
|
||
|
||
mapImages.observe(SERVER, HELLO)
|
||
await new Promise((r) => setTimeout(r, 10))
|
||
assert.equal(calls.info, 1, 'the same boot, wipe and map is not asked about twice')
|
||
|
||
mapImages.observe(SERVER, { ...HELLO, wipeId: 'w-2' })
|
||
await new Promise((r) => setTimeout(r, 10))
|
||
assert.equal(calls.info, 2, 'a wipe is a reason to ask')
|
||
})
|
||
|
||
// ── What moves ────────────────────────────────────────────────────────────
|
||
|
||
test('D111: any number of viewers inside the window cost one ask of the game', async (t) => {
|
||
mapLive._reset()
|
||
let asks = 0
|
||
const saved = client.mapLive
|
||
client.mapLive = async () => {
|
||
asks += 1
|
||
await new Promise((r) => setTimeout(r, 5))
|
||
return { ok: true, status: 'ok', data: { kind: 'map.live', ...LIVE } }
|
||
}
|
||
t.after(() => {
|
||
client.mapLive = saved
|
||
mapLive._reset()
|
||
})
|
||
|
||
let now = 1000
|
||
const clock = () => now
|
||
const answers = await Promise.all(Array.from({ length: 12 }, () => mapLive.live(SERVER, clock)))
|
||
assert.equal(asks, 1, 'concurrent viewers share the ask in flight')
|
||
assert.ok(answers.every((a) => a.ok))
|
||
|
||
now += map.LIVE_CACHE_MS - 1
|
||
await mapLive.live(SERVER, clock)
|
||
assert.equal(asks, 1, 'inside the window')
|
||
|
||
now += 2
|
||
await mapLive.live(SERVER, clock)
|
||
assert.equal(asks, 2, 'after it')
|
||
})
|
||
|
||
test('a game that does not answer is asked once per window too', async (t) => {
|
||
mapLive._reset()
|
||
let asks = 0
|
||
const saved = client.mapLive
|
||
client.mapLive = async () => {
|
||
asks += 1
|
||
return { ok: false, status: 'http-503' }
|
||
}
|
||
t.after(() => {
|
||
client.mapLive = saved
|
||
mapLive._reset()
|
||
})
|
||
|
||
const clock = () => 5000
|
||
assert.equal((await mapLive.live(SERVER, clock)).ok, false)
|
||
assert.equal((await mapLive.live(SERVER, clock)).ok, false)
|
||
assert.equal(asks, 1)
|
||
})
|
||
|
||
// ── The picture's route ───────────────────────────────────────────────────
|
||
|
||
function fakeRes() {
|
||
const res = { headers: {}, statusCode: 200, body: null, vary: () => res }
|
||
res.set = (k, v) => {
|
||
res.headers[k] = v
|
||
return res
|
||
}
|
||
res.status = (c) => {
|
||
res.statusCode = c
|
||
return res
|
||
}
|
||
res.json = (b) => {
|
||
res.body = b
|
||
return res
|
||
}
|
||
res.send = (b) => {
|
||
res.body = b
|
||
return res
|
||
}
|
||
return res
|
||
}
|
||
|
||
test('the picture is immutable under its hash, and a stale hash is a 404 that nothing caches', async (t) => {
|
||
const controller = require('../router/public/rust.controller')
|
||
const servers = require('../model/servers/servers.model')
|
||
const sha = 'a'.repeat(64)
|
||
const saved = { getPublic: servers.getPublic, getBytes: mapDb.getBytes }
|
||
servers.getPublic = async (id) => (id === 'main' ? { id } : null)
|
||
mapDb.getBytes = async (id, v) => (v === sha ? Buffer.from([0xff, 0xd8, 0xff]) : null)
|
||
t.after(() => {
|
||
servers.getPublic = saved.getPublic
|
||
mapDb.getBytes = saved.getBytes
|
||
})
|
||
|
||
const hit = fakeRes()
|
||
await controller.getMapImage({ params: { id: 'main' }, query: { v: sha } }, hit)
|
||
assert.equal(hit.statusCode, 200)
|
||
assert.equal(hit.headers['Cache-Control'], 'public, max-age=31536000, immutable')
|
||
assert.equal(hit.headers['Content-Type'], 'image/jpeg')
|
||
|
||
const stale = fakeRes()
|
||
await controller.getMapImage({ params: { id: 'main' }, query: { v: 'b'.repeat(64) } }, stale)
|
||
assert.equal(stale.statusCode, 404)
|
||
assert.equal(stale.headers['Cache-Control'], 'no-store')
|
||
|
||
const junk = fakeRes()
|
||
await controller.getMapImage({ params: { id: 'main' }, query: { v: '../../etc' } }, junk)
|
||
assert.equal(junk.statusCode, 404)
|
||
})
|