mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Three changes, all about the same failure: sharing appears to be on while nothing actually arrives. SEND ON OPT-IN. Turning sharing on now reports immediately instead of waiting for the next daily tick. Two reasons: the operator is standing right there, and "nothing has been sent" for the next 24h reads as broken at exactly the moment someone is checking whether it works. It also means an egress-filtered network fails HERE, where we can name the host to unblock, rather than silently tonight where nobody is watching. NAME THE FAILURE. Failed attempts are now recorded separately from successes, so Settings can say which address did not answer and why, instead of showing an empty "nothing sent yet". A blocked outbound connection is the normal failure on a self-hosted box and is otherwise completely invisible — the operator cannot tell a firewall from a broken feature. A later success clears the complaint, so a stale warning never outlives the problem it describes. Docs gained a section on it, and the UI states plainly that nothing needs opening inbound. OPERATOR COLLECTOR, ADDITIVE. TELEMETRY_EXTRA_ENDPOINT lets an operator post the same three fields to their own collector. The naming is the point. It replaces TELEMETRY_ENDPOINT, which was a true override — and an override is the wrong shape here, because a variable called "endpoint" that silently redirected the report someone agreed to SHARE would make the opt-in mean something other than what the UI says. Our address is hard-wired and not overridable; theirs is explicitly additional and named so it cannot be mistaken for a replacement. Settings lists every destination a report goes to. The operator collector is independent of the sharing switch, because it is their server posting to their host and our opt-in has no business gating it. So an operator who wants internal fleet numbers with nothing leaving for us sets it and leaves sharing off — supported on purpose, and tested. Destinations are attempted separately: one unreachable collector must not cost the other its report. 1662/1662 pass. Tests pin the properties that matter: an operator collector never replaces the shared report, sharing-off still sends nothing to us whatever else is configured, one dead destination does not stop the other, and a failure records the address actually tried.
233 lines
10 KiB
JavaScript
233 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
// Opt-in install statistics. The promises this feature makes are all negative ones — it does not
|
|
// send until asked, it does not send more than three fields, it does not ask twice — and a
|
|
// negative promise is exactly the kind that rots silently. These bites pin each one.
|
|
|
|
const { test, after, mock } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
|
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-'));
|
|
process.env.DATA_DIR = tmp;
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
const { db } = require('../db/database');
|
|
const appSettings = require('../lib/app-settings');
|
|
const telemetry = require('../lib/telemetry');
|
|
|
|
after(() => { telemetry.stop(); fs.rmSync(tmp, { recursive: true, force: true }); });
|
|
|
|
function reset() {
|
|
db.prepare('DELETE FROM app_settings').run();
|
|
appSettings.__reload();
|
|
}
|
|
|
|
test('an install that has not been asked reports nothing', async () => {
|
|
reset();
|
|
assert.equal(telemetry.state(), 'unasked');
|
|
|
|
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); });
|
|
try {
|
|
const r = await telemetry.report(db);
|
|
assert.equal(r.sent, false); assert.equal(r.reason, 'not_enabled');
|
|
assert.equal(spy.mock.callCount(), 0, 'no outbound request may be made before consent');
|
|
} finally { spy.mock.restore(); }
|
|
});
|
|
|
|
test('declining is remembered, so the prompt does not return after an update', () => {
|
|
reset();
|
|
telemetry.setEnabled(false);
|
|
assert.equal(telemetry.state(), 'off', 'a decline must persist as off, never fall back to unasked');
|
|
appSettings.__reload(); // survives a restart
|
|
assert.equal(telemetry.state(), 'off');
|
|
});
|
|
|
|
test('a declined install still reports nothing', async () => {
|
|
reset();
|
|
telemetry.setEnabled(false);
|
|
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('must not be called'); });
|
|
try {
|
|
const d = await telemetry.report(db); assert.equal(d.sent, false); assert.equal(d.reason, 'not_enabled');
|
|
assert.equal(spy.mock.callCount(), 0);
|
|
} finally { spy.mock.restore(); }
|
|
});
|
|
|
|
test('the payload is exactly three fields, and no more', async () => {
|
|
reset();
|
|
const body = telemetry.payload(db);
|
|
assert.deepEqual(Object.keys(body).sort(), ['instance_id', 'screen_count', 'version'],
|
|
'adding a field here is a privacy decision, not a refactor — it must fail this test first');
|
|
assert.match(body.instance_id, /^[0-9a-f-]{36}$/i);
|
|
assert.equal(typeof body.version, 'string');
|
|
assert.equal(typeof body.screen_count, 'number');
|
|
});
|
|
|
|
test('the instance id is stable across reads and restarts', () => {
|
|
reset();
|
|
const first = telemetry.instanceId();
|
|
assert.equal(telemetry.instanceId(), first, 'must not mint a new id per call');
|
|
appSettings.__reload();
|
|
assert.equal(telemetry.instanceId(), first, 'must survive a restart, or every install counts twice');
|
|
});
|
|
|
|
test('screen_count counts paired displays, not provisioning rows', () => {
|
|
reset();
|
|
db.prepare('DELETE FROM devices').run();
|
|
const ins = db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES (?, ?, ?, ?, 'offline')");
|
|
ins.run('d1', 'One', '111111', 'tok1');
|
|
ins.run('d2', 'Two', '222222', 'tok2');
|
|
// Never paired: a provisioning row nobody connected is not a deployed screen.
|
|
db.prepare("INSERT INTO devices (id, name, pairing_code, device_token, status) VALUES ('d3','Three','333333',NULL,'offline')").run();
|
|
|
|
assert.equal(telemetry.payload(db).screen_count, 2);
|
|
db.prepare('DELETE FROM devices').run();
|
|
});
|
|
|
|
test('when enabled it sends exactly the payload, and records what it sent', async () => {
|
|
reset();
|
|
telemetry.setEnabled(true);
|
|
|
|
let seen = null;
|
|
const spy = mock.method(globalThis, 'fetch', async (url, opts) => {
|
|
seen = { url, body: JSON.parse(opts.body), method: opts.method };
|
|
return { ok: true, status: 200 };
|
|
});
|
|
try {
|
|
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
|
assert.equal(r.sent, true);
|
|
assert.equal(seen.method, 'POST');
|
|
assert.equal(seen.url, 'https://example.test/report');
|
|
assert.deepEqual(Object.keys(seen.body).sort(), ['instance_id', 'screen_count', 'version'],
|
|
'the bytes on the wire must match the audited payload, not a superset');
|
|
|
|
// An operator can check rather than trust: what was sent is retrievable verbatim.
|
|
const last = telemetry.getLastReport();
|
|
assert.deepEqual(last.body, seen.body);
|
|
assert.equal(typeof last.at, 'number');
|
|
} finally { spy.mock.restore(); }
|
|
});
|
|
|
|
test('a blocked outbound connection is recorded, with the address that was blocked', async () => {
|
|
// Egress filtering is the normal failure on a self-hosted box and is otherwise invisible: the
|
|
// operator sees nothing arriving and cannot tell a firewall from a broken feature. The UI can
|
|
// only name the host to allowlist if the failure is recorded here.
|
|
reset();
|
|
telemetry.setEnabled(true);
|
|
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
|
|
try {
|
|
await telemetry.report(db, { urls: [{ url: 'https://stats.example.test/report', kind: 'screentinker' }] });
|
|
const err = telemetry.getLastError();
|
|
assert.ok(err, 'a failed attempt must be recorded, or the operator has nothing to act on');
|
|
assert.equal(err.reason, 'network');
|
|
assert.equal(err.url, 'https://stats.example.test/report', 'must record the address actually tried');
|
|
assert.equal(typeof err.at, 'number');
|
|
} finally { spy.mock.restore(); }
|
|
});
|
|
|
|
test('a later success clears the stale failure', async () => {
|
|
reset();
|
|
telemetry.setEnabled(true);
|
|
const bad = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
|
|
try { await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] }); } finally { bad.mock.restore(); }
|
|
assert.ok(telemetry.getLastError(), 'precondition: a failure was recorded');
|
|
|
|
const good = mock.method(globalThis, 'fetch', async () => ({ ok: true, status: 200 }));
|
|
try {
|
|
await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
|
assert.equal(telemetry.getLastError(), null,
|
|
'a stale firewall warning must not outlive the problem it describes');
|
|
} finally { good.mock.restore(); }
|
|
});
|
|
|
|
test('an operator collector is ADDITIONAL — it never replaces the shared report', async () => {
|
|
// The whole point of naming it EXTRA rather than ENDPOINT: configuring your own collector must
|
|
// not silently redirect the report the operator agreed to share. If this ever becomes a
|
|
// redirect, the opt-in stops meaning what the UI says it means.
|
|
reset();
|
|
telemetry.setEnabled(true);
|
|
const original = process.env.TELEMETRY_EXTRA_ENDPOINT;
|
|
process.env.TELEMETRY_EXTRA_ENDPOINT = 'https://mine.example.test/collect';
|
|
try {
|
|
const dests = telemetry.destinations();
|
|
assert.equal(dests.length, 2, 'sharing on + own collector = both, never one');
|
|
assert.deepEqual(dests.map(d => d.kind).sort(), ['extra', 'screentinker']);
|
|
|
|
const hits = [];
|
|
const spy = mock.method(globalThis, 'fetch', async (url) => { hits.push(url); return { ok: true, status: 200 }; });
|
|
try {
|
|
await telemetry.report(db);
|
|
assert.equal(hits.length, 2, 'both destinations must receive the report');
|
|
assert.ok(hits.includes('https://mine.example.test/collect'));
|
|
assert.ok(hits.some(u => u.includes('screentinker.com')), 'the shared report must still be sent');
|
|
} finally { spy.mock.restore(); }
|
|
} finally {
|
|
if (original === undefined) delete process.env.TELEMETRY_EXTRA_ENDPOINT;
|
|
else process.env.TELEMETRY_EXTRA_ENDPOINT = original;
|
|
}
|
|
});
|
|
|
|
test('an operator can keep their own statistics while sharing nothing with us', async () => {
|
|
// Someone who wants internal fleet numbers but nothing leaving for us sets their own collector
|
|
// and leaves sharing off. Supported on purpose: it is their server posting to their host.
|
|
reset();
|
|
telemetry.setEnabled(false);
|
|
const original = process.env.TELEMETRY_EXTRA_ENDPOINT;
|
|
process.env.TELEMETRY_EXTRA_ENDPOINT = 'https://mine.example.test/collect';
|
|
try {
|
|
const hits = [];
|
|
const spy = mock.method(globalThis, 'fetch', async (url) => { hits.push(url); return { ok: true, status: 200 }; });
|
|
try {
|
|
await telemetry.report(db);
|
|
assert.deepEqual(hits, ['https://mine.example.test/collect']);
|
|
assert.ok(!hits.some(u => u.includes('screentinker.com')),
|
|
'sharing is off — nothing may reach us, whatever else is configured');
|
|
} finally { spy.mock.restore(); }
|
|
} finally {
|
|
if (original === undefined) delete process.env.TELEMETRY_EXTRA_ENDPOINT;
|
|
else process.env.TELEMETRY_EXTRA_ENDPOINT = original;
|
|
}
|
|
});
|
|
|
|
test('one unreachable destination does not stop the other', async () => {
|
|
reset();
|
|
telemetry.setEnabled(true);
|
|
const spy = mock.method(globalThis, 'fetch', async (url) => {
|
|
if (url.includes('broken')) throw new Error('ECONNREFUSED');
|
|
return { ok: true, status: 200 };
|
|
});
|
|
try {
|
|
const r = await telemetry.report(db, { urls: [
|
|
{ url: 'https://broken.example.test/a', kind: 'extra' },
|
|
{ url: 'https://working.example.test/b', kind: 'screentinker' },
|
|
] });
|
|
assert.equal(r.results.filter(x => x.sent).length, 1, 'the reachable one still receives it');
|
|
assert.equal(r.results.filter(x => !x.sent).length, 1);
|
|
assert.equal(telemetry.getLastError().url, 'https://broken.example.test/a',
|
|
'the failure names the destination that actually failed');
|
|
} finally { spy.mock.restore(); }
|
|
});
|
|
|
|
test('a failed send is quiet and local — never throws, never records a phantom report', async () => {
|
|
reset();
|
|
telemetry.setEnabled(true);
|
|
const spy = mock.method(globalThis, 'fetch', async () => { throw new Error('ECONNREFUSED'); });
|
|
try {
|
|
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
|
assert.equal(r.sent, false);
|
|
assert.equal(r.reason, 'network');
|
|
assert.equal(telemetry.getLastReport(), null, 'a failed send must not look like a successful one');
|
|
} finally { spy.mock.restore(); }
|
|
|
|
// An HTTP error is likewise not a success.
|
|
const spy2 = mock.method(globalThis, 'fetch', async () => ({ ok: false, status: 503 }));
|
|
try {
|
|
const r = await telemetry.report(db, { urls: [{ url: 'https://example.test/report', kind: 'screentinker' }] });
|
|
assert.equal(r.sent, false);
|
|
assert.equal(r.reason, 'http_503');
|
|
assert.equal(telemetry.getLastReport(), null);
|
|
} finally { spy2.mock.restore(); }
|
|
});
|