screentinker/server/test/branding.test.js
ScreenTinker eb13f716d0 feat(branding): instance-level default white-label branding (#15)
White-label is stored per-workspace (white_labels.workspace_id); unbranded and
new workspaces - and the login page - fell back to hardcoded ScreenTinker. Add a
single platform default that everything inherits beneath the per-workspace layer.

Resolution (lib/branding.js): workspace row -> custom-domain match -> platform
default -> hardcoded ScreenTinker. Row-level override: a workspace with its own
row keeps it (current behavior); only row-less workspaces inherit the default,
so editing the default propagates instantly (no row-copying at creation).

The platform default is a white_labels row with a FIXED id ('platform-default'),
not a "workspace_id IS NULL" sentinel - legacy pre-multitenancy rows can also
have a null workspace_id, which would be ambiguous.

- routes/admin.js: GET/PUT /api/admin/branding (requirePlatformAdmin) to read/
  upsert the single platform-default row; audit-logged.
- server.js: public GET /api/branding (domain match -> platform default ->
  hardcoded) for pre-login/pre-workspace contexts.
- routes/white-label.js: authed GET now falls back to the platform default
  (was hardcoded) for row-less workspaces.
- Frontend: login page resolves + applies branding (logo, name, colors, favicon,
  custom CSS) pre-auth; Admin page gets a "Default branding" form.

Tests: resolver order incl. legacy null-ws safety; admin GET/PUT (single row,
upsert, platform-admin-only 403). Full suite 37/37. Verified end-to-end:
public + authed + login-page all inherit the platform default; per-workspace
override preserved.

Closes #15.
2026-06-08 16:55:22 -05:00

119 lines
6.3 KiB
JavaScript

'use strict';
// Issue #15: instance-level default branding. Tests the resolver order
// (workspace row -> custom-domain -> platform default -> hardcoded) and the
// platform-admin GET/PUT /api/admin/branding endpoints.
const test = require('node:test');
const assert = require('node:assert/strict');
const Database = require('better-sqlite3');
process.env.JWT_SECRET = 'test-secret-branding';
const db = new Database(':memory:');
db.pragma('foreign_keys = ON');
db.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT DEFAULT '',
password_hash TEXT, auth_provider TEXT NOT NULL DEFAULT 'local', avatar_url TEXT,
role TEXT NOT NULL DEFAULT 'user', plan_id TEXT DEFAULT 'free', email_alerts INTEGER DEFAULT 1,
must_change_password INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE white_labels (
id TEXT PRIMARY KEY, user_id TEXT, brand_name TEXT NOT NULL DEFAULT 'ScreenTinker',
logo_url TEXT, favicon_url TEXT, primary_color TEXT DEFAULT '#3B82F6',
secondary_color TEXT DEFAULT '#1E293B', bg_color TEXT DEFAULT '#111827',
custom_domain TEXT, custom_css TEXT, hide_branding INTEGER DEFAULT 0,
workspace_id TEXT, created_at INTEGER DEFAULT (strftime('%s','now')), updated_at INTEGER DEFAULT (strftime('%s','now'))
);
CREATE TABLE activity_log (
id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT, device_id TEXT, action TEXT NOT NULL,
details TEXT, ip_address TEXT, workspace_id TEXT, created_at INTEGER DEFAULT (strftime('%s','now'))
);
`);
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db, pruneTelemetry() {}, pruneScreenshots() {} } };
const express = require('express');
const { generateToken, requireAuth } = require('../middleware/auth');
const { resolveBranding } = require('../lib/branding');
const adminRouter = require('../routes/admin');
db.prepare("INSERT INTO users (id, email, role, password_hash) VALUES ('u-admin','admin@test.local','platform_admin','x')").run();
db.prepare("INSERT INTO users (id, email, role, password_hash) VALUES ('u-reg','reg@test.local','user','x')").run();
const tokens = {
admin: generateToken({ id: 'u-admin', email: 'admin@test.local', role: 'platform_admin' }, null),
reg: generateToken({ id: 'u-reg', email: 'reg@test.local', role: 'user' }, null),
};
const app = express();
app.use(express.json());
app.use('/api/admin', requireAuth, adminRouter);
const server = app.listen(0);
let base;
test.before(async () => { await new Promise(r => server.listening ? r() : server.once('listening', r)); base = `http://127.0.0.1:${server.address().port}`; });
test.after(() => { server.close(); db.close(); });
const wl = (id, fields) => db.prepare(
`INSERT INTO white_labels (id, user_id, brand_name, custom_domain, workspace_id) VALUES (?, 'u-admin', ?, ?, ?)`
).run(id, fields.brand_name, fields.custom_domain || null, fields.workspace_id || null);
test('resolver order: workspace row > domain > platform default > hardcoded', () => {
db.prepare('DELETE FROM white_labels').run();
wl('w1', { brand_name: 'WS One', workspace_id: 'ws1' });
// a custom-domain row belongs to a workspace (realistic); also seed a legacy
// null-workspace row to prove the fixed-id sentinel ignores it.
wl('dom', { brand_name: 'Domain Brand', custom_domain: 'cust.example', workspace_id: 'ws-dom' });
wl('legacy', { brand_name: 'Legacy Null WS', workspace_id: null });
wl('platform-default', { brand_name: 'Global Default', workspace_id: null }); // fixed-id platform default
assert.equal(resolveBranding(db, { workspaceId: 'ws1' }).brand_name, 'WS One', 'workspace row wins');
assert.equal(resolveBranding(db, { domain: 'cust.example' }).brand_name, 'Domain Brand', 'domain match');
assert.equal(resolveBranding(db, { workspaceId: 'ws-none' }).brand_name, 'Global Default', 'unbranded workspace inherits platform default (not the legacy null-ws row)');
assert.equal(resolveBranding(db, {}).brand_name, 'Global Default', 'no context -> platform default');
db.prepare("DELETE FROM white_labels WHERE id='platform-default'").run();
assert.equal(resolveBranding(db, {}).brand_name, 'ScreenTinker', 'no platform default -> hardcoded (legacy null-ws row not used)');
});
test('GET /api/admin/branding returns hardcoded default when none set', async () => {
db.prepare('DELETE FROM white_labels').run();
const res = await fetch(base + '/api/admin/branding', { headers: { Authorization: `Bearer ${tokens.admin}` } });
assert.equal(res.status, 200);
assert.equal((await res.json()).brand_name, 'ScreenTinker');
});
test('PUT /api/admin/branding creates then updates the single platform-default row', async () => {
const put = (body) => fetch(base + '/api/admin/branding', {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens.admin}` }, body: JSON.stringify(body),
});
let res = await put({ brand_name: 'Acme Signage', primary_color: '#10b981', hide_branding: true });
assert.equal(res.status, 200);
// exactly one platform-default row, with workspace_id NULL
let rows = db.prepare("SELECT * FROM white_labels WHERE id = 'platform-default'").all();
assert.equal(rows.length, 1);
assert.equal(rows[0].brand_name, 'Acme Signage');
assert.equal(rows[0].primary_color, '#10b981');
assert.equal(rows[0].hide_branding, 1);
// second PUT updates the same row (no second row)
res = await put({ brand_name: 'Acme Displays' });
assert.equal(res.status, 200);
rows = db.prepare("SELECT * FROM white_labels WHERE id = 'platform-default'").all();
assert.equal(rows.length, 1, 'still a single platform-default row');
assert.equal(rows[0].brand_name, 'Acme Displays');
// and now an unbranded workspace resolves to it
assert.equal(resolveBranding(db, { workspaceId: 'whatever' }).brand_name, 'Acme Displays');
});
test('branding endpoints are platform-admin only (403 for a regular user)', async () => {
const get = await fetch(base + '/api/admin/branding', { headers: { Authorization: `Bearer ${tokens.reg}` } });
assert.equal(get.status, 403);
const put = await fetch(base + '/api/admin/branding', {
method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens.reg}` }, body: JSON.stringify({ brand_name: 'Hacker' }),
});
assert.equal(put.status, 403);
});