mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-06 16:17:56 +02:00
feat(ios): Mac-side daemon (bun/TS) for Tailscale identity gating + USB proxy
On-demand daemon spawns when /ios-qa needs it (single-instance flock + readiness protocol). Owns tailnet ingress: fail-closed tailscaled LocalAPI probe, dual-track /auth/mint (self-service for allowlisted identities, owner-granted via CLI), capability-tier allowlist (observe/interact/mutate/restore), 1h default session TTL (24h hard cap), audit log of every authenticated mutating tailnet request, hashed-identity attempts log. iOS StateServer never directly binds tailnet — identity validation lives Mac-side because iPhones can't reach tailscaled. 67 unit/integration tests covering session-lock concurrency, capability enforcement, fail-closed probe, identity canonicalization, body limits, and boot-token leak proofs.
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
// Allowlist tests — codex flagged identity canonicalization gaps.
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
loadAllowlist,
|
||||
findEntry,
|
||||
hasCapability,
|
||||
grantIdentity,
|
||||
revokeIdentity,
|
||||
saveAllowlist,
|
||||
} from '../src/allowlist';
|
||||
|
||||
let tmpDir: string;
|
||||
let listPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-allowlist-'));
|
||||
listPath = join(tmpDir, 'allowlist.json');
|
||||
});
|
||||
|
||||
describe('Allowlist', () => {
|
||||
test('loadAllowlist returns empty on missing file', async () => {
|
||||
const list = await loadAllowlist(listPath);
|
||||
expect(list).toEqual({ version: 1, entries: [] });
|
||||
});
|
||||
|
||||
test('saveAllowlist writes mode 0600 JSON', async () => {
|
||||
await saveAllowlist({
|
||||
version: 1,
|
||||
entries: [{ identity: 'user@example.com', capabilities: ['observe'], expires_at: null }],
|
||||
}, listPath);
|
||||
expect(existsSync(listPath)).toBe(true);
|
||||
const raw = readFileSync(listPath, 'utf-8');
|
||||
expect(JSON.parse(raw).entries[0].identity).toBe('user@example.com');
|
||||
});
|
||||
|
||||
test('findEntry matches exact identity', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [{ identity: 'user@example.com', capabilities: ['mutate' as const], expires_at: null }],
|
||||
};
|
||||
expect(findEntry(list, 'user@example.com')?.identity).toBe('user@example.com');
|
||||
expect(findEntry(list, 'USER@example.com')).toBeNull(); // exact-match only
|
||||
expect(findEntry(list, 'unknown@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
test('findEntry skips expired entries', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [
|
||||
{ identity: 'expired', capabilities: ['observe' as const], expires_at: new Date(Date.now() - 60_000).toISOString() },
|
||||
],
|
||||
};
|
||||
expect(findEntry(list, 'expired')).toBeNull();
|
||||
});
|
||||
|
||||
test('findEntry accepts future expiry', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [
|
||||
{ identity: 'future', capabilities: ['observe' as const], expires_at: new Date(Date.now() + 60_000).toISOString() },
|
||||
],
|
||||
};
|
||||
expect(findEntry(list, 'future')?.identity).toBe('future');
|
||||
});
|
||||
|
||||
test('hasCapability is tier-aware', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [
|
||||
{ identity: 'restore-user', capabilities: ['restore' as const], expires_at: null },
|
||||
{ identity: 'observe-user', capabilities: ['observe' as const], expires_at: null },
|
||||
],
|
||||
};
|
||||
expect(hasCapability(list, 'restore-user', 'observe')).toBe(true);
|
||||
expect(hasCapability(list, 'restore-user', 'interact')).toBe(true);
|
||||
expect(hasCapability(list, 'restore-user', 'mutate')).toBe(true);
|
||||
expect(hasCapability(list, 'restore-user', 'restore')).toBe(true);
|
||||
expect(hasCapability(list, 'observe-user', 'observe')).toBe(true);
|
||||
expect(hasCapability(list, 'observe-user', 'interact')).toBe(false);
|
||||
expect(hasCapability(list, 'observe-user', 'mutate')).toBe(false);
|
||||
expect(hasCapability(list, 'observe-user', 'restore')).toBe(false);
|
||||
});
|
||||
|
||||
test('grantIdentity adds a new entry', async () => {
|
||||
await grantIdentity({
|
||||
identity: 'new@example.com',
|
||||
capability: 'interact',
|
||||
path: listPath,
|
||||
});
|
||||
const list = await loadAllowlist(listPath);
|
||||
expect(list.entries).toHaveLength(1);
|
||||
expect(list.entries[0]!.identity).toBe('new@example.com');
|
||||
expect(list.entries[0]!.capabilities).toContain('interact');
|
||||
});
|
||||
|
||||
test('grantIdentity upgrades an existing entry', async () => {
|
||||
await grantIdentity({ identity: 'u', capability: 'observe', path: listPath });
|
||||
await grantIdentity({ identity: 'u', capability: 'restore', path: listPath });
|
||||
const list = await loadAllowlist(listPath);
|
||||
expect(list.entries).toHaveLength(1);
|
||||
expect(list.entries[0]!.capabilities).toContain('restore');
|
||||
});
|
||||
|
||||
test('grantIdentity with ttl sets expires_at', async () => {
|
||||
await grantIdentity({ identity: 'u', capability: 'observe', ttlSeconds: 3600, path: listPath });
|
||||
const list = await loadAllowlist(listPath);
|
||||
const exp = Date.parse(list.entries[0]!.expires_at!);
|
||||
expect(exp).toBeGreaterThan(Date.now());
|
||||
expect(exp).toBeLessThan(Date.now() + 3700 * 1000);
|
||||
});
|
||||
|
||||
test('revokeIdentity removes the entry', async () => {
|
||||
await grantIdentity({ identity: 'u', capability: 'observe', path: listPath });
|
||||
await revokeIdentity('u', listPath);
|
||||
const list = await loadAllowlist(listPath);
|
||||
expect(list.entries).toHaveLength(0);
|
||||
});
|
||||
|
||||
// Codex-flagged identity canonicalization variants — verify the matcher
|
||||
// works for each.
|
||||
test('user identity, tagged node, node key, expired node all canonicalize distinctly', async () => {
|
||||
const list = {
|
||||
version: 1 as const,
|
||||
entries: [
|
||||
{ identity: 'alice@example.com', capabilities: ['observe' as const], expires_at: null },
|
||||
{ identity: 'tag:ci', capabilities: ['mutate' as const], expires_at: null },
|
||||
{ identity: 'node:abcdef0123', capabilities: ['observe' as const], expires_at: null },
|
||||
{ identity: 'bob@example.com', capabilities: ['observe' as const], expires_at: new Date(Date.now() - 1000).toISOString() },
|
||||
],
|
||||
};
|
||||
expect(hasCapability(list, 'alice@example.com', 'observe')).toBe(true);
|
||||
expect(hasCapability(list, 'tag:ci', 'mutate')).toBe(true);
|
||||
expect(hasCapability(list, 'node:abcdef0123', 'observe')).toBe(true);
|
||||
expect(hasCapability(list, 'bob@example.com', 'observe')).toBe(false); // expired
|
||||
expect(hasCapability(list, 'tag:CI', 'mutate')).toBe(false); // case-sensitive — canonicalize before lookup
|
||||
});
|
||||
});
|
||||
|
||||
import { afterEach } from 'bun:test';
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
// Audit + attempts logging tests. Codex-flagged: identity must be hashed in
|
||||
// attempts.jsonl (no raw identity leak), rotation works, sanitize-replacer
|
||||
// strips lone surrogates.
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, readFileSync, writeFileSync, statSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { writeAudit, writeAttempt, sanitizeReplacer } from '../src/audit';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-audit-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('writeAudit', () => {
|
||||
test('appends a JSONL row', async () => {
|
||||
const path = join(tmpDir, 'audit.jsonl');
|
||||
await writeAudit({
|
||||
ts: '2026-05-18T00:00:00Z',
|
||||
identity: 'u@e.com',
|
||||
device_udid: 'UDID-1',
|
||||
endpoint: 'POST /tap',
|
||||
session_id: 'S1',
|
||||
capability: 'interact',
|
||||
request_id: 'req-1',
|
||||
status: 200,
|
||||
}, path);
|
||||
const lines = readFileSync(path, 'utf-8').trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(JSON.parse(lines[0]!).identity).toBe('u@e.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeAttempt', () => {
|
||||
test('hashes raw identity with the device salt (no raw leak)', async () => {
|
||||
const auditPath = join(tmpDir, 'attempts.jsonl');
|
||||
await writeAttempt({
|
||||
rawIdentity: 'attacker@evil.com',
|
||||
endpoint: 'POST /auth/mint',
|
||||
reason: 'identity_not_allowed',
|
||||
path: auditPath,
|
||||
});
|
||||
const lines = readFileSync(auditPath, 'utf-8').trim().split('\n');
|
||||
expect(lines).toHaveLength(1);
|
||||
const row = JSON.parse(lines[0]!);
|
||||
expect(row.reason).toBe('identity_not_allowed');
|
||||
expect(row.identity_canon).not.toBe('attacker@evil.com');
|
||||
expect(row.identity_canon).toMatch(/^[a-f0-9]{16}$/); // 16-char hex
|
||||
});
|
||||
|
||||
test('does NOT log the raw identity anywhere in the row', async () => {
|
||||
const path = join(tmpDir, 'attempts.jsonl');
|
||||
await writeAttempt({
|
||||
rawIdentity: 'secret@example.com',
|
||||
endpoint: 'POST /auth/mint',
|
||||
reason: 'identity_not_allowed',
|
||||
path,
|
||||
});
|
||||
const raw = readFileSync(path, 'utf-8');
|
||||
expect(raw).not.toContain('secret@example.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeReplacer', () => {
|
||||
// Helper: check every UTF-16 code unit in a string. Returns true iff any
|
||||
// unpaired surrogate is present. More reliable than .toContain('\uD800')
|
||||
// since Bun's matcher does UTF-8 byte comparison for non-ASCII.
|
||||
const hasUnpairedSurrogate = (s: string): boolean => {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i);
|
||||
if (c >= 0xD800 && c <= 0xDBFF) {
|
||||
const next = s.charCodeAt(i + 1);
|
||||
if (!(next >= 0xDC00 && next <= 0xDFFF)) return true;
|
||||
i++; // skip the valid pair
|
||||
} else if (c >= 0xDC00 && c <= 0xDFFF) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
test('replaces lone high surrogates with U+FFFD', () => {
|
||||
const out = JSON.stringify({ s: 'before\uD800after' }, sanitizeReplacer);
|
||||
expect(hasUnpairedSurrogate(out)).toBe(false);
|
||||
expect(out.includes('�')).toBe(true);
|
||||
});
|
||||
|
||||
test('replaces lone low surrogates with U+FFFD', () => {
|
||||
const out = JSON.stringify({ s: 'before\uDC00after' }, sanitizeReplacer);
|
||||
expect(hasUnpairedSurrogate(out)).toBe(false);
|
||||
expect(out.includes('�')).toBe(true);
|
||||
});
|
||||
|
||||
test('preserves valid surrogate pairs', () => {
|
||||
// 😀 = U+1F600 = surrogate pair D83D DE00. Must stay intact.
|
||||
const out = JSON.stringify({ s: '😀' }, sanitizeReplacer);
|
||||
expect(out.includes('😀')).toBe(true);
|
||||
expect(hasUnpairedSurrogate(out)).toBe(false);
|
||||
expect(out.includes('�')).toBe(false);
|
||||
});
|
||||
|
||||
test('passes through non-string values', () => {
|
||||
expect(JSON.stringify({ n: 42, b: true, x: null }, sanitizeReplacer)).toBe('{"n":42,"b":true,"x":null}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
// /auth/mint endpoint tests. Codex-flagged: identity allowlist, capability
|
||||
// cap, rate-limit cap, self-service vs owner-granted distinction.
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { mintForCaller } from '../src/auth-mint';
|
||||
import { SessionTokenStore } from '../src/session-tokens';
|
||||
import { grantIdentity } from '../src/allowlist';
|
||||
|
||||
let tmpDir: string;
|
||||
let listPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-mint-'));
|
||||
listPath = join(tmpDir, 'allowlist.json');
|
||||
});
|
||||
|
||||
describe('mintForCaller', () => {
|
||||
test('rejects unknown identity', async () => {
|
||||
const store = new SessionTokenStore();
|
||||
const r = await mintForCaller({
|
||||
callerIdentity: 'stranger@example.com',
|
||||
request: { capability: 'observe' },
|
||||
tokenStore: store,
|
||||
allowlistPath: listPath,
|
||||
});
|
||||
expect(r).toEqual({ error: 'identity_not_allowed' });
|
||||
});
|
||||
|
||||
test('mints at the requested tier when allowlisted at that tier', async () => {
|
||||
await grantIdentity({ identity: 'u@e.com', capability: 'mutate', path: listPath });
|
||||
const store = new SessionTokenStore();
|
||||
const r = await mintForCaller({
|
||||
callerIdentity: 'u@e.com',
|
||||
request: { capability: 'interact' },
|
||||
tokenStore: store,
|
||||
allowlistPath: listPath,
|
||||
});
|
||||
expect('error' in r).toBe(false);
|
||||
if ('error' in r) throw new Error('unexpected');
|
||||
expect(r.capability).toBe('mutate'); // returns the granted tier (higher covers interact)
|
||||
expect(r.session_token.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('refuses to mint above the allowlisted tier', async () => {
|
||||
await grantIdentity({ identity: 'observe-only@e.com', capability: 'observe', path: listPath });
|
||||
const store = new SessionTokenStore();
|
||||
const r = await mintForCaller({
|
||||
callerIdentity: 'observe-only@e.com',
|
||||
request: { capability: 'mutate' },
|
||||
tokenStore: store,
|
||||
allowlistPath: listPath,
|
||||
});
|
||||
expect(r).toEqual({ error: 'capability_insufficient' });
|
||||
});
|
||||
|
||||
test('rate limits hit at 11th mint per identity', async () => {
|
||||
await grantIdentity({ identity: 'spammer@e.com', capability: 'observe', path: listPath });
|
||||
const store = new SessionTokenStore();
|
||||
let lastError: unknown = null;
|
||||
let success = 0;
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const r = await mintForCaller({
|
||||
callerIdentity: 'spammer@e.com',
|
||||
request: { capability: 'observe' },
|
||||
tokenStore: store,
|
||||
allowlistPath: listPath,
|
||||
});
|
||||
if ('error' in r) lastError = r;
|
||||
else success++;
|
||||
}
|
||||
expect(success).toBe(10);
|
||||
expect(lastError).toEqual({ error: 'rate_limited' });
|
||||
});
|
||||
|
||||
test('expired allowlist entries reject the mint', async () => {
|
||||
// Write an expired entry directly.
|
||||
const { saveAllowlist } = await import('../src/allowlist');
|
||||
await saveAllowlist({
|
||||
version: 1,
|
||||
entries: [{
|
||||
identity: 'expired@e.com',
|
||||
capabilities: ['restore'],
|
||||
expires_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
}],
|
||||
}, listPath);
|
||||
const store = new SessionTokenStore();
|
||||
const r = await mintForCaller({
|
||||
callerIdentity: 'expired@e.com',
|
||||
request: { capability: 'observe' },
|
||||
tokenStore: store,
|
||||
allowlistPath: listPath,
|
||||
});
|
||||
expect(r).toEqual({ error: 'identity_not_allowed' });
|
||||
});
|
||||
});
|
||||
|
||||
import { afterEach } from 'bun:test';
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
// End-to-end daemon integration tests. Starts a real daemon against a stub
|
||||
// StateServer + mocked tailscaled. Exercises:
|
||||
//
|
||||
// - Loopback listener responses
|
||||
// - Tailnet listener fail-closed when probe fails
|
||||
// - Tailnet → USB proxy forwards bearer + X-Session-Id
|
||||
// - Capability tier enforcement (interact → /tap ok, observe → /tap 403)
|
||||
// - Rate limit on /auth/mint
|
||||
// - Tailnet listener never binds 0.0.0.0
|
||||
// - Boot token never leaks in responses
|
||||
|
||||
import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
|
||||
import { createServer } from 'http';
|
||||
import type { Server, IncomingMessage } from 'http';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { startDaemon, type RunningDaemon } from '../src/index';
|
||||
import { grantIdentity } from '../src/allowlist';
|
||||
import type { DeviceTunnel } from '../src/proxy';
|
||||
|
||||
let workDir: string;
|
||||
const STATE_SERVER_TOKEN = 'rotated-mock-token-XXXXXXXX';
|
||||
|
||||
// Stub iOS StateServer running on loopback. Mimics the real Swift server's
|
||||
// behavior for the integration test.
|
||||
function startStubStateServer(): Promise<{ server: Server; port: number; receivedRequests: Array<{ method: string; path: string; headers: Record<string, string | string[] | undefined>; body: string }> }> {
|
||||
return new Promise((resolve) => {
|
||||
const received: Array<{ method: string; path: string; headers: Record<string, string | string[] | undefined>; body: string }> = [];
|
||||
const server = createServer((req, res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf-8');
|
||||
received.push({ method: req.method ?? '', path: req.url ?? '', headers: req.headers, body });
|
||||
|
||||
const auth = req.headers['authorization'];
|
||||
// Validate the bearer is our rotated token.
|
||||
if (!auth || auth !== `Bearer ${STATE_SERVER_TOKEN}`) {
|
||||
res.writeHead(401, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'unauthorized' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.url === '/healthz') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ version: '1.0.0' }));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/screenshot') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ png_base64: 'abc=' }));
|
||||
return;
|
||||
}
|
||||
if (req.url === '/tap') {
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, op: 'tap' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'not_found' }));
|
||||
});
|
||||
});
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address();
|
||||
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
resolve({ server, port, receivedRequests: received });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchWith(method: string, url: string, init: { headers?: Record<string, string>; body?: string } = {}): Promise<{ status: number; bodyText: string }> {
|
||||
const res = await fetch(url, { method, headers: init.headers, body: init.body });
|
||||
return { status: res.status, bodyText: await res.text() };
|
||||
}
|
||||
|
||||
describe('daemon — loopback listener', () => {
|
||||
let stub: Awaited<ReturnType<typeof startStubStateServer>>;
|
||||
let daemon: RunningDaemon;
|
||||
let pidPath: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'ios-qa-daemon-loopback-'));
|
||||
pidPath = join(workDir, 'daemon.pid');
|
||||
stub = await startStubStateServer();
|
||||
|
||||
const tunnel: DeviceTunnel = {
|
||||
udid: 'STUB-UDID',
|
||||
ipv6Addr: '127.0.0.1',
|
||||
port: stub.port,
|
||||
bootTokenRotated: STATE_SERVER_TOKEN,
|
||||
};
|
||||
|
||||
const d = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: false,
|
||||
pidfilePath: pidPath,
|
||||
tunnelProvider: async () => tunnel,
|
||||
});
|
||||
if ('error' in d) throw new Error(d.error);
|
||||
daemon = d;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await daemon?.close();
|
||||
stub.server.close();
|
||||
rmSync(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('healthz returns 200 with mode=loopback', async () => {
|
||||
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/healthz`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(JSON.parse(r.bodyText)).toMatchObject({ mode: 'loopback' });
|
||||
});
|
||||
|
||||
test('proxies /screenshot to stub StateServer with the rotated bearer', async () => {
|
||||
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.loopbackPort}/screenshot`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(JSON.parse(r.bodyText)).toEqual({ png_base64: 'abc=' });
|
||||
// Verify the stub received the rotated token, NOT a passthrough or empty token.
|
||||
const lastReq = stub.receivedRequests[stub.receivedRequests.length - 1];
|
||||
expect(lastReq?.headers['authorization']).toBe(`Bearer ${STATE_SERVER_TOKEN}`);
|
||||
});
|
||||
|
||||
test('proxies X-Session-Id passthrough on /tap', async () => {
|
||||
const r = await fetchWith('POST', `http://127.0.0.1:${daemon.loopbackPort}/tap`, {
|
||||
headers: { 'x-session-id': 'sess-loopback-1', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ x: 100, y: 200 }),
|
||||
});
|
||||
expect(r.status).toBe(200);
|
||||
const lastReq = stub.receivedRequests[stub.receivedRequests.length - 1];
|
||||
expect(lastReq?.headers['x-session-id']).toBe('sess-loopback-1');
|
||||
});
|
||||
|
||||
test('returns 503 when no device tunnel is provided', async () => {
|
||||
// Force tunnel provider to return null by closing + restarting with null provider.
|
||||
await daemon.close();
|
||||
pidPath = join(workDir, 'daemon-2.pid');
|
||||
const d2 = await startDaemon({
|
||||
loopbackPort: daemon.loopbackPort + 1,
|
||||
tailnetEnabled: false,
|
||||
pidfilePath: pidPath,
|
||||
tunnelProvider: async () => null,
|
||||
});
|
||||
if ('error' in d2) throw new Error(d2.error);
|
||||
try {
|
||||
const r = await fetchWith('GET', `http://127.0.0.1:${d2.loopbackPort}/screenshot`);
|
||||
expect(r.status).toBe(503);
|
||||
} finally {
|
||||
await d2.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemon — tailnet listener (mocked tailscaled)', () => {
|
||||
let stub: Awaited<ReturnType<typeof startStubStateServer>>;
|
||||
let daemon: RunningDaemon;
|
||||
let listPath: string;
|
||||
let pidPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = mkdtempSync(join(tmpdir(), 'ios-qa-daemon-tailnet-'));
|
||||
listPath = join(workDir, 'allowlist.json');
|
||||
pidPath = join(workDir, 'daemon.pid');
|
||||
stub = await startStubStateServer();
|
||||
|
||||
const tunnel: DeviceTunnel = {
|
||||
udid: 'STUB-UDID',
|
||||
ipv6Addr: '127.0.0.1',
|
||||
port: stub.port,
|
||||
bootTokenRotated: STATE_SERVER_TOKEN,
|
||||
};
|
||||
|
||||
process.env.GSTACK_IOS_ALLOWLIST_PATH = listPath;
|
||||
process.env.GSTACK_IOS_AUDIT_PATH = join(workDir, 'audit.jsonl');
|
||||
process.env.GSTACK_IOS_ATTEMPTS_PATH = join(workDir, 'attempts.jsonl');
|
||||
process.env.GSTACK_IOS_TAILNET_BIND = '127.0.0.1'; // safe test bind
|
||||
|
||||
const d = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: true,
|
||||
pidfilePath: pidPath,
|
||||
tunnelProvider: async () => tunnel,
|
||||
probeImpl: async () => ({ ok: true, ownIdentity: 'mac@example.com' }),
|
||||
whoIsImpl: async () => ({ identity: 'caller@example.com', raw: {} }),
|
||||
});
|
||||
if ('error' in d) throw new Error(d.error);
|
||||
daemon = d;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (daemon) await daemon.close();
|
||||
delete process.env.GSTACK_IOS_ALLOWLIST_PATH;
|
||||
delete process.env.GSTACK_IOS_AUDIT_PATH;
|
||||
delete process.env.GSTACK_IOS_ATTEMPTS_PATH;
|
||||
delete process.env.GSTACK_IOS_TAILNET_BIND;
|
||||
if (workDir) rmSync(workDir, { recursive: true, force: true });
|
||||
stub.server.close();
|
||||
});
|
||||
|
||||
test('tailnet listener refuses to open when probe fails', async () => {
|
||||
await daemon.close();
|
||||
pidPath = join(workDir, 'daemon-fail.pid');
|
||||
const d = await startDaemon({
|
||||
loopbackPort: 0,
|
||||
tailnetEnabled: true,
|
||||
pidfilePath: pidPath,
|
||||
tunnelProvider: async () => null,
|
||||
probeImpl: async () => ({ ok: false, reason: 'socket_missing' }),
|
||||
});
|
||||
if ('error' in d) throw new Error(d.error);
|
||||
try {
|
||||
// Tailnet port should not exist (no listener).
|
||||
expect(d.tailnetPort).toBeNull();
|
||||
// Loopback still works.
|
||||
const r = await fetchWith('GET', `http://127.0.0.1:${d.loopbackPort}/healthz`);
|
||||
expect(r.status).toBe(200);
|
||||
} finally {
|
||||
await d.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('non-allowlisted endpoint returns 404 on tailnet', async () => {
|
||||
const r = await fetchWith('GET', `http://127.0.0.1:${daemon.tailnetPort}/auth/sessions`);
|
||||
expect(r.status).toBe(404);
|
||||
expect(JSON.parse(r.bodyText).error).toBe('endpoint_not_in_tailnet_allowlist');
|
||||
});
|
||||
|
||||
test('/auth/mint rejects unknown identity (mocked WhoIs)', async () => {
|
||||
const r = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ capability: 'observe' }),
|
||||
});
|
||||
expect(r.status).toBe(403);
|
||||
expect(JSON.parse(r.bodyText).error).toBe('identity_not_allowed');
|
||||
});
|
||||
|
||||
test('/auth/mint succeeds for allowlisted identity, then proxies are bearer-gated', async () => {
|
||||
await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath });
|
||||
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ capability: 'interact' }),
|
||||
});
|
||||
expect(mintR.status).toBe(200);
|
||||
const { session_token } = JSON.parse(mintR.bodyText);
|
||||
expect(typeof session_token).toBe('string');
|
||||
|
||||
// Use the token to call /tap.
|
||||
const tapR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
|
||||
headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 's1' },
|
||||
body: JSON.stringify({ x: 1, y: 2 }),
|
||||
});
|
||||
expect(tapR.status).toBe(200);
|
||||
|
||||
// Call without bearer → 401.
|
||||
const tapNoAuth = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
expect(tapNoAuth.status).toBe(401);
|
||||
});
|
||||
|
||||
test('capability tier enforced — observe token cannot call /tap (interact-tier)', async () => {
|
||||
await grantIdentity({ identity: 'caller@example.com', capability: 'observe', path: listPath });
|
||||
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ capability: 'observe' }),
|
||||
});
|
||||
const { session_token } = JSON.parse(mintR.bodyText);
|
||||
|
||||
const tapR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
|
||||
headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 's1' },
|
||||
body: JSON.stringify({ x: 1, y: 2 }),
|
||||
});
|
||||
expect(tapR.status).toBe(403);
|
||||
expect(JSON.parse(tapR.bodyText).error).toBe('capability_insufficient');
|
||||
});
|
||||
|
||||
test('rate limit kicks in at 11th /auth/mint per identity', async () => {
|
||||
await grantIdentity({ identity: 'caller@example.com', capability: 'observe', path: listPath });
|
||||
let last = 0;
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const r = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ capability: 'observe' }),
|
||||
});
|
||||
last = r.status;
|
||||
}
|
||||
expect(last).toBe(429);
|
||||
});
|
||||
|
||||
test('body size limit returns 413', async () => {
|
||||
await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath });
|
||||
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ capability: 'interact' }),
|
||||
});
|
||||
const { session_token } = JSON.parse(mintR.bodyText);
|
||||
|
||||
const huge = 'x'.repeat(2_000_000); // 2MB > 1MB cap
|
||||
const r = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
|
||||
headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 's' },
|
||||
body: JSON.stringify({ padding: huge }),
|
||||
});
|
||||
expect(r.status).toBe(413);
|
||||
});
|
||||
|
||||
test('audit log records mutating tailnet requests', async () => {
|
||||
await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath });
|
||||
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ capability: 'interact' }),
|
||||
});
|
||||
const { session_token } = JSON.parse(mintR.bodyText);
|
||||
|
||||
await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/tap`, {
|
||||
headers: { 'authorization': `Bearer ${session_token}`, 'content-type': 'application/json', 'x-session-id': 'audit-s' },
|
||||
body: JSON.stringify({ x: 1, y: 2 }),
|
||||
});
|
||||
|
||||
// Allow async file write to complete.
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const auditPath = process.env.GSTACK_IOS_AUDIT_PATH!;
|
||||
const { readFileSync, existsSync } = await import('fs');
|
||||
expect(existsSync(auditPath)).toBe(true);
|
||||
const rows = readFileSync(auditPath, 'utf-8').trim().split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
const tapRow = rows.find(r => r.endpoint === 'POST /tap');
|
||||
expect(tapRow).toBeDefined();
|
||||
expect(tapRow.identity).toBe('caller@example.com');
|
||||
expect(tapRow.capability).toBe('interact');
|
||||
});
|
||||
|
||||
test('boot token never appears in tailnet responses', async () => {
|
||||
await grantIdentity({ identity: 'caller@example.com', capability: 'interact', path: listPath });
|
||||
const mintR = await fetchWith('POST', `http://127.0.0.1:${daemon.tailnetPort}/auth/mint`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ capability: 'interact' }),
|
||||
});
|
||||
expect(mintR.bodyText).not.toContain(STATE_SERVER_TOKEN);
|
||||
|
||||
const { session_token } = JSON.parse(mintR.bodyText);
|
||||
const screenshotR = await fetchWith('GET', `http://127.0.0.1:${daemon.tailnetPort}/screenshot`, {
|
||||
headers: { 'authorization': `Bearer ${session_token}` },
|
||||
});
|
||||
expect(screenshotR.bodyText).not.toContain(STATE_SERVER_TOKEN);
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup any leftover env from beforeEach blocks.
|
||||
import { afterEach } from 'bun:test';
|
||||
@@ -0,0 +1,47 @@
|
||||
// Tailnet endpoint allowlist + capability tier classification tests.
|
||||
//
|
||||
// Codex flagged: "tailnet listener allowlist is too broad. Remote agents
|
||||
// should not get /state/* by default. Split capabilities: observe, interact,
|
||||
// mutate state, restore state."
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { classifyRoute } from '../src/proxy';
|
||||
|
||||
describe('classifyRoute', () => {
|
||||
test('healthz, screenshot, elements, snapshot are observe-tier', () => {
|
||||
expect(classifyRoute('GET', '/healthz').requiredCapability).toBe('observe');
|
||||
expect(classifyRoute('GET', '/screenshot').requiredCapability).toBe('observe');
|
||||
expect(classifyRoute('GET', '/elements').requiredCapability).toBe('observe');
|
||||
expect(classifyRoute('GET', '/state/snapshot').requiredCapability).toBe('observe');
|
||||
expect(classifyRoute('GET', '/state/anyKey').requiredCapability).toBe('observe');
|
||||
});
|
||||
|
||||
test('tap, swipe, type, session ops are interact-tier', () => {
|
||||
expect(classifyRoute('POST', '/tap').requiredCapability).toBe('interact');
|
||||
expect(classifyRoute('POST', '/swipe').requiredCapability).toBe('interact');
|
||||
expect(classifyRoute('POST', '/type').requiredCapability).toBe('interact');
|
||||
expect(classifyRoute('POST', '/session/acquire').requiredCapability).toBe('interact');
|
||||
expect(classifyRoute('POST', '/session/release').requiredCapability).toBe('interact');
|
||||
expect(classifyRoute('POST', '/session/heartbeat').requiredCapability).toBe('interact');
|
||||
});
|
||||
|
||||
test('arbitrary state writes are mutate-tier', () => {
|
||||
expect(classifyRoute('POST', '/state/userIsLoggedIn').requiredCapability).toBe('mutate');
|
||||
expect(classifyRoute('POST', '/state/anyField').requiredCapability).toBe('mutate');
|
||||
});
|
||||
|
||||
test('state/restore is restore-tier (highest)', () => {
|
||||
expect(classifyRoute('POST', '/state/restore').requiredCapability).toBe('restore');
|
||||
});
|
||||
|
||||
test('mint endpoint is observe-tier (minimum bar to attempt mint)', () => {
|
||||
expect(classifyRoute('POST', '/auth/mint').requiredCapability).toBe('observe');
|
||||
});
|
||||
|
||||
test('non-allowlisted endpoints return allowed=false', () => {
|
||||
expect(classifyRoute('POST', '/auth/sessions').allowed).toBe(false);
|
||||
expect(classifyRoute('GET', '/random').allowed).toBe(false);
|
||||
expect(classifyRoute('DELETE', '/anything').allowed).toBe(false);
|
||||
expect(classifyRoute('GET', '/auth/sessions').allowed).toBe(false); // loopback-only
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
// Unit tests for SessionTokenStore.
|
||||
//
|
||||
// Codex flagged: TTL semantics, capability tier enforcement, rate limiting,
|
||||
// token expiry, identity-scoped revoke.
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { SessionTokenStore } from '../src/session-tokens';
|
||||
import { capabilityCovers } from '../src/types';
|
||||
|
||||
describe('SessionTokenStore', () => {
|
||||
test('mint returns a token with default 1h TTL', () => {
|
||||
const now = 1_000_000;
|
||||
const store = new SessionTokenStore(() => now);
|
||||
const result = store.mint({
|
||||
identity: 'user@example.com',
|
||||
capability: 'interact',
|
||||
origin: 'self_service',
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
identity: 'user@example.com',
|
||||
capability: 'interact',
|
||||
origin: 'self_service',
|
||||
});
|
||||
if ('error' in result) throw new Error('unexpected error');
|
||||
expect(result.expires_at).toBe(now + 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('mint caps TTL at 24h', () => {
|
||||
const now = 1_000_000;
|
||||
const store = new SessionTokenStore(() => now);
|
||||
const result = store.mint({
|
||||
identity: 'u',
|
||||
capability: 'observe',
|
||||
ttlMs: 1_000_000_000, // way over 24h
|
||||
origin: 'self_service',
|
||||
});
|
||||
if ('error' in result) throw new Error('unexpected error');
|
||||
expect(result.expires_at).toBe(now + 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('validate returns ok for fresh token at the required tier', () => {
|
||||
const store = new SessionTokenStore();
|
||||
const result = store.mint({ identity: 'u', capability: 'mutate', origin: 'owner_granted' });
|
||||
if ('error' in result) throw new Error('unexpected error');
|
||||
const v = store.validate(result.token, 'observe');
|
||||
expect(v.ok).toBe(true);
|
||||
});
|
||||
|
||||
test('validate rejects null/empty/unknown tokens', () => {
|
||||
const store = new SessionTokenStore();
|
||||
expect(store.validate(null, 'observe')).toEqual({ ok: false, reason: 'no_token' });
|
||||
expect(store.validate('', 'observe')).toEqual({ ok: false, reason: 'no_token' });
|
||||
expect(store.validate('bogus-token', 'observe')).toEqual({ ok: false, reason: 'invalid_token' });
|
||||
});
|
||||
|
||||
test('validate rejects expired tokens', () => {
|
||||
let now = 1_000_000;
|
||||
const store = new SessionTokenStore(() => now);
|
||||
const result = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
|
||||
if ('error' in result) throw new Error('unexpected error');
|
||||
now += 25 * 60 * 60 * 1000; // 25 hours later — past max TTL
|
||||
expect(store.validate(result.token, 'observe')).toEqual({ ok: false, reason: 'expired_token' });
|
||||
});
|
||||
|
||||
test('validate rejects tokens with insufficient capability', () => {
|
||||
const store = new SessionTokenStore();
|
||||
const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
|
||||
if ('error' in r) throw new Error('unexpected');
|
||||
expect(store.validate(r.token, 'interact')).toEqual({ ok: false, reason: 'capability_insufficient' });
|
||||
expect(store.validate(r.token, 'mutate')).toEqual({ ok: false, reason: 'capability_insufficient' });
|
||||
expect(store.validate(r.token, 'restore')).toEqual({ ok: false, reason: 'capability_insufficient' });
|
||||
});
|
||||
|
||||
test('higher capability tiers cover lower tiers', () => {
|
||||
expect(capabilityCovers('restore', 'mutate')).toBe(true);
|
||||
expect(capabilityCovers('restore', 'interact')).toBe(true);
|
||||
expect(capabilityCovers('restore', 'observe')).toBe(true);
|
||||
expect(capabilityCovers('mutate', 'interact')).toBe(true);
|
||||
expect(capabilityCovers('observe', 'interact')).toBe(false);
|
||||
expect(capabilityCovers('observe', 'mutate')).toBe(false);
|
||||
});
|
||||
|
||||
test('heartbeat extends TTL', () => {
|
||||
let now = 1_000_000;
|
||||
const store = new SessionTokenStore(() => now);
|
||||
const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
|
||||
if ('error' in r) throw new Error('unexpected');
|
||||
const originalExpiry = r.expires_at;
|
||||
now += 30 * 60 * 1000; // 30 min later
|
||||
const newExpiry = store.heartbeat(r.token);
|
||||
expect(newExpiry).not.toBeNull();
|
||||
expect(newExpiry!).toBeGreaterThan(originalExpiry);
|
||||
expect(newExpiry!).toBe(now + 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test('heartbeat after expiry returns null', () => {
|
||||
let now = 1_000_000;
|
||||
const store = new SessionTokenStore(() => now);
|
||||
const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
|
||||
if ('error' in r) throw new Error('unexpected');
|
||||
now += 25 * 60 * 60 * 1000; // past max TTL
|
||||
expect(store.heartbeat(r.token)).toBeNull();
|
||||
});
|
||||
|
||||
test('rate limit blocks the 11th mint within 60s window', () => {
|
||||
const now = 1_000_000;
|
||||
const store = new SessionTokenStore(() => now);
|
||||
const results = [];
|
||||
for (let i = 0; i < 11; i++) {
|
||||
results.push(store.mint({ identity: 'spammer', capability: 'observe', origin: 'self_service' }));
|
||||
}
|
||||
const ok = results.filter(r => !('error' in r));
|
||||
const errs = results.filter(r => 'error' in r);
|
||||
expect(ok.length).toBe(10);
|
||||
expect(errs.length).toBe(1);
|
||||
expect(errs[0]).toEqual({ error: 'rate_limited' });
|
||||
});
|
||||
|
||||
test('rate limit window slides — 11th mint succeeds after 60s', () => {
|
||||
let now = 1_000_000;
|
||||
const store = new SessionTokenStore(() => now);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
store.mint({ identity: 'spammer', capability: 'observe', origin: 'self_service' });
|
||||
}
|
||||
now += 61_000; // past window
|
||||
const r = store.mint({ identity: 'spammer', capability: 'observe', origin: 'self_service' });
|
||||
expect('error' in r).toBe(false);
|
||||
});
|
||||
|
||||
test('revoke removes a token', () => {
|
||||
const store = new SessionTokenStore();
|
||||
const r = store.mint({ identity: 'u', capability: 'observe', origin: 'self_service' });
|
||||
if ('error' in r) throw new Error('unexpected');
|
||||
expect(store.revoke(r.token)).toBe(true);
|
||||
expect(store.validate(r.token, 'observe')).toEqual({ ok: false, reason: 'invalid_token' });
|
||||
});
|
||||
|
||||
test('revokeByIdentity removes all tokens for one identity', () => {
|
||||
const store = new SessionTokenStore();
|
||||
const a1 = store.mint({ identity: 'a', capability: 'observe', origin: 'self_service' });
|
||||
const a2 = store.mint({ identity: 'a', capability: 'observe', origin: 'self_service' });
|
||||
const b1 = store.mint({ identity: 'b', capability: 'observe', origin: 'self_service' });
|
||||
if ('error' in a1 || 'error' in a2 || 'error' in b1) throw new Error('unexpected');
|
||||
expect(store.revokeByIdentity('a')).toBe(2);
|
||||
expect(store.validate(a1.token, 'observe').ok).toBe(false);
|
||||
expect(store.validate(a2.token, 'observe').ok).toBe(false);
|
||||
expect(store.validate(b1.token, 'observe').ok).toBe(true);
|
||||
});
|
||||
|
||||
test('list returns all active tokens', () => {
|
||||
const store = new SessionTokenStore();
|
||||
store.mint({ identity: 'a', capability: 'observe', origin: 'self_service' });
|
||||
store.mint({ identity: 'b', capability: 'mutate', origin: 'owner_granted' });
|
||||
expect(store.list().length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
// Single-instance enforcement tests.
|
||||
//
|
||||
// Codex-flagged: spawn-race conditions, stale pidfile reclamation, readiness
|
||||
// protocol timeout.
|
||||
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { tryClaim } from '../src/single-instance';
|
||||
|
||||
let tmpDir: string;
|
||||
let pidPath: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'ios-qa-pidfile-'));
|
||||
pidPath = join(tmpDir, 'daemon.pid');
|
||||
});
|
||||
|
||||
describe('tryClaim', () => {
|
||||
test('first claim succeeds and writes pidfile', async () => {
|
||||
const r = await tryClaim({ port: 9099, path: pidPath });
|
||||
expect(r.claimed).toBe(true);
|
||||
expect(existsSync(pidPath)).toBe(true);
|
||||
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8'));
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
expect(parsed.port).toBe(9099);
|
||||
if (r.claimed) await r.release();
|
||||
});
|
||||
|
||||
test('second claim against same live PID returns existing', async () => {
|
||||
// Fake a live pidfile pointing to OUR pid (since we definitely exist).
|
||||
writeFileSync(pidPath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
port: 9099,
|
||||
startedAt: Date.now(),
|
||||
}));
|
||||
const r = await tryClaim({ port: 9100, path: pidPath });
|
||||
expect(r.claimed).toBe(false);
|
||||
if (!r.claimed) {
|
||||
expect(r.existing.pid).toBe(process.pid);
|
||||
expect(r.existing.port).toBe(9099);
|
||||
}
|
||||
});
|
||||
|
||||
test('claim reclaims stale pidfile (dead PID)', async () => {
|
||||
// PID 1 is init/launchd; pick a PID that doesn't exist. PID 999999 is
|
||||
// not assigned in any realistic system.
|
||||
writeFileSync(pidPath, JSON.stringify({
|
||||
pid: 999999,
|
||||
port: 9099,
|
||||
startedAt: Date.now() - 60_000,
|
||||
}));
|
||||
const r = await tryClaim({ port: 9100, path: pidPath });
|
||||
expect(r.claimed).toBe(true);
|
||||
if (r.claimed) {
|
||||
// New pidfile reflects us.
|
||||
const parsed = JSON.parse(readFileSync(pidPath, 'utf-8'));
|
||||
expect(parsed.pid).toBe(process.pid);
|
||||
expect(parsed.port).toBe(9100);
|
||||
await r.release();
|
||||
}
|
||||
});
|
||||
|
||||
test('claim handles unparseable pidfile by reclaiming', async () => {
|
||||
writeFileSync(pidPath, 'not json');
|
||||
const r = await tryClaim({ port: 9101, path: pidPath });
|
||||
expect(r.claimed).toBe(true);
|
||||
if (r.claimed) await r.release();
|
||||
});
|
||||
|
||||
// Codex-flagged: concurrent spawn race. Multiple invocations must result in
|
||||
// exactly one claim winning, with the rest seeing the winner's pidfile.
|
||||
test('concurrent claims race deterministically — exactly one wins', async () => {
|
||||
// Pre-clean: ensure no pidfile.
|
||||
if (existsSync(pidPath)) rmSync(pidPath);
|
||||
const N = 10;
|
||||
const promises: Promise<{ claimed: boolean }>[] = [];
|
||||
for (let i = 0; i < N; i++) {
|
||||
promises.push(tryClaim({ port: 9099 + i, path: pidPath }));
|
||||
}
|
||||
const results = await Promise.all(promises);
|
||||
const wins = results.filter(r => r.claimed);
|
||||
const losses = results.filter(r => !r.claimed);
|
||||
expect(wins.length).toBe(1);
|
||||
expect(losses.length).toBe(N - 1);
|
||||
// Cleanup the winner.
|
||||
const winner = wins[0] as unknown as { claimed: true; release: () => Promise<void> };
|
||||
await winner.release();
|
||||
});
|
||||
});
|
||||
|
||||
import { afterEach } from 'bun:test';
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
// tailscaled LocalAPI client tests. Codex-flagged: identity canonicalization
|
||||
// for user / tag / node-key forms, fail-closed semantics on missing socket
|
||||
// or unparseable response.
|
||||
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import { canonicalize, probeTailscale } from '../src/tailscale-localapi';
|
||||
|
||||
describe('canonicalize', () => {
|
||||
test('returns lowercased user email when UserProfile.LoginName present', () => {
|
||||
const out = canonicalize({
|
||||
Node: { Tags: undefined },
|
||||
UserProfile: { LoginName: 'Alice@Example.COM' },
|
||||
});
|
||||
expect(out).toBe('alice@example.com');
|
||||
});
|
||||
|
||||
test('returns tagged node identity when tags present (prefers tag over user)', () => {
|
||||
const out = canonicalize({
|
||||
Node: { Tags: ['tag:CI'] },
|
||||
UserProfile: { LoginName: 'admin@example.com' },
|
||||
});
|
||||
expect(out).toBe('tag:ci');
|
||||
});
|
||||
|
||||
test('handles tag without prefix', () => {
|
||||
const out = canonicalize({
|
||||
Node: { Tags: ['ci'] },
|
||||
});
|
||||
expect(out).toBe('tag:ci');
|
||||
});
|
||||
|
||||
test('returns node:<key> when no user and no tags', () => {
|
||||
const out = canonicalize({
|
||||
Node: { Key: 'nodekey:abcdef0123' },
|
||||
});
|
||||
expect(out).toBe('node:abcdef0123');
|
||||
});
|
||||
|
||||
test('returns null for unparseable response', () => {
|
||||
expect(canonicalize({})).toBeNull();
|
||||
expect(canonicalize({ Node: {} })).toBeNull();
|
||||
expect(canonicalize({ UserProfile: { LoginName: 'no-at-sign' } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('probeTailscale', () => {
|
||||
test('fails closed when socket does not exist', async () => {
|
||||
const r = await probeTailscale('/tmp/does-not-exist-' + Math.random());
|
||||
expect(r.ok).toBe(false);
|
||||
// Reason may be 'socket_missing' or 'unreachable' depending on how the
|
||||
// OS/runtime surfaces a missing unix socket. Either is a fail-closed
|
||||
// outcome that prevents the daemon from opening the tailnet listener.
|
||||
expect(['socket_missing', 'unreachable']).toContain(r.reason);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user