screentinker/server/test/admin-users.test.js
ScreenTinker 5502a3eaa8 fix(roles): make platform_operator assignable + add deny/assign regression tests
The bug: #13 added 'platform_operator' to the frontend role dropdown
(PLATFORM_ROLE_OPTIONS) but #14's PUT /api/auth/users/:id/role whitelist
(ASSIGNABLE_PLATFORM_ROLES) only listed ['user','platform_admin'], so
selecting "Platform operator" returned 400 "Invalid role" - the role was
unassignable via the UI.

Fix: add 'platform_operator' to ASSIGNABLE_PLATFORM_ROLES. One line; the
self-demote guard is intentionally left untouched (a platform_admin still
cannot self-assign the non-owner operator role and lock themselves out).

Tests (node:test, isolated in-memory DB injection - no DB_PATH change):
- admin-users.test.js: platform_admin can PUT role=platform_operator on a
  target user -> 200 and the row persists as platform_operator (regression
  guard for the whitelist gap).
- operator-permissions.test.js (new): verify-then-test of the highest-blast
  -radius deny. Operator CAN update/delete a workspace-scoped content row
  (cross-org write works) but is denied (403) updating or deleting a shared
  (workspace_id IS NULL) row - proving the separate PLATFORM_ROLES gate in
  content.js's checkContentWrite still holds after canWrite was broadened to
  isPlatformStaff.

Verified read-only (no leak): the other shared-asset write sites keep their
PLATFORM_ROLES gate that excludes operator - kiosk.js:57, widgets.js:110,
folders.js:31, layouts.js:59/117/133.

cd server && npm test -> 12 pass / 0 fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:44:39 -05:00

241 lines
11 KiB
JavaScript

