|
import { Database } from "bun:sqlite"; |
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; |
|
import fs from "node:fs"; |
|
import os from "node:os"; |
|
import path from "node:path"; |
|
|
|
const SCRIPT = path.join(import.meta.dir, "sqlite_readonly.ts"); |
|
const TEST_DB = "/tmp/oc-database-test.db"; |
|
|
|
/** |
|
* Runs the sqlite_readonly.ts script with the given arguments. |
|
* @param args - Command-line arguments to pass to the script |
|
* @returns Object with stdout, stderr, and exitCode |
|
*/ |
|
async function run(...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { |
|
const proc = Bun.spawn(["bun", SCRIPT, ...args], { |
|
stdout: "pipe", |
|
stderr: "pipe", |
|
}); |
|
const [stdout, stderr, exitCode] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited]); |
|
return { stdout, stderr, exitCode }; |
|
} |
|
|
|
beforeAll(() => { |
|
try { |
|
fs.unlinkSync(TEST_DB); |
|
} catch {} |
|
const db = new Database(TEST_DB, { create: true }); |
|
db.run("CREATE TABLE IF NOT EXISTS project (id INTEGER PRIMARY KEY, name TEXT, ACCESS_TOKEN TEXT, REFRESH_TOKEN TEXT, SECRET TEXT)"); |
|
db.run("INSERT INTO project (name, ACCESS_TOKEN, REFRESH_TOKEN, SECRET) VALUES ('test', 'secret123', 'refresh456', 'topsecret')"); |
|
db.close(); |
|
}); |
|
|
|
afterAll(() => { |
|
try { |
|
fs.unlinkSync(TEST_DB); |
|
} catch {} |
|
// clean up symlink if created by symlink safety test |
|
try { |
|
fs.unlinkSync("/tmp/oc-database-test-symlink.db"); |
|
} catch {} |
|
}); |
|
|
|
describe("sqlite_readonly", () => { |
|
// no arguments → exit 1, usage printed |
|
test("no-arg: exits 1 and prints usage when no SQL given", async () => { |
|
const { stdout, stderr, exitCode } = await run("--db", TEST_DB); |
|
expect(exitCode).toBe(1); |
|
expect(stdout + stderr).toContain("Usage"); |
|
}); |
|
|
|
// --help → exit 0, stdout contains "Usage" |
|
test("--help: exits 0 and prints usage", async () => { |
|
const { stdout, exitCode } = await run("--help"); |
|
expect(exitCode).toBe(0); |
|
expect(stdout).toContain("Usage"); |
|
}); |
|
|
|
// valid SELECT → exit 0, output contains data |
|
test("valid-select: exits 0 and returns query results", async () => { |
|
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project"); |
|
expect(exitCode).toBe(0); |
|
expect(stdout).toContain("test"); |
|
}); |
|
|
|
// CTE query with explicit column list → exit 0 |
|
test("cte-select: exits 0 for WITH...SELECT query", async () => { |
|
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "WITH cte AS (SELECT name FROM project) SELECT name FROM cte"); |
|
expect(exitCode).toBe(0); |
|
expect(stdout).toContain("test"); |
|
}); |
|
|
|
// semicolons → exit 1 |
|
test("injection-semicolon: exits 1 when SQL contains semicolon", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT name FROM project; DROP TABLE project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// DML — INSERT → exit 1 |
|
test("injection-insert: exits 1 for INSERT statement", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "INSERT INTO project (name) VALUES ('evil')"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// DML — UPDATE → exit 1 |
|
test("injection-update: exits 1 for UPDATE statement", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "UPDATE project SET name = 'evil'"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// DML — DELETE → exit 1 |
|
test("injection-delete: exits 1 for DELETE statement", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "DELETE FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// DDL — CREATE → exit 1 |
|
test("injection-create: exits 1 for CREATE TABLE statement", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "CREATE TABLE evil (id INTEGER)"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// DDL — DROP → exit 1 |
|
test("injection-drop: exits 1 for DROP TABLE statement", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "DROP TABLE project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// DDL — ALTER → exit 1 |
|
test("injection-alter: exits 1 for ALTER TABLE statement", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "ALTER TABLE project ADD COLUMN x TEXT"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// dot-commands — .tables → exit 1 |
|
test("dot-command-tables: exits 1 for .tables dot-command", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, ".tables"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// dot-commands — .schema → exit 1 |
|
test("dot-command-schema: exits 1 for .schema dot-command", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, ".schema"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// newlines in SQL that hide a DML keyword → exit 1 |
|
test("newline-injection: exits 1 when SQL contains embedded newline", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT name FROM project\nDELETE FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// SELECT * → exit 1 |
|
test("wildcard-star: exits 1 for SELECT * query", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT * FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// table.* → exit 1 |
|
test("wildcard-table-star: exits 1 for table.* wildcard", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT project.* FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// COUNT(*) → exit 0 (exempted) |
|
test("count-star-exemption: exits 0 for COUNT(*) query", async () => { |
|
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "SELECT COUNT(*) FROM project"); |
|
expect(exitCode).toBe(0); |
|
expect(stdout).toContain("1"); |
|
}); |
|
|
|
// arithmetic * → exit 0 (exempted) |
|
test("arithmetic-star-exemption: exits 0 for arithmetic multiplication", async () => { |
|
const { exitCode } = await run("--db", TEST_DB, "-q", "SELECT 2 * 3 FROM project"); |
|
expect(exitCode).toBe(0); |
|
}); |
|
|
|
// forbidden column ACCESS_TOKEN → exit 1 |
|
test("forbidden-access-token: exits 1 for query referencing ACCESS_TOKEN", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT ACCESS_TOKEN FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// forbidden column case-insensitive → exit 1 |
|
test("forbidden-access-token-lowercase: exits 1 for lowercase access_token", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT access_token FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// forbidden column REFRESH_TOKEN → exit 1 |
|
test("forbidden-refresh-token: exits 1 for query referencing REFRESH_TOKEN", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT REFRESH_TOKEN FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// forbidden column SECRET → exit 1 |
|
test("forbidden-secret: exits 1 for query referencing SECRET column", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT SECRET FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// trailing line comment → exit 1 (only leading comments are allowed) |
|
test("comment-trailing: exits 1 when SQL contains trailing -- comment", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project -- some comment"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// leading block comment stripped → exit 0 |
|
test("comment-block: exits 0 when leading /* */ block comment is stripped", async () => { |
|
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "/* a comment */ SELECT name FROM project"); |
|
expect(exitCode).toBe(0); |
|
expect(stdout).toContain("test"); |
|
}); |
|
|
|
// mid-query comment → exit 1 (obfuscation prevention) |
|
test("comment-mid-query: exits 1 when comment marker appears mid-query", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT name FROM project -- hidden injection"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
|
|
const { stderr: stderr2, exitCode: exitCode2 } = await run("--db", TEST_DB, "SELECT /* evil */ name FROM project"); |
|
expect(exitCode2).toBe(1); |
|
expect(stderr2).toContain("ERROR:"); |
|
}); |
|
|
|
// output format — column format is default |
|
test("format-column: exits 0 with column format and headers", async () => { |
|
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project"); |
|
expect(exitCode).toBe(0); |
|
expect(stdout).toContain("name"); |
|
}); |
|
|
|
// missing database → exit 1, stderr contains error |
|
test("missing-db: exits 1 when database file does not exist", async () => { |
|
const { stderr, exitCode } = await run("--db", "/tmp/this-does-not-exist-oc-database.db", "SELECT name FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// symlink safety: symlink to real DB → exit 1 |
|
test("symlink-safety: exits 1 when --db path is a symlink", async () => { |
|
const symlinkPath = "/tmp/oc-database-test-symlink.db"; |
|
try { |
|
fs.unlinkSync(symlinkPath); |
|
} catch {} |
|
fs.symlinkSync(TEST_DB, symlinkPath); |
|
|
|
const { stderr, exitCode } = await run("--db", symlinkPath, "SELECT name FROM project"); |
|
|
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// quiet mode: -q suppresses informational lines |
|
test("quiet-mode: exits 0 and suppresses DB/Out info lines with -q", async () => { |
|
const { stdout, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project"); |
|
expect(exitCode).toBe(0); |
|
expect(stdout).not.toContain("DB:"); |
|
expect(stdout).not.toContain("Out:"); |
|
expect(stdout).not.toContain("Results saved to:"); |
|
}); |
|
|
|
// normal mode: without -q, info lines are present |
|
test("normal-mode: exits 0 and includes DB/Out info lines without -q", async () => { |
|
const { stdout, exitCode } = await run("--db", TEST_DB, "SELECT name FROM project"); |
|
expect(exitCode).toBe(0); |
|
expect(stdout).toContain("DB:"); |
|
expect(stdout).toContain("Out:"); |
|
}); |
|
|
|
// --out slug validation: valid slug succeeds |
|
test("out-slug-valid: exits 0 with valid --out slug", async () => { |
|
const { exitCode } = await run("--db", TEST_DB, "-q", "--out", "test-output.txt", "SELECT name FROM project"); |
|
expect(exitCode).toBe(0); |
|
// cleanup |
|
try { |
|
fs.unlinkSync("/tmp/oc-database/test-output.txt"); |
|
} catch {} |
|
}); |
|
|
|
// --out slug with invalid chars → exit 1 |
|
test("out-slug-invalid: exits 1 when --out slug contains invalid characters", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "--out", "../../etc/passwd", "SELECT name FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// --out slug of '.' → exit 1 |
|
test("out-slug-dot: exits 1 when --out slug is '.'", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "--out", ".", "SELECT name FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// --format option should be rejected as unknown |
|
test("format-option-rejected: exits 1 for --format option", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "--format", "column", "SELECT name FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
expect(stderr).toContain("Unknown option"); |
|
}); |
|
|
|
// unknown option → exit 1 |
|
test("unknown-option: exits 1 for unknown flag", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "--unknown-flag", "SELECT name FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// DML keyword inside WITH clause → exit 1 |
|
test("with-clause-dml: exits 1 when WITH clause contains DELETE keyword", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "WITH cte AS (SELECT name FROM project) DELETE FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// WITH clause without SELECT → exit 1 |
|
test("with-clause-no-select: exits 1 when WITH clause is not followed by SELECT", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "WITH cte AS (SELECT name FROM project)"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// output is written to /tmp/oc-database/ by default |
|
test("output-saved: result file is written to /tmp/oc-database/", async () => { |
|
const before = fs.readdirSync("/tmp/oc-database").filter((f: string) => f.endsWith("_result.txt")).length; |
|
|
|
const { exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project"); |
|
expect(exitCode).toBe(0); |
|
|
|
const after = fs.readdirSync("/tmp/oc-database").filter((f: string) => f.endsWith("_result.txt")).length; |
|
expect(after).toBeGreaterThan(before); |
|
}); |
|
|
|
// REFRESH_TOKEN in WITH clause → exit 1 |
|
test("with-clause-refresh-token: exits 1 when WITH clause references REFRESH_TOKEN", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "WITH cte AS (SELECT name FROM project) SELECT REFRESH_TOKEN FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// SELECT DISTINCT * → exit 1 |
|
test("select-distinct-star: exits 1 for SELECT DISTINCT * query", async () => { |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "SELECT DISTINCT * FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("ERROR:"); |
|
}); |
|
|
|
// ensureOutputDir behavior: creates output directory if it doesn't exist |
|
test("output-dir-created: creates /tmp/oc-database directory if not present", async () => { |
|
// Remove output directory to test creation |
|
try { |
|
fs.rmSync("/tmp/oc-database", { recursive: true, force: true }); |
|
} catch {} |
|
|
|
// Run - should create /tmp/oc-database and write output |
|
const { exitCode } = await run("--db", TEST_DB, "-q", "--out", "test-created.txt", "SELECT name FROM project"); |
|
expect(exitCode).toBe(0); |
|
expect(fs.existsSync("/tmp/oc-database")).toBe(true); |
|
expect(fs.existsSync("/tmp/oc-database/test-created.txt")).toBe(true); |
|
|
|
// Cleanup |
|
try { |
|
fs.unlinkSync("/tmp/oc-database/test-created.txt"); |
|
} catch {} |
|
}); |
|
|
|
// ensureOutputDir behavior: creates nested directories recursively in base output dir |
|
test("output-dir-nested: ensures /tmp/oc-database is created recursively", async () => { |
|
// Remove /tmp/oc-database to ensure it's created from scratch |
|
try { |
|
fs.rmSync("/tmp/oc-database", { recursive: true, force: true }); |
|
} catch {} |
|
|
|
// Run script - it should create /tmp/oc-database |
|
const { exitCode } = await run("--db", TEST_DB, "-q", "--out", "test-nested.txt", "SELECT name FROM project"); |
|
expect(exitCode).toBe(0); |
|
expect(fs.existsSync("/tmp/oc-database")).toBe(true); |
|
expect(fs.existsSync("/tmp/oc-database/test-nested.txt")).toBe(true); |
|
|
|
// Cleanup |
|
try { |
|
fs.unlinkSync("/tmp/oc-database/test-nested.txt"); |
|
} catch {} |
|
}); |
|
|
|
// ensureOutputDir behavior: throws error if output directory is a symlink |
|
test("output-dir-symlink: exits 1 when /tmp/oc-database is a symlink", async () => { |
|
// Create a real directory, then replace /tmp/oc-database with a symlink |
|
const realDir = "/tmp/oc-database-real-backup"; |
|
const symlinkPath = "/tmp/oc-database"; |
|
|
|
try { |
|
// Create the real directory |
|
fs.mkdirSync(realDir, { recursive: true }); |
|
|
|
// Remove existing /tmp/oc-database if it exists (keep contents for safety) |
|
let existingDirContents: string[] = []; |
|
try { |
|
existingDirContents = fs.readdirSync(symlinkPath); |
|
} catch {} |
|
fs.rmSync(symlinkPath, { recursive: true, force: true }); |
|
|
|
// Create symlink |
|
fs.symlinkSync(realDir, symlinkPath); |
|
|
|
// Run the script - should fail because output dir is a symlink |
|
const { stderr, exitCode } = await run("--db", TEST_DB, "-q", "SELECT name FROM project"); |
|
expect(exitCode).toBe(1); |
|
expect(stderr).toContain("symlink"); |
|
} finally { |
|
// Cleanup: remove symlink and restore directory |
|
try { |
|
fs.unlinkSync(symlinkPath); |
|
} catch {} |
|
try { |
|
fs.rmSync(realDir, { recursive: true, force: true }); |
|
} catch {} |
|
// Recreate original /tmp/oc-database |
|
try { |
|
fs.mkdirSync(symlinkPath, { recursive: true }); |
|
} catch {} |
|
} |
|
}); |
|
}); |