Merge commit 'f518eb7a1394ee29b825e551e6557dd71cd95a28'
This commit is contained in:
Generated
+9
-2
@@ -1,15 +1,16 @@
|
||||
{
|
||||
"name": "openbureau-cms-api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openbureau-cms-api",
|
||||
"version": "0.1.0",
|
||||
"version": "0.6.0",
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
@@ -529,6 +530,12 @@
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.13.7",
|
||||
"@supabase/supabase-js": "^2.47.10",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hono": "^4.6.14",
|
||||
"marked": "^14.1.4",
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Local auth provider — file-based users (bcrypt) + self-signed JWTs of the SAME
|
||||
// claim shape as Supabase/GoTrue (sub / email / app_metadata.role / exp), so the
|
||||
// rest of the engine (auth.js verifyToken, roleOf) treats local and supabase
|
||||
// tokens identically. Lets a core instance run DB-less (no Supabase/Postgres):
|
||||
// Node + Hugo + nginx. Activated by config.auth === 'local'.
|
||||
//
|
||||
// Users live in a JSON file (USERS_FILE, default <SITE_DIR>/cms-users.json):
|
||||
// [{ id, email, password: <bcrypt hash>, role, created_at, last_sign_in_at }]
|
||||
import path from 'node:path';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { sign } from 'hono/jwt';
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const USERS_FILE = process.env.USERS_FILE || path.join(SITE_DIR, 'cms-users.json');
|
||||
const JWT_SECRET = process.env.JWT_SECRET || '';
|
||||
const ROLES = ['user', 'editor', 'admin'];
|
||||
|
||||
// bcryptjs is pure-JS (no native build) and lazily imported — a supabase-auth
|
||||
// instance never loads it.
|
||||
async function bcrypt() { return (await import('bcryptjs')).default; }
|
||||
|
||||
export async function loadUsers() {
|
||||
try {
|
||||
const j = JSON.parse(await readFile(USERS_FILE, 'utf8'));
|
||||
return Array.isArray(j) ? j : (j.users || []);
|
||||
} catch { return []; }
|
||||
}
|
||||
async function persist(users) {
|
||||
await mkdir(path.dirname(USERS_FILE), { recursive: true });
|
||||
await writeFile(USERS_FILE, JSON.stringify(users, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
export async function hashPassword(pw) { return (await bcrypt()).hash(pw, 10); }
|
||||
export async function verifyPassword(pw, hash) {
|
||||
try { return await (await bcrypt()).compare(pw, hash); } catch { return false; }
|
||||
}
|
||||
|
||||
// Mint a GoTrue-shaped HS256 access token (verified by auth.js with JWT_SECRET).
|
||||
export async function mintToken(user, ttl = 3600) {
|
||||
if (!JWT_SECRET) throw new Error('JWT_SECRET fehlt — für auth: local nötig');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return sign({
|
||||
sub: user.id, email: user.email, role: 'authenticated',
|
||||
app_metadata: { role: user.role || 'user' }, iat: now, exp: now + ttl,
|
||||
}, JWT_SECRET, 'HS256');
|
||||
}
|
||||
|
||||
export async function login(email, password) {
|
||||
const users = await loadUsers();
|
||||
const u = users.find((x) => (x.email || '').toLowerCase() === (email || '').toLowerCase());
|
||||
if (!u || !u.password) return null;
|
||||
if (!(await verifyPassword(password, u.password))) return null;
|
||||
u.last_sign_in_at = new Date().toISOString();
|
||||
await persist(users);
|
||||
return {
|
||||
access_token: await mintToken(u),
|
||||
token_type: 'bearer',
|
||||
user: { id: u.id, email: u.email, role: u.role || 'user' },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Admin CRUD (mirrors routes/users.js shapes) ──────────────────────────────
|
||||
export async function listUsers(adminEmails = []) {
|
||||
const users = await loadUsers();
|
||||
return users.map((u) => {
|
||||
const fixedAdmin = adminEmails.includes((u.email || '').toLowerCase());
|
||||
const role = u.role || 'user';
|
||||
return {
|
||||
id: u.id, email: u.email, created_at: u.created_at || null,
|
||||
last_sign_in_at: u.last_sign_in_at || null,
|
||||
role: fixedAdmin ? 'admin' : role, isAdmin: fixedAdmin || role === 'admin', fixedAdmin,
|
||||
};
|
||||
});
|
||||
}
|
||||
export async function countByRole(adminEmails = []) {
|
||||
const list = await listUsers(adminEmails);
|
||||
const out = { total: list.length, admin: 0, editor: 0, user: 0 };
|
||||
for (const u of list) out[u.role] = (out[u.role] || 0) + 1;
|
||||
return out;
|
||||
}
|
||||
export async function createUser({ email, password, role }) {
|
||||
if (!email || !password) throw new Error('E-Mail und Passwort nötig');
|
||||
if (role && !ROLES.includes(role)) throw new Error('Unbekannte Rolle');
|
||||
const users = await loadUsers();
|
||||
if (users.some((u) => (u.email || '').toLowerCase() === email.toLowerCase())) {
|
||||
throw new Error('E-Mail existiert bereits');
|
||||
}
|
||||
const u = {
|
||||
id: randomUUID(), email, password: await hashPassword(password),
|
||||
role: role || 'user', created_at: new Date().toISOString(), last_sign_in_at: null,
|
||||
};
|
||||
users.push(u); await persist(users);
|
||||
return u.id;
|
||||
}
|
||||
export async function updateUser(id, { password, role }) {
|
||||
const users = await loadUsers();
|
||||
const u = users.find((x) => x.id === id);
|
||||
if (!u) throw new Error('Nutzer:in nicht gefunden');
|
||||
if (password) u.password = await hashPassword(password);
|
||||
if (role) { if (!ROLES.includes(role)) throw new Error('Unbekannte Rolle'); u.role = role; }
|
||||
await persist(users);
|
||||
}
|
||||
export async function deleteUser(id) {
|
||||
const users = await loadUsers();
|
||||
const next = users.filter((u) => u.id !== id);
|
||||
if (next.length === users.length) throw new Error('Nutzer:in nicht gefunden');
|
||||
await persist(next);
|
||||
}
|
||||
@@ -17,6 +17,9 @@ async function verifyToken(token) {
|
||||
return { id: p.sub, email: p.email || '', app_metadata: p.app_metadata || {} };
|
||||
} catch { return null; }
|
||||
}
|
||||
// Fallback (kein JWT_SECRET): Remote-Prüfung gegen GoTrue — nur wenn ein
|
||||
// Supabase-Auth-Client existiert (DB-loser Betrieb hat keinen).
|
||||
if (!supabaseAuth) return null;
|
||||
const { data, error } = await supabaseAuth.auth.getUser(token);
|
||||
if (error || !data?.user) return null;
|
||||
return data.user;
|
||||
|
||||
@@ -20,6 +20,7 @@ export const config = mod.default || mod;
|
||||
config.collections ||= [];
|
||||
config.plugins ||= [];
|
||||
config.admins ||= [];
|
||||
config.auth ||= 'supabase'; // 'supabase' (GoTrue) | 'local' (file users, DB-less)
|
||||
|
||||
export const hasPlugin = (name) => config.plugins.includes(name);
|
||||
|
||||
|
||||
@@ -14,11 +14,20 @@ import users from './routes/users.js';
|
||||
import stats from './routes/stats.js';
|
||||
import system from './routes/system.js';
|
||||
import history from './routes/history.js';
|
||||
import authRoute from './routes/auth.js';
|
||||
import { requireAuth, requireAdmin } from './auth.js';
|
||||
import { config } from './config.js';
|
||||
import { supabase } from './supabase.js';
|
||||
import { manager } from './plugins.js';
|
||||
import { runMigrations } from './migrate.js';
|
||||
|
||||
// Fail-fast: auth:'supabase' ohne erreichbaren Service-Client ist ein Fehlstart.
|
||||
// (auth:'local' braucht keinen — DB-loser Betrieb.)
|
||||
if (config.auth === 'supabase' && !supabase) {
|
||||
console.error('FEHLT: SUPABASE_URL/SERVICE_KEY für auth: supabase');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const SITE_DIR = process.env.SITE_DIR || '/site';
|
||||
const ADMIN_DIR = process.env.ADMIN_DIR || '/app/admin-dist';
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
@@ -64,9 +73,12 @@ app.get('/api/health', (c) => c.json({ ok: true, hugo: '0.161.1+extended' }));
|
||||
// lesen + Widget-Login. Jede Route bringt ihre eigenen Limits mit (dialog drosselt
|
||||
// den Login auf 10 Versuche/IP pro 5 Minuten). Pluginlose Site: nichts gemountet.
|
||||
manager.mountPublic(app);
|
||||
// Lokaler Login (nur auth: 'local') — ersetzt GoTrue, öffentlich + gedrosselt.
|
||||
// Supabase-Sites loggen direkt gegen GoTrue ein und brauchen das nicht.
|
||||
if (config.auth === 'local') app.route('/api/auth', authRoute);
|
||||
// Öffentlich: Versionsverlauf der Beiträge (Git-History) — auf der Site anzeigbar.
|
||||
app.route('/api/history', history);
|
||||
// Alles weitere unter /api/* braucht ein gültiges Supabase-Token.
|
||||
// Alles weitere unter /api/* braucht ein gültiges Token (Supabase oder lokal).
|
||||
app.use('/api/*', requireAuth);
|
||||
// Schreibzugriffe drosseln (Spam-Schutz, auch bei gekapertem Token):
|
||||
// 60 Mutationen/Minute je Nutzer. Lesen (GET) bleibt frei.
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Hono } from 'hono';
|
||||
import { rateLimit } from '../ratelimit.js';
|
||||
import { login } from '../auth-local.js';
|
||||
|
||||
// Lokaler Login (auth: 'local') — ersetzt GoTrue. Liefert ein GoTrue-förmiges
|
||||
// Token, das requireAuth genauso prüft wie ein Supabase-Token. Nur gemountet,
|
||||
// wenn config.auth === 'local' (siehe index.js); Brute-Force gedrosselt.
|
||||
const auth = new Hono();
|
||||
|
||||
auth.post('/login', rateLimit({ max: 10, windowMs: 5 * 60_000 }), async (c) => {
|
||||
let body = {};
|
||||
try { body = await c.req.json(); } catch { /* leerer/kaputter Body → 400 unten */ }
|
||||
const { email, password } = body;
|
||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||
const res = await login(email, password);
|
||||
if (!res) return c.json({ error: 'Login fehlgeschlagen' }, 401);
|
||||
return c.json(res);
|
||||
});
|
||||
|
||||
export default auth;
|
||||
@@ -5,6 +5,10 @@ import { requireAdmin, roleOf } from '../auth.js';
|
||||
import { config } from '../config.js';
|
||||
import { contentStats } from '../collections.js';
|
||||
import { manager } from '../plugins.js';
|
||||
import { countByRole } from '../auth-local.js';
|
||||
|
||||
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
// Kennzahlen für die Admin-Übersicht. Nur Admins; rein lesend.
|
||||
const stats = new Hono();
|
||||
@@ -16,12 +20,16 @@ stats.get('/', async (c) => {
|
||||
try { content = contentStats(await listEntries(), config.collections); }
|
||||
catch { /* Filesystem nicht lesbar → leer */ }
|
||||
|
||||
// Nutzer nach Rolle (GoTrue).
|
||||
const users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||
try {
|
||||
const { data } = await supabase.auth.admin.listUsers();
|
||||
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||
} catch { /* GoTrue nicht erreichbar */ }
|
||||
// Nutzer nach Rolle: lokal aus der User-Datei, sonst aus GoTrue.
|
||||
let users = { total: 0, admin: 0, editor: 0, user: 0 };
|
||||
if (config.auth === 'local') {
|
||||
try { users = await countByRole(ADMIN_EMAILS); } catch { /* User-Datei fehlt → 0 */ }
|
||||
} else if (supabase) {
|
||||
try {
|
||||
const { data } = await supabase.auth.admin.listUsers();
|
||||
for (const u of data?.users || []) { users.total++; users[roleOf(u)] = (users[roleOf(u)] || 0) + 1; }
|
||||
} catch { /* GoTrue nicht erreichbar */ }
|
||||
}
|
||||
|
||||
// Plugin-Kennzahlen einsammeln (jedes Plugin unter seinem Namen). Nur geladene
|
||||
// Plugins lesen die DB — eine pluginlose Site macht hier null DB-Zugriffe.
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { Hono } from 'hono';
|
||||
import { supabase } from '../supabase.js';
|
||||
import { requireAdmin, roleOf } from '../auth.js';
|
||||
import { config } from '../config.js';
|
||||
import * as local from '../auth-local.js';
|
||||
|
||||
// Autoren-/Nutzerverwaltung über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
||||
// Autoren-/Nutzerverwaltung. Mit auth:'local' gegen die User-Datei (DB-los),
|
||||
// sonst über die GoTrue-Admin-API (Service-Key). Nur Admins.
|
||||
const ADMINS = (process.env.ADMIN_EMAILS || '')
|
||||
.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
|
||||
const isLocal = () => config.auth === 'local';
|
||||
|
||||
const users = new Hono();
|
||||
users.use('*', requireAdmin);
|
||||
|
||||
users.get('/', async (c) => {
|
||||
if (isLocal()) {
|
||||
try { return c.json(await local.listUsers(ADMINS)); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 500); }
|
||||
}
|
||||
const { data, error } = await supabase.auth.admin.listUsers();
|
||||
if (error) return c.json({ error: error.message }, 500);
|
||||
const list = (data?.users || []).map((u) => {
|
||||
@@ -32,6 +40,10 @@ users.post('/', async (c) => {
|
||||
const { email, password, role } = await c.req.json();
|
||||
if (!email || !password) return c.json({ error: 'E-Mail und Passwort nötig' }, 400);
|
||||
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||
if (isLocal()) {
|
||||
try { return c.json({ ok: true, id: await local.createUser({ email, password, role }) }); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
}
|
||||
const payload = { email, password, email_confirm: true };
|
||||
if (role && role !== 'user') payload.app_metadata = { role };
|
||||
const { data, error } = await supabase.auth.admin.createUser(payload);
|
||||
@@ -41,19 +53,25 @@ users.post('/', async (c) => {
|
||||
|
||||
users.put('/:id', async (c) => {
|
||||
const { password, role } = await c.req.json();
|
||||
if (role && !['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||
if (!password && !role) return c.json({ error: 'Nichts zu ändern' }, 400);
|
||||
if (isLocal()) {
|
||||
try { await local.updateUser(c.req.param('id'), { password, role }); return c.json({ ok: true }); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
}
|
||||
const patch = {};
|
||||
if (password) patch.password = password;
|
||||
if (role) {
|
||||
if (!['user', 'editor', 'admin'].includes(role)) return c.json({ error: 'Unbekannte Rolle' }, 400);
|
||||
patch.app_metadata = { role };
|
||||
}
|
||||
if (!Object.keys(patch).length) return c.json({ error: 'Nichts zu ändern' }, 400);
|
||||
if (role) patch.app_metadata = { role };
|
||||
const { error } = await supabase.auth.admin.updateUserById(c.req.param('id'), patch);
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
users.delete('/:id', async (c) => {
|
||||
if (isLocal()) {
|
||||
try { await local.deleteUser(c.req.param('id')); return c.json({ ok: true }); }
|
||||
catch (e) { return c.json({ error: String(e.message || e) }, 400); }
|
||||
}
|
||||
const { error } = await supabase.auth.admin.deleteUser(c.req.param('id'));
|
||||
if (error) return c.json({ error: error.message }, 400);
|
||||
return c.json({ ok: true });
|
||||
|
||||
@@ -3,19 +3,27 @@ import { createClient } from '@supabase/supabase-js';
|
||||
const url = process.env.SUPABASE_URL;
|
||||
const key = process.env.SUPABASE_SERVICE_KEY;
|
||||
|
||||
if (!url || !key) {
|
||||
console.error('FEHLT: SUPABASE_URL und/oder SUPABASE_SERVICE_KEY in .env');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const opts = { auth: { persistSession: false, autoRefreshToken: false } };
|
||||
|
||||
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
|
||||
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
||||
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
||||
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
||||
export const supabase = createClient(url, key, opts);
|
||||
// Mit Keys: echte Clients. Ohne (DB-loser core, auth:'local'): Clients bleiben
|
||||
// null; die Aufrufer (auth/stats/users) sind null-sicher und nutzen den lokalen
|
||||
// Provider. DB-Plugins (dialog) laufen ohnehin nur mit Supabase. Den Fail-Fast
|
||||
// für auth:'supabase' macht index.js (das kennt die config) — supabase.js bleibt
|
||||
// bewusst config-frei, damit Unit-Tests es ohne CMS_CONFIG importieren können.
|
||||
let _supabase = null;
|
||||
let _supabaseAuth = null;
|
||||
if (url && key) {
|
||||
// Daten-Client: Service-Role-Key, umgeht RLS. NUR für DB-Zugriffe (from/insert/…).
|
||||
// Wichtig: hier niemals signInWithPassword aufrufen — das schaltet den
|
||||
// Authorization-Header des Clients prozessweit auf das User-Token um (SIGNED_IN),
|
||||
// wodurch anschließende Inserts als role=authenticated laufen und an RLS scheitern.
|
||||
_supabase = createClient(url, key, opts);
|
||||
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
||||
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
||||
_supabaseAuth = createClient(url, key, opts);
|
||||
} else {
|
||||
console.warn('supabase.js: kein SUPABASE_URL/SERVICE_KEY — DB-loser Betrieb (auth: local erwartet).');
|
||||
}
|
||||
|
||||
// Eigener Client nur für Auth (Login, Token-Prüfung). Getrennt, damit ein
|
||||
// signInWithPassword den Daten-Client oben nicht „vergiftet". Niemals ins Frontend.
|
||||
export const supabaseAuth = createClient(url, key, opts);
|
||||
export const supabase = _supabase;
|
||||
export const supabaseAuth = _supabaseAuth;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { rm, readFile } from 'node:fs/promises';
|
||||
|
||||
// Isolated users file + a known secret BEFORE importing the module (it reads env
|
||||
// at import time). No Supabase needed — this is the DB-less path.
|
||||
const USERS = join(tmpdir(), `cms-users-${process.pid}.json`);
|
||||
process.env.USERS_FILE = USERS;
|
||||
process.env.JWT_SECRET ||= 'test-secret-xyz';
|
||||
|
||||
const local = await import('../src/auth-local.js');
|
||||
const { verify } = await import('hono/jwt');
|
||||
|
||||
test.after(async () => { await rm(USERS, { force: true }); });
|
||||
|
||||
test('create → list → login mints a GoTrue-shaped, verifiable token', async () => {
|
||||
const id = await local.createUser({ email: 'Karim@Example.com', password: 'hunter2', role: 'admin' });
|
||||
assert.ok(id);
|
||||
const list = await local.listUsers(['someone@else.com']);
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0].email, 'Karim@Example.com');
|
||||
assert.equal(list[0].role, 'admin');
|
||||
|
||||
// wrong password rejected, right password accepted (case-insensitive email)
|
||||
assert.equal(await local.login('karim@example.com', 'nope'), null);
|
||||
const res = await local.login('karim@example.com', 'hunter2');
|
||||
assert.ok(res?.access_token);
|
||||
assert.equal(res.user.email, 'Karim@Example.com');
|
||||
|
||||
const claims = await verify(res.access_token, process.env.JWT_SECRET, 'HS256');
|
||||
assert.equal(claims.sub, id);
|
||||
assert.equal(claims.email, 'Karim@Example.com');
|
||||
assert.equal(claims.app_metadata.role, 'admin'); // same shape auth.js/roleOf read
|
||||
assert.ok(claims.exp > Math.floor(Date.now() / 1000));
|
||||
});
|
||||
|
||||
test('passwords are bcrypt-hashed at rest (never plaintext)', async () => {
|
||||
const raw = JSON.parse(await readFile(USERS, 'utf8'));
|
||||
assert.match(raw[0].password, /^\$2[aby]\$/); // bcrypt hash prefix
|
||||
assert.notEqual(raw[0].password, 'hunter2');
|
||||
});
|
||||
|
||||
test('countByRole reflects roles; fixed-admin email forced to admin', async () => {
|
||||
await local.createUser({ email: 'red@x.com', password: 'pw', role: 'editor' });
|
||||
const counts = await local.countByRole([]);
|
||||
assert.equal(counts.total, 2);
|
||||
assert.equal(counts.admin, 1);
|
||||
assert.equal(counts.editor, 1);
|
||||
// an env-admin email overrides the stored role in the listing
|
||||
const forced = await local.listUsers(['red@x.com']);
|
||||
assert.equal(forced.find((u) => u.email === 'red@x.com').role, 'admin');
|
||||
});
|
||||
|
||||
test('update + delete', async () => {
|
||||
const id = await local.createUser({ email: 'tmp@x.com', password: 'pw' });
|
||||
await local.updateUser(id, { role: 'editor' });
|
||||
assert.equal((await local.listUsers([])).find((u) => u.id === id).role, 'editor');
|
||||
await local.deleteUser(id);
|
||||
assert.equal((await local.listUsers([])).some((u) => u.id === id), false);
|
||||
await assert.rejects(() => local.deleteUser('nope'), /nicht gefunden/);
|
||||
});
|
||||
|
||||
test('duplicate email rejected', async () => {
|
||||
await assert.rejects(() => local.createUser({ email: 'red@x.com', password: 'pw' }), /existiert bereits/);
|
||||
});
|
||||
Reference in New Issue
Block a user