From 7674f6dc9f0b27c412fc7a32e557410cf2f41031 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Fri, 5 Jun 2026 11:23:06 -0500 Subject: [PATCH] test(admin): node:test coverage for Add User + role gating Adds server/test/admin-users.test.js and a `npm test` (node --test) script. No DB_PATH override: the suite mounts the real routers against an isolated in-memory better-sqlite3 instance injected into the require cache, seeded by the test itself. Node v20 built-ins only (node:test, node:assert, fetch). Covers: Add User success (response omits password/hash, hash stored not plaintext, membership written, hosted lifecycle sentinels stamped, audit row without the password), duplicate-email 409 (no overwrite), non-admin 403, platform_operator denied (403), org_admin scoped to their own org only, input validation, and the must_change_password lifecycle (set on create, surfaced on login, cleared on PUT /api/auth/me). Co-Authored-By: Claude Opus 4.8 (1M context) --- server/package.json | 3 +- server/test/admin-users.test.js | 226 ++++++++++++++++++++++++++++++++ 2 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 server/test/admin-users.test.js diff --git a/server/package.json b/server/package.json index 9beba77..203d440 100644 --- a/server/package.json +++ b/server/package.json @@ -5,7 +5,8 @@ "main": "server.js", "scripts": { "start": "node --env-file-if-exists=.env server.js", - "dev": "node --watch --env-file-if-exists=.env server.js" + "dev": "node --watch --env-file-if-exists=.env server.js", + "test": "node --test" }, "dependencies": { "@azure/msal-node": "^5.2.1", diff --git a/server/test/admin-users.test.js b/server/test/admin-users.test.js new file mode 100644 index 0000000..44f0555 --- /dev/null +++ b/server/test/admin-users.test.js @@ -0,0 +1,226 @@ +'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' }); + +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'); +});