feat(api): add first-run setup endpoint for admin bootstrap
POST /api/v1/auth/setup — unauthenticated, only succeeds when the users table is empty. Enables first-admin creation via HTTP for Docker deployments without shell access to the container volume. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { test, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const tmpDir = mkdtempSync(join(tmpdir(), 'oikos-setup-test-'));
|
||||
|
||||
process.env.SESSION_SECRET = 'test-setup-secret-minimum-32-chars-x';
|
||||
process.env.DB_PATH = join(tmpDir, 'test.db');
|
||||
process.env.SESSION_SECURE = 'false';
|
||||
process.env.PORT = '13099';
|
||||
|
||||
// Dynamic import so env vars are set before module initialization
|
||||
const { default: app } = await import('./server/index.js');
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
|
||||
const BASE = 'http://localhost:13099';
|
||||
|
||||
after(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Validation tests run first (DB is empty at this point)
|
||||
|
||||
test('POST /api/v1/auth/setup: 400 when required fields missing', async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin' }),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('POST /api/v1/auth/setup: 400 when username invalid', async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'a!b', display_name: 'Test', password: 'password123' }),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('POST /api/v1/auth/setup: 400 when password too short', async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', display_name: 'Test', password: 'short' }),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('POST /api/v1/auth/setup: 400 when display_name too long', async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', display_name: 'x'.repeat(129), password: 'password123' }),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test('POST /api/v1/auth/setup: 201 creates first admin', async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin', display_name: 'Test Admin', password: 'password123' }),
|
||||
});
|
||||
assert.equal(res.status, 201);
|
||||
const data = await res.json();
|
||||
assert.equal(data.user.username, 'admin');
|
||||
assert.equal(data.user.display_name, 'Test Admin');
|
||||
assert.equal(data.user.role, 'admin');
|
||||
assert.ok(typeof data.user.id === 'number');
|
||||
assert.ok(typeof data.user.avatar_color === 'string' && data.user.avatar_color.startsWith('#'));
|
||||
});
|
||||
|
||||
test('POST /api/v1/auth/setup: 403 when users already exist', async () => {
|
||||
const res = await fetch(`${BASE}/api/v1/auth/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: 'admin2', display_name: 'Another', password: 'password123' }),
|
||||
});
|
||||
assert.equal(res.status, 403);
|
||||
const data = await res.json();
|
||||
assert.equal(data.code, 403);
|
||||
});
|
||||
Reference in New Issue
Block a user