mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
203 lines
8.0 KiB
TypeScript
203 lines
8.0 KiB
TypeScript
/**
|
|
* @fileoverview Phase 5 admin API tests (live server, port 3173).
|
|
*
|
|
* Covers the admin user-management endpoints: multi-user gate, requireAdmin,
|
|
* create (one-time password), patch + last-admin invariant, reset-password,
|
|
* disable-revokes-sessions, and delete (last-admin refusal + delete-space).
|
|
*/
|
|
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { WebServer } from '../src/web/server.js';
|
|
import { TmuxManager } from '../src/tmux-manager.js';
|
|
import { createUser, invalidateUsersCache } from '../src/user-store.js';
|
|
|
|
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
|
|
|
|
const PORT = 3173;
|
|
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
|
|
const url = (p: string) => `http://localhost:${PORT}${p}`;
|
|
const admin = { Authorization: basic('root', 'rootpass123'), 'Content-Type': 'application/json' };
|
|
const adminNoBody = { Authorization: basic('root', 'rootpass123') };
|
|
const regular = { Authorization: basic('joe', 'joepass1234'), 'Content-Type': 'application/json' };
|
|
|
|
let server: WebServer;
|
|
let dataDir: string;
|
|
let spacesDir: string;
|
|
const saved: Record<string, string | undefined> = {};
|
|
|
|
beforeAll(async () => {
|
|
dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-data-'));
|
|
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'admin-spaces-'));
|
|
for (const k of [
|
|
'CODEMAN_DATA_DIR',
|
|
'CODEMAN_USER_SPACES_DIR',
|
|
'CODEMAN_MULTIUSER',
|
|
'CODEMAN_PASSWORD',
|
|
'CODEMAN_USERNAME',
|
|
]) {
|
|
saved[k] = process.env[k];
|
|
}
|
|
process.env.CODEMAN_DATA_DIR = dataDir;
|
|
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
delete process.env.CODEMAN_PASSWORD;
|
|
delete process.env.CODEMAN_USERNAME;
|
|
invalidateUsersCache();
|
|
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
|
|
await createUser({ username: 'joe', role: 'user', password: 'joepass1234' });
|
|
server = new WebServer(PORT, false, true);
|
|
await server.start();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server?.stop();
|
|
for (const [k, v] of Object.entries(saved)) {
|
|
if (v === undefined) delete process.env[k];
|
|
else process.env[k] = v;
|
|
}
|
|
invalidateUsersCache();
|
|
await fs.rm(dataDir, { recursive: true, force: true }).catch(() => {});
|
|
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
describe('admin API', () => {
|
|
it('rejects a non-admin (403)', async () => {
|
|
const res = await fetch(url('/api/admin/users'), { headers: regular });
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it('lists users for an admin', async () => {
|
|
const res = await fetch(url('/api/admin/users'), { headers: admin });
|
|
expect(res.status).toBe(200);
|
|
const { data } = await res.json();
|
|
expect(data.map((u: { username: string }) => u.username).sort()).toEqual(['joe', 'root']);
|
|
expect(data[0]).not.toHaveProperty('password');
|
|
});
|
|
|
|
it('creates a user with a one-time password', async () => {
|
|
const res = await fetch(url('/api/admin/users'), {
|
|
method: 'POST',
|
|
headers: admin,
|
|
body: JSON.stringify({ username: 'newbie', role: 'user' }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
const { data } = await res.json();
|
|
expect(data.oneTimePassword).toBeTypeOf('string');
|
|
expect(data.user).toMatchObject({ username: 'newbie', mustChangePassword: true });
|
|
});
|
|
|
|
it('toggles canBypassPermissions via PATCH', async () => {
|
|
const res = await fetch(url('/api/admin/users/joe'), {
|
|
method: 'PATCH',
|
|
headers: admin,
|
|
body: JSON.stringify({ canBypassPermissions: true }),
|
|
});
|
|
expect(res.status).toBe(200);
|
|
expect((await res.json()).data.user.canBypassPermissions).toBe(true);
|
|
});
|
|
|
|
it('refuses to demote the last admin (409)', async () => {
|
|
const res = await fetch(url('/api/admin/users/root'), {
|
|
method: 'PATCH',
|
|
headers: admin,
|
|
body: JSON.stringify({ role: 'user' }),
|
|
});
|
|
expect(res.status).toBe(409);
|
|
expect((await res.json()).errorCode).toBe('LAST_ADMIN');
|
|
});
|
|
|
|
it('resets a password (one-time) and forces change', async () => {
|
|
const res = await fetch(url('/api/admin/users/joe/reset-password'), { method: 'POST', headers: adminNoBody });
|
|
expect(res.status).toBe(200);
|
|
const { data } = await res.json();
|
|
expect(data.oneTimePassword).toBeTypeOf('string');
|
|
// joe must now change password before other actions.
|
|
const gated = await fetch(url('/api/status'), { headers: { Authorization: basic('joe', data.oneTimePassword) } });
|
|
expect(gated.status).toBe(403);
|
|
expect((await gated.json()).errorCode).toBe('PASSWORD_CHANGE_REQUIRED');
|
|
});
|
|
|
|
it('refuses to delete the last admin, deletes a regular user + space', async () => {
|
|
const del = await fetch(url('/api/admin/users/root'), { method: 'DELETE', headers: adminNoBody });
|
|
expect(del.status).toBe(409);
|
|
|
|
await fs.mkdir(path.join(spacesDir, 'newbie', 'cases'), { recursive: true });
|
|
const del2 = await fetch(url('/api/admin/users/newbie'), {
|
|
method: 'DELETE',
|
|
headers: admin,
|
|
body: JSON.stringify({ deleteSpace: true }),
|
|
});
|
|
expect(del2.status).toBe(200);
|
|
await expect(fs.stat(path.join(spacesDir, 'newbie'))).rejects.toBeTruthy();
|
|
});
|
|
|
|
it('404s admin routes in single-user mode', async () => {
|
|
// Flip the flag off for one request path check.
|
|
process.env.CODEMAN_MULTIUSER = '';
|
|
try {
|
|
const res = await fetch(url('/api/admin/users'), { headers: admin });
|
|
expect(res.status).toBe(404);
|
|
} finally {
|
|
process.env.CODEMAN_MULTIUSER = '1';
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('admin case-folder API', () => {
|
|
const kim = { Authorization: basic('kim', 'kimpass1234') };
|
|
|
|
it('lists a user case folders (admin only, hidden dirs excluded)', async () => {
|
|
// Fresh regular user with a known password (joe's was reset above).
|
|
const created = await fetch(url('/api/admin/users'), {
|
|
method: 'POST',
|
|
headers: admin,
|
|
body: JSON.stringify({ username: 'kim', role: 'user', password: 'kimpass1234' }),
|
|
});
|
|
expect(created.status).toBe(200);
|
|
await fs.mkdir(path.join(spacesDir, 'kim', 'cases', 'proj1'), { recursive: true });
|
|
await fs.mkdir(path.join(spacesDir, 'kim', 'cases', '.hidden'), { recursive: true });
|
|
|
|
const forbidden = await fetch(url('/api/admin/users/kim/cases'), { headers: kim });
|
|
expect(forbidden.status).toBe(403);
|
|
|
|
const res = await fetch(url('/api/admin/users/kim/cases'), { headers: adminNoBody });
|
|
expect(res.status).toBe(200);
|
|
const { data } = await res.json();
|
|
expect(data.cases.map((c: { name: string }) => c.name)).toEqual(['proj1']);
|
|
expect(data.cases[0].liveSessions).toBe(0);
|
|
});
|
|
|
|
it('404s for an unknown user', async () => {
|
|
const res = await fetch(url('/api/admin/users/ghost/cases'), { headers: adminNoBody });
|
|
expect(res.status).toBe(404);
|
|
});
|
|
|
|
it('deletes a case folder, refusing unsafe names and symlinks', async () => {
|
|
// Traversal-shaped name: rejected before any filesystem access.
|
|
const bad = await fetch(url('/api/admin/users/kim/cases/..%2Fescape'), {
|
|
method: 'DELETE',
|
|
headers: adminNoBody,
|
|
});
|
|
expect([400, 404]).toContain(bad.status);
|
|
|
|
// A symlinked "case" is refused, never followed.
|
|
await fs.mkdir(path.join(spacesDir, 'outside'), { recursive: true });
|
|
await fs.symlink(path.join(spacesDir, 'outside'), path.join(spacesDir, 'kim', 'cases', 'link'));
|
|
const sl = await fetch(url('/api/admin/users/kim/cases/link'), { method: 'DELETE', headers: adminNoBody });
|
|
expect(sl.status).toBe(400);
|
|
await expect(fs.stat(path.join(spacesDir, 'outside'))).resolves.toBeTruthy();
|
|
|
|
// A real folder is deleted.
|
|
const del = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
|
|
expect(del.status).toBe(200);
|
|
await expect(fs.stat(path.join(spacesDir, 'kim', 'cases', 'proj1'))).rejects.toBeTruthy();
|
|
|
|
// Deleting it again 404s.
|
|
const gone = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
|
|
expect(gone.status).toBe(404);
|
|
});
|
|
});
|