mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-06 05:35:46 +02:00
test: comprehensive tests for auth ordering, tunnel, ngrok, headed mode
16 new tests covering: - /command sits above blanket auth gate (Wintermute bug) - /command uses getTokenInfo not validateAuth - /tunnel/start requires root, checks native ngrok config, returns already_active - /pair creates setup keys not session tokens - Tab ownership checked before command dispatch - Activity events include clientId - Instruction block teaches snapshot→@ref pattern - pair-agent auto-headed mode, process.execPath, --headless skip - isNgrokAvailable checks all 3 sources (gstack env, env var, native config) - handlePairAgent calls /tunnel/start not server restart Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,4 +77,71 @@ describe('Server auth security', () => {
|
||||
// Should not have wildcard CORS for the SSE stream
|
||||
expect(streamBlock).not.toContain("Access-Control-Allow-Origin': '*'");
|
||||
});
|
||||
|
||||
// Test 7: /command accepts scoped tokens (not just root)
|
||||
// This was the Wintermute bug — /command was BELOW the blanket validateAuth gate
|
||||
// which only accepts root tokens. Scoped tokens got 401'd before reaching getTokenInfo.
|
||||
test('/command endpoint sits ABOVE the blanket root-only auth gate', () => {
|
||||
const commandIdx = SERVER_SRC.indexOf("url.pathname === '/command'");
|
||||
const blanketGateIdx = SERVER_SRC.indexOf("Auth-required endpoints (root token only)");
|
||||
// /command must appear BEFORE the blanket gate in source order
|
||||
expect(commandIdx).toBeGreaterThan(0);
|
||||
expect(blanketGateIdx).toBeGreaterThan(0);
|
||||
expect(commandIdx).toBeLessThan(blanketGateIdx);
|
||||
});
|
||||
|
||||
// Test 7b: /command uses getTokenInfo (accepts scoped tokens), not validateAuth (root-only)
|
||||
test('/command uses getTokenInfo for auth, not validateAuth', () => {
|
||||
const commandBlock = sliceBetween(SERVER_SRC, "url.pathname === '/command'", "Auth-required endpoints");
|
||||
expect(commandBlock).toContain('getTokenInfo');
|
||||
expect(commandBlock).not.toContain('validateAuth');
|
||||
});
|
||||
|
||||
// Test 8: /tunnel/start requires root token
|
||||
test('/tunnel/start requires root token', () => {
|
||||
const tunnelBlock = sliceBetween(SERVER_SRC, "/tunnel/start", "Refs endpoint");
|
||||
expect(tunnelBlock).toContain('isRootRequest');
|
||||
expect(tunnelBlock).toContain('Root token required');
|
||||
});
|
||||
|
||||
// Test 8b: /tunnel/start checks ngrok native config paths
|
||||
test('/tunnel/start reads ngrok native config files', () => {
|
||||
const tunnelBlock = sliceBetween(SERVER_SRC, "/tunnel/start", "Refs endpoint");
|
||||
expect(tunnelBlock).toContain("'ngrok.yml'");
|
||||
expect(tunnelBlock).toContain('authtoken');
|
||||
});
|
||||
|
||||
// Test 8c: /tunnel/start returns already_active if tunnel is running
|
||||
test('/tunnel/start returns already_active when tunnel exists', () => {
|
||||
const tunnelBlock = sliceBetween(SERVER_SRC, "/tunnel/start", "Refs endpoint");
|
||||
expect(tunnelBlock).toContain('already_active');
|
||||
expect(tunnelBlock).toContain('tunnelActive');
|
||||
});
|
||||
|
||||
// Test 9: /pair requires root token
|
||||
test('/pair requires root token', () => {
|
||||
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "/tunnel/start");
|
||||
expect(pairBlock).toContain('isRootRequest');
|
||||
expect(pairBlock).toContain('Root token required');
|
||||
});
|
||||
|
||||
// Test 9b: /pair calls createSetupKey (not createToken)
|
||||
test('/pair creates setup keys, not session tokens', () => {
|
||||
const pairBlock = sliceBetween(SERVER_SRC, "url.pathname === '/pair'", "/tunnel/start");
|
||||
expect(pairBlock).toContain('createSetupKey');
|
||||
expect(pairBlock).not.toContain('createToken');
|
||||
});
|
||||
|
||||
// Test 10: tab ownership check happens before command dispatch
|
||||
test('tab ownership check runs before command dispatch for scoped tokens', () => {
|
||||
const handleBlock = sliceBetween(SERVER_SRC, "async function handleCommand", "Block mutation commands while watching");
|
||||
expect(handleBlock).toContain('checkTabAccess');
|
||||
expect(handleBlock).toContain('Tab not owned by your agent');
|
||||
});
|
||||
|
||||
// Test 10b: activity attribution includes clientId
|
||||
test('activity events include clientId from token', () => {
|
||||
const commandStartBlock = sliceBetween(SERVER_SRC, "Activity: emit command_start", "try {");
|
||||
expect(commandStartBlock).toContain('clientId: tokenInfo?.clientId');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,4 +148,97 @@ describe('generateInstructionBlock', () => {
|
||||
expect(block).toContain('403');
|
||||
expect(block).toContain('429');
|
||||
});
|
||||
|
||||
it('teaches the snapshot→@ref pattern', () => {
|
||||
const block = generateInstructionBlock({
|
||||
setupKey: 'gsk_setup_snap',
|
||||
serverUrl: 'https://test.ngrok.dev',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: '2026-04-06T00:00:00Z',
|
||||
});
|
||||
|
||||
// Must explain the snapshot→@ref workflow
|
||||
expect(block).toContain('snapshot');
|
||||
expect(block).toContain('@e1');
|
||||
expect(block).toContain('@e2');
|
||||
expect(block).toContain("Always snapshot first");
|
||||
expect(block).toContain("Don't guess selectors");
|
||||
});
|
||||
|
||||
it('shows SERVER URL prominently', () => {
|
||||
const block = generateInstructionBlock({
|
||||
setupKey: 'gsk_setup_url',
|
||||
serverUrl: 'https://my-tunnel.ngrok.dev',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: '2026-04-06T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(block).toContain('SERVER: https://my-tunnel.ngrok.dev');
|
||||
});
|
||||
|
||||
it('includes newtab in COMMAND REFERENCE', () => {
|
||||
const block = generateInstructionBlock({
|
||||
setupKey: 'gsk_setup_ref',
|
||||
serverUrl: 'https://test.ngrok.dev',
|
||||
scopes: ['read', 'write'],
|
||||
expiresAt: '2026-04-06T00:00:00Z',
|
||||
});
|
||||
|
||||
expect(block).toContain('"command": "newtab"');
|
||||
expect(block).toContain('"command": "goto"');
|
||||
expect(block).toContain('"command": "snapshot"');
|
||||
expect(block).toContain('"command": "click"');
|
||||
expect(block).toContain('"command": "fill"');
|
||||
});
|
||||
});
|
||||
|
||||
// Test CLI source-level behavior (pair-agent headed mode, ngrok detection)
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const CLI_SRC = fs.readFileSync(path.join(import.meta.dir, '../src/cli.ts'), 'utf-8');
|
||||
|
||||
describe('pair-agent CLI behavior', () => {
|
||||
// Extract the pair-agent block: from "pair-agent" dispatch to "process.exit(0)"
|
||||
const pairStart = CLI_SRC.indexOf("command === 'pair-agent'");
|
||||
const pairEnd = CLI_SRC.indexOf('process.exit(0)', pairStart);
|
||||
const pairBlock = CLI_SRC.slice(pairStart, pairEnd);
|
||||
|
||||
it('auto-switches to headed mode unless --headless', () => {
|
||||
expect(pairBlock).toContain("state.mode !== 'headed'");
|
||||
expect(pairBlock).toContain("--headless");
|
||||
expect(pairBlock).toContain("connect");
|
||||
});
|
||||
|
||||
it('uses process.execPath for binary path (not argv[1] which is virtual in compiled)', () => {
|
||||
expect(pairBlock).toContain('process.execPath');
|
||||
// browseBin should be set to execPath, not argv[1]
|
||||
expect(pairBlock).toContain('const browseBin = process.execPath');
|
||||
});
|
||||
|
||||
it('isNgrokAvailable checks gstack env, NGROK_AUTHTOKEN, and native config', () => {
|
||||
const ngrokBlock = CLI_SRC.slice(
|
||||
CLI_SRC.indexOf('function isNgrokAvailable'),
|
||||
CLI_SRC.indexOf('// ─── Pair-Agent DX')
|
||||
);
|
||||
// Three sources checked (paths are in path.join() calls, check the string literals)
|
||||
expect(ngrokBlock).toContain("'ngrok.env'");
|
||||
expect(ngrokBlock).toContain('NGROK_AUTHTOKEN');
|
||||
expect(ngrokBlock).toContain("'ngrok.yml'");
|
||||
// Checks macOS, Linux XDG, and legacy paths
|
||||
expect(ngrokBlock).toContain("'Application Support'");
|
||||
expect(ngrokBlock).toContain("'.config'");
|
||||
expect(ngrokBlock).toContain("'.ngrok2'");
|
||||
});
|
||||
|
||||
it('calls POST /tunnel/start when ngrok is available (not restart)', () => {
|
||||
const handleBlock = CLI_SRC.slice(
|
||||
CLI_SRC.indexOf('async function handlePairAgent'),
|
||||
CLI_SRC.indexOf('function main()')
|
||||
);
|
||||
expect(handleBlock).toContain('/tunnel/start');
|
||||
// Must NOT contain server restart logic
|
||||
expect(handleBlock).not.toContain('Bun.spawn([\'bun\', \'run\'');
|
||||
expect(handleBlock).not.toContain('BROWSE_TUNNEL');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user