236 lines
8.2 KiB
TypeScript
236 lines
8.2 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 AionUi (aionui.com)
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
ensureAdminPassword,
|
|
type EnsureAdminPasswordDeps,
|
|
} from '../../../packages/web-cli/src/ensureAdminPassword.js';
|
|
|
|
type FetchCall = { url: string; init?: RequestInit };
|
|
|
|
function mockResponse(status: number, body: unknown): Response {
|
|
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
status,
|
|
headers: { 'content-type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
function makeDeps(opts: { handlers: Array<(url: string, init?: RequestInit) => Response | Promise<Response>> }): {
|
|
deps: EnsureAdminPasswordDeps;
|
|
calls: FetchCall[];
|
|
logs: string[];
|
|
warns: string[];
|
|
sleeps: number[];
|
|
} {
|
|
const calls: FetchCall[] = [];
|
|
const logs: string[] = [];
|
|
const warns: string[] = [];
|
|
const sleeps: number[] = [];
|
|
let nowVal = 0;
|
|
let idx = 0;
|
|
const deps: EnsureAdminPasswordDeps = {
|
|
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
const url = typeof input === 'string' ? input : input.toString();
|
|
calls.push({ url, init });
|
|
const handler = opts.handlers[idx] ?? opts.handlers[opts.handlers.length - 1];
|
|
idx++;
|
|
return handler(url, init);
|
|
}) as typeof fetch,
|
|
log: (msg) => logs.push(msg),
|
|
warn: (msg) => warns.push(msg),
|
|
sleep: async (ms) => {
|
|
sleeps.push(ms);
|
|
nowVal += ms;
|
|
},
|
|
now: () => nowVal,
|
|
};
|
|
return { deps, calls, logs, warns, sleeps };
|
|
}
|
|
|
|
describe('ensureAdminPassword', () => {
|
|
it('seeds password on fresh install (needs_setup=true)', async () => {
|
|
const { deps, calls, logs, warns } = makeDeps({
|
|
handlers: [
|
|
// /api/auth/status
|
|
() => mockResponse(200, { needs_setup: true }),
|
|
// POST /api/webui/reset-password
|
|
() => mockResponse(200, { data: { new_password: 'SuperSecret123' } }),
|
|
// /api/auth/internal/users/system
|
|
() => mockResponse(200, { data: { username: 'admin' } }),
|
|
],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808 }, deps);
|
|
|
|
expect(calls[0].url).toBe('http://127.0.0.1:25808/api/auth/status');
|
|
expect(calls[1].url).toBe('http://127.0.0.1:25808/api/webui/reset-password');
|
|
expect(calls[1].init?.method).toBe('POST');
|
|
expect(logs).toContain('[aionui-web] Generated initial admin password: SuperSecret123');
|
|
expect(logs.some((m) => m.includes('Log in with username "admin"'))).toBe(true);
|
|
expect(warns).toEqual([]);
|
|
});
|
|
|
|
it('accepts top-level new_password in reset-password response', async () => {
|
|
const { deps, logs } = makeDeps({
|
|
handlers: [
|
|
() => mockResponse(200, { needs_setup: true }),
|
|
() => mockResponse(200, { new_password: 'FromTopLevel' }),
|
|
() => mockResponse(200, { data: { username: 'admin' } }),
|
|
],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808 }, deps);
|
|
|
|
expect(logs).toContain('[aionui-web] Generated initial admin password: FromTopLevel');
|
|
});
|
|
|
|
it('reads needs_setup from nested data field', async () => {
|
|
const { deps, calls, logs } = makeDeps({
|
|
handlers: [
|
|
() => mockResponse(200, { data: { needs_setup: true } }),
|
|
() => mockResponse(200, { data: { new_password: 'Nested' } }),
|
|
() => mockResponse(200, { data: { username: 'custom-admin' } }),
|
|
],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808 }, deps);
|
|
|
|
expect(calls).toHaveLength(3);
|
|
expect(logs.some((m) => m.includes('custom-admin'))).toBe(true);
|
|
});
|
|
|
|
it('no-op when admin already provisioned (needs_setup=false)', async () => {
|
|
const { deps, calls, logs, warns } = makeDeps({
|
|
handlers: [
|
|
() => mockResponse(200, { needs_setup: false }),
|
|
() => mockResponse(200, { data: { username: 'admin' } }),
|
|
],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808 }, deps);
|
|
|
|
expect(calls).toHaveLength(2);
|
|
expect(calls[1].url).toBe('http://127.0.0.1:25808/api/auth/internal/users/system');
|
|
expect(logs.some((m) => m.includes('resetpass'))).toBe(true);
|
|
expect(logs.every((m) => !m.includes('Generated initial admin password'))).toBe(true);
|
|
expect(warns).toEqual([]);
|
|
});
|
|
|
|
it('polls /api/auth/status until backend is ready', async () => {
|
|
let statusAttempts = 0;
|
|
const { deps, sleeps, logs } = makeDeps({
|
|
handlers: [
|
|
// First three fetches are all /api/auth/status: 2 failures then success.
|
|
() => {
|
|
statusAttempts++;
|
|
throw new Error('connect ECONNREFUSED');
|
|
},
|
|
() => {
|
|
statusAttempts++;
|
|
throw new Error('connect ECONNREFUSED');
|
|
},
|
|
() => {
|
|
statusAttempts++;
|
|
return mockResponse(200, { needs_setup: true });
|
|
},
|
|
() => mockResponse(200, { data: { new_password: 'Pw' } }),
|
|
() => mockResponse(200, { data: { username: 'admin' } }),
|
|
],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808, statusTimeoutMs: 10_000, statusPollIntervalMs: 100 }, deps);
|
|
|
|
expect(statusAttempts).toBe(3);
|
|
expect(sleeps.length).toBeGreaterThanOrEqual(2);
|
|
expect(logs.some((m) => m.includes('Generated initial admin password'))).toBe(true);
|
|
});
|
|
|
|
it('warns (not throws) when status never comes up within budget', async () => {
|
|
const { deps, warns, logs } = makeDeps({
|
|
handlers: [() => mockResponse(500, 'oops')],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808, statusTimeoutMs: 1_000, statusPollIntervalMs: 250 }, deps);
|
|
|
|
expect(warns.some((w) => w.includes('could not verify admin credentials'))).toBe(true);
|
|
expect(logs).toEqual([]);
|
|
});
|
|
|
|
it('warns (not throws) when reset-password fails', async () => {
|
|
const { deps, warns, logs } = makeDeps({
|
|
handlers: [() => mockResponse(200, { needs_setup: true }), () => mockResponse(500, 'boom')],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808 }, deps);
|
|
|
|
expect(warns.some((w) => w.includes('/api/webui/reset-password returned 500'))).toBe(true);
|
|
expect(logs).toEqual([]);
|
|
});
|
|
|
|
it('warns (not throws) when reset-password returns no password', async () => {
|
|
const { deps, warns, logs } = makeDeps({
|
|
handlers: [() => mockResponse(200, { needs_setup: true }), () => mockResponse(200, { data: {} })],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808 }, deps);
|
|
|
|
expect(warns.some((w) => w.includes('returned no new_password'))).toBe(true);
|
|
expect(logs).toEqual([]);
|
|
});
|
|
|
|
it('falls back to "admin" username when system user lookup fails', async () => {
|
|
const { deps, logs } = makeDeps({
|
|
handlers: [
|
|
() => mockResponse(200, { needs_setup: true }),
|
|
() => mockResponse(200, { data: { new_password: 'Pw' } }),
|
|
() => mockResponse(500, 'broken'),
|
|
],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808 }, deps);
|
|
|
|
expect(logs.some((m) => m.includes('Log in with username "admin"'))).toBe(true);
|
|
});
|
|
|
|
it('uses caller-supplied resetCommand in the "Forgot the password" hint', async () => {
|
|
const { deps, logs } = makeDeps({
|
|
handlers: [
|
|
() => mockResponse(200, { needs_setup: false }),
|
|
() => mockResponse(200, { data: { username: 'admin' } }),
|
|
],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808, resetCommand: 'bun run resetpass' }, deps);
|
|
|
|
expect(logs.some((m) => m.includes('bun run resetpass'))).toBe(true);
|
|
expect(logs.every((m) => !m.includes('aionui-web resetpass'))).toBe(true);
|
|
});
|
|
|
|
it('defaults to `aionui-web resetpass` when resetCommand is not provided', async () => {
|
|
const { deps, logs } = makeDeps({
|
|
handlers: [
|
|
() => mockResponse(200, { needs_setup: false }),
|
|
() => mockResponse(200, { data: { username: 'admin' } }),
|
|
],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808 }, deps);
|
|
|
|
expect(logs.some((m) => m.includes('aionui-web resetpass'))).toBe(true);
|
|
});
|
|
|
|
it('propagates resetCommand into warn messages when reset-password fails', async () => {
|
|
const { deps, warns } = makeDeps({
|
|
handlers: [() => mockResponse(200, { needs_setup: true }), () => mockResponse(500, 'boom')],
|
|
});
|
|
|
|
await ensureAdminPassword({ backendPort: 25808, resetCommand: 'bun run resetpass' }, deps);
|
|
|
|
expect(warns.some((w) => w.includes('bun run resetpass'))).toBe(true);
|
|
});
|
|
});
|