Files
Haven/test/memberDefaults.test.js
T
Amnibro 936e89cb84 Default Theme and the shared new-member theme are one setting (#5747) (#5748)
Default Theme in Appearance & Welcome and the theme in Defaults for New
Members were stored separately, so a new member saw one on the sign-in page
and the other once the shared defaults applied. Sharing a theme now also sets
the Default Theme (when it is a built-in or a published custom theme),
changing the Default Theme updates the shared theme, choosing None removes it
from the shared set, and unpublishing a custom default drops it from both.
The shared version is untouched, so nobody is re-sent defaults.
2026-10-09 23:24:08 -04:00

242 lines
12 KiB
JavaScript

'use strict';
// Defaults for new members (#5739): an admin shares a snapshot of their own
// look-and-feel settings; each member receives it once, only over settings
// they have not changed themselves.
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const test = require('node:test');
const Database = require('better-sqlite3');
const md = require('../src/memberDefaults');
const { checkSetting } = require('../src/serverTemplate');
const register = require('../src/socketHandlers/memberDefaults');
function freshDb() {
const db = new Database(':memory:');
db.exec(`
CREATE TABLE server_settings (key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE user_preferences (user_id INTEGER, key TEXT, value TEXT, PRIMARY KEY (user_id, key));
`);
return db;
}
const pref = (db, uid, key) => db.prepare('SELECT value FROM user_preferences WHERE user_id = ? AND key = ?').get(uid, key)?.value;
const setPref = (db, uid, key, value) => db.prepare('INSERT OR REPLACE INTO user_preferences (user_id, key, value) VALUES (?, ?, ?)').run(uid, key, value);
test('only allowlisted cosmetic settings with valid values are kept', () => {
const s = md.cleanSettings({
theme: 'matrix', density: 'compact', zoom: 120, blur_nsfw: false, layout: 'BraidLayout',
effects: '["crt","matrix"]', interface_icons: 'emoji',
// Not cosmetic, or not valid: all dropped.
e2e: 'off', push_to_talk: 'Space', input_device: 'x', notifications: 'all',
reaction_size: 'huge', zoom2: 1, toggle_style: 'switch ',
});
assert.deepEqual(Object.keys(s).sort(), ['blur_nsfw', 'density', 'effects', 'interface_icons', 'layout', 'theme', 'zoom']);
assert.deepEqual(md.cleanSettings({ theme: 'custom', zoom: 300, layout: '../evil', effects: '["<x>"]', hide_send: 'true' }), {});
assert.deepEqual(md.cleanSettings({ theme: 'file:Neon.theme.css' }), { theme: 'file:Neon.theme.css' });
assert.deepEqual(md.cleanSettings({ theme: 'file:../x.css' }), {});
assert.deepEqual(md.cleanSettings('not an object'), {});
});
test('saving keeps the version, clearing keeps it, applying to everyone raises it', () => {
const db = freshDb();
assert.equal(md.saveDefaults(db, { bogus: 1 }), null, 'nothing valid to save');
md.saveDefaults(db, { density: 'compact' });
assert.deepEqual(md.readSnapshot(db), { v: 1, s: { density: 'compact' } });
md.saveDefaults(db, { density: 'spacious', theme: 'nord' });
assert.equal(md.readSnapshot(db).v, 1, 'a new snapshot alone does not re-send to members who got one');
md.bumpDefaults(db, 5000);
assert.equal(md.readSnapshot(db).v, 5000);
md.bumpDefaults(db, 10);
assert.equal(md.readSnapshot(db).v, 5001, 'always goes up');
md.clearDefaults(db);
assert.deepEqual(md.readSnapshot(db), { v: 5001, s: {} });
assert.equal(md.bumpDefaults(db), null, 'nothing to apply once cleared');
});
test('a member gets account defaults once, only where they never chose', () => {
const db = freshDb();
md.saveDefaults(db, { theme: 'matrix', effects: 'none', density: 'compact' });
setPref(db, 7, 'effects', '["crt"]'); // their own pick
assert.deepEqual(md.applyAccountDefaults(db, 7), { theme: 'matrix' });
assert.equal(pref(db, 7, 'theme'), 'matrix');
assert.equal(pref(db, 7, 'effects'), '["crt"]', 'their own effects stay');
assert.deepEqual(JSON.parse(pref(db, 7, md.RECORD_PREF)), { v: 1, a: { theme: 'matrix' } });
// Second load: already received.
assert.deepEqual(md.applyAccountDefaults(db, 7), {});
// They change the theme; nothing puts it back.
setPref(db, 7, 'theme', 'dracula');
md.saveDefaults(db, { theme: 'nord' });
assert.deepEqual(md.applyAccountDefaults(db, 7), {});
assert.equal(pref(db, 7, 'theme'), 'dracula');
});
test('applying to everyone updates untouched settings and leaves changed ones', () => {
const db = freshDb();
md.saveDefaults(db, { theme: 'matrix', effects: 'none' });
md.applyAccountDefaults(db, 1);
md.applyAccountDefaults(db, 2);
setPref(db, 2, 'theme', 'halo'); // member 2 picked their own theme
md.saveDefaults(db, { theme: 'nord', effects: 'auto' });
md.bumpDefaults(db, 100);
assert.deepEqual(md.applyAccountDefaults(db, 1), { theme: 'nord', effects: 'auto' });
assert.deepEqual(md.applyAccountDefaults(db, 2), { effects: 'auto' });
assert.equal(pref(db, 2, 'theme'), 'halo');
// A member who joins later gets the current set.
assert.deepEqual(md.applyAccountDefaults(db, 3), { theme: 'nord', effects: 'auto' });
});
test('a setting left out of a later set stays on record while untouched', () => {
const db = freshDb();
md.saveDefaults(db, { theme: 'matrix' });
md.applyAccountDefaults(db, 1);
md.saveDefaults(db, { effects: 'none' });
md.bumpDefaults(db, 100);
md.applyAccountDefaults(db, 1);
assert.deepEqual(JSON.parse(pref(db, 1, md.RECORD_PREF)).a, { theme: 'matrix', effects: 'none' });
md.saveDefaults(db, { theme: 'nord' });
md.bumpDefaults(db, 200);
assert.deepEqual(md.applyAccountDefaults(db, 1), { theme: 'nord' });
});
test('server templates carry the settings without the version', () => {
const stored = JSON.stringify({ v: 98765, s: { density: 'compact', theme: 'matrix', junk: 1 } });
assert.deepEqual(JSON.parse(checkSetting('member_defaults', stored)), { v: 0, s: { theme: 'matrix', density: 'compact' } });
assert.equal(checkSetting('member_defaults', JSON.stringify({ v: 3, s: {} })), '');
assert.equal(checkSetting('member_defaults', '{oops'), '');
});
function fakeSocket(user, perms = () => false) {
const handlers = new Map();
const sent = [];
const settingChanges = [];
const socket = {
user,
on: (ev, fn) => handlers.set(ev, fn),
emit: (ev, data) => sent.push([ev, data]),
};
const db = freshDb();
register(socket, {
db, io: null, userHasPermission: perms,
settingEffects: {
emitSettingChanged: (k, v) => settingChanges.push([k, v]),
auditSettingChange: () => {},
},
});
return { handlers, sent, settingChanges, db };
}
test('only admins or manage_server holders can change the defaults, and errors deny', () => {
const member = fakeSocket({ id: 2, isAdmin: false });
member.handlers.get('set-member-defaults')({ settings: { density: 'compact' } });
member.handlers.get('push-member-defaults')();
member.handlers.get('clear-member-defaults')();
assert.deepEqual(md.readSnapshot(member.db), { v: 0, s: {} });
assert.equal(member.settingChanges.length, 0);
const broken = fakeSocket({ id: 3, isAdmin: false }, () => { throw new Error('db gone'); });
broken.handlers.get('set-member-defaults')({ settings: { density: 'compact' } });
assert.deepEqual(md.readSnapshot(broken.db), { v: 0, s: {} }, 'a failed permission check denies');
const manager = fakeSocket({ id: 4, isAdmin: false }, (uid, perm) => perm === 'manage_server');
manager.handlers.get('set-member-defaults')({ settings: { density: 'compact' } });
assert.deepEqual(md.readSnapshot(manager.db).s, { density: 'compact' });
assert.equal(manager.settingChanges[0][0], 'member_defaults');
// Any member may ask for their own defaults.
manager.handlers.get('apply-member-defaults')();
assert.deepEqual(manager.sent.at(-1), ['member-defaults-applied', {}]);
});
// One theme choice in two places (#5747): Default Theme in Appearance &
// Welcome and a shared theme for new members stay the same.
const defaultTheme = (db) => db.prepare("SELECT value FROM server_settings WHERE key = 'default_theme'").get()?.value;
test('a shared theme follows the Default Theme, and None takes it out', () => {
const db = freshDb();
assert.equal(md.followDefaultTheme(db, 'nord'), null, 'no shared theme: nothing is added');
assert.deepEqual(md.readSnapshot(db).s, {});
md.saveDefaults(db, { theme: 'matrix', density: 'compact' });
md.bumpDefaults(db, 4000);
assert.equal(md.followDefaultTheme(db, 'matrix'), null, 'already the same');
md.followDefaultTheme(db, 'nord');
assert.deepEqual(md.readSnapshot(db), { v: 4000, s: { theme: 'nord', density: 'compact' } }, 'the version stays');
md.followDefaultTheme(db, '');
assert.deepEqual(md.readSnapshot(db), { v: 4000, s: { density: 'compact' } });
});
test('sharing a theme makes it the Default Theme when it can be one', () => {
const admin = fakeSocket({ id: 1, isAdmin: true });
admin.handlers.get('set-member-defaults')({ settings: { theme: 'dracula', density: 'compact' } });
assert.equal(defaultTheme(admin.db), 'dracula');
assert.deepEqual(admin.settingChanges.map(([k]) => k), ['member_defaults', 'default_theme']);
// Sharing again without a change leaves the Default Theme alone.
admin.handlers.get('set-member-defaults')({ settings: { theme: 'dracula' } });
assert.deepEqual(admin.settingChanges.map(([k]) => k), ['member_defaults', 'default_theme', 'member_defaults']);
// Without a theme in the set, the Default Theme stays.
admin.handlers.get('set-member-defaults')({ settings: { density: 'cozy' } });
assert.equal(defaultTheme(admin.db), 'dracula');
// A custom theme that is not published cannot be the Default Theme.
admin.handlers.get('set-member-defaults')({ settings: { theme: 'file:braid.theme.css' } });
assert.equal(defaultTheme(admin.db), 'dracula');
admin.db.prepare('INSERT OR REPLACE INTO server_settings (key, value) VALUES (?, ?)').run('published_themes', '["braid.theme.css"]');
admin.handlers.get('set-member-defaults')({ settings: { theme: 'file:braid.theme.css' } });
assert.equal(defaultTheme(admin.db), 'file:braid.theme.css');
});
test('changing the Default Theme changes the shared theme too', () => {
const registerAdmin = require('../src/socketHandlers/admin');
const db = freshDb();
const handlers = new Map();
const changes = [];
registerAdmin({ user: { id: 1, isAdmin: true }, on: (ev, fn) => handlers.set(ev, fn), emit() {} }, {
db, io: { except: () => ({ emit() {} }) }, state: { channelUsers: new Map() },
userHasPermission: () => true, logAudit() {}, automod: { invalidate() {}, settings: () => ({}) },
settingEffects: {
emitSettingChanged: (k, v) => changes.push([k, v]),
auditSettingChange() {}, afterSettingSaved() {}, broadcastLinkPolicy() {},
},
});
const update = handlers.get('update-server-setting');
update({ key: 'default_theme', value: 'nord' });
assert.deepEqual(md.readSnapshot(db).s, {}, 'nothing shared, nothing added');
md.saveDefaults(db, { theme: 'matrix', zoom: 110 });
update({ key: 'default_theme', value: 'tron' });
assert.deepEqual(md.readSnapshot(db).s, { theme: 'tron', zoom: 110 });
assert.deepEqual(changes.slice(-2).map(([k]) => k), ['default_theme', 'member_defaults']);
update({ key: 'default_theme', value: '' });
assert.deepEqual(md.readSnapshot(db).s, { zoom: 110 }, 'None (user\'s choice) is no shared theme either');
// Unpublishing the custom theme that was the default drops it from both.
update({ key: 'published_themes', value: '["braid.theme.css"]' });
md.saveDefaults(db, { theme: 'file:braid.theme.css', zoom: 110 });
update({ key: 'default_theme', value: 'file:braid.theme.css' });
update({ key: 'published_themes', value: '[]' });
assert.equal(defaultTheme(db), '');
assert.deepEqual(md.readSnapshot(db).s, { zoom: 110 });
});
test('the client applies through the pickers and keeps a per-account device record', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'public/js/modules/app-member-defaults.js'), 'utf8');
assert.match(src, /const RECORD_PREFIX = 'haven_member_defaults_';/);
assert.match(src, /cur === def\.dflt \|\| \(rec && rec\.a\[def\.key\] === cur\)/);
const app = fs.readFileSync(path.join(__dirname, '..', 'public/js/app.js'), 'utf8');
assert.ok(app.indexOf('this._setupMemberDefaults()') > app.indexOf('this._setupToolbarIconPicker()'),
'set up after the pickers it presses');
// Client keys match the server allowlist.
const clientKeys = [...src.matchAll(/^\s{4}key: '([a-z_]+)'/gm)].map(m => m[1]).sort();
assert.deepEqual(clientKeys, Object.keys(md.SETTINGS).sort());
});