fix(qa): version check is opt-out-able (QA-B16-01)

- updateCheckService: gate the external request on `update_check_enabled`
  (default on); when off, no network call, returns { disabled: true }
- aboutAdmin: GET/PUT /update-check-setting (admin-only) to toggle it
- StatusPage: a Switch on the admin System Status card to enable/disable
- privacy.js: state that an admin can disable it (was called "optional" with
  no actual opt-out)
- tests/updateCheckOptOut.test.js: proves no external fetch when disabled
- docs: archive QA-B16-01, B16 

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-03 10:05:37 -05:00
parent e8190170dc
commit 2963d11d1b
8 changed files with 138 additions and 5 deletions

View File

@ -3,6 +3,7 @@
### 🐛 QA Fixes
- **[Privacy] The version check is now opt-out-able** — the privacy policy described the external version check as "optional", but there was no way to disable it (it hit a hardcoded upstream host whenever the About/Status/version page loaded). Added an **admin toggle**: an `update_check_enabled` setting gates the request in `services/updateCheckService.js` (default on — when off, **no external request is made**), exposed via `GET`/`PUT /api/about-admin/update-check-setting` and a switch on the admin **System Status** page. Privacy policy updated to state an admin can disable it. Test: `tests/updateCheckOptOut.test.js`. (was QA-B16-01)
- **[Security] Bill name could inject HTML into reminder emails** — `buildEmailHtml` (`services/notificationService.js`) escaped the bill name in the detail table but interpolated it **raw** into the reminder message line (`<strong>${bill.name}</strong> is due…`), so a bill named `<img src=x onerror=…>` landed unescaped in the email HTML. Self-XSS (reminder emails go to the bill's owner), but a clear inconsistent-escaping bug — now escaped everywhere. Covered by `tests/notificationDelivery.test.js`. (was QA-B14-04)
- **[Notifications] "Send test push" was completely broken** — `services/notificationService.js` attached its `_push` export (the ntfy/Gotify/Discord/Telegram helpers) *before* the final `module.exports = {…}`, which clobbered it, so `require('…/notificationService')._push` was `undefined`. `routes/notifications.js` (`const { sendTestPush } = require(…)._push || {}`) therefore always hit `throw 'Push service not initialised'`**`POST /api/notifications/test-push` always returned 500** for every user testing their push channel. Scheduled reminders were unaffected (they call `sendPushToUser` in-scope). Moved the `_push` assignment after the reassignment. Covered by `tests/notificationDelivery.test.js` (per-channel payloads, dispatch, error handling, and a check that the auth token never leaks into the message body). (was QA-B10-01)
- **[Summary/Analytics] Non-monthly bills were counted in every month** — the Summary expense list/total and the Analytics "expected vs actual" line both counted annual (and off-month quarterly) bills for months they weren't due, over-stating the obligation and disagreeing with the Tracker (e.g. a yearly insurance bill inflated every month). Both `routes/summary.js` and `services/analyticsService.js` now gate bills by `resolveDueDate` — the same occurrence check the Tracker uses. Guarded by Tracker↔Summary and Tracker↔Analytics reconciliation checks in `e2e/api.probe.spec.js`. The SimpleFIN bank-tracking `unpaid_this_month` metric had the same gap and is fixed the same way (fetch + JS `resolveDueDate` filter, since SQL can't call it), covered by `tests/summaryBankTracking.test.js`. (was QA-B5-01, QA-B5-02, QA-B5-03)

View File