'use strict';
// Tests for #10 (admin-provisioned user creation) and the must_change_password
// lifecycle, plus the #13 operator denial on this endpoint.
//
// No DB_PATH override (per project constraint): we mount the real routers
// against an isolated in-memory better-sqlite3 instance that we seed here, by
// injecting it into the require cache for ../db/database BEFORE any module that
// requires it is loaded. Node v20 built-ins only (node:test, node:assert, fetch).
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const Database = require('better-sqlite3');
process.env.JWT_SECRET = 'test-secret-admin-users';
// --- isolated in-memory DB + minimal schema (only what these paths touch) ---
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 NOT NULL DEFAULT '',
password_hash TEXT,
auth_provider TEXT NOT NULL DEFAULT 'local',
provider_id TEXT,
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,
welcome_email_sent_at INTEGER,
activation_nudge_sent_at INTEGER,
last_login INTEGER,
trial_started INTEGER,
trial_plan TEXT,
stripe_customer_id TEXT,
stripe_subscription_id TEXT,
subscription_status TEXT DEFAULT 'active',
subscription_ends INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE workspaces (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
name TEXT NOT NULL,
slug TEXT,
created_by TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
CREATE TABLE organization_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
organization_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'org_admin',
invited_by TEXT,
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(organization_id, user_id)
);
CREATE TABLE workspace_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id TEXT NOT NULL,
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'workspace_viewer',
invited_by TEXT,
joined_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
UNIQUE(workspace_id, user_id)
);
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 NOT NULL DEFAULT (strftime('%s','now'))
);
`);
// Inject the mock BEFORE requiring anything that pulls ../db/database.
const dbModulePath = require.resolve('../db/database');
require.cache[dbModulePath] = {
id: dbModulePath,
filename: dbModulePath,
loaded: true,
exports: { db, pruneTelemetry() {}, pruneScreenshots() {} },
};
const express = require('express');
const bcrypt = require('bcryptjs');
const { generateToken, requireAuth } = require('../middleware/auth');
const { activityLogger } = require('../services/activity');
const adminRouter = require('../routes/admin');
const authRouter = require('../routes/auth');
// --- seed orgs/workspaces/users ---
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-a','org-a','Workspace A')").run();
db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-b','org-b','Workspace B')").run();
function seedUser({ id, email, role = 'user' }) {
db.prepare("INSERT INTO users (id, email, name, password_hash, auth_provider, role) VALUES (?, ?, ?, 'x', 'local', ?)")
.run(id, email, email.split('@')[0], role);
return { id, email, role };
}
const adminUser = seedUser({ id: 'u-admin', email: 'admin@test.local', role: 'platform_admin' });
const orgAdminA = seedUser({ id: 'u-orgadmin-a', email: 'orgadmin-a@test.local', role: 'user' });
db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES ('org-a','u-orgadmin-a','org_admin')").run();
const operator = seedUser({ id: 'u-operator', email: 'operator@test.local', role: 'platform_operator' });
const regular = seedUser({ id: 'u-regular', email: 'regular@test.local', role: 'user' });
// Dedicated target for the role-assignment regression test (kept separate so it
// can't perturb the non-admin/operator tokens used by the deny tests above).
seedUser({ id: 'u-role-target', email: 'role-target@test.local', role: 'user' });
const tokens = {
admin: generateToken(adminUser, null),
orgAdminA: generateToken(orgAdminA, 'ws-a'),
operator: generateToken(operator, null),
regular: generateToken(regular, null),
};
// --- build + start the app ---
const app = express();
app.use(express.json());
app.use('/api/auth', authRouter); // matches prod: auth before activityLogger
app.use(activityLogger);
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(); });
function post(pathname, token, body) {
return fetch(base + pathname, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: JSON.stringify(body),
});
}
const newUserBody = (over = {}) => ({
email: 'created@test.local', name: 'Created User', password: 'TempPass123',
workspaceId: 'ws-a', role: 'workspace_editor', mustChangePassword: true, ...over,
});
test('platform_admin can create a user (201); response omits password/hash; membership written', async () => {
const res = await post('/api/admin/users', tokens.admin, newUserBody());
assert.equal(res.status, 201);
const body = await res.json();
assert.equal(body.email, 'created@test.local');
assert.equal(body.workspace_role, 'workspace_editor');
assert.equal(body.must_change_password, 1);
assert.ok(!('password' in body), 'response must not include password');
assert.ok(!('password_hash' in body), 'response must not include hash');
const row = db.prepare('SELECT * FROM users WHERE email = ?').get('created@test.local');
assert.ok(row && row.password_hash && row.password_hash !== 'TempPass123', 'password is hashed, not plaintext');
const mem = db.prepare("SELECT * FROM workspace_members WHERE workspace_id='ws-a' AND user_id=?").get(row.id);
assert.equal(mem.role, 'workspace_editor');
assert.equal(mem.invited_by, 'u-admin');
// HOSTED: excluded from welcome + activation-nudge lifecycle.
assert.ok(row.welcome_email_sent_at && row.activation_nudge_sent_at, 'lifecycle sentinels stamped');
// Audit row written, never the password.
const audit = db.prepare("SELECT * FROM activity_log WHERE action='admin_create_user'").get();
assert.ok(audit && /created@test\.local/.test(audit.details));
assert.ok(!/TempPass123/.test(audit.details), 'audit must not contain the password');
});
test('duplicate email returns 409 and does not overwrite', async () => {
const res = await post('/api/admin/users', tokens.admin, newUserBody({ password: 'Different999' }));
assert.equal(res.status, 409);
// original hash unchanged
const row = db.prepare('SELECT password_hash FROM users WHERE email = ?').get('created@test.local');
assert.ok(bcrypt.compareSync('TempPass123', row.password_hash), 'existing password untouched');
});
test('non-admin user is denied (403)', async () => {
const res = await post('/api/admin/users', tokens.regular, newUserBody({ email: 'x1@test.local' }));
assert.equal(res.status, 403);
assert.equal(db.prepare('SELECT COUNT(*) c FROM users WHERE email=?').get('x1@test.local').c, 0);
});
test('platform_operator is denied from Add User (403) - user mgmt is owner-only', async () => {
const res = await post('/api/admin/users', tokens.operator, newUserBody({ email: 'x2@test.local' }));
assert.equal(res.status, 403);
assert.equal(db.prepare('SELECT COUNT(*) c FROM users WHERE email=?').get('x2@test.local').c, 0);
});
test('org_admin can create in their own org but NOT another org', async () => {
const ok = await post('/api/admin/users', tokens.orgAdminA, newUserBody({ email: 'in-a@test.local', workspaceId: 'ws-a' }));
assert.equal(ok.status, 201);
const denied = await post('/api/admin/users', tokens.orgAdminA, newUserBody({ email: 'in-b@test.local', workspaceId: 'ws-b' }));
assert.equal(denied.status, 403);
assert.equal(db.prepare('SELECT COUNT(*) c FROM users WHERE email=?').get('in-b@test.local').c, 0);
});
test('validation: bad email 400, bad role 400, short password 400, missing workspace 404', async () => {
assert.equal((await post('/api/admin/users', tokens.admin, newUserBody({ email: 'nope' }))).status, 400);
assert.equal((await post('/api/admin/users', tokens.admin, newUserBody({ email: 'r@test.local', role: 'org_admin' }))).status, 400);
assert.equal((await post('/api/admin/users', tokens.admin, newUserBody({ email: 'p@test.local', password: 'short' }))).status, 400);
assert.equal((await post('/api/admin/users', tokens.admin, newUserBody({ email: 'w@test.local', workspaceId: 'ws-missing' }))).status, 404);
});
test('must_change_password lifecycle: set on create, surfaced on login, cleared on /me password change', async () => {
// created@test.local was created with mustChangePassword:true in the first test.
const login = await post('/api/auth/login', null, { email: 'created@test.local', password: 'TempPass123' });
assert.equal(login.status, 200);
const loginBody = await login.json();
assert.equal(loginBody.user.must_change_password, 1, 'login response carries the flag (drives the redirect)');
// Change password via PUT /api/auth/me -> clears the flag.
const meRes = await fetch(base + '/api/auth/me', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${loginBody.token}` },
body: JSON.stringify({ password: 'BrandNewPass1', current_password: 'TempPass123' }),
});
assert.equal(meRes.status, 200);
const meBody = await meRes.json();
assert.equal(meBody.must_change_password, 0, '/me response shows the flag cleared');
const row = db.prepare('SELECT must_change_password FROM users WHERE email=?').get('created@test.local');
assert.equal(row.must_change_password, 0, 'flag cleared in the DB');
});
test('platform_operator is assignable via PUT /users/:id/role (regression for #13/#14 whitelist gap)', async () => {
const res = await fetch(base + '/api/auth/users/u-role-target/role', {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${tokens.admin}` },
body: JSON.stringify({ role: 'platform_operator' }),
});
assert.equal(res.status, 200);
const dbRole = db.prepare('SELECT role FROM users WHERE id = ?').get('u-role-target').role;
assert.equal(dbRole, 'platform_operator', 'role actually persisted as platform_operator');
});