Files
Codeman/test/user-store.test.ts
T
Codeman maintainer f496e35d71 feat(multiuser): phase 1, user store, mode plumbing, CLI
Opt-in multi-user foundation (off by default; no behavior change without
CODEMAN_MULTIUSER/--multiuser):

- src/config/multiuser.ts: isMultiUserMode(), getUserSpacesDir()/userCasesDir(),
  maxUsers(), maxSessionsPerUser() (per-user fairness cap = global/2).
- src/types/user.ts: UserRecord/PasswordHash/AuthUser/PublicUser/UserRole.
- src/user-store.ts: ~/.codeman/users.json (atomic tmp+rename, mode 0600, short
  TTL cache). scrypt hashing with per-record params + timingSafeEqual verify plus
  rehash detection; createUser/setPassword/updateUser/deleteUser with last-admin
  invariants; guarded deleteUserSpace (symlink + realpath confinement, section 8);
  pure section-6.3 resolvers (resolveClaudeModeForUser downgrades bypass to auto
  for non-granted users; canRunPrivilegedCommands); bootstrapInitialAdmin.
- src/cli.ts: "codeman users add|passwd|list|rm" (hidden prompt or
  --password-stdin) operating directly on users.json; a --multiuser flag on the
  web command.

Tests: test/user-store.test.ts (29 tests: hashing/verify/rehash, username
validation, atomic 0600 write, last-admin invariants, 6.3 resolvers,
delete-space guards, bootstrap).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 02:58:51 +02:00

302 lines
12 KiB
TypeScript

