mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-10-13 03:17:21 +00:00
Part C of #2128: an admin area with an overview, the user table and a global audit log. Off by default like the rest of user management: with `userManagement` off nothing changes, and non-admins get no new UI. This is the first of 4 stacked PRs (C, D, E, then a small export/backup follow-up). Each later one contains this branch; review them in order. ## The situation - An admin could manage users one by one in `#/admin/users`, but nothing showed whether the instance needs attention: accounts stuck in activation, bouncing mail, someone guessing a password, an MQTT source that dropped. - The audit log existed (`internal/users/audit.go`) but could only be read per user, and logins were not recorded. ## What this PR adds **Store (`internal/users`)** - Schema v3: an index on `audit_log(at)`. - `AuditList` with filters (action or group prefix like `user.login.*`, user as actor or target, period) and keyset pagination; `PruneAudit`; `Stats` for the user figures. **Server** - `GET /api/admin/stats` (typed struct): accounts by status, admins, registrations and active users over 7 and 30 days, logins and failed logins in 24 hours, mail by final status, and the attention items computed server-side. - `GET /api/admin/audit`: filtered, newest first, `next` cursor. - `GET /api/admin/users` gains `bouncing=1`. - Logins are audited as `user.login` and `user.login.failed` (reason `wrong_password`, `pending` or `disabled`). An unknown address writes no row. The writes are asynchronous, so the login response does not wait on `users.db`. Login rows are pruned after 90 days. **Frontend** - `#/admin?tab=overview|users|audit`: `admin.js` (tab shell), `admin-overview.js` ("Needs attention", Users card, System card from the existing health, MQTT and observer endpoints), `admin-audit.js` (filters in the URL, "Load more"). `admin-users.js` becomes the Users tab; the old `#/admin/users` link rewrites to it. - `/api/healthz` is read on open and on Refresh only, not on the 60-second timer, because it walks every packet under a read lock. Spec: [`docs/specs/2026-10-07-admin-dashboard-design.md`](https://github.com/efiten/CoreScope/blob/feat/admin-dashboard/docs/specs/2026-10-07-admin-dashboard-design.md). ## Verification - `internal/users` and `cmd/server`: `go vet` and `go test` pass locally, 26 new Go tests. One upstream test, `TestSaveGeoFilterPreservesFileMode`, also fails on Windows on plain `master` (file modes) and is unrelated. - `sh test-all.sh` exits 0; the XSS gate in diff mode passes. - User-management E2E with the `e2etest` build: 13 of 13 steps locally. - Running on our staging and production instance since 7 October 2026. ## Not in this PR - Restricting existing pages (perf, MQTT status) to logged-in users or admins. The admin area adds no access mechanism of its own, so that stays a later router rule plus endpoint check. - Server-enforced customizer tab restrictions (#1508). - The 24-hour, 5-attempt and 10-minute attention thresholds are constants for now (AGENTS.md rule 8: customizer later). --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
710 lines
30 KiB
JavaScript
710 lines
30 KiB
JavaScript
/* Unit tests for customizer v2 core functions */
|
|
'use strict';
|
|
const vm = require('vm');
|
|
const fs = require('fs');
|
|
const assert = require('assert');
|
|
|
|
let passed = 0, failed = 0;
|
|
function test(name, fn) {
|
|
try { fn(); passed++; console.log(` ✅ ${name}`); }
|
|
catch (e) { failed++; console.log(` ❌ ${name}: ${e.message}`); }
|
|
}
|
|
|
|
function makeSandbox() {
|
|
const storage = {};
|
|
const localStorage = {
|
|
_data: storage,
|
|
getItem(k) { return k in storage ? storage[k] : null; },
|
|
setItem(k, v) { storage[k] = String(v); },
|
|
removeItem(k) { delete storage[k]; },
|
|
clear() { for (const k in storage) delete storage[k]; }
|
|
};
|
|
const ctx = {
|
|
window: {
|
|
addEventListener: () => {},
|
|
dispatchEvent: () => {},
|
|
SITE_CONFIG: {},
|
|
_SITE_CONFIG_ORIGINAL_HOME: null,
|
|
},
|
|
document: {
|
|
readyState: 'loading',
|
|
createElement: (tag) => ({
|
|
id: '', textContent: '', innerHTML: '', className: '',
|
|
setAttribute: () => {}, appendChild: () => {},
|
|
style: {}, addEventListener: () => {},
|
|
querySelectorAll: () => [], querySelector: () => null,
|
|
}),
|
|
head: { appendChild: () => {} },
|
|
getElementById: () => null,
|
|
addEventListener: () => {},
|
|
querySelectorAll: () => [],
|
|
querySelector: () => null,
|
|
documentElement: {
|
|
style: { setProperty: () => {}, removeProperty: () => {}, getPropertyValue: () => '' },
|
|
dataset: { theme: 'dark' },
|
|
getAttribute: () => 'dark',
|
|
},
|
|
},
|
|
console,
|
|
localStorage,
|
|
setTimeout: (fn) => fn(),
|
|
clearTimeout: () => {},
|
|
Date, Math, Array, Object, JSON, String, Number, Boolean,
|
|
parseInt, parseFloat, isNaN, Infinity, NaN, undefined,
|
|
MutationObserver: class { observe() {} },
|
|
HashChangeEvent: class {},
|
|
CustomEvent: class CustomEvent { constructor(type, opts) { this.type = type; this.detail = opts && opts.detail; } },
|
|
getComputedStyle: () => ({ getPropertyValue: () => '' }),
|
|
};
|
|
ctx.window.localStorage = localStorage;
|
|
ctx.self = ctx.window;
|
|
return ctx;
|
|
}
|
|
|
|
function loadCustomizer() {
|
|
const ctx = makeSandbox();
|
|
const labelsCode = fs.readFileSync('public/payload-labels.js', 'utf8');
|
|
const code = fs.readFileSync('public/customize-v2.js', 'utf8');
|
|
vm.createContext(ctx);
|
|
vm.runInContext(labelsCode, ctx, { filename: 'payload-labels.js' });
|
|
vm.runInContext(code, ctx, { filename: 'customize-v2.js' });
|
|
return { ctx, api: ctx.window._customizerV2, ls: ctx.localStorage };
|
|
}
|
|
|
|
console.log('\n📋 Customizer V2 — Core Function Tests\n');
|
|
|
|
// ── readOverrides ──
|
|
console.log('readOverrides:');
|
|
test('returns {} when key is absent', () => {
|
|
const { api } = loadCustomizer();
|
|
const result = api.readOverrides();
|
|
assert.strictEqual(JSON.stringify(result), '{}');
|
|
});
|
|
|
|
test('returns {} when key contains invalid JSON', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', 'not json{{{');
|
|
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
|
});
|
|
|
|
test('returns {} when key contains a non-object (string)', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', '"just a string"');
|
|
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
|
});
|
|
|
|
test('returns {} when key contains an array', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', '[1,2,3]');
|
|
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
|
});
|
|
|
|
test('returns {} when key contains a number', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', '42');
|
|
assert.strictEqual(JSON.stringify(api.readOverrides()), '{}');
|
|
});
|
|
|
|
test('returns parsed object when valid', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const data = { theme: { accent: '#ff0000' } };
|
|
ls.setItem('cs-theme-overrides', JSON.stringify(data));
|
|
assert.deepStrictEqual(api.readOverrides(), data);
|
|
});
|
|
|
|
// ── writeOverrides ──
|
|
console.log('\nwriteOverrides:');
|
|
test('writes serialized JSON to localStorage', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const data = { theme: { accent: '#ff0000' } };
|
|
api.writeOverrides(data);
|
|
assert.deepStrictEqual(JSON.parse(ls.getItem('cs-theme-overrides')), data);
|
|
});
|
|
|
|
test('removes key when delta is empty {}', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', '{"theme":{}}');
|
|
api.writeOverrides({});
|
|
assert.strictEqual(ls.getItem('cs-theme-overrides'), null);
|
|
});
|
|
|
|
test('round-trips correctly (write → read = identical)', () => {
|
|
const { api } = loadCustomizer();
|
|
const data = { theme: { accent: '#abc', text: '#def' }, nodeColors: { repeater: '#111' } };
|
|
api.writeOverrides(data);
|
|
assert.deepStrictEqual(api.readOverrides(), data);
|
|
});
|
|
|
|
test('strips invalid color values silently', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
api.writeOverrides({ theme: { accent: 'not-a-color' } });
|
|
// Invalid color is stripped by _validateDelta; remaining empty object is stored as '{}'
|
|
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
|
|
assert.strictEqual(stored.theme, undefined);
|
|
});
|
|
|
|
test('strips out-of-range opacity', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
api.writeOverrides({ heatmapOpacity: 1.5 });
|
|
const stored1 = JSON.parse(ls.getItem('cs-theme-overrides'));
|
|
assert.strictEqual(stored1.heatmapOpacity, undefined);
|
|
api.writeOverrides({ heatmapOpacity: -0.1 });
|
|
const stored2 = JSON.parse(ls.getItem('cs-theme-overrides'));
|
|
assert.strictEqual(stored2.heatmapOpacity, undefined);
|
|
});
|
|
|
|
test('accepts valid opacity', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
api.writeOverrides({ heatmapOpacity: 0.5 });
|
|
const stored = JSON.parse(ls.getItem('cs-theme-overrides'));
|
|
assert.strictEqual(stored.heatmapOpacity, 0.5);
|
|
});
|
|
|
|
// ── computeEffective ──
|
|
console.log('\ncomputeEffective:');
|
|
test('preserves all server sections when overrides is {} and supplies missing home defaults', () => {
|
|
const { api } = loadCustomizer();
|
|
const defaults = { theme: { accent: '#aaa', text: '#bbb' }, nodeColors: { repeater: '#ccc' } };
|
|
const result = api.computeEffective(defaults, {});
|
|
const { home, ...serverSections } = result;
|
|
assert.deepStrictEqual(serverSections, defaults);
|
|
assert.ok(home && typeof home === 'object', 'missing home config gets built-in defaults');
|
|
assert.strictEqual(defaults.home, undefined, 'server input is not mutated');
|
|
});
|
|
|
|
test('overrides a single key in a section', () => {
|
|
const { api } = loadCustomizer();
|
|
const defaults = { theme: { accent: '#aaa', text: '#bbb' } };
|
|
const result = api.computeEffective(defaults, { theme: { accent: '#ff0000' } });
|
|
assert.strictEqual(result.theme.accent, '#ff0000');
|
|
assert.strictEqual(result.theme.text, '#bbb');
|
|
});
|
|
|
|
test('overrides multiple keys across sections', () => {
|
|
const { api } = loadCustomizer();
|
|
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
|
|
const result = api.computeEffective(defaults, { theme: { accent: '#111' }, nodeColors: { repeater: '#222' } });
|
|
assert.strictEqual(result.theme.accent, '#111');
|
|
assert.strictEqual(result.nodeColors.repeater, '#222');
|
|
});
|
|
|
|
test('does not mutate either input', () => {
|
|
const { api } = loadCustomizer();
|
|
const defaults = { theme: { accent: '#aaa' } };
|
|
const overrides = { theme: { accent: '#bbb' } };
|
|
const defCopy = JSON.stringify(defaults);
|
|
const ovrCopy = JSON.stringify(overrides);
|
|
api.computeEffective(defaults, overrides);
|
|
assert.strictEqual(JSON.stringify(defaults), defCopy);
|
|
assert.strictEqual(JSON.stringify(overrides), ovrCopy);
|
|
});
|
|
|
|
test('handles missing sections in overrides gracefully', () => {
|
|
const { api } = loadCustomizer();
|
|
const defaults = { theme: { accent: '#aaa' }, nodeColors: { repeater: '#bbb' } };
|
|
const result = api.computeEffective(defaults, { theme: { accent: '#ccc' } });
|
|
assert.strictEqual(result.nodeColors.repeater, '#bbb');
|
|
});
|
|
|
|
test('array values in home are fully replaced, not merged', () => {
|
|
const { api } = loadCustomizer();
|
|
const defaults = { home: { steps: [{ emoji: '1', title: 'a', description: 'b' }], heroTitle: 'X' } };
|
|
const overrides = { home: { steps: [{ emoji: '2', title: 'c', description: 'd' }, { emoji: '3', title: 'e', description: 'f' }] } };
|
|
const result = api.computeEffective(defaults, overrides);
|
|
assert.strictEqual(result.home.steps.length, 2);
|
|
assert.strictEqual(result.home.steps[0].emoji, '2');
|
|
assert.strictEqual(result.home.heroTitle, 'X'); // untouched
|
|
});
|
|
|
|
test('top-level scalars are directly replaced', () => {
|
|
const { api } = loadCustomizer();
|
|
const defaults = { heatmapOpacity: 0.5 };
|
|
const result = api.computeEffective(defaults, { heatmapOpacity: 0.8 });
|
|
assert.strictEqual(result.heatmapOpacity, 0.8);
|
|
});
|
|
|
|
// ── validateShape ──
|
|
console.log('\nvalidateShape:');
|
|
test('accepts valid delta objects', () => {
|
|
const { api } = loadCustomizer();
|
|
const result = api.validateShape({ theme: { accent: '#fff' }, heatmapOpacity: 0.5 });
|
|
assert.strictEqual(result.valid, true);
|
|
});
|
|
|
|
test('accepts empty object', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api.validateShape({}).valid, true);
|
|
});
|
|
|
|
test('rejects non-objects (string)', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api.validateShape('hello').valid, false);
|
|
});
|
|
|
|
test('rejects non-objects (array)', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api.validateShape([1, 2]).valid, false);
|
|
});
|
|
|
|
test('rejects non-objects (null)', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api.validateShape(null).valid, false);
|
|
});
|
|
|
|
test('warns on unknown top-level keys', () => {
|
|
const { api } = loadCustomizer();
|
|
const result = api.validateShape({ unknownKey: {} });
|
|
// Unknown keys produce a console.warn but validateShape still returns valid
|
|
assert.strictEqual(result.valid, true);
|
|
assert.strictEqual(result.errors.length, 0);
|
|
});
|
|
|
|
test('validates section types (rejects non-object section)', () => {
|
|
const { api } = loadCustomizer();
|
|
const result = api.validateShape({ theme: 'not an object' });
|
|
assert.strictEqual(result.valid, false);
|
|
});
|
|
|
|
test('accepts valid rgb() color values in theme', () => {
|
|
const { api } = loadCustomizer();
|
|
const result = api.validateShape({ theme: { accent: 'rgb(1,2,3)' } });
|
|
assert.strictEqual(result.valid, true);
|
|
});
|
|
|
|
test('rejects out-of-range opacity values', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api.validateShape({ heatmapOpacity: 2.0 }).valid, false);
|
|
assert.strictEqual(api.validateShape({ liveHeatmapOpacity: -1 }).valid, false);
|
|
});
|
|
|
|
// ── migrateOldKeys ──
|
|
console.log('\nmigrateOldKeys:');
|
|
test('migrates all 7 keys correctly', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, branding: { siteName: 'Test' } }));
|
|
ls.setItem('meshcore-timestamp-mode', 'absolute');
|
|
ls.setItem('meshcore-timestamp-timezone', 'utc');
|
|
ls.setItem('meshcore-timestamp-format', 'iso-seconds');
|
|
ls.setItem('meshcore-timestamp-custom-format', 'YYYY-MM-DD');
|
|
ls.setItem('meshcore-heatmap-opacity', '0.7');
|
|
ls.setItem('meshcore-live-heatmap-opacity', '0.3');
|
|
const result = api.migrateOldKeys();
|
|
assert.strictEqual(result.theme.accent, '#f00');
|
|
assert.strictEqual(result.branding.siteName, 'Test');
|
|
assert.strictEqual(result.timestamps.defaultMode, 'absolute');
|
|
assert.strictEqual(result.timestamps.timezone, 'utc');
|
|
assert.strictEqual(result.heatmapOpacity, 0.7);
|
|
assert.strictEqual(result.liveHeatmapOpacity, 0.3);
|
|
// Legacy keys removed
|
|
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
|
|
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
|
|
// New key written
|
|
assert.notStrictEqual(ls.getItem('cs-theme-overrides'), null);
|
|
});
|
|
|
|
test('handles partial migration (only some keys)', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('meshcore-timestamp-mode', 'ago');
|
|
const result = api.migrateOldKeys();
|
|
assert.strictEqual(result.timestamps.defaultMode, 'ago');
|
|
assert.strictEqual(ls.getItem('meshcore-timestamp-mode'), null);
|
|
});
|
|
|
|
test('handles invalid JSON in meshcore-user-theme', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('meshcore-user-theme', '{bad json');
|
|
const result = api.migrateOldKeys();
|
|
// Should not crash, returns delta (possibly empty besides what was valid)
|
|
assert(result !== null);
|
|
assert.strictEqual(ls.getItem('meshcore-user-theme'), null);
|
|
});
|
|
|
|
test('skips migration if cs-theme-overrides already exists', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', '{"theme":{}}');
|
|
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' } }));
|
|
const result = api.migrateOldKeys();
|
|
assert.strictEqual(result, null);
|
|
// Legacy key NOT removed (migration skipped entirely)
|
|
assert.notStrictEqual(ls.getItem('meshcore-user-theme'), null);
|
|
});
|
|
|
|
test('returns null when no legacy keys found', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api.migrateOldKeys(), null);
|
|
});
|
|
|
|
test('drops unknown keys from meshcore-user-theme', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('meshcore-user-theme', JSON.stringify({ theme: { accent: '#f00' }, unknownStuff: 'hi' }));
|
|
const result = api.migrateOldKeys();
|
|
assert.strictEqual(result.theme.accent, '#f00');
|
|
assert.strictEqual(result.unknownStuff, undefined);
|
|
});
|
|
|
|
// ── THEME_CSS_MAP completeness ──
|
|
console.log('\nTHEME_CSS_MAP:');
|
|
test('includes surface3 mapping', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api.THEME_CSS_MAP.surface3, '--surface-3');
|
|
});
|
|
|
|
test('includes sectionBg mapping', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api.THEME_CSS_MAP.sectionBg, '--section-bg');
|
|
});
|
|
|
|
test('includes the green text-on-tint mapping for the admin tables', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api.THEME_CSS_MAP.statusGreenTintText, '--status-green-tint-text');
|
|
});
|
|
|
|
test('matches all keys from old app.js varMap', () => {
|
|
const { api } = loadCustomizer();
|
|
const expectedKeys = [
|
|
'accent', 'accentHover', 'navBg', 'navBg2', 'navText', 'navTextMuted',
|
|
'background', 'text', 'textMuted', 'border',
|
|
'statusGreen', 'statusYellow', 'statusRed',
|
|
'surface1', 'surface2', 'surface3',
|
|
'cardBg', 'contentBg', 'inputBg',
|
|
'rowStripe', 'rowHover', 'detailBg',
|
|
'selectedBg', 'sectionBg',
|
|
'font', 'mono'
|
|
];
|
|
for (const key of expectedKeys) {
|
|
assert(key in api.THEME_CSS_MAP, `Missing key: ${key}`);
|
|
}
|
|
});
|
|
|
|
// ── _isOverridden tests ──
|
|
console.log('\n_isOverridden (value comparison):');
|
|
|
|
test('returns false when no overrides exist', () => {
|
|
const { api } = loadCustomizer();
|
|
api.init({ theme: { accent: '#aaa' } });
|
|
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
|
|
});
|
|
|
|
test('returns false when override matches server default', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#aaa' } }));
|
|
api.init({ theme: { accent: '#aaa' } });
|
|
assert.strictEqual(api.isOverridden('theme', 'accent'), false);
|
|
});
|
|
|
|
test('returns true when override differs from server default', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
|
api.init({ theme: { accent: '#aaa' } });
|
|
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
|
|
});
|
|
|
|
test('returns false for key not in overrides', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
|
api.init({ theme: { accent: '#aaa', border: '#ccc' } });
|
|
assert.strictEqual(api.isOverridden('theme', 'border'), false);
|
|
});
|
|
|
|
test('returns true when server has no default for overridden key', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#bbb' } }));
|
|
api.init({});
|
|
assert.strictEqual(api.isOverridden('theme', 'accent'), true);
|
|
});
|
|
|
|
// ── Bug #518 Fixes ──
|
|
|
|
test('phantom overrides cleaned on init — matching scalars removed', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' }, typeColors: { ADVERT: '#22c55e' } };
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#4a9eff' }, typeColors: { ADVERT: '#22c55e' } }));
|
|
api.init(server);
|
|
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
|
assert.ok(!delta.theme, 'phantom theme override should be cleaned');
|
|
assert.ok(!delta.typeColors, 'phantom typeColors override should be cleaned');
|
|
});
|
|
|
|
test('phantom overrides cleaned on init — matching arrays removed', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do it' }] } };
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do it' }] } }));
|
|
api.init(server);
|
|
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
|
assert.ok(!delta.home, 'phantom home array override should be cleaned');
|
|
});
|
|
|
|
test('real overrides preserved after init cleanup', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const server = { theme: { accent: '#4a9eff' } };
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
|
|
api.init(server);
|
|
const delta = JSON.parse(ls.getItem('cs-theme-overrides'));
|
|
assert.strictEqual(delta.theme.accent, '#ff0000');
|
|
});
|
|
|
|
test('isOverridden handles array comparison via JSON.stringify', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } };
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } }));
|
|
api.init(server);
|
|
assert.strictEqual(api.isOverridden('home', 'steps'), false, 'matching array should not be overridden');
|
|
});
|
|
|
|
test('isOverridden returns true for differing arrays', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const server = { home: { steps: [{ emoji: '📡', title: 'Go', description: 'Do' }] } };
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { steps: [{ emoji: '🚀', title: 'New', description: 'Changed' }] } }));
|
|
api.init(server);
|
|
assert.strictEqual(api.isOverridden('home', 'steps'), true, 'differing array should be overridden');
|
|
});
|
|
|
|
test('setOverride prunes value matching server default', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const server = { theme: { accent: '#4a9eff' } };
|
|
api.init(server);
|
|
api.setOverride('theme', 'accent', '#4a9eff');
|
|
// debounce fires synchronously in sandbox
|
|
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
|
assert.ok(!delta.theme || !delta.theme.accent, 'matching value should be pruned after setOverride');
|
|
});
|
|
|
|
// ── Fix #2: _cleanPhantomOverrides when server has no section ──
|
|
|
|
test('phantom overrides cleaned when server has NO home section', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
// Server has theme but NO home — the common deployment case
|
|
const server = { theme: { accent: '#4a9eff' } };
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ home: { checklist: [], steps: [] } }));
|
|
api.init(server);
|
|
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
|
assert.ok(!delta.home, 'phantom home override should be removed when server has no home section');
|
|
});
|
|
|
|
test('phantom overrides cleaned when server section is undefined — empty arrays removed', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const server = { theme: { accent: '#4a9eff' }, nodeColors: { repeater: '#dc2626' } };
|
|
// timestamps has actual values (not phantom), home has empty arrays (phantom)
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({
|
|
timestamps: { defaultMode: 'ago', timezone: 'local' },
|
|
home: { checklist: [], steps: [] }
|
|
}));
|
|
api.init(server);
|
|
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
|
assert.ok(!delta.home, 'phantom home with empty arrays should be removed');
|
|
// timestamps has non-empty values — preserved even without server section
|
|
assert.ok(delta.timestamps, 'timestamps with actual values should be preserved');
|
|
assert.strictEqual(delta.timestamps.defaultMode, 'ago');
|
|
});
|
|
|
|
// ── Fix #4: setOverride with value matching server default is NOT stored ──
|
|
|
|
test('setOverride with value matching server default is not stored', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' } };
|
|
api.init(server);
|
|
// Set override to same value as server default
|
|
api.setOverride('theme', 'accent', '#4a9eff');
|
|
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
|
assert.ok(!delta.theme || !delta.theme.accent, 'value matching server default should not be stored');
|
|
});
|
|
|
|
test('existing user overrides are NOT pruned by setOverride on other keys', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
const server = { theme: { accent: '#4a9eff', border: '#e2e5ea' } };
|
|
// User previously chose a custom accent (different from server default)
|
|
ls.setItem('cs-theme-overrides', JSON.stringify({ theme: { accent: '#ff0000' } }));
|
|
api.init(server);
|
|
// Now user changes border — accent should be preserved
|
|
api.setOverride('theme', 'border', '#00ff00');
|
|
const delta = JSON.parse(ls.getItem('cs-theme-overrides') || '{}');
|
|
assert.strictEqual(delta.theme.accent, '#ff0000', 'pre-existing custom override should be preserved');
|
|
assert.strictEqual(delta.theme.border, '#00ff00', 'new non-matching override should be stored');
|
|
});
|
|
|
|
// ── Fix #895: export/import includes favorites and claimed nodes ──
|
|
|
|
test('readOverrides includes favorites from localStorage', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
api.init({});
|
|
ls.setItem('meshcore-favorites', JSON.stringify(['abc123', 'def456']));
|
|
const data = api.readOverrides();
|
|
assert.deepStrictEqual(data.favorites, ['abc123', 'def456'], 'favorites should be included in export');
|
|
});
|
|
|
|
test('readOverrides includes myNodes from localStorage', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
api.init({});
|
|
ls.setItem('meshcore-my-nodes', JSON.stringify([{pubkey: 'abc123', name: 'Node1', addedAt: 1000}]));
|
|
const data = api.readOverrides();
|
|
assert.deepStrictEqual(data.myNodes, [{pubkey: 'abc123', name: 'Node1', addedAt: 1000}], 'myNodes should be included in export');
|
|
});
|
|
|
|
test('writeOverrides restores favorites to localStorage', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
api.init({});
|
|
api.writeOverrides({ favorites: ['abc123', 'def456'] });
|
|
const favs = JSON.parse(ls.getItem('meshcore-favorites') || '[]');
|
|
assert.deepStrictEqual(favs, ['abc123', 'def456'], 'favorites should be written to meshcore-favorites');
|
|
});
|
|
|
|
test('writeOverrides restores myNodes to localStorage', () => {
|
|
const { api, ls } = loadCustomizer();
|
|
api.init({});
|
|
const nodes = [{pubkey: 'abc123', name: 'Node1', addedAt: 1000}];
|
|
api.writeOverrides({ myNodes: nodes });
|
|
const stored = JSON.parse(ls.getItem('meshcore-my-nodes') || '[]');
|
|
assert.deepStrictEqual(stored, nodes, 'myNodes should be written to meshcore-my-nodes');
|
|
});
|
|
|
|
test('validateShape accepts favorites array', () => {
|
|
const { api } = loadCustomizer();
|
|
api.init({});
|
|
const result = api.validateShape({ favorites: ['abc123'] });
|
|
assert.ok(result.valid, 'favorites array should be valid');
|
|
});
|
|
|
|
test('validateShape accepts myNodes array', () => {
|
|
const { api } = loadCustomizer();
|
|
api.init({});
|
|
const result = api.validateShape({ myNodes: [{pubkey: 'abc', name: 'N', addedAt: 1}] });
|
|
assert.ok(result.valid, 'myNodes array should be valid');
|
|
});
|
|
|
|
test('validateShape rejects non-array favorites', () => {
|
|
const { api } = loadCustomizer();
|
|
api.init({});
|
|
const result = api.validateShape({ favorites: 'not-an-array' });
|
|
assert.ok(!result.valid, 'non-array favorites should be invalid');
|
|
});
|
|
|
|
test('validateShape rejects non-array myNodes', () => {
|
|
const { api } = loadCustomizer();
|
|
api.init({});
|
|
const result = api.validateShape({ myNodes: 'not-an-array' });
|
|
assert.ok(!result.valid, 'non-array myNodes should be invalid');
|
|
});
|
|
|
|
// ── geofilter editor access (optional user management) ──
|
|
console.log('_gfCanEdit:');
|
|
test('_gfCanEdit: writeEnabled key allows edit', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api._gfCanEdit({ writeEnabled: true }, undefined), true);
|
|
});
|
|
test('_gfCanEdit: admin session allows edit without writeEnabled', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api._gfCanEdit({ writeEnabled: false }, { isAdmin: () => true }), true);
|
|
});
|
|
test('_gfCanEdit: non-admin session and no key denies edit', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api._gfCanEdit({ writeEnabled: false }, { isAdmin: () => false }), false);
|
|
assert.strictEqual(api._gfCanEdit(null, undefined), false);
|
|
});
|
|
|
|
|
|
// _gfAuthHeaders: an admin session replaces the API key (CSRF header, no key).
|
|
function gfContainer(apiKey) {
|
|
return { querySelector: (sel) => (sel === '#cv2-gf-apikey' ? { value: apiKey } : null) };
|
|
}
|
|
const ADMIN = { isAdmin: () => true, adminHeaders: () => ({ 'X-CS-CSRF': 'csrf1' }) };
|
|
const USER = { isAdmin: () => false, adminHeaders: () => ({}) };
|
|
console.log('_gfAuthHeaders:');
|
|
test('_gfAuthHeaders: admin session sends X-CS-CSRF and no X-API-Key', () => {
|
|
const { api } = loadCustomizer();
|
|
const h = api._gfAuthHeaders(gfContainer('typed-key'), { 'Content-Type': 'application/json' }, ADMIN);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(h)), { 'Content-Type': 'application/json', 'X-CS-CSRF': 'csrf1' });
|
|
});
|
|
test('_gfAuthHeaders: non-admin with a typed key sends X-API-Key', () => {
|
|
const { api } = loadCustomizer();
|
|
const h = api._gfAuthHeaders(gfContainer('typed-key'), {}, USER);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(h)), { 'X-API-Key': 'typed-key' });
|
|
});
|
|
test('_gfAuthHeaders: no feature and a typed key sends X-API-Key', () => {
|
|
const { api } = loadCustomizer();
|
|
const h = api._gfAuthHeaders(gfContainer('typed-key'), {}, undefined);
|
|
assert.deepStrictEqual(JSON.parse(JSON.stringify(h)), { 'X-API-Key': 'typed-key' });
|
|
});
|
|
test('_gfAuthHeaders: neither session nor key returns null', () => {
|
|
const { api } = loadCustomizer();
|
|
assert.strictEqual(api._gfAuthHeaders(gfContainer(''), {}, USER), null);
|
|
assert.strictEqual(api._gfAuthHeaders(gfContainer(''), {}, undefined), null);
|
|
});
|
|
|
|
// _gfApplyAuth: edit controls, prune section and key field follow the
|
|
// current auth state, so a later login or logout is picked up.
|
|
function gfPanel() {
|
|
const els = {
|
|
'#cv2-gf-edit': { style: { display: 'none' } },
|
|
'#cv2-gf-prune-section': { style: { display: 'none' } },
|
|
'#cv2-gf-apikey-field': { hidden: false },
|
|
};
|
|
return { els, querySelector: (sel) => els[sel] || null };
|
|
}
|
|
console.log('_gfApplyAuth:');
|
|
test('_gfApplyAuth: a login after load shows the editor and hides the key field', () => {
|
|
const { api } = loadCustomizer();
|
|
const c = gfPanel();
|
|
api._gfApplyAuth(c, { writeEnabled: false }, USER, 0);
|
|
assert.strictEqual(c.els['#cv2-gf-edit'].style.display, 'none');
|
|
assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, false);
|
|
api._gfApplyAuth(c, { writeEnabled: false }, ADMIN, 4);
|
|
assert.strictEqual(c.els['#cv2-gf-edit'].style.display, '');
|
|
assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, '');
|
|
assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, true);
|
|
});
|
|
test('_gfApplyAuth: a logout hides the editor again when the server has no write key', () => {
|
|
const { api } = loadCustomizer();
|
|
const c = gfPanel();
|
|
api._gfApplyAuth(c, { writeEnabled: false }, ADMIN, 4);
|
|
api._gfApplyAuth(c, { writeEnabled: false }, USER, 4);
|
|
assert.strictEqual(c.els['#cv2-gf-edit'].style.display, 'none');
|
|
assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none');
|
|
assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, false);
|
|
});
|
|
test('_gfApplyAuth: a write key keeps the editor without a session; prune needs 3 points', () => {
|
|
const { api } = loadCustomizer();
|
|
const c = gfPanel();
|
|
api._gfApplyAuth(c, { writeEnabled: true }, undefined, 2);
|
|
assert.strictEqual(c.els['#cv2-gf-edit'].style.display, '');
|
|
assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none');
|
|
assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, false);
|
|
});
|
|
|
|
// _gfReapply: the cached re-render and later auth changes. Feature off it
|
|
// does nothing (master kept the controls hidden until a reload); feature on
|
|
// it sizes the prune section from the saved server polygon.
|
|
const ADMIN_ON = { isEnabled: () => true, isAdmin: () => true, adminHeaders: () => ({}) };
|
|
const OFF = { isEnabled: () => false, isAdmin: () => false, adminHeaders: () => ({}) };
|
|
const SAVED4 = { writeEnabled: true, polygon: [[1, 1], [1, 2], [2, 2], [2, 1]] };
|
|
console.log('_gfReapply:');
|
|
test('_gfReapply: feature off leaves edit controls and prune hidden on a cached re-render', () => {
|
|
const { api } = loadCustomizer();
|
|
const c = gfPanel();
|
|
assert.strictEqual(api._gfReapply(c, SAVED4, OFF), false);
|
|
assert.strictEqual(api._gfReapply(c, SAVED4, undefined), false);
|
|
assert.strictEqual(c.els['#cv2-gf-edit'].style.display, 'none');
|
|
assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none');
|
|
assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, false);
|
|
});
|
|
test('_gfReapply: feature on with an admin shows edit controls and prune for a saved polygon', () => {
|
|
const { api } = loadCustomizer();
|
|
const c = gfPanel();
|
|
assert.strictEqual(api._gfReapply(c, { writeEnabled: false, polygon: SAVED4.polygon }, ADMIN_ON), true);
|
|
assert.strictEqual(c.els['#cv2-gf-edit'].style.display, '');
|
|
assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, '');
|
|
assert.strictEqual(c.els['#cv2-gf-apikey-field'].hidden, true);
|
|
});
|
|
test('_gfReapply: prune follows the saved polygon, not unsaved drawing', () => {
|
|
const { api } = loadCustomizer();
|
|
const c = gfPanel();
|
|
api._gfReapply(c, { writeEnabled: false, polygon: null }, ADMIN_ON);
|
|
assert.strictEqual(c.els['#cv2-gf-edit'].style.display, '');
|
|
assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none');
|
|
api._gfReapply(c, { writeEnabled: false, polygon: [[1, 1], [1, 2]] }, ADMIN_ON);
|
|
assert.strictEqual(c.els['#cv2-gf-prune-section'].style.display, 'none');
|
|
});
|
|
|
|
// ── Summary ──
|
|
console.log(`\n${passed + failed} tests: ${passed} passed, ${failed} failed\n`);
|
|
process.exit(failed > 0 ? 1 : 0);
|