openvibers/openvibe.network Commits

Keep probe and test accounts off Network's public surfaces (#839)

OP OpenVibers committed 2 days ago · 355b6f30d9 · 16 files, +143 −21

* Mark probe and test accounts and keep them off Network's public surfaces

* Rerun CI (perf-budget flake: 210.8 KB vs 210.5 KB on unchanged HTML)

Changes

ddiff --git a/migrations/0075_test_accounts.sql b/migrations/0075_test_accounts.sql
nnew file mode 100644
iindex 00000000..500ba4ab
---- /dev/null
++++ b/migrations/0075_test_accounts.sql
@@@ -0,0 +1,16 @@
++-- phase: expand
++-- Test accounts: the production probes and the accounts older manual tests signed up. A test account is a real
++-- account (it can sign in, act and be tested as itself) that must never reach a public surface: member lists, feeds,
++-- leaderboards and the account counts. The column is the authority; OpenVibe.Network's /api/auth/register sets it for
++-- every account whose name starts with ovprobe_ (the probe tooling's sign-up path), and this migration marks the
++-- accounts that existed before the column did. Matching is on the exact names below, never a pattern, so a person
++-- whose name merely looks like a test is not hidden.
++ALTER TABLE users ADD COLUMN IF NOT EXISTS is_test bigint NOT NULL DEFAULT 0;
++
++UPDATE users SET is_test = 1, updated_at = ov_now()
++WHERE lower(username) IN (
++ 'ovprobe_renamed', 'ovprobe_revoke2', 'ovprobe_revoke3', 'ovprobe_modules', 'ovprobe_modules2',
++ 'ovprobe_devpath', 'ovprobe_publish', 'ovprobe_rt1', 'ovprobe_rt2', 'ovprobe_rt3', 'ovprobe_rt4', 'ovprobe_rt5',
++ 'ovprobe_merge_keep', 'ovprobe_merge_fold', 'ovprobe_worker_other', 'ovprobe_call',
++ 'testing_tester', 'testing_testing', 'testing_testers', 'Testtestuser123'
++) AND is_test = 0;
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex ac4276da..f29a1023 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -171,9 +171,9 @@ router.post('/register', async (req, res) => {
const passwordHash = bcrypt.hashSync(password, 10);
const result = await db.prepare(`
-- INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id)
-- VALUES (?, ?, ?, ?, '#8b5cf6', ?) RETURNING id
-- `).run(username, email || null, passwordHash, username, subjects.newUserSubjectId());
++ INSERT INTO users (username, email, password_hash, display_name, profile_color, subject_id, is_test)
++ VALUES (?, ?, ?, ?, '#8b5cf6', ?, ?) RETURNING id
++ `).run(username, email || null, passwordHash, username, subjects.newUserSubjectId(), subjects.isTestUsername(username) ? 1 : 0);
// Create default preferences
await db.prepare('INSERT INTO user_preferences (user_id) VALUES (?) ON CONFLICT DO NOTHING').run(result.lastInsertRowid);
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 0a88c8cd..890d3247 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -509,7 +509,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const out = [];
try {
const since = new Date(nowMs - 7 * 86_400_000).toISOString().slice(0, 19).replace('T', ' ');
-- const r = await db.prepare("SELECT COUNT(*) AS n FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(deleted_at, '') = '' AND created_at >= ?").get(since);
++ const r = await db.prepare("SELECT COUNT(*) AS n FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(deleted_at, '') = '' AND COALESCE(is_test, 0) = 0 AND created_at >= ?").get(since);
const n = r ? Number(r.n) : NaN;
if (Number.isFinite(n) && n >= 0) out.push({ id: 'joined', label: 'Joined this week', value: n, icon: 'members', url: 'https://openvibe.network/stats' });
} catch { /* the count is optional */ }
@@@ -530,16 +530,18 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
let rows = [];
if (subjects.length) {
try {
-- rows = await db.prepare(`SELECT u.username, u.display_name, u.subject_id, p.show_status, p.state
++ rows = await db.prepare(`SELECT u.username, u.display_name, u.subject_id, u.is_test, p.show_status, p.state
FROM person_presence p JOIN users u ON u.subject_id = p.subject_id
WHERE p.subject_id = ANY(?) AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.deleted_at, '') = ''`).all(subjects);
} catch { rows = []; }
}
const bySubject = new Map(rows.map((r) => [r.subject_id, r]));
++ const testSeen = rows.filter((r) => Number(r.is_test)).length;
const visible = [];
for (const [subject, entry] of snap.members) {
const r = bySubject.get(subject);
-- if (!r) continue;
++ // A test account (the probes) is never a public member: it is counted out, not just unnamed.
++ if (!r || Number(r.is_test)) continue;
// The person's own presence choice decides whether they may be named: the "Show my status"
// switch off, or Invisible, means counted among members and never in the list.
if (!Number(r.show_status) || r.state === 'invisible') continue;
@@@ -552,10 +554,11 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
visible.sort((a, b) => b.at - a.at);
const live = await readLive(nowMs);
const views = Object.entries(rank.scores || {}).filter(([k]) => !k.includes(':')).reduce((a, [, n]) => a + (Number(n) || 0), 0);
++ const members = Math.max(0, snap.members.size - testSeen);
onlineBase = {
-- members: snap.members.size,
++ members,
guests: snap.guests,
-- total: snap.members.size + snap.guests,
++ total: members + snap.guests,
visible,
counts: await countsOf(nowMs),
live: live ? { channels: live.channels, watching: live.watching, first: live.list.slice(0, 3).map((s) => ({ name: s.name, url: s.url })) } : { channels: 0, watching: 0, first: [] },
ddiff --git a/server/identity/internal-routes.js b/server/identity/internal-routes.js
iindex dbcb49f1..823518b7 100644
---- a/server/identity/internal-routes.js
++++ b/server/identity/internal-routes.js
@@@ -41,6 +41,16 @@ router.get('/resolve', async (req, res) => {
* -> { results: { <subject_id | id>: projection | null } }
* Used by services that show authors (Community pastes) and by importers mapping legacy ids.
*/
++/**
++ * GET /internal/identity/test-subjects
++ * -> { subjects: [identity.subject-projection@1] }
++ * The probe and test accounts (users.is_test), so a service can leave them out of a public member list, feed or
++ * leaderboard without asking about everyone. Small by design; services cache it and refresh on a timer.
++ */
++router.get('/test-subjects', async (req, res) => {
++ res.json({ subjects: await subjects.testProjections(req.app.locals.db) });
++});
++
router.post('/resolve-batch', express.json({ limit: '256kb' }), async (req, res) => {
const db = req.app.locals.db;
const b = req.body || {};
ddiff --git a/server/identity/subjects.js b/server/identity/subjects.js
iindex a20a2c0c..6df1f381 100644
---- a/server/identity/subjects.js
++++ b/server/identity/subjects.js
@@@ -14,6 +14,15 @@ const { ids, validate } = require('openvibe-contracts');
const SELF = 'network';
++/**
++ * The probe tooling signs its accounts up through the ordinary /api/auth/register form with this prefix. A name
++ * starting with it is a test account forever: `is_test` is what every public surface filters on, so the prefix is
++ * the one way a new probe account can be created already marked (server/auth/routes.js). Ordinary people never see
++ * or claim these names on a public list.
++ */
++const TEST_USERNAME_PREFIX = 'ovprobe_';
++const isTestUsername = (username) => String(username || '').toLowerCase().startsWith(TEST_USERNAME_PREFIX);
++
/** SQLite ov_now() ('YYYY-MM-DD HH:MM:SS', UTC) -> ms, so backfilled ULIDs sort by account age. */
function createdMs(value) {
const t = value ? Date.parse(String(value).replace(' ', 'T') + (/[zZ]|[+-]\d\d:?\d\d$/.test(value) ? '' : 'Z')) : NaN;
@@@ -93,9 +102,9 @@ async function projection(db, subjectId) {
const u = await db.prepare('SELECT * FROM users WHERE subject_id = ?').get(subjectId);
if (!u) return null;
// A deleted account (ADR-033) resolves as a tombstone, so services show "Deleted account" and keep nothing.
-- if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, deleted: true };
++ if (u.deleted_at) return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: 'Deleted account', avatar_url: null, banned: false, is_test: Boolean(u.is_test), deleted: true };
return { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,
-- display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned) };
++ display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, banned: Boolean(u.is_banned), is_test: Boolean(u.is_test) };
}
if (subjectId.startsWith('gst_')) {
const g = await db.prepare('SELECT id, subject_id, anon_number, display_name FROM anon_users WHERE subject_id = ?').get(subjectId);
@@@ -106,6 +115,16 @@ async function projection(db, subjectId) {
return null;
}
++/** Every test account's projection, for the services that must leave them out of public surfaces. */
++async function testProjections(db) {
++ const rows = await db.prepare('SELECT * FROM users WHERE COALESCE(is_test, 0) = 1 ORDER BY id').all();
++ return rows.map((u) => ({
++ subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username,
++ display_name: u.display_name || u.username, avatar_url: u.avatar_url || null,
++ banned: Boolean(u.is_banned), is_test: true,
++ })).filter((p) => p.subject.id);
++}
++
/** Resolve by subject id or by (system, type, id). Returns the projection plus the matching legacy ids. */
async function resolve(db, { subject_id, source_system, source_type, source_id }) {
let sid = subject_id;
@@@ -177,4 +196,4 @@ async function upsertLegacy(db, entries) {
return out;
}
--module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, resolve, upsertLegacy, createdMs, resolveUsername };
++module.exports = { ensureSchema, backfill, seedLegacyMap, ensureUserSubject, newUserSubjectId, newGuestSubjectId, projection, testProjections, resolve, upsertLegacy, createdMs, resolveUsername, TEST_USERNAME_PREFIX, isTestUsername };
ddiff --git a/server/index.js b/server/index.js
iindex 4a6dd94b..7784acef 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1101,7 +1101,7 @@ require('./home/activity').configure({
const d = await openStats.collect(7);
return d && Array.isArray(d.sites) ? Object.fromEntries(d.sites.map((s) => [s.id, Number(s.pageviews) || 0])) : null;
},
-- accounts: async () => (await db.prepare('SELECT COUNT(*) AS cnt FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0').get()).cnt,
++ accounts: async () => (await db.prepare('SELECT COUNT(*) AS cnt FROM users WHERE COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0').get()).cnt,
services: () => {
const live = require('./status/routes').rows(ecosystem).filter((r) => r.exposure && r.exposure.state === 'live');
const known = live.filter((r) => r.status !== 'unknown');
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex bd8d5760..8830a730 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -52,6 +52,7 @@ router.use('/chat-app-links', principals.guard('identity.subject.resolve'), requ
// Identity lookups accept a service token with identity.subject.resolve (or the key, as before).
router.get('/identity/resolve', principals.guard('identity.subject.resolve'));
router.post('/identity/resolve-batch', principals.guard('identity.subject.resolve'));
++router.get('/identity/test-subjects', principals.guard('identity.subject.resolve'));
// Writing the legacy map is part of identity resolution (ADR-001; identity.subject.resolve takes
// identity.legacy-identity-map@1). TODO(contracts): a narrower identity.legacy_map.write capability.
router.post('/identity/legacy-map', principals.guard('identity.subject.resolve'), ownSourceSystem);
ddiff --git a/server/levels/store.js b/server/levels/store.js
iindex 75153670..63422b4c 100644
---- a/server/levels/store.js
++++ b/server/levels/store.js
@@@ -72,7 +72,8 @@ async function board(db, userId) {
FROM users u LEFT JOIN person_levels l ON l.user_id = u.id
${weekly ? "LEFT JOIN (SELECT user_id, SUM(amount) AS xp FROM experience_events WHERE earned_at >= date_trunc('week', CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AT TIME ZONE 'UTC' GROUP BY user_id) w ON w.user_id = u.id" : ''}
WHERE ${ACTIVE} AND u.subject_id IS NOT NULL AND COALESCE(l.leaderboard, true)
-- ) SELECT * FROM ranked WHERE rank <= 50 OR ABS(rank - (SELECT rank FROM ranked WHERE id = ?)) <= 5 ORDER BY rank`).all(userId || 0);
++ AND (COALESCE(u.is_test, 0) = 0 OR u.id = ?)
++ ) SELECT * FROM ranked WHERE rank <= 50 OR ABS(rank - (SELECT rank FROM ranked WHERE id = ?)) <= 5 ORDER BY rank`).all(userId || 0, userId || 0);
const me = rows.find(r => Number(r.id) === Number(userId));
const clean = r => ({ subject: r.subject, username: r.username, level: Number(r.level), xp: Number(r.xp), rank: Number(r.rank) });
return { top: rows.filter(r => Number(r.rank) <= 50).map(clean), me: me ? clean(me) : null, around: me ? rows.filter(r => Math.abs(Number(r.rank) - Number(me.rank)) <= 5).map(clean) : [] };
ddiff --git a/server/stats/open.js b/server/stats/open.js
iindex 51973030..46d6fcf9 100644
---- a/server/stats/open.js
++++ b/server/stats/open.js
@@@ -134,7 +134,7 @@ function createOpenStats({ db, selfToken = () => null, statusRows = () => [], pr
// ── Accounts and OpenCoins: Network's own tables ──
async function accounts(days) {
-- const real = 'COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0';
++ const real = 'COALESCE(is_anon, 0) = 0 AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0';
const since = (d) => new Date(now() - d * DAY).toISOString().slice(0, 19).replace('T', ' ');
const total = await one(`SELECT COUNT(*) AS n FROM users WHERE ${real}`);
if (total == null) return null;
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex d098f2e8..5979413a 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/frame-online.test.js b/test/frame-online.test.js
iindex c0849975..cc4e612c 100644
---- a/test/frame-online.test.js
++++ b/test/frame-online.test.js
@@@ -20,6 +20,7 @@ const ANN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPA';
const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPB';
const CAT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPC';
const DAN = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPD';
++const PROBE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPE';
const MIN = 60_000;
(async () => {
@@@ -31,13 +32,16 @@ const ago = (ms) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
let NOW = Date.parse('2026-11-10T10:00:00Z');
await db.prepare(`INSERT INTO users (id, username, display_name, password_hash, subject_id, role, created_at) VALUES
(1, 'ann', 'Ann', 'x', ?, 'user', ?), (2, 'bob', 'Bob', 'x', ?, 'streamer', ?),
-- (3, 'cat', 'Cat', 'x', ?, 'user', ?), (4, 'dan', 'Dan', 'x', ?, 'user', ?)`)
-- .run(ANN, ago(NOW - 2 * 86_400_000), BOB, ago(NOW - 3 * 86_400_000), CAT, ago(NOW - 4 * 86_400_000), DAN, ago(NOW - 40 * 86_400_000));
++ (3, 'cat', 'Cat', 'x', ?, 'user', ?), (4, 'dan', 'Dan', 'x', ?, 'user', ?),
++ (5, 'ovprobe_frame', 'Probe', 'x', ?, 'user', ?)`)
++ .run(ANN, ago(NOW - 2 * 86_400_000), BOB, ago(NOW - 3 * 86_400_000), CAT, ago(NOW - 4 * 86_400_000), DAN, ago(NOW - 40 * 86_400_000), PROBE, ago(NOW - 86_400_000));
++await db.prepare('UPDATE users SET is_test = 1 WHERE id = 5').run();
// Ann shows her status; Bob turned it off; Cat is invisible. Dan has never beaten (no presence row).
const ins = db.prepare(`INSERT INTO person_presence (subject_id, state, show_status, last_active_at, last_heartbeat_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`);
await ins.run(ANN, 'auto', 1, ago(NOW), ago(NOW), ago(NOW));
await ins.run(BOB, 'auto', 0, ago(NOW), ago(NOW), ago(NOW));
await ins.run(CAT, 'invisible', 1, ago(NOW), ago(NOW), ago(NOW));
++await ins.run(PROBE, 'auto', 1, ago(NOW), ago(NOW), ago(NOW));
// A Live edge whose list the test controls; Live is the only service /online reads over the network.
let liveRows = [{ username: 'eve', display_name: 'Eve', is_live: 1, total_viewer_count: 7 },
@@@ -79,11 +83,13 @@ seen.seenMember(ANN, 'live', NOW - MIN);
seen.seenMember(BOB, 'games', NOW - 2 * MIN);
seen.seenMember(CAT, 'live', NOW - 3 * MIN);
seen.seenMember(DAN, 'community', NOW - 4 * MIN); // seen, but no presence row: counted, not named
++seen.seenMember(PROBE, 'live', NOW); // a test account: counted out and never named
let o = await svc.online('live');
--assert.strictEqual(o.members, 4, 'four signed-in people were seen, including the two who hide');
++assert.strictEqual(o.members, 4, 'four signed-in people were seen, including the two who hide; the test account is not one of them');
assert.strictEqual(o.guests, 0);
--assert.deepStrictEqual(o.people.map((p) => p.username), ['ann'], 'only Ann may be named');
++assert.deepStrictEqual(o.people.map((p) => p.username), ['ann'], 'only Ann may be named; a test account never is');
++assert.ok(!JSON.stringify(o).includes('ovprobe_frame'), 'the test account is nowhere in the public read');
assert.deepStrictEqual(o.people[0], { name: 'Ann', username: 'ann', avatar: 'https://openvibe.network/avatar/ann?s=64', site: 'Live' });
assert.ok(!JSON.stringify(o).includes('bob') && !JSON.stringify(o).includes('cat'), 'a hidden member is never named');
assert.strictEqual(o.more, 0);
ddiff --git a/test/identity-subjects.test.js b/test/identity-subjects.test.js
iindex b40da8c4..3a09e20f 100644
---- a/test/identity-subjects.test.js
++++ b/test/identity-subjects.test.js
@@@ -55,6 +55,15 @@ assert.strictEqual((await subjects.resolve(db, { subject_id: guest.subject_id })
assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '321' })).subject.id, beth.subject_id, 'seeded from a reported Live link');
assert.strictEqual((await db.prepare("SELECT COUNT(*) n FROM identity_legacy_map WHERE source_system = 'tools'").get()).n, 0, "OAuth 'network:<id>' links say nothing about site ids");
assert.strictEqual(await subjects.resolve(db, { source_system: 'live', source_id: '999' }), null);
++assert.strictEqual((await subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 1 })).is_test, false, 'a person is not a test account');
++
++// Test accounts: the flag is in every projection, and the internal list names only the test accounts.
++await db.prepare("INSERT INTO users (id, username, password_hash, is_test) VALUES (4, 'ovprobe_merge_keep', 'x', 1)").run();
++const probeProjection = await subjects.resolve(db, { source_system: 'network', source_type: 'user', source_id: 4 });
++assert.strictEqual(probeProjection.is_test, true, 'the projection says the account is a test one');
++assert.deepStrictEqual((await subjects.testProjections(db)).map((p) => p.username), ['ovprobe_merge_keep']);
++assert.ok(subjects.isTestUsername('ovprobe_call') && subjects.isTestUsername('OVPROBE_new'), 'the probe sign-up prefix marks a new account');
++assert.ok(!subjects.isTestUsername('alex') && !subjects.isTestUsername('probe_alex'), 'an ordinary name is not a test account');
// A row inserted by a path that forgot subject_id is fixed lazily.
await db.prepare("INSERT INTO users (id, username, password_hash) VALUES (3, 'cat', 'x')").run();
@@@ -132,6 +141,11 @@ const { signToken } = require('../server/auth/routes');
assert.strictEqual((await subjects.resolve(db, { source_system: 'live', source_id: '555' })).network_user_id, 3, '/internal/link-account also writes the legacy map');
h = await call('/legacy-map', { method: 'POST', body: JSON.stringify({ entries: [] }) });
assert.strictEqual(h.status, 400);
++ h = await call('/test-subjects');
++ assert.strictEqual(h.status, 200);
++ assert.deepStrictEqual(h.body.subjects.map((p) => [p.username, p.is_test]), [['ovprobe_merge_keep', true]]);
++ h = await fetch(base + '/test-subjects').then(async x => ({ status: x.status, body: await x.json() }));
++ assert.strictEqual(h.status, 401, 'the test-account list is internal');
h = await fetch(base + '/resolve?system=live&id=77').then(x => ({ status: x.status, code: x.headers.get('content-type') }));
assert.strictEqual(h.status, 401, 'no service token, no identity data');
h = await fetch(base + '/resolve?system=live&id=77', { headers: { 'x-internal-key': 'k-test' } }).then(async x => ({ status: x.status, body: await x.json() }));
ddiff --git a/test/levels.test.js b/test/levels.test.js
iindex 8d4b2bb5..666edc49 100644
---- a/test/levels.test.js
++++ b/test/levels.test.js
@@@ -36,6 +36,14 @@ const { backfill } = require('../scripts/backfill-levels');
const dry = await backfill(db); assert.equal(dry.awarded, 0); assert.equal(dry.existing, 1);
const applied = await backfill(db, { apply: true }); assert.equal(applied.awarded, 0);
await db.prepare('UPDATE users SET is_banned = 0 WHERE id = ?').run(other.id);
++ // A test account is left out of the public leaderboard, but its own signed-in board still shows it.
++ const probeSubject = ids.newId('user');
++ const probe = await db.prepare("INSERT INTO users(username, password_hash, subject_id, is_test) VALUES ('ovprobe_levels', 'unused', ?, 1) RETURNING id").get(probeSubject);
++ await levels.award(db, 'quest', { subject: probeSubject, reason: 'quest.completed', amount: 500, idempotency_key: 'probe-hi' });
++ const publicBoard = await levels.board(db, null);
++ assert.ok(!publicBoard.all_time.top.some((r) => r.subject === probeSubject), 'the public board leaves a test account out');
++ const ownBoard = await levels.board(db, probe.id);
++ assert.ok(ownBoard.all_time.me && ownBoard.all_time.me.subject === probeSubject, 'its own signed-in board still shows it');
console.log('levels: duplicate refused; earned coins once; refunds/transfers excluded; privacy, caps and dry-run passed');
process.exit(0);
})().catch(e => { console.error(e); process.exit(1); });
ddiff --git a/test/open-stats.test.js b/test/open-stats.test.js
iindex 54b09037..61b50973 100644
---- a/test/open-stats.test.js
++++ b/test/open-stats.test.js
@@@ -28,6 +28,8 @@ const { createOpenStats, render } = require('../server/stats/open');
await user.run(9003, 'osc', 'x', at(9), 0, 0);
await user.run(9004, 'osguest', 'x', at(1), 1, 0);
await user.run(9005, 'osbanned', 'x', at(1), 0, 1);
++ await user.run(9006, 'ovprobe_os', 'x', at(1), 0, 0);
++ await db.prepare('UPDATE users SET is_test = 1 WHERE id = 9006').run();
// Live's analytics and home numbers; Prometheus down.
const asked = [];
@@@ -55,7 +57,9 @@ const { createOpenStats, render } = require('../server/stats/open');
assert.ok(!d.sites.some((s) => s.host === 'evil.example'), 'only OpenVibe sites');
assert.strictEqual(d.sites[0].id, 'live', 'most visited first');
assert.strictEqual(d.totals.pageviews, d.sites.reduce((a, s) => a + (s.pageviews || 0), 0));
-- assert.strictEqual(d.accounts.new, 2, 'two new accounts this week (no guests, no banned)');
++ assert.strictEqual(d.accounts.new, 2, 'two new accounts this week (no guests, no banned, no test accounts)');
++ assert.strictEqual(d.accounts.total, 3, 'a test account is not in the public account count');
++ assert.strictEqual(d.accounts.daily.reduce((a, p) => a + p.n, 0), 2, 'the daily series leaves test accounts out too');
assert.strictEqual(d.accounts.prev, 1);
assert.deepStrictEqual(d.totals.services, { up: 1, checked: 2 });
assert.strictEqual(d.live.live_now, 2);
ddiff --git a/test/test-accounts.test.js b/test/test-accounts.test.js
nnew file mode 100644
iindex 00000000..7bbdc572
---- /dev/null
++++ b/test/test-accounts.test.js
@@@ -0,0 +1,40 @@
++'use strict';
++// Test accounts (migrations/0075_test_accounts.sql; server/auth/routes.js): a name the probe tooling signs up with
++// (ovprobe_) is created already marked is_test, and an ordinary account is not. The flag is the identity authority's
++// answer to every public surface's "leave test accounts out".
++// node test/test-accounts.test.js
++const assert = require('assert');
++const { bootServer } = require('./helpers/boot-server');
++
++(async () => {
++ const srv = await bootServer();
++ try {
++ const register = (username) => fetch(`${srv.base}/api/auth/register`, {
++ method: 'POST', headers: { 'content-type': 'application/json' },
++ body: JSON.stringify({ username, password: 'correct-horse-battery' }),
++ }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++ const db = srv.app.locals.db;
++ // The test's bootstrap profile may leave sign-ups closed; this test is about how a sign-up is marked.
++ await db.prepare("INSERT INTO site_settings (key, value, type) VALUES ('registration_open', 'true', 'boolean') ON CONFLICT (key) DO UPDATE SET value = 'true'").run();
++
++ let r = await register('ovprobe_registered');
++ assert.strictEqual(r.status, 201, `register answered ${r.status}`);
++ const probe = await db.prepare('SELECT username, is_test, subject_id FROM users WHERE lower(username) = ?').get('ovprobe_registered');
++ assert.strictEqual(Number(probe.is_test), 1, 'the probe sign-up path marks the account as a test account');
++ assert.ok(/^usr_/.test(probe.subject_id), 'it is still a real account with a subject');
++
++ r = await register('ordinary_person');
++ assert.strictEqual(r.status, 201);
++ const person = await db.prepare('SELECT username, is_test FROM users WHERE lower(username) = ?').get('ordinary_person');
++ assert.strictEqual(Number(person.is_test), 0, 'an ordinary account is never marked as a test account');
++
++ console.log('test accounts: the probe sign-up path marks is_test; an ordinary registration does not');
++ } catch (err) {
++ console.error(err);
++ console.error(srv.logs());
++ process.exitCode = 1;
++ } finally {
++ await srv.stop();
++ }
++ process.exit(process.exitCode || 0);
++})();
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 16320924..f006e22a 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -56,7 +56,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand'], ['0063', 'retire_forgejo_client', 'migrate'], ['0064', 'history_timeline', 'expand'], ['0065', 'levels', 'expand'], ['0066', 'authenticator', 'expand'], ['0067', 'passkeys', 'expand'], ['0068', 'link_send_resume', 'expand'], ['0069', 'user_card', 'expand'], ['0070', 'coin_balance_reads', 'expand'], ['0071', 'handoff_tickets', 'expand'], ['0072', 'composer_prefs', 'expand'], ['0073', 'card_actions', 'expand'], ['0074', 'channel_points_reads', 'expand'], ['0075', 'test_accounts', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
// 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.