'use strict'; // A plan can be hidden from customers by setting active = 0 — that is how a comped or beta tier is // kept off the pricing page. But the only plan listing was /api/subscription/plans, which filters // `active = 1` because it FEEDS that pricing page. So a hidden plan was invisible to the operator // too: no way to see it existed, or who was on it, from the admin screen that is supposed to show // exactly that. // // Two invariants, and they pull in opposite directions, which is why both are pinned here: // - the PUBLIC list must never leak an inactive plan (that is the whole point of hiding it) // - the ADMIN list must show every plan, with how many accounts are on each // // Isolated in-memory DB injected into the require cache, same approach as admin-users.test.js. const test = require('node:test'); const assert = require('node:assert/strict'); const path = require('path'); const Database = require('better-sqlite3'); const express = require('express'); process.env.JWT_SECRET = 'test-secret-admin-plans'; const db = new Database(':memory:'); db.pragma('foreign_keys = ON'); db.exec(` CREATE TABLE plans ( id TEXT PRIMARY KEY, name TEXT NOT NULL, display_name TEXT NOT NULL, max_devices INTEGER NOT NULL DEFAULT 2, max_storage_mb INTEGER NOT NULL DEFAULT 500, remote_control INTEGER NOT NULL DEFAULT 0, remote_url INTEGER NOT NULL DEFAULT 0, priority_support INTEGER NOT NULL DEFAULT 0, price_monthly REAL NOT NULL DEFAULT 0, price_yearly REAL NOT NULL DEFAULT 0, sort_order INTEGER NOT NULL DEFAULT 0, active INTEGER NOT NULL DEFAULT 1 ); -- Columns here are the ones resolveSessionUser selects; a missing one makes requireAuth throw -- and the request comes back 401, which reads like an auth bug rather than a schema gap. CREATE TABLE users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL DEFAULT '', password_hash TEXT, auth_provider TEXT NOT NULL DEFAULT 'local', avatar_url TEXT, role TEXT NOT NULL DEFAULT 'user', plan_id TEXT, email_alerts INTEGER DEFAULT 1, must_change_password INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); CREATE TABLE organizations ( id TEXT PRIMARY KEY, name TEXT NOT NULL, owner_user_id TEXT NOT NULL, plan_id TEXT ); CREATE TABLE workspaces ( id TEXT PRIMARY KEY, organization_id TEXT NOT NULL, name TEXT NOT NULL ); CREATE TABLE devices ( id TEXT PRIMARY KEY, name TEXT, workspace_id TEXT ); INSERT INTO plans (id,name,display_name,max_devices,price_monthly,sort_order,active) VALUES ('free','free','Free',1,0,0,1), ('pro','pro','Pro',15,99,3,1), ('beta','beta','Beta Tester',8,0,9,0); INSERT INTO users (id,email,role,plan_id) VALUES ('padmin','padmin@t.local','platform_admin','pro'), ('u1','u1@t.local','user','beta'), ('u2','u2@t.local','user','beta'), ('u3','u3@t.local','user','pro'); INSERT INTO organizations (id,name,owner_user_id,plan_id) VALUES ('o1','Org One','u1','beta'); INSERT INTO workspaces (id,organization_id,name) VALUES ('w1','o1','WS One'); INSERT INTO devices (id,name,workspace_id) VALUES ('d1','Screen','w1'), ('d2','Screen 2','w1'); `); const dbModulePath = require.resolve('../db/database'); require.cache[dbModulePath] = { id: dbModulePath, filename: dbModulePath, loaded: true, exports: { db } }; const { requireAuth, generateToken } = require('../middleware/auth'); const adminRouter = require('../routes/admin'); const subscriptionRouter = require('../routes/subscription'); // Use the app's own token factory rather than hand-rolling claims — it owns the secret, the // algorithm and the claim shape, and a test that guesses those tests the guess. const row = (id) => db.prepare('SELECT id, email, role FROM users WHERE id = ?').get(id); const adminToken = generateToken(row('padmin'), null); const userToken = generateToken(row('u1'), null); const app = express(); app.use(express.json()); app.use('/api/admin', requireAuth, adminRouter); app.use('/api/subscription', subscriptionRouter); const server = app.listen(0); async function get(pathname, token) { await new Promise(r => (server.listening ? r() : server.once('listening', r))); const res = await fetch(`http://127.0.0.1:${server.address().port}${pathname}`, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); let body = null; try { body = await res.json(); } catch (_) {} return { status: res.status, body }; } test('THE POINT: the admin list includes a hidden plan', async () => { const { status, body } = await get('/api/admin/plans', adminToken); assert.equal(status, 200); const ids = body.plans.map(p => p.id); assert.ok(ids.includes('beta'), `hidden plan missing from the admin view: ${ids.join(',')}`); assert.equal(body.plans.find(p => p.id === 'beta').active, 0); }); test('and it reports how many accounts are on each plan', async () => { const { body } = await get('/api/admin/plans', adminToken); const by = Object.fromEntries(body.plans.map(p => [p.id, p])); assert.equal(by.beta.user_count, 2); assert.equal(by.pro.user_count, 2); // padmin + u3 assert.equal(by.free.user_count, 0); assert.equal(by.beta.org_count, 1); assert.equal(by.beta.device_count, 2, 'screens owned by accounts on the plan'); }); test('THE OTHER HALF: the public list must NOT leak a hidden plan', async () => { // If this ever fails, hiding a comped tier stops working and it appears on the pricing page. const { status, body } = await get('/api/subscription/plans'); assert.equal(status, 200); const ids = body.map(p => p.id); assert.deepEqual(ids.sort(), ['free', 'pro'], `inactive plan leaked: ${ids.join(',')}`); }); test('visible plans sort before hidden ones, so the list reads naturally', async () => { const { body } = await get('/api/admin/plans', adminToken); const firstHidden = body.plans.findIndex(p => !p.active); const lastVisible = body.plans.reduce((acc, p, i) => (p.active ? i : acc), -1); assert.ok(firstHidden > lastVisible, 'hidden plans should come after the visible ones'); }); test('a non-platform-admin cannot read the plan overview', async () => { assert.equal((await get('/api/admin/plans', userToken)).status, 403); }); test('an unauthenticated caller cannot read it either', async () => { assert.equal((await get('/api/admin/plans')).status, 401); }); test.after(() => { server.close(); });