openvibers/openvibe.live Commits

Leave test accounts out of Live's public people surfaces (#830)

OP OpenVibers committed 2 days ago · 3bb43def82 · 10 files, +190 −20

* Leave test accounts out of Live's public people surfaces

* Classify the test-account sync in the SSRF inventory

* Sync test accounts before Live opens its listener

Changes

ddiff --git a/migrations/0040_test_accounts.sql b/migrations/0040_test_accounts.sql
nnew file mode 100644
iindex 00000000..50396204
---- /dev/null
++++ b/migrations/0040_test_accounts.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- Test accounts (probe and older manual test accounts, OpenVibe.Network users.is_test). Live keeps the flag on the
++-- local user row so its public people surfaces (Recently Online, the directory, the home digest and the people
++-- counts) can leave them out in SQL. The projection cache keeps it too, so the sync can follow linked accounts by
++-- subject. Additive: an older release never reads either column.
++ALTER TABLE users ADD COLUMN IF NOT EXISTS is_test bigint NOT NULL DEFAULT 0;
++ALTER TABLE subject_projection ADD COLUMN IF NOT EXISTS is_test bigint NOT NULL DEFAULT 0;
ddiff --git a/server/auth/auth.js b/server/auth/auth.js
iindex 743a054d..aa9aa98d 100644
---- a/server/auth/auth.js
++++ b/server/auth/auth.js
@@@ -113,6 +113,9 @@ async function _syncSsoUserFields(user, decoded) {
&& _ROLE_RANK[decoded.role] > (_ROLE_RANK[user.role] ?? 0)) {
updates.push('role = ?'); params.push(decoded.role);
}
++ // The probe tooling's sign-up prefix (OpenVibe.Network /api/auth/register marks the same names is_test): a probe
++ // that signs in is marked at once, before the hourly identity list reaches this account.
++ if (/^ovprobe_/i.test(String(decoded.username || '')) && !Number(user.is_test)) updates.push('is_test = 1');
if (!updates.length) return user;
try {
params.push(user.id);
@@@ -169,6 +172,8 @@ async function resolveNetworkUser(decoded) {
if (decoded.role && ['user', 'streamer', 'global_mod', 'admin'].includes(decoded.role)) {
updates.push('role = ?'); params.push(decoded.role);
}
++ // A probe account is marked when it is created here too (server/auth/test-accounts.js keeps the list).
++ if (/^ovprobe_/i.test(String(decoded.username || ''))) updates.push('is_test = 1');
if (updates.length > 0) {
params.push(user.id);
await db.getDb().prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
ddiff --git a/server/auth/test-accounts.js b/server/auth/test-accounts.js
nnew file mode 100644
iindex 00000000..268d1133
---- /dev/null
++++ b/server/auth/test-accounts.js
@@@ -0,0 +1,75 @@
++'use strict';
++/**
++ * Test accounts on Live (OpenVibe.Network users.is_test).
++ *
++ * The probes sign in as real accounts, and Live's public people surfaces must never show them: Recently Online
++ * (the directory), the home digest's new-members faces and counts, and the hero's people counts. The identity
++ * authority lists its test accounts at GET /internal/identity/test-subjects (identity.subject.resolve); this module
++ * writes them into subject_projection with is_test = 1 and follows linked_accounts to the local users row, so the
++ * public queries filter `users.is_test` in SQL and never call Network while a page is read.
++ *
++ * A refresh replaces the whole flag set in one transaction: a subject the Network unmarks becomes ordinary again,
++ * and a failed refresh (Network down, no client secret) changes nothing, so the last good list keeps test accounts
++ * hidden. The account's own signed-in views are untouched: they read their own rows, not the public lists.
++ */
++const db = require('../db/database');
++const principal = require('../net/network-principal');
++const { OV_NETWORK_INTERNAL_URL } = require('../utils/notify');
++
++const REFRESH_MS = 60 * 60 * 1000;
++const TIMEOUT_MS = 8000;
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++let timer = null;
++let last = { at: null, count: 0, error: null };
++
++/** One full sync. Throws with a short, safe reason; never logs a token. */
++async function refresh({ fetchImpl = globalThis.fetch } = {}) {
++ const headers = await principal.headersFor('/internal/identity/test-subjects');
++ const res = await fetchImpl(`${OV_NETWORK_INTERNAL_URL}/internal/identity/test-subjects`, {
++ headers: { Accept: 'application/json', ...headers }, signal: AbortSignal.timeout(TIMEOUT_MS),
++ });
++ if (res.status === 401) principal.tokenRejected('401');
++ const body = await res.json().catch(() => null);
++ if (!res.ok || !body || !Array.isArray(body.subjects)) {
++ throw new Error(`Network ${res.status}${body && (body.code || body.error) ? `: ${body.code || body.error}` : ''}`);
++ }
++ const rows = body.subjects.filter((p) => p && p.subject && SUBJECT_RE.test(String(p.subject.id)))
++ .map((p) => ({ subject: p.subject.id, network_user_id: Number.isInteger(p.network_user_id) ? p.network_user_id : null,
++ username: typeof p.username === 'string' ? p.username.slice(0, 64) : null }));
++ const d = db.getDb();
++ await d.tx(async () => {
++ await d.prepare('UPDATE users SET is_test = 0 WHERE is_test <> 0').run();
++ await d.prepare('UPDATE subject_projection SET is_test = 0 WHERE is_test <> 0').run();
++ for (const r of rows) {
++ // A projection the events may not have sent yet: the row is enough for the link, revision 0 never beats an event.
++ await d.prepare(`INSERT INTO subject_projection (subject_id, network_user_id, revision, username, role, is_test)
++ VALUES (?, ?, 0, ?, 'user', 1)
++ ON CONFLICT (subject_id) DO UPDATE SET is_test = 1,
++ network_user_id = COALESCE(excluded.network_user_id, subject_projection.network_user_id)`)
++ .run(r.subject, r.network_user_id, r.username);
++ if (r.username) await d.prepare('UPDATE users SET is_test = 1 WHERE lower(username) = lower(?)').run(r.username);
++ }
++ // The subject link is the authority when a local name and the Network's current name differ (a rename).
++ await d.prepare(`UPDATE users SET is_test = 1 WHERE id IN (
++ SELECT la.user_id FROM linked_accounts la JOIN subject_projection sp ON sp.subject_id = la.subject_id
++ WHERE sp.is_test = 1)`).run();
++ });
++ last = { at: new Date().toISOString(), count: rows.length, error: null };
++ if (rows.length) console.log(`[Test accounts] ${rows.length} marked (users.is_test)`);
++ return rows.length;
++}
++
++/** Refresh now (unless the caller already awaited one), then hourly; a failure leaves the last good flags. */
++function start({ refreshNow = true } = {}) {
++ if (timer) return timer;
++ if (refreshNow) refresh().catch((err) => { last.error = err.message; console.warn('[Test accounts] refresh failed:', err.message); });
++ timer = setInterval(() => {
++ refresh().catch((err) => { last.error = err.message; console.warn('[Test accounts] refresh failed:', err.message); });
++ }, REFRESH_MS);
++ if (timer.unref) timer.unref();
++ return timer;
++}
++
++function stop() { if (timer) clearInterval(timer); timer = null; }
++
++module.exports = { refresh, start, stop, status: () => ({ ...last }), REFRESH_MS };
ddiff --git a/server/db/database.js b/server/db/database.js
iindex f714c88b..c4930f81 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -1428,7 +1428,7 @@ async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
FROM streams s
JOIN users u ON s.user_id = u.id
LEFT JOIN streamer_overviews o ON o.user_id = u.id
-- WHERE (s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1
++ WHERE ((s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1) AND COALESCE(u.is_test, 0) = 0
GROUP BY u.id, o.overview, o.overview_short
ORDER BY last_online_at DESC
LIMIT ? OFFSET ?
@@@ -1437,9 +1437,9 @@ async function getRecentlyOnlineStreamers(limit = 20, offset = 0) {
async function countRecentlyOnlineStreamers() {
return (await get(`
-- SELECT COUNT(DISTINCT user_id) AS count
-- FROM streams
-- WHERE (is_live = 0 AND ended_at IS NOT NULL) OR is_live = 1
++ SELECT COUNT(DISTINCT s.user_id) AS count
++ FROM streams s JOIN users u ON u.id = s.user_id
++ WHERE ((s.is_live = 0 AND s.ended_at IS NOT NULL) OR s.is_live = 1) AND COALESCE(u.is_test, 0) = 0
`))?.count || 0;
}
@@@ -1775,14 +1775,14 @@ async function _computeHomeStats() {
vods: null,
clips: null,
liveSessions: await c(`SELECT COUNT(*) AS count FROM streams`),
-- streamers: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL`),
++ streamers: await c(`SELECT COUNT(DISTINCT s.user_id) AS count FROM streams s JOIN users u ON u.id = s.user_id WHERE COALESCE(u.is_test, 0) = 0`),
// Distinct people who went live in the last seven days, and the seven before.
-- streamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-7 days')`),
-- prevStreamedWeek: await c(`SELECT COUNT(DISTINCT user_id) AS count FROM streams WHERE user_id IS NOT NULL AND created_at >= datetime('now', '-14 days') AND created_at < datetime('now', '-7 days')`),
++ streamedWeek: await c(`SELECT COUNT(DISTINCT s.user_id) AS count FROM streams s JOIN users u ON u.id = s.user_id WHERE COALESCE(u.is_test, 0) = 0 AND s.created_at >= datetime('now', '-7 days')`),
++ prevStreamedWeek: await c(`SELECT COUNT(DISTINCT s.user_id) AS count FROM streams s JOIN users u ON u.id = s.user_id WHERE COALESCE(u.is_test, 0) = 0 AND s.created_at >= datetime('now', '-14 days') AND s.created_at < datetime('now', '-7 days')`),
// OpenVibe.Chat owns chat_messages (roadmap T3): the total comes from Chat's read API.
// A Chat outage answers the cache, else null — never a 500 for the home page.
chatMessages: await (async () => { try { const s = await require('../chat/chat-reads').siteStatsPeek(); return s && s.messages != null ? s.messages : null; } catch { return null; } })(),
-- users: await c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0`),
++ users: await c(`SELECT COUNT(*) AS count FROM users WHERE COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0`),
anons: await c(`SELECT COUNT(*) AS count FROM anon_ip_mappings`),
follows: await c(`SELECT COUNT(*) AS count FROM follows`),
// No platform-wide emote total: OpenVibe.Chat owns `emotes` (roadmap T3) and exposes counts
ddiff --git a/server/home/routes.js b/server/home/routes.js
iindex 410cdef1..bc5f103b 100644
---- a/server/home/routes.js
++++ b/server/home/routes.js
@@@ -564,7 +564,7 @@ router.get('/digest', optionalAuth, async (req, res) => {
const uid = req.user ? req.user.id : null;
const followed = new Set(uid ? (await db.all('SELECT streamer_id FROM follows WHERE follower_id = ?', [uid]) || []).map(r => r.streamer_id) : []);
const liveNow = (await db.all(`SELECT u.id AS user_id, u.username, u.display_name, u.avatar_url, u.profile_color, s.title, s.viewer_count, s.started_at
-- FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0
++ FROM streams s JOIN users u ON u.id = s.user_id WHERE s.is_live = 1 AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0
AND NOT (s.content_labels->'labels' @> '["18+"]'::jsonb)
ORDER BY s.viewer_count DESC, s.started_at DESC LIMIT 10`) || []).map(r => ({ ...r, followed: followed.has(r.user_id) }));
const liveIds = new Set(liveNow.map(r => r.user_id));
@@@ -574,7 +574,7 @@ router.get('/digest', optionalAuth, async (req, res) => {
(SELECT title FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_title,
(SELECT id FROM streams t WHERE t.user_id = u.id AND t.started_at >= ? ORDER BY t.started_at DESC LIMIT 1) AS last_stream_id
FROM streams s JOIN users u ON u.id = s.user_id
-- WHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0
++ WHERE s.started_at >= ? AND s.is_live = 0 AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0
GROUP BY u.id ORDER BY hours DESC, last_at DESC LIMIT 14`, [sinceSql, sinceSql, sinceSql]) || [])
.filter(r => !liveIds.has(r.user_id));
const streamed = [];
@@@ -601,10 +601,10 @@ router.get('/digest', optionalAuth, async (req, res) => {
// rollback); a synchronous peek answers the last good count while Chat refreshes.
chat_lines: await (async () => { try { const s = await require('../chat/chat-reads').windowStatsPeek({ since: sinceMs }); return s ? s.messages : 0; } catch { return 0; } })(),
new_follows: Number((await one('SELECT COUNT(*) AS n FROM follows WHERE created_at >= ?', [sinceSql]) || {}).n || 0),
-- new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL', [sinceSql]) || {}).n || 0),
++ new_members: Number((await one('SELECT COUNT(*) AS n FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0 AND deleted_at IS NULL', [sinceSql]) || {}).n || 0),
takes: Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id
${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @since
-- AND COALESCE(u.is_banned, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { since: sinceSql, viewer: null }) || {}).n || 0),
++ AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { since: sinceSql, viewer: null }) || {}).n || 0),
};
const previous = new Date(now - 86400000).toISOString().replace('T', ' ').slice(0, 19);
const prior = new Date(now - 2 * 86400000).toISOString().replace('T', ' ').slice(0, 19);
@@@ -615,14 +615,14 @@ router.get('/digest', optionalAuth, async (req, res) => {
};
const publicTakes = async (start, end) => Number((await one(`SELECT COUNT(*) AS n FROM rundown_segments s JOIN users u ON u.id = s.user_id
${rundownRead.AUDIENCE_JOINS('s')} WHERE s.status = 'live' AND s.said_at >= @start AND s.said_at < @end
-- AND COALESCE(u.is_banned, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { start, end, viewer: null }) || {}).n || 0);
++ AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 AND ${rundownRead.VISIBLE('s')}`, { start, end, viewer: null }) || {}).n || 0);
const changes = {
streams: await changeFor('streams', 'started_at'),
takes: await publicTakes(previous, new Date(now).toISOString().replace('T', ' ').slice(0, 19)) - await publicTakes(prior, previous),
new_follows: await changeFor('follows', 'created_at'),
-- new_members: await changeFor('users', 'created_at', 'COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL'),
++ new_members: await changeFor('users', 'created_at', 'COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0 AND deleted_at IS NULL'),
};
-- const newPeople = (await db.all('SELECT username, display_name, avatar_url FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 3', [sinceSql]) || []);
++ const newPeople = (await db.all('SELECT username, display_name, avatar_url FROM users WHERE created_at >= ? AND COALESCE(is_banned, 0) = 0 AND COALESCE(is_test, 0) = 0 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 3', [sinceSql]) || []);
// A take is shown only when the stream is in this visit's window, the topic is live, and Rundown allows everyone to see it.
let topics = [];
try {
@@@ -632,14 +632,14 @@ router.get('/digest', optionalAuth, async (req, res) => {
JOIN users u ON u.id = s.user_id JOIN rundown_topics t ON t.id = s.topic_id
${AUDIENCE_JOINS('s')}
WHERE s.status = 'live' AND t.status = 'live' AND s.said_at >= @since AND stream.started_at >= @since
-- AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 AND ${VISIBLE('s')}
++ AND s.score >= 4 AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 AND ${VISIBLE('s')}
ORDER BY s.score DESC, s.said_at DESC LIMIT 40`, { since: sinceSql, viewer: null });
topics = selectTopics(rows);
} catch { topics = []; }
let nextStream = null;
if (!stats.streams && !stats.takes && !stats.new_members && !stats.new_follows && !stats.chat_lines) {
nextStream = await one(`SELECT a.title, a.starts_at, u.username, u.display_name FROM agent_stream_schedules a JOIN users u ON u.id = a.user_id
-- WHERE a.state = 'scheduled' AND a.visibility = 'public' AND a.starts_at > ? AND COALESCE(u.is_banned, 0) = 0 ORDER BY a.starts_at LIMIT 1`, [new Date(now).toISOString()]);
++ WHERE a.state = 'scheduled' AND a.visibility = 'public' AND a.starts_at > ? AND COALESCE(u.is_banned, 0) = 0 AND COALESCE(u.is_test, 0) = 0 ORDER BY a.starts_at LIMIT 1`, [new Date(now).toISOString()]);
}
const across = await acrossOpenVibe(sinceMs);
const digest = { since, signed_in: !!uid, liveNow, streamed, stats, topics, newPeople, changes, nextStream, across };
ddiff --git a/server/index.js b/server/index.js
iindex ac08708d..82753fbf 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -1524,6 +1524,15 @@ async function start() {
// 6f. VOD storage/offload is owned by OpenVibe.Media now — nothing to start here.
++ // 6h. Test accounts (Network users.is_test): mark the local users the public people surfaces must leave out.
++ // The first refresh is awaited before the listener opens, so the first public page already filters; a failure
++ // keeps the last good flags and the hourly timer still runs.
++ try {
++ const testAccounts = require('./auth/test-accounts');
++ await testAccounts.refresh().catch((err) => console.warn('[Test accounts] refresh failed:', err.message));
++ testAccounts.start({ refreshNow: false });
++ } catch (err) { console.warn('[Test accounts] not started:', err.message); }
++
// 7. Start HTTP server.
//
// Zero-downtime restarts: when systemd owns the listening socket (a .socket unit with
ddiff --git a/test/home-digest-rundown.test.js b/test/home-digest-rundown.test.js
iindex 78299ae5..ec677cfe 100644
---- a/test/home-digest-rundown.test.js
++++ b/test/home-digest-rundown.test.js
@@@ -30,12 +30,15 @@ const take = async (uid, sid, { title, score, quote = null, status = 'live', wit
[sid, uid, withTopic ? topic : null, title, `${title}.`, score, JSON.stringify(quote ? [{ text: quote, sec: 12, en: null }] : []), ago(150), status]);
const ann = await mk('annstreams'), bob = await mk('bobstreams'), ban = await mk('bannedone', { banned: true });
--const sa = await stream(ann), sb = await stream(bob), sx = await stream(ban);
++await db.run("INSERT INTO users (username, email, password_hash, display_name, stream_key, is_test) VALUES ('ovprobe_digest', 'ovprobe@x', 'x', 'ovprobe_digest', ?, 1)", ['ovprobe-digest'.padEnd(32, '0')]);
++const probe = (await db.get('SELECT id FROM users WHERE username = ?', ['ovprobe_digest'])).id;
++const sa = await stream(ann), sb = await stream(bob), sx = await stream(ban), sp = await stream(probe);
await take(ann, sa, { title: 'Autofill is a scam on support mains', score: 5, quote: 'autofill put me on support again chat' });
await take(bob, sb, { title: 'Bob on his new keyboard', score: 4, withTopic: false });
await take(bob, sb, { title: 'A quiet stretch', score: 3 });
await take(ann, sa, { title: 'Taken down', score: 5, quote: 'never shown', status: 'hidden' });
await take(ban, sx, { title: 'Banned streamer take', score: 5, quote: 'never shown either' });
++await take(probe, sp, { title: 'Test account take', score: 5, quote: 'a probe must not be public' });
const app = express();
app.use('/api/home', require('../server/home/routes'));
@@@ -51,6 +54,9 @@ try {
assert.ok(d.html.includes(`/rundown/e/${sa}#rd-take-${d.topics[0].id}`));
assert.ok(d.html.includes('What they talked about'));
assert.ok(d.html.includes('New here'));
++ assert.ok(!d.newPeople.some((p) => p.username === 'ovprobe_digest'), 'a test account is never a "New here" face');
++ assert.ok(!d.streamed.some((p) => p.username === 'ovprobe_digest'), 'a test account is never a "Who streamed" row');
++ assert.ok(!d.html.includes('ovprobe_digest') && !d.html.includes('Test account take'), 'the test account is nowhere in the digest');
assert.ok(!d.html.includes('Bob on his new keyboard'));
console.log('✅ the digest reads The Rundown');
} finally {
ddiff --git a/test/recently-online-cards.test.js b/test/recently-online-cards.test.js
iindex c864a492..9ce40380 100644
---- a/test/recently-online-cards.test.js
++++ b/test/recently-online-cards.test.js
@@@ -22,9 +22,11 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r
(async () => {
await db.initDb();
const mk = async (username) => { await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: username, stream_key: username.padEnd(32, '0') }); const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id; await db.ensureChannel(id); return id; };
-- const pic = await mk('picasso'), nov = await mk('novod'), liv = await mk('liveone');
++ const pic = await mk('picasso'), nov = await mk('novod'), liv = await mk('liveone'), probe = await mk('ovprobe_recently');
const slot = async (uid, slug) => Number((await db.run('INSERT INTO managed_streams (user_id, slug, title, protocol, stream_key, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ov_now(), ov_now()) RETURNING id', [uid, slug, `${slug} title`, 'webrtc', `${slug}-key-${uid}`])).lastInsertRowid);
-- const picSlot = await slot(pic, 'main'), livSlot = await slot(liv, 'main');
++ const picSlot = await slot(pic, 'main'), livSlot = await slot(liv, 'main'), probeSlot = await slot(probe, 'main');
++ // A test account (OpenVibe.Network users.is_test) with a stream must never be a public card.
++ await db.run('UPDATE users SET is_test = 1 WHERE id = ?', [probe]);
const stream = async (uid, ms, { live = false, startedAgo, endedAgo = null, thumb = null }) => await db.run(
`INSERT INTO streams (user_id, managed_stream_id, title, protocol, is_live, viewer_count, started_at, ended_at, thumbnail_url, created_at)
VALUES (?, ?, ?, 'webrtc', ?, 0, to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS'), ${live ? 'NULL' : "to_char(now() - make_interval(mins => ?), 'YYYY-MM-DD HH24:MI:SS')"}, ?, ov_now())`,
@@@ -36,6 +38,7 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r
await stream(nov, null, { startedAgo: 700, endedAgo: 650, thumb: '/api/thumbnails/stream-9103-3.jpg' });
// A live stream: its capture still exists.
await stream(liv, livSlot, { live: true, startedAgo: 10, thumb: '/api/thumbnails/stream-9104-live.jpg' });
++ await stream(probe, probeSlot, { startedAgo: 400, endedAgo: 350, thumb: '/api/thumbnails/stream-9105-probe.jpg' });
// A stored card line in the old shape (name first, colon, two sentences).
await db.run("INSERT INTO streamer_overviews (user_id, overview, overview_short, model, sources, generated_at) VALUES (?, ?, ?, 'x', '{}', ov_now())",
@@@ -63,6 +66,9 @@ const KEYS = ['streams_this_week', 'hours_this_week', 'usual_time', 'days_in_a_r
const by = (u) => body.streamers.find((s) => s.username === u);
const p = by('picasso'), n = by('novod'), l = by('liveone');
assert.ok(p && n && l, JSON.stringify(body.streamers.map((s) => s.username)));
++ assert.strictEqual(by('ovprobe_recently'), undefined, 'a test account is never on Recently Online');
++ assert.ok(!JSON.stringify(body).includes('ovprobe_recently'), 'the test account is nowhere in the public read');
++ assert.strictEqual(body.streamers.length, 3, 'the probe is counted out too');
// The replay rows picture the recording, not the deleted capture.
assert.strictEqual(p.recent_facts.streams.length, 2);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 6aae87f9..cd190798 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -360,6 +360,7 @@ async function check(name, fn) {
'server/utils/notify.js': 'OpenVibe.Network (configured)',
'server/auth/identity-sync.js': 'OpenVibe.Network (configured)',
'server/auth/account-data.js': 'OpenVibe.Network (configured): export parts and deletion confirmations',
++ 'server/auth/test-accounts.js': 'OpenVibe.Network (configured OV_NETWORK_INTERNAL_URL): the fixed /internal/identity/test-subjects path, no URL from a person',
'server/agent-streams/sharing.js': 'OpenVibe.Network (configured): the sharing system\'s name lookup and person resolution',
'server/rundown/citations.js': 'OpenVibe.Report public session-report list (OV_REPORT_URL, configured; fixed path, no URL from a person)',
'server/ai/viewer-quota.js': 'OpenVibe.AI (configured OV_AI_INTERNAL_URL): a streamer\'s daily AI-viewer cap',
ddiff --git a/test/test-accounts.test.js b/test/test-accounts.test.js
nnew file mode 100644
iindex 00000000..9ed195e6
---- /dev/null
++++ b/test/test-accounts.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++// Test accounts on Live (server/auth/test-accounts.js): the identity authority's is_test list becomes users.is_test,
++// follows linked accounts by subject, and replaces the whole set on success; a failed refresh keeps the last good
++// flags so a Network outage never makes a probe public again.
++// node --import ./test/helpers/pg-preload.mjs test/test-accounts.test.js
++const assert = require('assert');
++
++process.env.NODE_ENV = 'test';
++console.warn = () => {};
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++
++const db = require('../server/db/database');
++const principal = require('../server/net/network-principal');
++const testAccounts = require('../server/auth/test-accounts');
++
++const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
++
++(async () => {
++ await db.initDb();
++ const d = db.getDb();
++ const user = await d.prepare("INSERT INTO users (username, password_hash, display_name) VALUES ('ovprobe_live', 'x', 'ovprobe_live') RETURNING id").get();
++ const linked = await d.prepare("INSERT INTO users (username, password_hash, display_name) VALUES ('renamed_probe', 'x', 'renamed_probe') RETURNING id").get();
++ await d.prepare("INSERT INTO linked_accounts (user_id, service, service_user_id, subject_id) VALUES (?, 'network', '410', ?)").run(linked.id, SUBJECT);
++ await d.prepare("UPDATE users SET is_test = 0").run();
++
++ // The token is minted by the same principal the service uses; the Network answer is this stub.
++ principal.headersFor = async () => ({ authorization: 'Bearer test-service-token' });
++ let answer = { subjects: [{ subject: { type: 'user', id: SUBJECT }, network_user_id: 410, username: 'ovprobe_live', is_test: true }] };
++ let down = false;
++ const fetchImpl = async (url) => {
++ if (down) return new Response('{}', { status: 503 });
++ assert.ok(String(url).endsWith('/internal/identity/test-subjects'));
++ return new Response(JSON.stringify(answer), { status: 200, headers: { 'content-type': 'application/json' } });
++ };
++
++ assert.strictEqual(await testAccounts.refresh({ fetchImpl }), 1);
++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(user.id)).is_test), 1, 'the probe name is marked');
++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(linked.id)).is_test), 1, 'a renamed account follows its subject link');
++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM subject_projection WHERE subject_id = ?').get(SUBJECT)).is_test), 1, 'the projection carries the flag');
++
++ // A failed refresh changes nothing.
++ down = true;
++ await assert.rejects(testAccounts.refresh({ fetchImpl }));
++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(user.id)).is_test), 1, 'the last good list stays after an outage');
++
++ // A successful refresh with an empty list unmarks everyone (the Network no longer calls them test accounts).
++ down = false; answer = { subjects: [] };
++ assert.strictEqual(await testAccounts.refresh({ fetchImpl }), 0);
++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(user.id)).is_test), 0);
++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM users WHERE id = ?').get(linked.id)).is_test), 0);
++ assert.strictEqual(Number((await d.prepare('SELECT is_test FROM subject_projection WHERE subject_id = ?').get(SUBJECT)).is_test), 0);
++
++ // A second subject is fine too and the module never trusts a malformed id.
++ answer = { subjects: [{ subject: { type: 'user', id: OTHER }, network_user_id: 411, username: 'ovprobe_two' }, { subject: { type: 'user', id: 'nope' } }] };
++ assert.strictEqual(await testAccounts.refresh({ fetchImpl }), 1);
++
++ console.log('test accounts: marked by name and by subject link, kept through an outage, unmarked by a new list');
++ process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });