mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
d56d5cd947
22 tests. verify: accepts canonical pooler URL (argv + stdin modes), rejects direct-connection URL with exit 3, rejects wrong scheme, wrong port, empty password, missing userinfo, plain 'postgres' user (catches direct-URL paste errors), wrong host, empty URL. Case-insensitive host match. Explicit negative: error messages never echo the URL password. lib.sh read_secret_to_env: reads piped stdin into the named env var, exports to subprocesses, redacted-preview emits masked form on stderr with the seed password absent, rejects invalid var names (lowercase, leading digit, hyphens), rejects missing/unknown flags, secret value never appears on stdout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
258 lines
8.4 KiB
TypeScript
258 lines
8.4 KiB
TypeScript
/**
|
|
* gstack-gbrain-supabase-verify + gstack-gbrain-lib.sh — Slice 3 of /setup-gbrain.
|
|
*
|
|
* verify: structural URL check (scheme, userinfo, host, port). No network
|
|
* call; pure regex. Rejects direct-connection URLs with a distinct exit
|
|
* code + UX because that's the most common paste mistake.
|
|
*
|
|
* lib.sh: shared secret-read helper (read_secret_to_env) sourced by the
|
|
* skill template and by gstack-gbrain-supabase-provision. Validates var
|
|
* name, handles stdin=TTY and stdin=pipe (CI) paths, supports optional
|
|
* redacted-preview echo.
|
|
*
|
|
* Not tested here: TTY path with stty manipulation. `bun test` runs under
|
|
* pipe stdin so [ -t 0 ] is false and the stty branches skip. That's the
|
|
* right test matrix for CI; TTY behavior is covered by the manual test
|
|
* matrix on a real terminal.
|
|
*/
|
|
|
|
import { describe, test, expect } from 'bun:test';
|
|
import * as path from 'path';
|
|
import { spawnSync } from 'child_process';
|
|
|
|
const ROOT = path.resolve(import.meta.dir, '..');
|
|
const VERIFY = path.join(ROOT, 'bin', 'gstack-gbrain-supabase-verify');
|
|
const LIB = path.join(ROOT, 'bin', 'gstack-gbrain-lib.sh');
|
|
|
|
function runVerify(arg: string, stdin?: string) {
|
|
const res = spawnSync(VERIFY, arg === '' ? [] : [arg], {
|
|
input: stdin,
|
|
encoding: 'utf-8',
|
|
});
|
|
return {
|
|
stdout: (res.stdout || '').trim(),
|
|
stderr: (res.stderr || '').trim(),
|
|
status: res.status ?? -1,
|
|
};
|
|
}
|
|
|
|
// Invoke a bash snippet that sources the lib and runs something against it.
|
|
// Returns stdout + stderr + exit code. Stdin is piped so [ -t 0 ] = false.
|
|
function runLibSnippet(snippet: string, stdin: string = '') {
|
|
const script = `set -euo pipefail\n. ${JSON.stringify(LIB)}\n${snippet}`;
|
|
const res = spawnSync('bash', ['-c', script], {
|
|
input: stdin,
|
|
encoding: 'utf-8',
|
|
});
|
|
return {
|
|
stdout: (res.stdout || '').trim(),
|
|
stderr: (res.stderr || '').trim(),
|
|
status: res.status ?? -1,
|
|
};
|
|
}
|
|
|
|
describe('gstack-gbrain-supabase-verify', () => {
|
|
const VALID =
|
|
'postgresql://postgres.abcdefghijklmnopqrst:secretpass@aws-0-us-east-1.pooler.supabase.com:6543/postgres';
|
|
|
|
test('accepts canonical Session Pooler URL', () => {
|
|
const r = runVerify(VALID);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toBe('ok');
|
|
});
|
|
|
|
test('accepts postgres:// scheme (without ql)', () => {
|
|
const r = runVerify(VALID.replace('postgresql://', 'postgres://'));
|
|
expect(r.status).toBe(0);
|
|
});
|
|
|
|
test('accepts URL via stdin with "-"', () => {
|
|
const r = runVerify('-', VALID);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toBe('ok');
|
|
});
|
|
|
|
test('accepts URL via stdin with no argv', () => {
|
|
const r = runVerify('', VALID);
|
|
expect(r.status).toBe(0);
|
|
});
|
|
|
|
test('rejects direct-connection URL with exit code 3', () => {
|
|
const url = 'postgresql://postgres:secret@db.abcdefghijk.supabase.co:5432/postgres';
|
|
const r = runVerify(url);
|
|
expect(r.status).toBe(3);
|
|
expect(r.stderr).toContain('rejected direct-connection URL');
|
|
expect(r.stderr).toContain('Session Pooler');
|
|
// Error message should not echo the URL back (it contains a password)
|
|
expect(r.stderr).not.toContain('secret');
|
|
});
|
|
|
|
test('rejects wrong scheme', () => {
|
|
const r = runVerify('mysql://user:pass@aws-0-us-east-1.pooler.supabase.com:6543/postgres');
|
|
expect(r.status).toBe(2);
|
|
expect(r.stderr).toContain('bad scheme');
|
|
});
|
|
|
|
test('rejects non-6543 port', () => {
|
|
const r = runVerify(
|
|
'postgresql://postgres.ref:pass@aws-0-us-east-1.pooler.supabase.com:5432/postgres'
|
|
);
|
|
expect(r.status).toBe(2);
|
|
expect(r.stderr).toContain('6543');
|
|
});
|
|
|
|
test('rejects empty password', () => {
|
|
const r = runVerify(
|
|
'postgresql://postgres.ref:@aws-0-us-east-1.pooler.supabase.com:6543/postgres'
|
|
);
|
|
expect(r.status).toBe(2);
|
|
expect(r.stderr).toContain('empty password');
|
|
});
|
|
|
|
test('rejects missing userinfo', () => {
|
|
const r = runVerify('postgresql://aws-0-us-east-1.pooler.supabase.com:6543/postgres');
|
|
expect(r.status).toBe(2);
|
|
expect(r.stderr).toContain('missing userinfo');
|
|
});
|
|
|
|
test('rejects plain "postgres" user (no .ref) to catch direct-URL paste mistakes', () => {
|
|
const r = runVerify(
|
|
'postgresql://postgres:pass@aws-0-us-east-1.pooler.supabase.com:6543/postgres'
|
|
);
|
|
expect(r.status).toBe(2);
|
|
expect(r.stderr).toContain("user portion 'postgres'");
|
|
});
|
|
|
|
test('rejects wrong host (not *.pooler.supabase.com)', () => {
|
|
const r = runVerify('postgresql://postgres.ref:pass@example.com:6543/postgres');
|
|
expect(r.status).toBe(2);
|
|
expect(r.stderr).toContain('pooler.supabase.com');
|
|
});
|
|
|
|
test('rejects empty URL', () => {
|
|
const r = runVerify('-', '');
|
|
expect(r.status).toBe(2);
|
|
expect(r.stderr).toContain('empty URL');
|
|
});
|
|
|
|
test('case-insensitive host match (POOLER.SUPABASE.COM passes)', () => {
|
|
const r = runVerify(
|
|
'postgresql://postgres.ref:pass@AWS-0-US-EAST-1.POOLER.SUPABASE.COM:6543/postgres'
|
|
);
|
|
expect(r.status).toBe(0);
|
|
});
|
|
|
|
test('error messages never echo the URL password', () => {
|
|
// Supply a URL with a distinctive password; verify none of the errors
|
|
// leak the password to stderr.
|
|
const r = runVerify(
|
|
'mysql://user:VERY-DISTINCT-SECRET-dk3984@aws-0-us-east-1.pooler.supabase.com:6543/postgres'
|
|
);
|
|
expect(r.status).toBe(2);
|
|
expect(r.stderr).not.toContain('VERY-DISTINCT-SECRET');
|
|
});
|
|
});
|
|
|
|
describe('gstack-gbrain-lib.sh read_secret_to_env', () => {
|
|
test('reads secret from piped stdin into the named env var', () => {
|
|
const r = runLibSnippet(
|
|
`
|
|
read_secret_to_env MY_SECRET "Enter: "
|
|
echo "captured=[$MY_SECRET]"
|
|
echo "len=\${#MY_SECRET}"
|
|
`,
|
|
'hello-world-123'
|
|
);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('captured=[hello-world-123]');
|
|
expect(r.stdout).toContain('len=15');
|
|
});
|
|
|
|
test('exports the var so sub-processes see it', () => {
|
|
const r = runLibSnippet(
|
|
`
|
|
read_secret_to_env TEST_VAR "Enter: "
|
|
bash -c 'echo "child-sees=[$TEST_VAR]"'
|
|
`,
|
|
'child-test-value'
|
|
);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).toContain('child-sees=[child-test-value]');
|
|
});
|
|
|
|
test('redacted preview uses the provided sed expression (password masked)', () => {
|
|
const r = runLibSnippet(
|
|
`
|
|
read_secret_to_env MY_URL "URL: " --echo-redacted 's#://[^@]*@#://***@#'
|
|
echo "ok"
|
|
`,
|
|
'postgresql://user:SECRET123@host:5432/db'
|
|
);
|
|
expect(r.status).toBe(0);
|
|
// Redacted preview goes to stderr
|
|
expect(r.stderr).toContain('Got: postgresql://***@host:5432/db');
|
|
// Password must not appear in the preview
|
|
expect(r.stderr).not.toContain('SECRET123');
|
|
});
|
|
|
|
test('rejects invalid var names (must match [A-Z_][A-Z0-9_]*)', () => {
|
|
const r = runLibSnippet(
|
|
`
|
|
read_secret_to_env "lower-case" "Prompt: " || echo "correctly-rejected"
|
|
`,
|
|
'anything'
|
|
);
|
|
expect(r.status).toBe(0); // snippet returns 0 via the || fallback
|
|
expect(r.stdout).toContain('correctly-rejected');
|
|
expect(r.stderr).toContain('invalid var name');
|
|
});
|
|
|
|
test('rejects var names that start with a digit', () => {
|
|
const r = runLibSnippet(
|
|
`
|
|
read_secret_to_env "1VAR" "Prompt: " || echo "correctly-rejected"
|
|
`,
|
|
'x'
|
|
);
|
|
expect(r.stdout).toContain('correctly-rejected');
|
|
});
|
|
|
|
test('rejects missing args', () => {
|
|
const r = runLibSnippet(
|
|
`
|
|
read_secret_to_env || echo "correctly-rejected"
|
|
`
|
|
);
|
|
expect(r.stdout).toContain('correctly-rejected');
|
|
expect(r.stderr).toContain('usage');
|
|
});
|
|
|
|
test('rejects unknown flags', () => {
|
|
const r = runLibSnippet(
|
|
`
|
|
read_secret_to_env MY_VAR "Prompt: " --unknown-flag xxx || echo "correctly-rejected"
|
|
`,
|
|
'x'
|
|
);
|
|
expect(r.stdout).toContain('correctly-rejected');
|
|
expect(r.stderr).toContain('unknown flag');
|
|
});
|
|
|
|
test('secret value never appears on stdout', () => {
|
|
// The entire stdout comes from our `echo` statements, not read_secret_to_env.
|
|
// Verify that an uncaptured secret doesn't leak via the prompt or anywhere.
|
|
const r = runLibSnippet(
|
|
`
|
|
read_secret_to_env HIDDEN "Enter: "
|
|
echo "len=\${#HIDDEN}"
|
|
`,
|
|
'this-must-not-leak-abc'
|
|
);
|
|
expect(r.status).toBe(0);
|
|
expect(r.stdout).not.toContain('this-must-not-leak-abc');
|
|
expect(r.stdout).toBe('len=22');
|
|
// The prompt goes to stderr; secret must not appear there either.
|
|
expect(r.stderr).not.toContain('this-must-not-leak-abc');
|
|
});
|
|
});
|