import { describe, expect, test } from 'bun:test';
import { chmod, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { findReadableSecrets, osUserNameFor, runAsArgv } from './os-user';

// The tests that matter here are the two that prove the MECHANISM rather than the plumbing: that Bun
// ignores `uid`, and that `setpriv` does not. Everything else in os-user.ts touches the passwd database
// and is exercised by hand — see docs/per-user-linux-accounts.md.

describe('Bun.spawn uid', () => {
  // THE pin. `Bun.spawn` accepts `uid`/`gid` at RUNTIME and silently ignores them, so a privilege drop
  // written the obvious way runs as the parent while looking correct. This test exists so that:
  //
  //   - nobody "simplifies" runAs into a uid option, and
  //   - if Bun ever implements it, this fails and tells us we may.
  //
  // Bun's own types do not declare `uid`, so typed code cannot reach this by accident — hence the cast,
  // which stands in for the ways you WOULD reach it: a spread of untyped config, or an `as any` in a
  // hurry. The runtime is what silently accepts it, and the runtime is what this pins.
  //
  // Skipped when running as root, where setuid would actually be permitted and the observation changes.
  test.skipIf(process.getuid?.() === 0)('is ignored at runtime — this is why runAs exists', async () => {
    const nobody = 65534;
    expect(process.getuid?.()).not.toBe(nobody);

    const options = { uid: nobody, gid: nobody, stdout: 'pipe', stderr: 'pipe' } as unknown as {
      stdout: 'pipe';
      stderr: 'pipe';
    };
    const proc = Bun.spawn(['id', '-u'], options);
    const seen = (await new Response(proc.stdout).text()).trim();
    const code = await proc.exited;

    // If this ever fails, read the assertion rather than fixing it: either the child ran as `nobody`
    // (Bun now honours the option) or it refused with EPERM (Bun now tries). Both are good news.
    expect(code).toBe(0);
    expect(seen).toBe(String(process.getuid?.()));
    expect(seen).not.toBe(String(nobody));
  });
});

/** Whether this machine can actually drop privileges. See the sudo note in runAsArgv. */
const canSudo = await (async () => {
  const proc = Bun.spawn(['sudo', '-n', 'true'], { stdout: 'ignore', stderr: 'ignore' });
  return (await proc.exited) === 0;
})();

describe('runAsArgv', () => {
  test('wraps the command in sudo + setpriv with real ids, groups and a reset environment', () => {
    expect(runAsArgv('officer_ana', ['zsh', '-i'])).toEqual([
      'sudo',
      '-n',
      'setpriv',
      '--reuid=officer_ana',
      '--regid=officer_ana',
      '--init-groups',
      '--reset-env',
      '--',
      'zsh',
      '-i',
    ]);
  });

  // --init-groups and --reset-env are not decoration: without the first the process keeps the owner's
  // supplementary groups, and without the second it inherits everything Bun loaded from .env.
  test('never omits --init-groups or --reset-env', () => {
    const argv = runAsArgv('officer_ana', ['true']);
    expect(argv).toContain('--init-groups');
    expect(argv).toContain('--reset-env');
  });

  test('refuses an empty user or command rather than running as the owner', () => {
    expect(() => runAsArgv('', ['true'])).toThrow();
    expect(() => runAsArgv('officer_ana', [])).toThrow();
  });

  // Proves the argv composes and runs end to end. Targets the CURRENT account so no test user has to be
  // created, which still exercises sudo, setpriv, initgroups and the env reset.
  //
  // Skipped where passwordless sudo is unavailable — that is a machine that cannot run this feature at
  // all, and a red test there would say "the code is broken" instead of "this host is not set up".
  test.skipIf(!canSudo)('runs the command as the requested account', async () => {
    const me = process.getuid?.() ?? 0;
    const proc = Bun.spawn(runAsArgv(String(me), ['id', '-u']), { stdout: 'pipe', stderr: 'pipe' });
    const out = (await new Response(proc.stdout).text()).trim();
    const err = (await new Response(proc.stderr).text()).trim();
    expect(await proc.exited, `setpriv failed: ${err}`).toBe(0);
    expect(out).toBe(String(me));
  });

  // The property the whole feature rests on: the platform's environment does not cross the boundary. This
  // process is started by PM2 in the platform directory, so Bun has auto-loaded `.env` into it — the JWT
  // secret and POSTGRES_URL are in `process.env` right now. A member's shell must not see them.
  test.skipIf(!canSudo)('does not pass the platform environment through', async () => {
    const me = process.getuid?.() ?? 0;
    const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "[${OFFICER_LEAK_PROBE:-unset}]"']), {
      env: { ...process.env, OFFICER_LEAK_PROBE: 'this-must-not-cross' },
      stdout: 'pipe',
      stderr: 'pipe',
    });
    const out = (await new Response(proc.stdout).text()).trim();
    expect(await proc.exited).toBe(0);
    expect(out).toBe('[unset]');
  });

  // …and HOME is the target account's, not the caller's. This is what makes a member's shell and their
  // agent's config land in their own directory rather than the owner's.
  test.skipIf(!canSudo)('sets HOME from the target account, not the caller', async () => {
    const me = process.getuid?.() ?? 0;
    const proc = Bun.spawn(runAsArgv(String(me), ['sh', '-c', 'echo "$HOME"']), { stdout: 'pipe', stderr: 'pipe' });
    const out = (await new Response(proc.stdout).text()).trim();
    expect(await proc.exited).toBe(0);
    expect(out).toBeTruthy();
    // Read from passwd rather than inherited: `--reset-env` cleared the caller's HOME before setting it.
    const passwd = Bun.spawn(['sh', '-c', `getent passwd ${me} | cut -d: -f6`], { stdout: 'pipe' });
    expect(out).toBe((await new Response(passwd.stdout).text()).trim());
  });
});

