fix(connections): enforce scan_enabled:false on explicit scan/ingest commands

scan_enabled:false promised the connection is 'never used as a scan/ingest
target,' but the predicate only gated automatic selection — explicit
ktx scan <id> / ktx ingest <id> still resolved the connection id and reached the
live-database introspection path, so an execute-only connection could still be
scanned or ingested.

Guard runKtxScan and runKtxIngest at entry: if the target connection is
execute-only, refuse with an actionable error (remove the flag to scan, or use
ktx sql to query) before doing any work. This makes the flag a single declaration
honored on every scan/ingest entry point, not just auto-selection.
This commit is contained in:
Andrey Avtomonov 2026-06-09 14:28:05 +02:00
parent f446d207ba
commit 9ac37166f5
5 changed files with 85 additions and 4 deletions

View file

@ -54,6 +54,40 @@ describe('runKtxIngest', () => {
await rm(tempDir, { recursive: true, force: true });
});
it('refuses to ingest a connection marked execute-only (scan_enabled: false)', async () => {
const projectDir = join(tempDir, 'project');
await initKtxProject({ projectDir });
await writeFile(
join(projectDir, 'ktx.yaml'),
[
'connections:',
' public_bq:',
' driver: bigquery',
' scan_enabled: false',
'ingest:',
' adapters:',
' - fake',
' embeddings:',
' backend: none',
'',
].join('\n'),
'utf-8',
);
const runLocal = vi.fn();
const io = makeIo();
await expect(
runKtxIngest(
{ command: 'run', projectDir, connectionId: 'public_bq', adapter: 'fake', outputMode: 'plain' },
io.io,
{ runLocalIngest: runLocal },
),
).resolves.toBe(1);
expect(runLocal).not.toHaveBeenCalled();
expect(io.stderr()).toContain('scan_enabled: false');
});
it('runs local ingest and reads status', async () => {
const projectDir = join(tempDir, 'project');
await writeWarehouseConfig(projectDir);

View file

@ -332,6 +332,35 @@ describe('runKtxScan', () => {
await rm(tempDir, { recursive: true, force: true });
});
it('refuses to scan a connection marked execute-only (scan_enabled: false)', async () => {
await initKtxProject({ projectDir: tempDir });
await writeFile(
join(tempDir, 'ktx.yaml'),
['connections:', ' public_bq:', ' driver: bigquery', ' scan_enabled: false', ''].join('\n'),
'utf-8',
);
const runLocalScan = vi.fn();
const io = makeIo();
await expect(
runKtxScan(
{
command: 'run',
projectDir: tempDir,
connectionId: 'public_bq',
mode: 'structural',
detectRelationships: false,
dryRun: false,
},
io.io,
{ runLocalScan, createLocalIngestAdapters: noLocalIngestAdapters },
),
).resolves.toBe(1);
expect(runLocalScan).not.toHaveBeenCalled();
expect(io.stderr()).toContain('scan_enabled: false');
});
it('runs structural scans and prints a dev-friendly plain summary', async () => {
await initKtxProject({ projectDir: tempDir });
const runLocalScan = vi.fn(