/**
* @fileoverview Unit tests for the multi-user store (src/user-store.ts).
*
* Pure helpers (hashing/verify/params-upgrade/username validation/6.3 resolvers)
* plus the IO layer against a per-test temp data dir (CODEMAN_DATA_DIR) so nothing
* touches the real ~/.codeman. No server, no tmux.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import fs from 'node:fs/promises';
import { existsSync, statSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import {
bootstrapInitialAdmin,
canRunPrivilegedCommands,
countEnabledAdmins,
createUser,
DEFAULT_SCRYPT_PARAMS,
deleteUser,
deleteUserSpace,
findUser,
generateOneTimePassword,
hashPassword,
hasUsers,
invalidateUsersCache,
isValidUsername,
needsRehash,
normalizeUsername,
readUsers,
resolveClaudeModeForUser,
setPassword,
toPublicUser,
touchLastLogin,
updateUser,
UserStoreError,
verifyPasswordHash,
} from '../src/user-store.js';
let tmpDir: string;
let spacesDir: string;
const savedEnv: Record<string, string | undefined> = {};
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-users-'));
spacesDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-spaces-'));
for (const k of [
'CODEMAN_DATA_DIR',
'CODEMAN_USER_SPACES_DIR',
'CODEMAN_MULTIUSER',
'CODEMAN_MAX_USERS',
'CODEMAN_USERNAME',
'CODEMAN_PASSWORD',
]) {
savedEnv[k] = process.env[k];
}
process.env.CODEMAN_DATA_DIR = tmpDir;
process.env.CODEMAN_USER_SPACES_DIR = spacesDir;
delete process.env.CODEMAN_MAX_USERS;
invalidateUsersCache();
});
afterEach(async () => {
for (const [k, v] of Object.entries(savedEnv)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
invalidateUsersCache();
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
await fs.rm(spacesDir, { recursive: true, force: true }).catch(() => {});
});
describe('username validation', () => {
it('accepts valid slugs', () => {
for (const n of ['alice', 'bob99', 'a1', 'x_y-z', 'user-name_1']) {
expect(isValidUsername(n)).toBe(true);
}
});
it('rejects invalid slugs', () => {
for (const n of [
'',
'a',
'A',
'1',
'_leading',
'-leading',
'has space',
'has.dot',
'a/b',
'..',
'toolongxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
]) {
expect(isValidUsername(n)).toBe(false);
}
});
it('accepts mixed-case input by normalizing (case-insensitive usernames)', () => {
expect(isValidUsername('Alice')).toBe(true);
expect(normalizeUsername(' ALICE ')).toBe('alice');
});
});
describe('password hashing', () => {
it('round-trips a correct password and rejects a wrong one', async () => {
const h = await hashPassword('correct horse');
expect(h.algo).toBe('scrypt');
expect(h.salt).toMatch(/^[0-9a-f]+$/);
expect(await verifyPasswordHash('correct horse', h)).toBe(true);
expect(await verifyPasswordHash('wrong password', h)).toBe(false);
});
it('produces a distinct salt each time', async () => {
const a = await hashPassword('same');
const b = await hashPassword('same');
expect(a.salt).not.toBe(b.salt);
expect(a.hash).not.toBe(b.hash);
});
it('never throws on a malformed record', async () => {
expect(await verifyPasswordHash('x', { algo: 'scrypt', N: 1, r: 1, p: 1, salt: 'zz', hash: '' })).toBe(false);
// @ts-expect-error deliberately malformed
expect(await verifyPasswordHash('x', { algo: 'bogus' })).toBe(false);
});
it('needsRehash detects weaker params', async () => {
const h = await hashPassword('pw', DEFAULT_SCRYPT_PARAMS);
expect(needsRehash(h)).toBe(false);
expect(needsRehash({ ...h, N: 1024 })).toBe(true);
expect(needsRehash({ ...h, algo: 'md5' as unknown as 'scrypt' })).toBe(true);
});
it('generateOneTimePassword returns a >=8 char url-safe string', () => {
const pw = generateOneTimePassword();
expect(pw.length).toBeGreaterThanOrEqual(8);
expect(pw).toMatch(/^[A-Za-z0-9_-]+$/);
});
});
describe('resolveClaudeModeForUser (section 6.3)', () => {
it('admins are unrestricted', () => {
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'admin' })).toBe(
'dangerously-skip-permissions'
);
});
it('granted regular users keep bypass', () => {
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user', canBypassPermissions: true })).toBe(
'dangerously-skip-permissions'
);
});
it('non-granted regular users downgrade skip -> auto', () => {
expect(resolveClaudeModeForUser('dangerously-skip-permissions', { role: 'user' })).toBe('auto');
expect(resolveClaudeModeForUser(undefined, { role: 'user' })).toBe('auto');
});
it('non-granted regular users pass through modes already <= auto', () => {
expect(resolveClaudeModeForUser('auto', { role: 'user' })).toBe('auto');
expect(resolveClaudeModeForUser('normal', { role: 'user' })).toBe('normal');
expect(resolveClaudeModeForUser('allowedTools', { role: 'user' })).toBe('allowedTools');
});
it('canRunPrivilegedCommands follows the same grant', () => {
expect(canRunPrivilegedCommands({ role: 'admin' })).toBe(true);
expect(canRunPrivilegedCommands({ role: 'user', canBypassPermissions: true })).toBe(true);
expect(canRunPrivilegedCommands({ role: 'user' })).toBe(false);
});
});
describe('user store IO', () => {
it('creates, reads back, and writes users.json at mode 0600 atomically', async () => {
expect(await hasUsers()).toBe(false);
const u = await createUser({ username: 'Alice', role: 'admin', password: 'password1' });
expect(u.username).toBe('alice');
expect(u.role).toBe('admin');
expect(await hasUsers()).toBe(true);
const file = path.join(tmpDir, 'users.json');
expect(existsSync(file)).toBe(true);
// 0600 on POSIX
if (process.platform !== 'win32') {
expect(statSync(file).mode & 0o777).toBe(0o600);
}
// no leftover tmp file
expect(existsSync(file + '.tmp')).toBe(false);
const found = await findUser('ALICE');
expect(found?.username).toBe('alice');
expect(toPublicUser(found!)).not.toHaveProperty('password');
});
it('rejects duplicate usernames case-insensitively', async () => {
await createUser({ username: 'bob', role: 'user', password: 'password1' });
await expect(createUser({ username: 'BOB', role: 'user', password: 'password2' })).rejects.toMatchObject({
code: 'USER_EXISTS',
});
});
it('rejects invalid username and short password', async () => {
await expect(createUser({ username: 'Bad Name', role: 'user', password: 'password1' })).rejects.toBeInstanceOf(
UserStoreError
);
await expect(createUser({ username: 'good', role: 'user', password: 'short' })).rejects.toMatchObject({
code: 'INVALID_INPUT',
});
});
it('enforces MAX_USERS', async () => {
process.env.CODEMAN_MAX_USERS = '2';
await createUser({ username: 'a1', role: 'admin', password: 'password1' });
await createUser({ username: 'a2', role: 'user', password: 'password1' });
await expect(createUser({ username: 'a3', role: 'user', password: 'password1' })).rejects.toMatchObject({
code: 'INVALID_INPUT',
});
});
it('setPassword changes the hash and can clear mustChangePassword', async () => {
await createUser({ username: 'carol', role: 'user', password: 'password1', mustChangePassword: true });
const before = await findUser('carol');
expect(before?.mustChangePassword).toBe(true);
await setPassword('carol', 'password2', { mustChangePassword: false });
const after = await findUser('carol');
expect(after?.mustChangePassword).toBe(false);
expect(await verifyPasswordHash('password2', after!.password)).toBe(true);
expect(await verifyPasswordHash('password1', after!.password)).toBe(false);
});
it('touchLastLogin records a timestamp', async () => {
await createUser({ username: 'dave', role: 'user', password: 'password1' });
expect((await findUser('dave'))?.lastLoginAt).toBeUndefined();
await touchLastLogin('dave');
expect((await findUser('dave'))?.lastLoginAt).toBeTypeOf('number');
});
});
describe('last-admin invariants', () => {
it('cannot demote the last enabled admin', async () => {
await createUser({ username: 'root', role: 'admin', password: 'password1' });
await createUser({ username: 'joe', role: 'user', password: 'password1' });
expect(countEnabledAdmins(await readUsers(true))).toBe(1);
await expect(updateUser('root', { role: 'user' })).rejects.toMatchObject({ code: 'LAST_ADMIN' });
await expect(updateUser('root', { disabled: true })).rejects.toMatchObject({ code: 'LAST_ADMIN' });
});
it('cannot delete the last enabled admin', async () => {
await createUser({ username: 'root', role: 'admin', password: 'password1' });
await expect(deleteUser('root')).rejects.toMatchObject({ code: 'LAST_ADMIN' });
});
it('allows demote/delete when another admin remains', async () => {
await createUser({ username: 'root', role: 'admin', password: 'password1' });
await createUser({ username: 'root2', role: 'admin', password: 'password1' });
await expect(updateUser('root', { role: 'user' })).resolves.toMatchObject({ role: 'user' });
await createUser({ username: 'root3', role: 'admin', password: 'password1' });
await expect(deleteUser('root2')).resolves.toBeUndefined();
});
it('updateUser toggles canBypassPermissions', async () => {
await createUser({ username: 'grantee', role: 'user', password: 'password1' });
const updated = await updateUser('grantee', { canBypassPermissions: true });
expect(updated.canBypassPermissions).toBe(true);
});
});
describe('deleteUserSpace guards (section 8)', () => {
it('deletes a real space dir inside USER_SPACES_DIR', async () => {
const dir = path.join(spacesDir, 'ed', 'cases', 'proj');
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(spacesDir, 'ed', 'cases', 'proj', 'f.txt'), 'x');
expect(existsSync(path.join(spacesDir, 'ed'))).toBe(true);
await deleteUserSpace('ed');
expect(existsSync(path.join(spacesDir, 'ed'))).toBe(false);
});
it('is a no-op when the space does not exist', async () => {
await expect(deleteUserSpace('ghost')).resolves.toBeUndefined();
});
it('refuses to delete a symlinked user space', async () => {
const outside = await fs.mkdtemp(path.join(os.tmpdir(), 'codeman-outside-'));
await fs.symlink(outside, path.join(spacesDir, 'evil'));
await expect(deleteUserSpace('evil')).rejects.toMatchObject({ code: 'INVALID_INPUT' });
// the symlink target still exists (was not followed + removed)
expect(existsSync(outside)).toBe(true);
await fs.rm(outside, { recursive: true, force: true });
});
});
describe('bootstrapInitialAdmin', () => {
it('creates the initial admin from env when no users exist', async () => {
process.env.CODEMAN_MULTIUSER = '1';
process.env.CODEMAN_USERNAME = 'boss';
process.env.CODEMAN_PASSWORD = 'password1';
const r = await bootstrapInitialAdmin();
expect(r).toMatchObject({ status: 'created', username: 'boss' });
expect((await findUser('boss'))?.role).toBe('admin');
});
it('reports missing-env when no users and no credentials', async () => {
delete process.env.CODEMAN_USERNAME;
delete process.env.CODEMAN_PASSWORD;
expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'missing-env' });
});
it('reports exists when users already present', async () => {
await createUser({ username: 'someone', role: 'admin', password: 'password1' });
expect(await bootstrapInitialAdmin()).toMatchObject({ status: 'exists' });
});
});