From 5aa660323674f13e745024a53e36f36ef366ad65 Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 9 Nov 2026 09:37:52 +0800 Subject: [PATCH 1/2] social: refuse a damaged Settings or identity file instead of reading it as absent readJson returned undefined for every failure, so a parked identity file that existed but did not parse read as "nothing saved": a server switch restarted the Bot as a stranger and the next park() replaced the only copy. It also made a torn flybot-social-server.json fall back to the config server as if the owner had never chosen one. Read only ENOENT as absent and refuse (bot/refused, path named) otherwise. A switch reads the target's parked file before it parks or writes anything, so a torn file refuses with the live layer and every byte still in place; the Settings choice refuses the controller at start instead of falling back; the known-server list warns and leaves a damaged entry out. Fixes #2717 --- changelog.d/2717-social-readjson.fixed.md | 1 + docs/subsystems/social.md | 4 +- plugins/social/src/server-config.js | 51 ++++++-- .../test/server-config-damaged-json.test.js | 121 ++++++++++++++++++ 4 files changed, 167 insertions(+), 10 deletions(-) create mode 100644 changelog.d/2717-social-readjson.fixed.md create mode 100644 plugins/social/test/server-config-damaged-json.test.js diff --git a/changelog.d/2717-social-readjson.fixed.md b/changelog.d/2717-social-readjson.fixed.md new file mode 100644 index 000000000..61ef62b4b --- /dev/null +++ b/changelog.d/2717-social-readjson.fixed.md @@ -0,0 +1 @@ +A Bot Social settings or identity file that exists but cannot be read or parsed is no longer treated as "nothing was ever saved". A torn `flybot-social-bots..json` used to make a server switch silently restart the Bot as a stranger — no member identity, no grants — and the next switch rewrote the only copy; the switch now refuses before anything moves, names the file and leaves its bytes alone. A torn `flybot-social-server.json` refuses at start instead of quietly connecting to the default server as if no server had been chosen, and a damaged credentials file of another server is warned about and left out of the known-server list. A file that is absent still means a first run. See [#2717](https://github.com/ThinkFlowLab/FlyBot/issues/2717). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index 13393150c..fd4788553 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -34,7 +34,7 @@ At phone width (`<= 640 px`, the last block in `client.js's stylesheet`) this pa | --- | --- | | `plugins/social/index.js` | Plugin entry. | | `plugins/social/src/social.js` | Host: roster, grants, inbox, `social/status` pending-approval queue, Approve / Reject, hide-remote, Manage access, shared-room approvals provider, face-to-face join/poll/leave/start/send and `social/face-auto`. | -| `plugins/social/src/server-config.js` | Which server this FlyBot uses (#337): address validation (`parseServerUrl`), the `GET /v1/platform` probe (`probeServer`), and `createServerController`, which starts the social layer for the effective server and switches it live. `index.js` wires it and registers `social/server`, `social/server-check`, `social/server-set`. | +| `plugins/social/src/server-config.js` | Which server this FlyBot uses (#337): address validation (`parseServerUrl`), the `GET /v1/platform` probe (`probeServer`), and `createServerController`, which starts the social layer for the effective server and switches it live. `index.js` wires it and registers `social/server`, `social/server-check`, `social/server-set`. The Settings choice and the parked Bot identities are read with the same `ENOENT`-vs-damaged rule as the credentials: absent means "nothing saved yet", a file that exists and does not parse refuses and names its path (#2717). | | `plugins/social/src/mcp-client.js` | Owner MCP client plus isolated grant/invite REST fallbacks (`listGrants`, `revokeGrant`, `updateBotProfile`, `declineGrant`, invites). | | `plugins/social/src/look.js` | Map Bot Social profile look fields to FlyBot `{ color, avatar }` (optional on older servers). A look's image is pasted into the page-wide mention stylesheet as `url("…")`, so `lookImage` keeps only a plain `https?://` URL or a `data:image/(png|jpeg|webp|gif);base64,` string and drops one carrying a quote, a paren, a backslash, whitespace or a control character (#2646); gif is accepted because the host stores one in the same field (#2649); `core/src/shared/look-image.js` is the same test at the core entries and sink, and `test/look.test.js` keeps the two in step. | | `plugins/social/src/face-rooms.js` | Face-to-face REST client (join/list/get/start/leave/messages, mint/revoke/preview/join invites, `parseInviteLink` / `parseFaceInviteLink`), error mapping, event rendering, peer-string sanitization. Auto-send is MCP in `social.js`. | @@ -164,7 +164,7 @@ Creating a group opens its Session with zero turns, and the host draws no sessio - **What may be written where.** `flybot-social.json`, the `.bak` copy `startSocial` keeps for another server (`flybot-social.json.`) and `flybot-social-settings.json` are written by one function, and it asks the leaf with `lstat` before the first byte: a missing file is created (mode 600), a plain file of its own (`nlink === 1`) is rewritten through a same-directory `..tmp` that is fsynced and renamed over the file (the temp name is a leaf of its own too, so a link left there refuses the save as well), and a symbolic link, a hard link, a directory or any other kind refuses the write — nothing is written there, the file behind a link keeps its bytes, one line names the path and carries no value from the file, and `startSocial` fails with that refusal instead of running on a link. The temp plus rename is what keeps a write that stops half way (ENOSPC, EFBIG, a kill) from taking the previous file with it: the old bytes stay, and a failed write removes its temp (#2631). The refusal is asked **before** onboarding: the onboarding is a real home on the server and a round-trip, so a start whose credentials path cannot be written refuses first and never spends one on a token it would drop. A refused `.bak` leaves the credentials in use untouched, and its name is asked in that same pre-check: the backup name follows from the server in the credentials file already on disk (`flybot-social.json.`), a local read, so a link left there refuses the start before the onboarding rather than after it, and a free name still keeps the old credentials under their server. The read side tells "nothing saved yet" from "damaged": `ENOENT` is a first start, while a file that exists and does not parse refuses with a message naming the path and is left where it is — a torn credentials file therefore never reads as "not configured", and `startSocial` never onboards a fresh home over an identity it could not read (#2631); `flybot-social-settings.json` is read with that same rule (a link at that name is followed, like any read) and written only when a setting changes, where a write refusal is caught and warned as `settings not saved` — no onboarding depends on that write, but a damaged settings file refuses the start instead of reading as empty. A read-only window (`flybot.readOnly?.()`) writes it not at all (#2231): its read endpoints (`social/remote-bots`, `social/friends`) still derive the one-shot inbound backfill, the hidden-remote aging and the accept-notice cleanup in memory and answer the same, but the save path returns without writing, so it cannot create the file, cannot age a hide on it, and cannot flush an older copy over what the writable window saved. - **Validation.** `parseServerUrl`: `https://` only, `http://` just for `localhost`, `127.0.0.1` and `[::1]`; no user name or password, query or fragment; at most 200 characters; an address without a scheme gets `https://` (`http://` for those local hosts). `probeServer` GETs `/v1/platform` without credentials, 8 s deadline, `redirect: 'manual'` (a redirect is refused, so the saved address is the checked one); the answer must look like Bot Social (a `name`, a `platform_key` or a `features` list) and its `features` are shown (cleaned, at most 40) in words (`featureLine` in `client.js`: `typed_by_owner` → typing as you, `mentions` → @mentions, `presence` → online status; an unknown id is only counted, and every raw id is in the line's tooltip, #421). `social/server-set` repeats both before it touches anything, so a UI that skipped **Check** gains nothing. - **A server is an identity.** The default server keeps its files. Any other server gets `flybot-social.json.` (credentials; the same suffix `startSocial` already used for backups, `tag` = first 8 hex of the sha-256 of the normalized address) and `flybot-social-settings..json`; a different server never reads another's file, and no credentials file is ever deleted or rewritten by a switch. The per-Bot parts that live in host state (the `member` identity, the `autoApprove` marks and the person/room grants, which are Bot Social ids and handles) are parked in `flybot-social-bots..json` (no token) and cleared before the new server starts, so a stale grant to `@bob.home` can never apply to a different Bob on the new server; switching back restores them and the saved credentials, without a second onboarding. The old server's friends' Bots leave the list when the new server's first roster fetch replaces it (`remote.publish`); face-to-face rooms already hosted stay as local transcripts, but nothing polls them. -- **Applied live, with a way back.** `social/server-set` (needs `confirm: true`; a write, so a read-only host and a multi-member team refuse it) stops the social layer, parks the old server's Bot identities, saves the choice, restores what the new server had, and starts again (first start onboards). If that start fails or takes over 45 s, the previous server's files, identities and choice are put back, it is started again, and the error says so; a start that finishes late is disposed. A disposed instance never re-arms its poll: a face poll already in flight when `dispose()` runs cannot install a new interval on the discarded layer, so it never keeps polling (or delivering from) the server it was built for. When the switch succeeds and the address really changed, the previous server's hosted face-to-face rooms — open or still forming (candidate) — are marked `left` here — a read-only past room whose composer is gone, since the new server cannot reach them. Renaming this home on the same server is not a switch: that server still reaches those rooms, so only a new address ends them (the room and its composer stay as they were). Switching back does not reopen them: a room this home left stays a past room (`faceSnapshotPast`, #182), and a line to it is refused (`bot/room-ended`). The Settings page says plainly that this is a new identity, that friends and sharing stay on the old server, and that the old sign-in is kept as a backup and never deleted. Tests: `plugins/social/test/social.test.js` (`Settings server`, `server controller`, `parseServerUrl`, `probeServer`). +- **Applied live, with a way back.** `social/server-set` (needs `confirm: true`; a write, so a read-only host and a multi-member team refuse it) stops the social layer, parks the old server's Bot identities, saves the choice, restores what the new server had, and starts again (first start onboards). If that start fails or takes over 45 s, the previous server's files, identities and choice are put back, it is started again, and the error says so; a start that finishes late is disposed. A disposed instance never re-arms its poll: a face poll already in flight when `dispose()` runs cannot install a new interval on the discarded layer, so it never keeps polling (or delivering from) the server it was built for. When the switch succeeds and the address really changed, the previous server's hosted face-to-face rooms — open or still forming (candidate) — are marked `left` here — a read-only past room whose composer is gone, since the new server cannot reach them. Renaming this home on the same server is not a switch: that server still reaches those rooms, so only a new address ends them (the room and its composer stay as they were). A damaged file refuses before anything moves (#2717): a torn `flybot-social-bots..json` of the server being switched to refuses the switch with the live layer still running and the file byte for byte where it was, instead of being read as "nothing parked" and then replaced by the next `park()`; a torn `flybot-social-server.json` refuses the controller at start with a message naming the path, instead of falling back to `config.server` as if the owner had never chosen; and a torn credentials file of another server is warned about and left out of `known[]` rather than taking the Settings page down with it. `ENOENT` stays the one answer that means "nothing saved yet", so first runs and servers never visited keep working. Switching back does not reopen them: a room this home left stays a past room (`faceSnapshotPast`, #182), and a line to it is refused (`bot/room-ended`). The Settings page says plainly that this is a new identity, that friends and sharing stay on the old server, and that the old sign-in is kept as a backup and never deleted. Tests: `plugins/social/test/social.test.js` (`Settings server`, `server controller`, `parseServerUrl`, `probeServer`). ### Friends and sharing on the Bot Social page (#1074) diff --git a/plugins/social/src/server-config.js b/plugins/social/src/server-config.js index 221558a7b..ff49e8f10 100644 --- a/plugins/social/src/server-config.js +++ b/plugins/social/src/server-config.js @@ -87,7 +87,21 @@ const writePrivate = (path, value) => { renameSync(temp, path) try { chmodSync(path, 0o600) } catch { /* file systems that ignore the write mode */ } } -const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')) } catch { return undefined } } +// Absent is the one answer that means "nothing saved yet" (`ENOENT`): a first run and a server +// never visited keep working. A file that exists but cannot be read or parsed is not that answer — +// reading it as absent lost a parked Bot identity and let the next `park()` replace the only copy, +// and a torn server choice fell back to the config server (#2717). It refuses, names the path, and +// leaves the file where a person can move it aside on purpose. +const readJson = (path, what = 'A saved Bot Social file') => { + let text + try { text = readFileSync(path, 'utf8') } catch (failure) { + if (failure?.code === 'ENOENT') return undefined + throw refused(`${what} at ${path} cannot be read: ${cleanLine(failure?.message ?? failure, 120)}. Nothing there was changed.`, 'unreadable') + } + try { return JSON.parse(text) } catch { + throw refused(`${what} at ${path} exists but does not parse as JSON. Move it aside on purpose to discard it; nothing here changed it.`, 'damaged') + } +} /** * @param base the plugin config (yaml) with the plugin defaults; `server` is the default server @@ -109,7 +123,7 @@ export function createServerController({ base: configured, flybot, start, regist const botsPath = tag => join(dir, `flybot-social-bots.${tag}.json`) const readOverride = () => { - const saved = readJson(overridePath) + const saved = readJson(overridePath, 'The Bot Social server chosen in Settings') if (typeof saved?.server !== 'string') return undefined try { return { server: parseServerUrl(saved.server), displayName: cleanLine(saved.displayName, 40) || undefined } } catch { return undefined } } @@ -219,8 +233,12 @@ export function createServerController({ base: configured, flybot, start, regist try { await flybot.grants?.set?.(id, []) } catch (failure) { warn('flybot-social: grants not cleared:', failure) } })) } - async function restore(key) { - const saved = readJson(botsPath(serverTag(key))) + // Read before a switch touches any file: a torn file for the server being switched to then + // refuses with every identity and every saved file where they were, instead of parking the + // previous server first and finding out here. The default argument keeps callers that read at + // restore time (a same-server rename) working. + const readParked = key => readJson(botsPath(serverTag(key)), 'The Bot identities saved for this server') + async function restore(key, saved = readParked(key)) { if (!saved || serverKey(saved.server) !== serverKey(key)) return for (const [id, entry] of Object.entries(saved.bots ?? {})) { if (!flybot.bot.get(id)) continue @@ -239,7 +257,16 @@ export function createServerController({ base: configured, flybot, start, regist // Servers this profile has saved credentials for (the address only, never the credentials). function knownServers() { const found = new Set() - const add = path => { const saved = readJson(path); if (typeof saved?.server === 'string' && saved.token) found.add(saved.server) } + // Listing the saved servers is a convenience, not an identity restore: one damaged credentials + // file is warned about and left out instead of taking the whole Settings page down with it. + const add = path => { + let saved + try { saved = readJson(path, 'Saved Bot Social credentials') } catch (failure) { + warn('flybot-social: saved credentials not listed:', failure) + return + } + if (typeof saved?.server === 'string' && saved.token) found.add(saved.server) + } add(credentialsPath) try { const stem = `${basename(credentialsPath)}.` @@ -270,7 +297,7 @@ export function createServerController({ base: configured, flybot, start, regist if (!address) throw refused('No server is set yet.', 'no_server') const info = await probe(address, { fetch: base.fetch }) if (serverKey(address) === serverKey(current.server)) lastProbe = { server: address, info } - return { server: address, ...info, same: serverKey(address) === serverKey(current.server), known: knownServers().some(item => serverKey(item) === serverKey(address)) || serverKey(address) === defaultKey && Boolean(readJson(credentialsPath)?.token) } + return { server: address, ...info, same: serverKey(address) === serverKey(current.server), known: knownServers().some(item => serverKey(item) === serverKey(address)) || serverKey(address) === defaultKey && Boolean(readJson(credentialsPath, 'The Bot Social credentials')?.token) } }, async set({ server, displayName, confirm } = {}) { if (flybot.readOnly?.() === true) throw refused('Another FlyBot window holds this team. Change the server there.', 'read_only') @@ -288,10 +315,14 @@ export function createServerController({ base: configured, flybot, start, regist const previousFaceRooms = (flybot.rooms?.list?.() ?? []) .filter(room => room.origin === 'face-to-face' && room.face?.roomId && (room.face.status === 'open' || room.face.status === 'candidate')) .map(room => room.face.roomId) + // A same-server rename is no switch (#729): park() rewrites this server's file and restore() + // reads it back, so it is read at restore time. Any other target is read before anything + // moves — a torn identity file refuses the switch with the live server still connected. + const savedIdentities = serverKey(address) === serverKey(previous.server) ? undefined : readParked(address) stop() if (previous.server) await park(previous.server) writeOverride(target) - await restore(address) + await restore(address, savedIdentities) current = target lastProbe = { server: address, info } try { @@ -302,7 +333,11 @@ export function createServerController({ base: configured, flybot, start, regist // A failed start leaves nothing of the new server worth parking: a failed park changes nothing here. if (target.server) await park(target.server).catch(() => undefined) writeOverride(previous) - if (previous.server) await restore(previous.server) + if (previous.server) { + // The rollback must not mask the switch failure; park() just wrote this file, so a + // failure here is reported and the file left where it is. + try { await restore(previous.server) } catch (failure) { warn('flybot-social: previous server identities not restored:', failure) } + } current = previous lastProbe = undefined // launch() has already recorded the failure (state, error, the unavailable answers); the message below reports it. diff --git a/plugins/social/test/server-config-damaged-json.test.js b/plugins/social/test/server-config-damaged-json.test.js new file mode 100644 index 000000000..052169495 --- /dev/null +++ b/plugins/social/test/server-config-damaged-json.test.js @@ -0,0 +1,121 @@ +// #2717: a settings or identity file that exists but does not parse is not "nothing was saved". +// Reading it as absent lost a parked Bot identity and let the next park() replace the only copy; +// a torn server choice silently fell back to the config server. Absent (ENOENT) still means +// "nothing saved yet", so first runs and servers never visited keep working. +import assert from 'node:assert/strict' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { createServerController, serverTag } from '../src/server-config.js' + +const homeOf = (t) => { + const home = mkdtempSync(join(tmpdir(), 'flybot-social-damaged-')) + t.after(() => rmSync(home, { recursive: true, force: true })) + return home +} + +// One local Bot holding the identity the server it is on issued: a member handle and one grant. +function fakeFlybot() { + const fields = new Map([['b1', { member: { id: 'm1', handle: '@b1.home' }, autoApprove: undefined }]]) + const grants = new Map([['b1', [{ scope: 'friends' }]]]) + return { + bot: { list: () => [{ id: 'b1' }], get: id => (id === 'b1' ? { id: 'b1' } : undefined) }, + fields: { + get: (id, key) => fields.get(id)?.[key], + set: (id, key, value) => { + const entry = fields.get(id) ?? {} + entry[key] = value + fields.set(id, entry) + }, + }, + grants: { of: id => grants.get(id) ?? [], set: async (id, value) => { grants.set(id, value) } }, + readOnly: () => false, + } +} + +const controllerFor = (home, flybot, { server = 'https://a.test', warn } = {}) => createServerController({ + base: { server, credentialsPath: join(home, 'flybot-social.json'), displayName: 'Me' }, + flybot, + warn, + probe: async () => ({ name: 'Bot Social', features: [] }), + start: async () => ({ home: { handle: '@me.home' }, dispose: () => {} }), + startTimeoutMs: 20, +}) + +const warningsOf = () => { + const lines = [] + const warn = (...args) => lines.push(args.map(value => String(value)).join(' ')) + return { lines, warn } +} + +test('#2717: a torn parked identity file refuses the switch and keeps its bytes', async (t) => { + const home = homeOf(t) + const say = warningsOf() + const controller = controllerFor(home, fakeFlybot(), { warn: say.warn }) + t.after(() => controller.dispose()) + // A real switch parks the identity of the server being left. + await controller.set({ server: 'https://b.test', confirm: true }) + assert.equal(controller.status().server, 'https://b.test') + const parked = join(home, `flybot-social-bots.${serverTag('https://a.test')}.json`) + const whole = readFileSync(parked) + assert.ok(whole.length > 0, 'the switch parked the identity') + const torn = whole.subarray(0, Math.floor(whole.length / 2)) + writeFileSync(parked, torn) + + await assert.rejects(controller.set({ server: 'https://a.test', confirm: true }), (error) => { + assert.equal(error.code, 'bot/refused') + assert.ok(error.message.includes(parked), `the refusal names the path: ${error.message}`) + assert.match(error.message, /does not parse as JSON/i) + assert.match(error.message, /nothing here changed it/i) + return true + }) + assert.deepEqual(readFileSync(parked), torn, 'the file it could not read is byte for byte where it was') + assert.equal(controller.status().server, 'https://b.test', 'the live server did not move') + + // Switching somewhere else afterwards still does not write over the torn file. + await controller.set({ server: 'https://c.test', confirm: true }) + assert.deepEqual(readFileSync(parked), torn, 'a later switch leaves the unreadable file alone') +}) + +test('#2717: a missing parked identity file is still "nothing saved yet"', async (t) => { + const home = homeOf(t) + const say = warningsOf() + const controller = controllerFor(home, fakeFlybot(), { warn: say.warn }) + t.after(() => controller.dispose()) + await controller.set({ server: 'https://b.test', confirm: true }) + assert.equal(controller.status().server, 'https://b.test', 'a server never visited starts fine') + assert.deepEqual(say.lines, [], 'nothing to warn about') + assert.equal(existsSync(join(home, `flybot-social-bots.${serverTag('https://b.test')}.json`)), false) +}) + +test('#2717: a torn server choice refuses instead of falling back to the config server', (t) => { + const home = homeOf(t) + const override = join(home, 'flybot-social-server.json') + writeFileSync(override, '{"server":"https://picked-in-settings.test","savedAt":"') + assert.throws(() => controllerFor(home, fakeFlybot()), (error) => { + assert.equal(error.code, 'bot/refused') + assert.ok(error.message.includes(override), `the refusal names the path: ${error.message}`) + assert.match(error.message, /does not parse as JSON/i) + return true + }) +}) + +test('#2717: no server choice still reads as the config default', (t) => { + const home = homeOf(t) + const controller = controllerFor(home, fakeFlybot()) + t.after(() => controller.dispose()) + assert.equal(controller.status().server, 'https://a.test') + assert.equal(controller.status().source, 'config') +}) + +test('#2717: a torn credentials file of another server is warned about, not read as absent', (t) => { + const home = homeOf(t) + const other = join(home, `flybot-social.json.${serverTag('https://b.test')}`) + writeFileSync(other, '{"server":"https://b.test","token":"') + const say = warningsOf() + const controller = controllerFor(home, fakeFlybot(), { warn: say.warn }) + t.after(() => controller.dispose()) + assert.deepEqual(controller.status().known, [], 'the unreadable entry is not listed as a known server') + assert.ok(say.lines.some(line => line.includes(other)), `a warning names the path, saw ${JSON.stringify(say.lines)}`) +}) From 22ad4b1dd2b5a98b5b0f0ac0441025dc8907b56e Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Mon, 9 Nov 2026 09:39:22 +0800 Subject: [PATCH 2/2] social: warn once per damaged credentials file The known-server list is read on every status poll, so one damaged credentials file would repeat the same warning on each one; the operator needs it once. Fixes #2717 --- plugins/social/src/server-config.js | 7 ++++++- plugins/social/test/server-config-damaged-json.test.js | 2 ++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/social/src/server-config.js b/plugins/social/src/server-config.js index ff49e8f10..333039e42 100644 --- a/plugins/social/src/server-config.js +++ b/plugins/social/src/server-config.js @@ -255,14 +255,19 @@ export function createServerController({ base: configured, flybot, start, regist } // Servers this profile has saved credentials for (the address only, never the credentials). + const warnedKnown = new Set() function knownServers() { const found = new Set() // Listing the saved servers is a convenience, not an identity restore: one damaged credentials // file is warned about and left out instead of taking the whole Settings page down with it. + // Warned once per path — `status` is polled, and one broken file is one fact for the operator. const add = path => { let saved try { saved = readJson(path, 'Saved Bot Social credentials') } catch (failure) { - warn('flybot-social: saved credentials not listed:', failure) + if (!warnedKnown.has(path)) { + warnedKnown.add(path) + warn('flybot-social: saved credentials not listed:', failure) + } return } if (typeof saved?.server === 'string' && saved.token) found.add(saved.server) diff --git a/plugins/social/test/server-config-damaged-json.test.js b/plugins/social/test/server-config-damaged-json.test.js index 052169495..e48bf2148 100644 --- a/plugins/social/test/server-config-damaged-json.test.js +++ b/plugins/social/test/server-config-damaged-json.test.js @@ -118,4 +118,6 @@ test('#2717: a torn credentials file of another server is warned about, not read t.after(() => controller.dispose()) assert.deepEqual(controller.status().known, [], 'the unreadable entry is not listed as a known server') assert.ok(say.lines.some(line => line.includes(other)), `a warning names the path, saw ${JSON.stringify(say.lines)}`) + controller.status() + assert.equal(say.lines.filter(line => line.includes(other)).length, 1, 'one broken file is one warning, even though status is polled') })