@ -344,6 +344,8 @@ export const api = {
roadmap: (refresh = false) => get(`/about-admin/roadmap${refresh ? '?refresh=1' : ''}`),
updateStatus: () => get('/version/update-status'),
checkForUpdates: () => post('/about-admin/check-updates'),
getUpdateCheckSetting: () => get('/about-admin/update-check-setting'),
setUpdateCheckSetting: (enabled) => put('/about-admin/update-check-setting', { enabled }),
devLog: () => get('/about-admin/dev-log'),
version: () => get('/version'),
releaseHistory: () => get('/version/history'),

View File

@ -9,6 +9,7 @@ import { toast } from 'sonner';
import { api } from '@/api';
import { cn, fmtUptime, fmtBytes } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { MarkdownText } from '@/components/MarkdownText';
// Helpers
@ -128,7 +129,7 @@ function SkeletonCard() {
const CATEGORY_ORDER = ['Added', 'Changed', 'Fixed', 'Removed', 'Deprecated', 'Security'];
function UpdateCard({ update, onCheckNow, checking }) {
function UpdateCard({ update, onCheckNow, checking, enabled = true, onToggle }) {
const hasUpdate = !!update.has_update;
const isKnown = update.up_to_date !== null && update.up_to_date !== undefined;
const hasError = !!update.error;
@ -173,8 +174,15 @@ function UpdateCard({ update, onCheckNow, checking }) {
{update.error && (
<p className="text-[11px] text-red-400 leading-relaxed pt-1.5">{update.error}</p>
)}
<div className="flex items-center justify-between gap-3 py-1.5 border-t border-border/40 mt-1">
<span className="text-xs text-muted-foreground">
Automatic version check
<span className="block text-[10px] text-muted-foreground/60">External request; off = no phone-home</span>
</span>
<Switch checked={enabled} onCheckedChange={onToggle} aria-label="Enable automatic version check" />
</div>
<div className="pt-3">
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking}
<Button variant="outline" size="sm" onClick={onCheckNow} disabled={checking || !enabled}
className="h-7 text-xs gap-1.5">
<RefreshCw className={cn('h-3 w-3', checking && 'animate-spin')} />
{checking ? 'Checking…' : 'Check Now'}
@ -229,6 +237,7 @@ export default function StatusPage() {
const [loading, setLoading] = useState(true);
const [updateData, setUpdateData] = useState(null);
const [updateChecking, setUpdateChecking] = useState(false);
const [updateEnabled, setUpdateEnabled] = useState(true);
const load = useCallback(async () => {
setLoading(true);
@ -243,6 +252,10 @@ export default function StatusPage() {
const historyData = await api.releaseHistory();
setHistoryMeta({ version: historyData.version, updated_at: historyData.updated_at });
} catch { setHistoryMeta(null); }
try {
const s = await api.getUpdateCheckSetting();
setUpdateEnabled(s.enabled !== false);
} catch { /* keep default */ }
} catch (err) {
toast.error(err.message || 'Failed to load status.');
} finally {
@ -263,6 +276,17 @@ export default function StatusPage() {
}
}, []);
const handleToggleUpdateCheck = useCallback(async (enabled) => {
setUpdateEnabled(enabled); // optimistic
try {
await api.setUpdateCheckSetting(enabled);
if (enabled) handleCheckNow();
} catch (err) {
setUpdateEnabled(!enabled); // revert
toast.error(err.message || 'Failed to update setting');
}
}, [handleCheckNow]);
// Normalize the nested response shape
const app = data?.application ?? data?.app ?? {};
const rt = data?.runtime ?? {};
@ -502,6 +526,8 @@ export default function StatusPage() {
update={updateData}
onCheckNow={handleCheckNow}
checking={updateChecking}
enabled={updateEnabled}
onToggle={handleToggleUpdateCheck}
/>
)}
<ReleaseNotesCard version={version} historyMeta={historyMeta} />

View File

@ -104,7 +104,7 @@ before cross-cutting; regression last). Update **Status** and **Findings** every
| B13 | API / backend direct | all `/api/*`: auth, CSRF, validation, rate limits, error shape, IDOR, cents | via HTTP client | ✅ | 0 / 1 |
| B14 | Non-functional | a11y, performance, PWA/offline, XSS/secrets, timezone/DST | large + adversarial | ✅ | 0 / 4 |
| B15 | Regression & sign-off | full smoke on **production build**, exit criteria | seeded | ✅ | 0 / 0 |
| B16 | Migrations, secrets & deploy | migration idempotency/rollback/fresh==migrated, encryption-key lifecycle, `docker-entrypoint` (perms/first-run/migrate), update-check phone-home | scratch + docker | 🔄 | 1 / 0 |
| B16 | Migrations, secrets & deploy | migration idempotency/rollback/fresh==migrated, encryption-key lifecycle, `docker-entrypoint` (perms/first-run/migrate), update-check phone-home | scratch + docker | ✅ | 0 / 1 |
> After B15, if any batch is 🔁 or has open S1/S2, loop back. Then start a new
> cycle from B0 against the next build/version.
@ -149,7 +149,7 @@ fixing. Keep only **Open / Fixing / Fixed** rows here. Once a finding is
| ID | Sev | Area (`file:line`) | Summary | Status | Notes / repro |
|----|-----|--------------------|---------|--------|---------------|
| QA-B16-01 | S4 | `services/updateCheckService.js` + `routes/privacy.js` | Privacy policy calls the version check "**optional**", but there is **no opt-out** — it phones a hardcoded host (`dream.scheller.ltd`) whenever About/Status/version is loaded | 🔴 Open | decision needed: add a toggle vs reword |
| _(none — all Cycle 1 findings fixed, verified & archived to `HISTORY.md` v0.41.0)_ | | | | | |
**Finding template** (paste a new row above; keep the full write-up here until archived):

View File

@ -487,4 +487,22 @@ router.post('/check-updates', requireAuth, requireAdmin, async (req, res) => {
}
});
// QA-B16-01: opt-out control for the external version check.
const { getSetting, setSetting } = require('../db/database');
// GET /api/about-admin/update-check-setting — is the external version check enabled?
router.get('/update-check-setting', requireAuth, requireAdmin, (req, res) => {
res.json({ enabled: getSetting('update_check_enabled') !== 'false' });
});
// PUT /api/about-admin/update-check-setting — enable/disable the external version check
router.put('/update-check-setting', requireAuth, requireAdmin, (req, res) => {
const { enabled } = req.body || {};
if (typeof enabled !== 'boolean') {
return res.status(400).json({ error: 'enabled must be a boolean' });
}
setSetting('update_check_enabled', enabled ? 'true' : 'false');
res.json({ enabled });
});
module.exports = router;

View File

@ -31,6 +31,7 @@ router.get('/', (req, res) => {
items: [
'The only external communication performed by the application is an optional version check to determine whether the latest software release is installed.',
'This communication does not include your bill data or personal information.',
'An administrator can disable the version check entirely in the admin panel; when disabled, the application makes no external requests.',
'Version check information is not tracked or stored server-side and is used solely to determine software update availability.',
],
},

View File

@ -4,9 +4,18 @@
* 5 minutes for errors) so the status page stays fast under load.
*/
const { getSetting } = require('../db/database');
const REPO_API_BASE = process.env.REPO_API_URL
|| 'https://dream.scheller.ltd/api/v1/repos/null/BillTracker';
// QA-B16-01: the version check is opt-out-able. Admins can disable the external
// request via the `update_check_enabled` setting (default on). When off, no
// network call is made and a `disabled` status is returned.
function updateCheckEnabled() {
return getSetting('update_check_enabled') !== 'false';
}
const TTL_OK_MS = 60 * 60 * 1000; // 1 hour on success
const TTL_ERROR_MS = 5 * 60 * 1000; // 5 min on error (avoid hammering)
const FETCH_TIMEOUT_MS = 8_000;
@ -39,6 +48,22 @@ function compareVersions(a, b) {
async function checkForUpdates(force = false) {
const now = Date.now();
// Opt-out: no external request when disabled by the admin.
if (!updateCheckEnabled()) {
return {
current_version: getCurrentVersion(),
latest_version: null,
up_to_date: null,
has_update: false,
latest_release_url: null,
published_at: null,
last_checked_at: null,
error: null,
disabled: true,
cached: false,
};
}
if (!force && _cache.result && now < _cache.expiresAt) {
return { ..._cache.result, cached: true };
}
@ -113,4 +138,4 @@ async function checkForUpdates(force = false) {
}
}
module.exports = { checkForUpdates };
module.exports = { checkForUpdates, updateCheckEnabled };

View File

@ -0,0 +1,60 @@
'use strict';
// B16 / QA-B16-01: the external version check must be opt-out-able. When the
// `update_check_enabled` setting is 'false', checkForUpdates must make NO network
// request and report `disabled`. Default (unset/'true') performs the check.
const test = require('node:test');
const assert = require('node:assert/strict');
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const dbPath = path.join(os.tmpdir(), `bill-tracker-updatecheck-test-${process.pid}.sqlite`);
process.env.DB_PATH = dbPath;
const { getDb, setSetting, closeDb } = require('../db/database');
const svc = require('../services/updateCheckService');
test('disabled: makes no external request and returns disabled', async () => {
getDb();
setSetting('update_check_enabled', 'false');
assert.equal(svc.updateCheckEnabled(), false);
const orig = global.fetch;
let called = false;
global.fetch = async () => { called = true; throw new Error('fetch must not be called when disabled'); };
try {
const r = await svc.checkForUpdates(true);
assert.equal(r.disabled, true, 'reports disabled');
assert.equal(called, false, 'no external fetch when disabled');
} finally {
global.fetch = orig;
}
});
test('enabled (default): performs the version check', async () => {
setSetting('update_check_enabled', 'true');
assert.equal(svc.updateCheckEnabled(), true);
const orig = global.fetch;
global.fetch = async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: 'v99.0.0', html_url: 'https://example/rel', published_at: '2026-01-01' }),
});
try {
const r = await svc.checkForUpdates(true);
assert.notEqual(r.disabled, true);
assert.equal(r.latest_version, '99.0.0');
assert.equal(r.has_update, true);
} finally {
global.fetch = orig;
}
});
test.after(() => {
closeDb();
for (const suffix of ['', '-wal', '-shm']) {
try { fs.rmSync(dbPath + suffix); } catch { /* ignore */ }
}
});