import { describe, expect, test } from 'bun:test'; import { guardDeletable, guardMemberTree, parseSubUidEntry } from './os-user-deprovision'; // The guards that stand between a database column and `sudo userdel` / `sudo chown -R`. // // Everything else in os-user-deprovision.ts needs a machine with accounts to delete on. These three are pure // precisely so the dangerous decisions can be tested without one — they are the part where being wrong is not // recoverable, and the manual teardown that produced the spec is not a thing anyone should repeat to check a // refactor. describe('guardDeletable', () => { const home = '/data/member@example.com/home'; test('accepts an account the platform made', () => { expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: home, expectedHome: home })).toEqual({ ok: true }); }); test('refuses root, and every other system account', () => { // The scenario: a `users` row whose osUser column says 'root'. Nothing else in the sequence would stop it // — `loginctl`, `pkill` and `userdel` would all simply do as they were told. const result = guardDeletable({ osUser: 'root', uid: 0, passwdHome: '/root', expectedHome: home }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain('system account'); expect(guardDeletable({ osUser: 'daemon', uid: 1, passwdHome: home, expectedHome: home }).ok).toBe(false); expect(guardDeletable({ osUser: 'nobody', uid: 999, passwdHome: home, expectedHome: home }).ok).toBe(false); }); test('refuses a same-named account that is not ours', () => { // A human account that happens to share a member's username. Its home is its own, so it fails the only // test that proves ownership. This is `ensureOsUser`'s adoption rule read backwards. const result = guardDeletable({ osUser: 'green', uid: 1002, passwdHome: '/home/green', expectedHome: home }); expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toContain('not an account the platform created'); }); test('refuses an account with no passwd home at all', () => { expect(guardDeletable({ osUser: 'green', uid: 1002, passwdHome: null, expectedHome: home }).ok).toBe(false); }); test('the uid floor is not sufficient on its own', () => { // Both halves are required: a high uid with a foreign home is still somebody else's account. expect(guardDeletable({ osUser: 'green', uid: 4000, passwdHome: '/srv/green', expectedHome: home }).ok).toBe(false); }); }); describe('guardMemberTree', () => { test('resolves a normal account directory', () => { const result = guardMemberTree('/data', 'member@example.com'); expect(result).toEqual({ ok: true, tree: '/data/member@example.com' }); }); test('refuses traversal out of DATA_PATH', () => { // The path this produces is the argument to `chown -R` and, under destroy, to a recursive delete. The // email reaches join() from a database row. expect(guardMemberTree('/data', '../../etc').ok).toBe(false); expect(guardMemberTree('/data', '..').ok).toBe(false); expect(guardMemberTree('/data', 'a/../../b').ok).toBe(false); }); test('refuses a nested path even without traversal', () => { // A member tree is one level down. Anything deeper is not an account directory, whatever it is. expect(guardMemberTree('/data', 'a/b').ok).toBe(false); }); test('refuses DATA_PATH itself', () => { expect(guardMemberTree('/data', '').ok).toBe(false); expect(guardMemberTree('/data', '.').ok).toBe(false); }); test('refuses an empty DATA_PATH', () => { // Unset DATA_PATH would otherwise make every member tree a path under the process's cwd. expect(guardMemberTree('', 'member@example.com').ok).toBe(false); }); }); describe('parseSubUidEntry', () => { const file = 'pastilhas:100000:65536\ngreen:231072:65536\nofficer_jg:165536:65536\n'; test('reads the range for the named account only', () => { expect(parseSubUidEntry(file, 'green')).toEqual({ start: 231072, count: 65536 }); expect(parseSubUidEntry(file, 'officer_jg')).toEqual({ start: 165536, count: 65536 }); }); test('a missing entry is null, not a guess', () => { // Normal: rootless Docker is tolerated when it fails, and accounts predating it have no entry. A guessed // range would make the audit check a different range than the one that was actually freed. expect(parseSubUidEntry(file, 'nobody')).toBeNull(); expect(parseSubUidEntry('', 'green')).toBeNull(); }); test('does not match on a prefix', () => { // 'green' must not match the 'green2' line — the ranges are different and the wrong one verifies nothing. expect(parseSubUidEntry('green2:300000:65536\n', 'green')).toBeNull(); }); test('rejects a malformed line rather than producing NaN', () => { // NaN would flow into `assert-uid-free.sh --check` as an argument and quietly scan nothing. expect(parseSubUidEntry('green:notanumber:65536\n', 'green')).toBeNull(); expect(parseSubUidEntry('green:231072:0\n', 'green')).toBeNull(); }); });