696c69a6d0
* fix(setup): put the setup token where a NAS user can find it (#1218) The token file was never missing — it was in a subdirectory nobody opens. The all-in-one image points DATA_DIR at /data/db, so the file lands beside the database inside the single volume; someone browsing that volume from a NAS container UI sees db/, storage/, logs/, backup/ and gives up. There is no shell on those boxes to run the documented `docker exec … cat` with, and the token value is deliberately kept out of the logs, so the install looked like it had swallowed its own bootstrap credential. When DATA_ROOT names a different directory, the token is now written there too — /data/SETUP_TOKEN, the first thing visible on opening the volume. The compose stack sets no DATA_ROOT and keeps exactly one file, so nothing changes there. Each copy is written independently: the canonical one failing while the volume-root copy succeeds still leaves a readable token, and only a run where every write failed falls back to logging the value. The startup banner names every copy rather than just the first, which is what sent people into db/. Both copies are 0600 and both are removed the moment setup completes. That is what makes a second copy of a single-use bootstrap secret acceptable rather than careless — and writing the test for it turned up that the burn path had TWO independent unlinks, one in clearSetupToken and one at the end of createInitialAdmin. Only the first had been updated, so the volume-root copy survived the burn: a live-looking token that no longer works, which is worse than no token at all. Docs for the same issue are already out (PicPeak/docs#15); .env.example now names the AIO paths too. * fix(setup): enforce 0600 on a token file that already exists (#1218) External review. fs.writeFileSync's `mode` applies only when the file is created — writing over an existing inode truncates it and leaves its permissions untouched. A SETUP_TOKEN someone had copied to the volume root by hand at 0644 would keep that mode, so the first-admin bootstrap credential sat group- and world-readable on a shared NAS mount while this code claimed 0600. Unlink then create, rather than chmod after write: recreating gives a fresh inode with the right mode and no window where the credential is on disk under the wrong one. The chmod stays as a fallback for an unlink that failed for a reason other than the file being absent. Test fails against the un-fixed code. * fix(setup): drop a token copy that cannot be made private (#1218) Round 2 of external review. Asking for 0600 is not the same as getting it: a CIFS/SMB mount — which is what a NAS commonly offers — carries no Unix modes, so chmod is a silent no-op and the file keeps whatever file_mode= the mount forces, typically 0644. This feature targets exactly those hosts, so it now verifies the resulting mode instead of assuming the request took. A copy that cannot be made private is removed rather than left lying there, and it does not count as written — so an install where neither copy can be protected falls through to the existing log fallback, which reaches the operator alone. Previously a chmod that threw after a successful write left the credential on disk, and a success on the other path cleared the error, so nothing reported the exposed copy at all. Test simulates the mode-less mount with chmod as a no-op and stat reporting 0644; it fails against the un-fixed code. * fix(setup): never write the token through a foreign inode, or into the logs (#1218) Round 3 of external review, two findings, both about the credential ending up readable by someone else on exactly the shared mounts this feature targets. **The log fallback defeated the point.** When no copy can be made private, the old branch logged the token at warn — and logger.js writes warnings to combined.log under LOG_DIR, which in the all-in-one image sits on the same mount as the token file. The credential moved from a file we had just refused to leave, into another file just as readable, that outlives setup. The warning no longer carries the token; server.js already prints it on stdout when no file was written, which reaches `docker logs` without touching the shared volume. **A file that could not be deleted was written through anyway.** The pre-write unlink swallowed every error, so a 0666 SETUP_TOKEN owned by another user in a sticky or ACL-controlled directory — still writable — received the live token into its existing inode. Only ENOENT is ignored now. And when the mode check finds an exposed copy it cannot remove, that is recorded separately and reported at error level: a success on the other path clears writeError, and an exposed credential must not be silenced by an unrelated success. Two tests, both failing against the un-fixed code. * fix(setup): fail closed on an exposed token, and refuse a raced symlink (#1218) Round 4 of external review. **An exposed copy left the token valid.** A directory that permits creation and denies deletion — ACL-backed or CIFS — could keep a group/world-readable file holding a live setup token, and /setup/admin went on accepting it: anyone able to read the mount could take the first super-admin account. Reporting that was not enough. The token is now revoked when a readable copy cannot be removed, which turns what is left on disk into a dead string. Private copies are removed with it, since they hold the same value. The next boot mints a fresh one and skips the undeletable file rather than rewriting it, so this converges instead of looping on the same exposure. **The write followed a raced symlink.** On a group-writable mount another local user could drop a symlink at the path between the unlink and the write, and the default 'w' flag would follow it — putting the live token in a file they own. Now created with 'wx' (O_CREAT|O_EXCL), which neither overwrites nor follows a link; having just unlinked, anything present again is that race. The mode check uses lstat for the same reason: it must describe the file, not a link target. **A verification that threw left the file behind.** writeFileSync succeeding and lstat then failing — plausible on the network filesystems this targets — left an unverified live copy on disk, and a success on the other path cleared the error so nothing said so. Cleanup is now keyed on 'did this iteration create a file', so every post-creation failure removes it. Three tests, one new; the new one fails against the un-fixed code. Full backend suite at the known baseline. * fix(setup): report the written token path again, so the banner stays quiet (#1218) A regression I introduced one commit ago. Rewriting the write loop dropped the three lines after it that publish the result, so writtenTokenFile stayed null even on a completely successful write. server.js prints the token itself only when no file was written. With this reporting nothing, the banner took that failure branch on every fresh install and put the live super-admin setup token into stdout and `docker logs` — beside a perfectly good 0600 file. That is the exact leak this path was built to close, reopened by a refactor that touched none of the logic around it. Found by external review, not by the suite: nothing asserted the accessor, only the files on disk. Now guarded — the new test fails against the regression. * fix(setup): survive a worker race, and revoke a copy that predates this run (#1218) Round 6 of external review. **A pre-existing exposed copy was invisible to the revocation.** A restart reuses the token from the database, so an old file holding that value is a live credential. If it had become group-readable and could not be deleted, nothing tracked it — created was false, so the fail-closed path never fired and /setup/admin kept accepting what was in that file. An undeletable file at the token path is now treated as live and triggers the same revocation. **A losing worker printed the token.** The shipped PM2 cluster config runs several workers against one DATA_DIR. Both pass the unlink, one wins the exclusive create, and the loser's wx write threw EEXIST — so it recorded nothing and its banner printed the live token into its own log while a perfectly good 0600 file already existed. EEXIST now checks the file: private, regular, and holding the same token counts as this loop's work already done. **A write that created the file and then threw left it behind.** ENOSPC, a short write, a delayed close on a network mount — writeFileSync can populate the inode before failing, and cleanup keyed on the call returning skipped it. Keyed on the write being attempted now, with an existence check. Two tests, both failing against the un-fixed code. Full backend suite at the known baseline (2342 passing). * refactor(setup): drop the volume-root token copy, keep the hardening (#1218) The second copy was for discoverability: DATA_DIR points into /data/db on the all-in-one image, and a NAS user browsing the volume does not open a folder called db. Six review rounds later it had earned a second inode to race, to verify, to clean up and to revoke — a symlink guard, an exclusive create, an lstat check, cluster-race handling and fail-closed revocation, nearly all of it load-bearing only because there were two files instead of one. That is a lot of attack surface for a convenience the documentation covers better. PicPeak/docs#15 now points NAS users at ADMIN_PASSWORD, which creates the admin on first boot and needs no file at all, and names the db/ subdirectory for anyone who does want the token. Neither needs a second copy. So: one file in DATA_DIR again, as before. Everything the review turned up stays, because none of it was about the second copy — the token is created with O_CREAT|O_EXCL so a raced symlink cannot capture it, its mode is verified with lstat rather than assumed, a copy that cannot be made private is removed, one that cannot be removed revokes the token instead of being logged about, a partial write is cleaned up, a concurrent worker's good file is accepted rather than triggering the log fallback, and the token never reaches the log files. setupTokenFilePaths and writtenSetupTokenFiles are gone with their tests; the hardening tests remain and still fail against unfixed code. * fix(setup): publish the token atomically instead of racing over one inode (#1218) Round 7 of external review found a race in the exclusive-create approach: two PM2 workers reaching the write together, the loser sees the winner's file after the inode exists but before its content lands, judges it wrong, and deletes it — after which the winner's own verification fails too, both report nothing written, and both print the live token into their logs. Rather than teach the loser to wait, the shared inode is gone. The token is written to a per-process temporary file, verified there, and published with rename(2). That is atomic: the file never appears at the published path with the wrong mode or half its content, a symlink sitting at that path is replaced rather than followed, and concurrent workers simply publish the same value one after another. The unlink-then-create dance, the EEXIST handling and the cross-worker deletion all disappear with it. Verifying the mode BEFORE the rename is the stronger order too: a credential that cannot be made private on a mode-less mount now never reaches the published path at all, instead of being written and then cleaned up. If publishing fails and something is still sitting at the token path, it is treated as a live credential we could not replace, and the token is revoked — unchanged in intent from the previous round, simpler in mechanism. * fix(setup): drop a dead assignment and an unused import (#1218) Both flagged by the code-quality review on #1219. `createdTmp = false` after rename(2) is never read — rename consumes the temp file, so the catch has nothing left to clean up either way. `os` was never used in the test. --------- Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
324 lines
15 KiB
JavaScript
324 lines
15 KiB
JavaScript
'use strict';
|
|
|
|
// First-run bootstrap service. bootCrmDb() must run BEFORE requiring the service
|
|
// so setupService shares this test's db instance (see crmDb.js note).
|
|
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-at-least-32-characters-long!!';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const request = require('supertest');
|
|
const { bootCrmDb, buildRouteApp } = require('./helpers/crmDb');
|
|
|
|
let db;
|
|
let cleanup;
|
|
let tmpDir;
|
|
let setupService;
|
|
let getAppSetting;
|
|
let upsertAppSetting;
|
|
let app;
|
|
|
|
const VALID_PW = 'Str0ng-Passw0rd!';
|
|
|
|
// bootCrmDb MUST run before any require of db.js (directly or transitively via a
|
|
// service/util), or db.js binds to the default path instead of the temp one.
|
|
beforeAll(async () => {
|
|
({ db, cleanup, tmpDir } = await bootCrmDb());
|
|
process.env.DATA_DIR = tmpDir; // isolate the SETUP_TOKEN file to the temp dir
|
|
setupService = require('../../src/services/setupService');
|
|
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
|
|
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
|
|
}, 120000);
|
|
|
|
afterAll(async () => {
|
|
await cleanup();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await db('admin_users').del();
|
|
await db('app_settings').where({ setting_key: 'setup_token' }).del();
|
|
});
|
|
|
|
describe('setupService (first-run bootstrap)', () => {
|
|
it('reports needsAdmin while no admin exists', async () => {
|
|
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
|
});
|
|
|
|
it('generates and persists a one-time token while no admin exists', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
expect(token).toEqual(expect.any(String));
|
|
expect(token.length).toBeGreaterThan(20);
|
|
expect(await getAppSetting('setup_token')).toBe(token);
|
|
// Idempotent — a second call returns the same token, not a fresh one.
|
|
expect(await setupService.ensureSetupToken()).toBe(token);
|
|
});
|
|
|
|
it('stores the token as valid JSON so the Postgres jsonb column accepts it', async () => {
|
|
// Regression guard for the SQLite-only miss: a bare token string is rejected
|
|
// by Postgres jsonb ("invalid input syntax for type json"). The raw column
|
|
// value must be JSON-parseable and round-trip back to the token.
|
|
const token = await setupService.ensureSetupToken();
|
|
const row = await db('app_settings').where({ setting_key: 'setup_token' }).first();
|
|
expect(() => JSON.parse(row.setting_value)).not.toThrow();
|
|
expect(JSON.parse(row.setting_value)).toBe(token);
|
|
});
|
|
|
|
it('rejects a wrong token', async () => {
|
|
await setupService.ensureSetupToken();
|
|
await expect(
|
|
setupService.createInitialAdmin({ token: 'nope', email: 'a@b.co', password: VALID_PW })
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: true, complete: false });
|
|
});
|
|
|
|
it('rejects a weak password', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
await expect(
|
|
setupService.createInitialAdmin({ token, email: 'a@b.co', password: 'weak' })
|
|
).rejects.toMatchObject({ statusCode: 400 });
|
|
});
|
|
|
|
it('creates the first admin as super_admin, issues a token, and burns the setup token', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
const result = await setupService.createInitialAdmin({
|
|
token, email: 'Owner@Example.com', password: VALID_PW, ip: '203.0.113.7',
|
|
});
|
|
|
|
expect(result.user.email).toBe('owner@example.com'); // normalised
|
|
expect(result.user.role.name).toBe('super_admin');
|
|
expect(result.token).toEqual(expect.any(String));
|
|
|
|
const row = await db('admin_users').first();
|
|
const role = await db('roles').where({ name: 'super_admin' }).first();
|
|
expect(row.role_id).toBe(role.id);
|
|
expect(row.password_hash).not.toBe(VALID_PW); // hashed
|
|
|
|
// One-time: token burned, status now complete.
|
|
expect(await getAppSetting('setup_token')).toBeFalsy();
|
|
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
|
});
|
|
|
|
it('writes the SETUP_TOKEN file while pending and removes it once setup completes', async () => {
|
|
const tokenFile = path.join(tmpDir, 'SETUP_TOKEN');
|
|
const token = await setupService.ensureSetupToken();
|
|
expect(fs.readFileSync(tokenFile, 'utf8').trim()).toBe(token);
|
|
await setupService.createInitialAdmin({ token, email: 'owner@example.com', password: VALID_PW });
|
|
expect(fs.existsSync(tokenFile)).toBe(false); // burned in DB + file removed
|
|
});
|
|
|
|
it('restores 0600 on a token file that already existed with looser permissions (#1218)', async () => {
|
|
// fs.writeFileSync's `mode` applies only when the file is created, so
|
|
// writing over a 0644 file left the first-admin credential group- and
|
|
// world-readable while the code claimed otherwise. On a NAS the volume is
|
|
// often a shared mount, which is exactly where that matters.
|
|
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
|
|
fs.writeFileSync(canonical, 'stale\n', { mode: 0o644 });
|
|
fs.chmodSync(canonical, 0o644);
|
|
expect(fs.statSync(canonical).mode & 0o777).toBe(0o644);
|
|
|
|
const token = await setupService.ensureSetupToken();
|
|
|
|
expect(fs.readFileSync(canonical, 'utf8').trim()).toBe(token);
|
|
expect(fs.statSync(canonical).mode & 0o777).toBe(0o600);
|
|
});
|
|
|
|
it('never publishes a token it cannot make private (#1218)', async () => {
|
|
// The CIFS/SMB case this targets: the mount carries no Unix modes, so
|
|
// chmod is a silent no-op. The check runs on the temporary file, before
|
|
// the rename, so a credential that cannot be protected never reaches the
|
|
// published path at all.
|
|
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
|
|
try { fs.unlinkSync(canonical); } catch (_) { /* start clean */ }
|
|
const chmodSpy = jest.spyOn(fs, 'chmodSync').mockImplementation(() => {});
|
|
const realLstat = fs.lstatSync;
|
|
const lstatSpy = jest.spyOn(fs, 'lstatSync').mockImplementation((target, ...rest) => {
|
|
const st = realLstat(target, ...rest);
|
|
return String(target).includes('SETUP_TOKEN')
|
|
? { ...st, mode: (st.mode & ~0o777) | 0o644 }
|
|
: st;
|
|
});
|
|
|
|
try {
|
|
const token = await setupService.ensureSetupToken();
|
|
// Setup stays completable — server.js prints the token on stdout when no
|
|
// file was written — but nothing readable was left on the volume.
|
|
expect(token).toBeTruthy();
|
|
expect(fs.existsSync(canonical)).toBe(false);
|
|
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
|
|
} finally {
|
|
chmodSpy.mockRestore();
|
|
lstatSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('keeps the token out of the log files when no private copy is possible (#1218)', async () => {
|
|
// LOG_DIR is on the same mount as the token in the all-in-one image, so
|
|
// logging the credential would put it in combined.log — as readable as the
|
|
// file we just refused to leave, and it outlives setup. stdout is the
|
|
// fallback instead, which server.js prints.
|
|
const logger = require('../../src/utils/logger');
|
|
try { fs.unlinkSync(path.join(tmpDir, 'SETUP_TOKEN')); } catch (_) { /* start clean */ }
|
|
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
|
const chmodSpy = jest.spyOn(fs, 'chmodSync').mockImplementation(() => {});
|
|
const realStat = fs.lstatSync;
|
|
const statSpy = jest.spyOn(fs, 'lstatSync').mockImplementation((target, ...rest) => {
|
|
const st = realStat(target, ...rest);
|
|
// The mode check runs on the temporary file, so match the prefix.
|
|
return String(target).includes('SETUP_TOKEN')
|
|
? { ...st, mode: (st.mode & ~0o777) | 0o644 }
|
|
: st;
|
|
});
|
|
|
|
try {
|
|
const token = await setupService.ensureSetupToken();
|
|
const logged = warnSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
|
expect(logged).toMatch(/could not write a private setup token file/i);
|
|
expect(logged).not.toContain(token);
|
|
} finally {
|
|
warnSpy.mockRestore();
|
|
chmodSpy.mockRestore();
|
|
statSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('tells the startup banner where the token went, so it is never printed (#1218)', async () => {
|
|
// server.js prints the token itself only when no file was written. If this
|
|
// reports nothing after a successful write, the banner takes that failure
|
|
// branch and puts the live credential into stdout and `docker logs` beside
|
|
// a perfectly good 0600 file.
|
|
const token = await setupService.ensureSetupToken();
|
|
expect(token).toBeTruthy();
|
|
|
|
expect(setupService.writtenSetupTokenFile()).toBe(path.join(tmpDir, 'SETUP_TOKEN'));
|
|
});
|
|
|
|
it('lets a second worker publish without disturbing the first (#1218)', async () => {
|
|
// The shipped PM2 cluster config runs several workers against one DATA_DIR.
|
|
// Publishing through rename means they simply overwrite the same value in
|
|
// turn — no shared inode to race, and neither worker can end up reporting
|
|
// nothing written and printing the live token to its own log.
|
|
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
|
|
const first = await setupService.ensureSetupToken();
|
|
expect(setupService.writtenSetupTokenFile()).toBe(canonical);
|
|
|
|
const second = await setupService.ensureSetupToken();
|
|
|
|
expect(second).toBe(first);
|
|
expect(setupService.writtenSetupTokenFile()).toBe(canonical);
|
|
expect(fs.readFileSync(canonical, 'utf8').trim()).toBe(first);
|
|
expect(fs.statSync(canonical).mode & 0o777).toBe(0o600);
|
|
// No temporary files left lying about.
|
|
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
|
|
});
|
|
|
|
it('revokes the token when it cannot replace an exposed file (#1218)', async () => {
|
|
// A restart reuses the token from the database, so a file left at the
|
|
// token path may hold the live value. If it cannot be replaced — an
|
|
// ACL-backed or read-only directory — that credential is out of our
|
|
// control, and /setup/admin would go on accepting it.
|
|
const canonical = path.join(tmpDir, 'SETUP_TOKEN');
|
|
await setupService.ensureSetupToken();
|
|
|
|
const renameSpy = jest.spyOn(fs, 'renameSync').mockImplementation(() => {
|
|
const err = new Error('EACCES'); err.code = 'EACCES'; throw err;
|
|
});
|
|
try {
|
|
expect(await setupService.ensureSetupToken()).toBeNull();
|
|
expect(await getAppSetting('setup_token')).toBeFalsy();
|
|
// And the temporary file did not survive the failure.
|
|
expect(fs.readdirSync(tmpDir).filter((f) => f.includes('.tmp'))).toEqual([]);
|
|
} finally {
|
|
renameSpy.mockRestore();
|
|
try { fs.unlinkSync(canonical); } catch (_) { /* may be gone */ }
|
|
}
|
|
});
|
|
|
|
it('refuses to create a second admin (setup already complete)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
|
await expect(
|
|
setupService.createInitialAdmin({ token, email: 'second@example.com', password: VALID_PW })
|
|
).rejects.toMatchObject({ statusCode: 409 });
|
|
});
|
|
|
|
it('serialises a double-submit — two concurrent valid-token calls create only one admin', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
const results = await Promise.allSettled([
|
|
setupService.createInitialAdmin({ token, email: 'a@example.com', password: VALID_PW }),
|
|
setupService.createInitialAdmin({ token, email: 'b@example.com', password: VALID_PW }),
|
|
]);
|
|
const fulfilled = results.filter((r) => r.status === 'fulfilled');
|
|
expect(fulfilled).toHaveLength(1); // the atomic token claim lets exactly one win
|
|
const count = await db('admin_users').count({ c: '*' }).first();
|
|
expect(Number(count.c)).toBe(1);
|
|
});
|
|
|
|
it('ensureSetupToken clears any stale token once an admin exists', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
|
// Simulate a stale token left in settings, then re-run the boot hook.
|
|
await upsertAppSetting('setup_token', JSON.stringify('stale'), 'string');
|
|
expect(await setupService.ensureSetupToken()).toBeNull();
|
|
expect(await getAppSetting('setup_token')).toBeFalsy();
|
|
});
|
|
});
|
|
|
|
describe('setup routes', () => {
|
|
it('GET /api/setup/status reports needsAdmin', async () => {
|
|
const res = await request(app).get('/api/setup/status');
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ needsAdmin: true, complete: false });
|
|
});
|
|
|
|
it('POST /api/setup/verify-token accepts the right token without burning it (200)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
|
expect(res.status).toBe(200);
|
|
expect(res.body).toEqual({ valid: true });
|
|
// Token is NOT consumed — it still works for the actual create.
|
|
expect(await getAppSetting('setup_token')).toBe(token);
|
|
});
|
|
|
|
it('POST /api/setup/verify-token rejects a wrong token (400, field token)', async () => {
|
|
await setupService.ensureSetupToken();
|
|
const res = await request(app).post('/api/setup/verify-token').send({ token: 'nope' });
|
|
expect(res.status).toBe(400);
|
|
expect(res.body.field).toBe('token');
|
|
});
|
|
|
|
it('POST /api/setup/verify-token is closed once an admin exists (409)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
|
const res = await request(app).post('/api/setup/verify-token').send({ token });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
|
|
it('POST /api/setup/admin rejects a wrong token (400)', async () => {
|
|
await setupService.ensureSetupToken();
|
|
const res = await request(app)
|
|
.post('/api/setup/admin')
|
|
.send({ token: 'nope', email: 'a@b.co', password: VALID_PW });
|
|
expect(res.status).toBe(400);
|
|
expect(await setupService.getSetupStatus()).toMatchObject({ needsAdmin: true });
|
|
});
|
|
|
|
it('POST /api/setup/admin creates the first admin + sets the auth cookie (201)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
const res = await request(app)
|
|
.post('/api/setup/admin')
|
|
.send({ token, email: 'owner@example.com', password: VALID_PW });
|
|
expect(res.status).toBe(201);
|
|
expect(res.body.user.role.name).toBe('super_admin');
|
|
expect((res.headers['set-cookie'] || []).join(';')).toMatch(/admin_token/);
|
|
expect(await setupService.getSetupStatus()).toEqual({ needsAdmin: false, complete: true });
|
|
});
|
|
|
|
it('POST /api/setup/admin is closed once an admin exists (409)', async () => {
|
|
const token = await setupService.ensureSetupToken();
|
|
await setupService.createInitialAdmin({ token, email: 'first@example.com', password: VALID_PW });
|
|
const res = await request(app)
|
|
.post('/api/setup/admin')
|
|
.send({ token, email: 'second@example.com', password: VALID_PW });
|
|
expect(res.status).toBe(409);
|
|
});
|
|
});
|