describe('osUserNameFor', () => {
  // The chosen username, verbatim — so `whoami` in a member's terminal says who they are. Measured on this
  // host: useradd accepts dots, hyphens, underscores and uppercase, i.e. everything validateUsername lets
  // through.
  test('uses the chosen username as-is', () => {
    expect(osUserNameFor({ username: 'ana', email: 'ana@example.com' })).toBe('ana');
    expect(osUserNameFor({ username: 'ana.silva', email: 'a@b.com' })).toBe('ana.silva');
    expect(osUserNameFor({ username: 'Ana-Silva_2', email: 'a@b.com' })).toBe('Ana-Silva_2');
  });

  test('falls back to the email local part when there is no username', () => {
    expect(osUserNameFor({ username: null, email: 'Ana.Silva@example.com' })).toBe('ana.silva');
    expect(osUserNameFor({ username: '   ', email: 'Ana.Silva@example.com' })).toBe('ana.silva');
  });

  test('stays within the 32-character limit useradd enforces', () => {
    expect(osUserNameFor({ username: 'a'.repeat(40), email: 'a@b.com' })).toHaveLength(32);
  });

  // No longer defended by a prefix, so it must be defended by adoption rules instead: `ensureOsUser`
  // refuses a name whose existing passwd home is not the one we are about to confine, and refuses any uid
  // below 1000 outright. This test records that the NAME itself is no longer the protection.
  test('does not neutralise a dangerous name — that is ensureOsUser-s job now', () => {
    expect(osUserNameFor({ username: 'root', email: 'r@b.com' })).toBe('root');
  });
});

describe('findReadableSecrets', () => {
  test('reports a group- or world-readable .env, and nothing when it is 600', async () => {
    const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
    const env = join(dir, '.env');
    await writeFile(env, 'JWT_SECRET=x\n');

    await chmod(env, 0o644);
    expect(await findReadableSecrets(dir)).toEqual([env]);

    // Group-only still counts: a member's supplementary groups are not ours to predict.
    await chmod(env, 0o640);
    expect(await findReadableSecrets(dir)).toEqual([env]);

    await chmod(env, 0o600);
    expect(await findReadableSecrets(dir)).toEqual([]);
  });

  test('an absent .env is not a finding', async () => {
    const dir = await mkdtemp(join(tmpdir(), 'officer-secrets-'));
    expect(await findReadableSecrets(dir)).toEqual([]);
  });
});
