127 lines
6.0 KiB
TypeScript
127 lines
6.0 KiB
TypeScript
/**
|
|
* Local-auth repository layer: password hashing, local account creation,
|
|
* the shared `local` system admin, and the user-deletion guards.
|
|
*
|
|
* See docs/superpowers/plans/2026-06-09-local-auth.md.
|
|
*/
|
|
import { afterEach, describe, it, expect, beforeEach } from 'vitest';
|
|
import { mkdtempSync, rmSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { tmpdir } from 'os';
|
|
import { Repository } from './repository.js';
|
|
import { runMigrations } from './migrate.js';
|
|
|
|
describe('Repository local-auth', () => {
|
|
let tempDir = '';
|
|
let repo: Repository;
|
|
|
|
beforeEach(() => {
|
|
tempDir = mkdtempSync(join(tmpdir(), 'maestro-localauth-'));
|
|
repo = new Repository(join(tempDir, 'orchestrator.db'));
|
|
runMigrations(repo.getDb());
|
|
});
|
|
|
|
afterEach(() => {
|
|
repo.close();
|
|
if (tempDir) {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
tempDir = '';
|
|
}
|
|
});
|
|
|
|
// ── password hashing ──────────────────────────────────────────
|
|
|
|
it('setLocalPassword + verifyLocalPassword round-trips', () => {
|
|
const u = repo.createUser({ email: 'a@x.com', name: 'A', role: 'user', status: 'active' });
|
|
repo.setLocalPassword(u.id, 'correct horse battery staple');
|
|
expect(repo.verifyLocalPassword(u.id, 'correct horse battery staple')).toBe(true);
|
|
expect(repo.verifyLocalPassword(u.id, 'wrong')).toBe(false);
|
|
});
|
|
|
|
it('verifyLocalPassword returns false for a user with no credential', () => {
|
|
const u = repo.createUser({ email: 'b@x.com', name: 'B', role: 'user', status: 'active' });
|
|
expect(repo.verifyLocalPassword(u.id, 'anything')).toBe(false);
|
|
});
|
|
|
|
it('setLocalPassword is idempotent-overwrite (re-set changes the password)', () => {
|
|
const u = repo.createUser({ email: 'c@x.com', name: 'C', role: 'user', status: 'active' });
|
|
repo.setLocalPassword(u.id, 'first');
|
|
repo.setLocalPassword(u.id, 'second');
|
|
expect(repo.verifyLocalPassword(u.id, 'first')).toBe(false);
|
|
expect(repo.verifyLocalPassword(u.id, 'second')).toBe(true);
|
|
});
|
|
|
|
it('stores a per-user random salt (two users, same password → different hash)', () => {
|
|
const u1 = repo.createUser({ email: 'd@x.com', name: 'D', role: 'user', status: 'active' });
|
|
const u2 = repo.createUser({ email: 'e@x.com', name: 'E', role: 'user', status: 'active' });
|
|
repo.setLocalPassword(u1.id, 'same');
|
|
repo.setLocalPassword(u2.id, 'same');
|
|
const db = repo.getDb();
|
|
const r1 = db.prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id=?').get(u1.id) as { password_hash: string; salt: string };
|
|
const r2 = db.prepare('SELECT password_hash, salt FROM local_credentials WHERE user_id=?').get(u2.id) as { password_hash: string; salt: string };
|
|
expect(r1.salt).not.toBe(r2.salt);
|
|
expect(r1.password_hash).not.toBe(r2.password_hash);
|
|
});
|
|
|
|
// ── createLocalUser ───────────────────────────────────────────
|
|
|
|
it('createLocalUser creates a pending user with a local identity + password', () => {
|
|
const u = repo.createLocalUser({ email: 'new@x.com', password: 'pw12345', role: 'user', status: 'pending' });
|
|
expect(u.email).toBe('new@x.com');
|
|
expect(u.status).toBe('pending');
|
|
expect(repo.verifyLocalPassword(u.id, 'pw12345')).toBe(true);
|
|
const idn = repo.getDb().prepare("SELECT provider, provider_id FROM oauth_accounts WHERE user_id=? AND provider='local'").get(u.id) as { provider: string; provider_id: string };
|
|
expect(idn.provider).toBe('local');
|
|
expect(idn.provider_id).toBe('new@x.com');
|
|
});
|
|
|
|
it('createLocalUser REJECTS an email that already exists (no account takeover)', () => {
|
|
repo.createUser({ email: 'taken@x.com', name: 'T', role: 'user', status: 'active' });
|
|
expect(() => repo.createLocalUser({ email: 'taken@x.com', password: 'pw', role: 'user', status: 'pending' }))
|
|
.toThrow(/exist/i);
|
|
});
|
|
|
|
// ── upsertLocalSystemAdmin (shared 'local' identity) ──────────
|
|
|
|
it('upsertLocalSystemAdmin creates the shared id=local admin (active)', () => {
|
|
const u = repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'adminpw' });
|
|
expect(u.id).toBe('local');
|
|
expect(u.role).toBe('admin');
|
|
expect(u.status).toBe('active');
|
|
expect(repo.verifyLocalPassword('local', 'adminpw')).toBe(true);
|
|
});
|
|
|
|
it('upsertLocalSystemAdmin is idempotent and keeps id=local across re-seeds', () => {
|
|
repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw1' });
|
|
const again = repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw2' });
|
|
expect(again.id).toBe('local');
|
|
// password updated on re-seed
|
|
expect(repo.verifyLocalPassword('local', 'pw2')).toBe(true);
|
|
// exactly one users row with id=local
|
|
const cnt = repo.getDb().prepare("SELECT COUNT(*) c FROM users WHERE id='local'").get() as { c: number };
|
|
expect(cnt.c).toBe(1);
|
|
});
|
|
|
|
it('local-owned data survives because the admin IS the local owner (id=local)', () => {
|
|
// Simulate no-auth data owned by 'local', then seed the admin onto it.
|
|
repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw' });
|
|
expect(repo.getUserById('local')?.role).toBe('admin');
|
|
});
|
|
|
|
// ── deleteUser guards ─────────────────────────────────────────
|
|
|
|
it('deleteUser REFUSES to delete the local/system user', () => {
|
|
repo.upsertLocalSystemAdmin({ email: 'admin@x.com', password: 'pw' });
|
|
expect(() => repo.deleteUser('local')).toThrow(/local|system/i);
|
|
expect(repo.getUserById('local')).not.toBeNull();
|
|
});
|
|
|
|
it('deleteUser cascades local_credentials (FK ON DELETE CASCADE)', () => {
|
|
const u = repo.createLocalUser({ email: 'z@x.com', password: 'pw', role: 'user', status: 'active' });
|
|
expect(repo.verifyLocalPassword(u.id, 'pw')).toBe(true);
|
|
repo.deleteUser(u.id);
|
|
const row = repo.getDb().prepare('SELECT 1 FROM local_credentials WHERE user_id=?').get(u.id);
|
|
expect(row).toBeUndefined();
|
|
});
|
|
});
|