postgresai 0.14.0-dev.84 → 0.14.0-dev.86
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/postgres-ai.ts +37 -8
- package/dist/bin/postgres-ai.js +51 -18
- package/dist/sql/03.permissions.sql +41 -2
- package/dist/sql/sql/03.permissions.sql +41 -2
- package/lib/init.ts +31 -10
- package/package.json +1 -1
- package/sql/03.permissions.sql +41 -2
- package/test/checkup.test.ts +41 -0
- package/test/init.test.ts +338 -1
package/bin/postgres-ai.ts
CHANGED
|
@@ -1643,9 +1643,9 @@ program
|
|
|
1643
1643
|
});
|
|
1644
1644
|
|
|
1645
1645
|
program
|
|
1646
|
-
.command("checkup [conn]")
|
|
1646
|
+
.command("checkup [checkIdOrConn] [conn]")
|
|
1647
1647
|
.description("generate health check reports directly from PostgreSQL (express mode)")
|
|
1648
|
-
.option("--check-id <id>", `specific check to run (see list below), or ALL
|
|
1648
|
+
.option("--check-id <id>", `specific check to run (see list below), or ALL`)
|
|
1649
1649
|
.option("--node-name <name>", "node name for reports", "node-01")
|
|
1650
1650
|
.option("--output <path>", "output directory for JSON files")
|
|
1651
1651
|
.option("--upload", "upload JSON results to PostgresAI (requires API key)")
|
|
@@ -1664,13 +1664,43 @@ program
|
|
|
1664
1664
|
"",
|
|
1665
1665
|
"Examples:",
|
|
1666
1666
|
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
1667
|
-
" postgresai checkup postgresql://user:pass@host:5432/db
|
|
1667
|
+
" postgresai checkup H002 postgresql://user:pass@host:5432/db",
|
|
1668
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
|
|
1668
1669
|
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
1669
|
-
" postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
|
|
1670
1670
|
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json",
|
|
1671
1671
|
].join("\n")
|
|
1672
1672
|
)
|
|
1673
|
-
.action(async (
|
|
1673
|
+
.action(async (checkIdOrConn: string | undefined, connArg: string | undefined, opts: CheckupOptions, cmd: Command) => {
|
|
1674
|
+
// Support both syntaxes:
|
|
1675
|
+
// pgai checkup postgresql://... -> run ALL checks
|
|
1676
|
+
// pgai checkup H002 postgresql://... -> run specific check (positional)
|
|
1677
|
+
// pgai checkup --check-id H002 postgresql:// -> run specific check (option)
|
|
1678
|
+
const checkIdPattern = /^[A-Z]\d{3}$/i;
|
|
1679
|
+
let conn: string | undefined;
|
|
1680
|
+
let checkId: string;
|
|
1681
|
+
|
|
1682
|
+
if (!checkIdOrConn) {
|
|
1683
|
+
cmd.outputHelp();
|
|
1684
|
+
process.exitCode = 1;
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
if (checkIdPattern.test(checkIdOrConn)) {
|
|
1689
|
+
// First arg is a check ID
|
|
1690
|
+
checkId = checkIdOrConn.toUpperCase();
|
|
1691
|
+
conn = connArg;
|
|
1692
|
+
if (!conn) {
|
|
1693
|
+
console.error(`Error: Connection string required when specifying check ID "${checkId}"`);
|
|
1694
|
+
console.error(`\nUsage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname\n`);
|
|
1695
|
+
process.exitCode = 1;
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
} else {
|
|
1699
|
+
// First arg is the connection string
|
|
1700
|
+
conn = checkIdOrConn;
|
|
1701
|
+
checkId = opts.checkId?.toUpperCase() || "ALL";
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1674
1704
|
if (!conn) {
|
|
1675
1705
|
cmd.outputHelp();
|
|
1676
1706
|
process.exitCode = 1;
|
|
@@ -1718,12 +1748,11 @@ program
|
|
|
1718
1748
|
|
|
1719
1749
|
// Generate reports
|
|
1720
1750
|
let reports: Record<string, any>;
|
|
1721
|
-
if (
|
|
1751
|
+
if (checkId === "ALL") {
|
|
1722
1752
|
reports = await generateAllReports(client, opts.nodeName, (p) => {
|
|
1723
1753
|
spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
|
|
1724
1754
|
});
|
|
1725
1755
|
} else {
|
|
1726
|
-
const checkId = opts.checkId.toUpperCase();
|
|
1727
1756
|
const generator = REPORT_GENERATORS[checkId];
|
|
1728
1757
|
if (!generator) {
|
|
1729
1758
|
spinner.stop();
|
|
@@ -1733,7 +1762,7 @@ program
|
|
|
1733
1762
|
console.error(`Check ${checkId} (${dictEntry.title}) is not yet available in express mode.`);
|
|
1734
1763
|
console.error(`Express-mode checks: ${Object.keys(CHECK_INFO).join(", ")}`);
|
|
1735
1764
|
} else {
|
|
1736
|
-
console.error(`Unknown check ID: ${
|
|
1765
|
+
console.error(`Unknown check ID: ${checkId}`);
|
|
1737
1766
|
console.error(`See 'postgresai checkup --help' for available checks.`);
|
|
1738
1767
|
}
|
|
1739
1768
|
process.exitCode = 1;
|
package/dist/bin/postgres-ai.js
CHANGED
|
@@ -13064,7 +13064,7 @@ var {
|
|
|
13064
13064
|
// package.json
|
|
13065
13065
|
var package_default = {
|
|
13066
13066
|
name: "postgresai",
|
|
13067
|
-
version: "0.14.0-dev.
|
|
13067
|
+
version: "0.14.0-dev.86",
|
|
13068
13068
|
description: "postgres_ai CLI",
|
|
13069
13069
|
license: "Apache-2.0",
|
|
13070
13070
|
private: false,
|
|
@@ -15889,7 +15889,7 @@ var Result = import_lib.default.Result;
|
|
|
15889
15889
|
var TypeOverrides = import_lib.default.TypeOverrides;
|
|
15890
15890
|
var defaults = import_lib.default.defaults;
|
|
15891
15891
|
// package.json
|
|
15892
|
-
var version = "0.14.0-dev.
|
|
15892
|
+
var version = "0.14.0-dev.86";
|
|
15893
15893
|
var package_default2 = {
|
|
15894
15894
|
name: "postgresai",
|
|
15895
15895
|
version,
|
|
@@ -24971,14 +24971,7 @@ end $$;`;
|
|
|
24971
24971
|
});
|
|
24972
24972
|
let permissionsSql = applyTemplate(loadSqlTemplate("03.permissions.sql"), vars);
|
|
24973
24973
|
if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
|
|
24974
|
-
permissionsSql = permissionsSql.
|
|
24975
|
-
`).filter((line) => {
|
|
24976
|
-
const trimmed = line.trim();
|
|
24977
|
-
if (trimmed.startsWith("--") || trimmed === "")
|
|
24978
|
-
return true;
|
|
24979
|
-
return !/^\s*alter\s+user\s+/i.test(line);
|
|
24980
|
-
}).join(`
|
|
24981
|
-
`);
|
|
24974
|
+
permissionsSql = permissionsSql.replace(/-- \[SEARCH_PATH_BLOCK_START\][\s\S]*?-- \[SEARCH_PATH_BLOCK_END\]\n?/, "");
|
|
24982
24975
|
}
|
|
24983
24976
|
steps.push({
|
|
24984
24977
|
name: "03.permissions",
|
|
@@ -25162,6 +25155,19 @@ async function verifyInitSetup(params) {
|
|
|
25162
25155
|
if (!schemaUsageRes.rows?.[0]?.ok) {
|
|
25163
25156
|
missingRequired.push("USAGE on schema public");
|
|
25164
25157
|
}
|
|
25158
|
+
const extSchemaRes = await params.client.query(`
|
|
25159
|
+
select n.nspname as schema
|
|
25160
|
+
from pg_extension e
|
|
25161
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
25162
|
+
where e.extname = 'pg_stat_statements'
|
|
25163
|
+
`);
|
|
25164
|
+
const extSchema = extSchemaRes.rows?.[0]?.schema;
|
|
25165
|
+
if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
|
|
25166
|
+
const extSchemaUsageRes = await params.client.query("select has_schema_privilege($1, $2, 'USAGE') as ok", [role, extSchema]);
|
|
25167
|
+
if (!extSchemaUsageRes.rows?.[0]?.ok) {
|
|
25168
|
+
missingRequired.push(`USAGE on schema ${extSchema} (pg_stat_statements location)`);
|
|
25169
|
+
}
|
|
25170
|
+
}
|
|
25165
25171
|
if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
|
|
25166
25172
|
const rolcfgRes = await params.client.query("select rolconfig from pg_catalog.pg_roles where rolname = $1", [role]);
|
|
25167
25173
|
const rolconfig = rolcfgRes.rows?.[0]?.rolconfig;
|
|
@@ -25173,6 +25179,11 @@ async function verifyInitSetup(params) {
|
|
|
25173
25179
|
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
25174
25180
|
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
25175
25181
|
}
|
|
25182
|
+
if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
|
|
25183
|
+
if (!sp.includes(extSchema.toLowerCase())) {
|
|
25184
|
+
missingRequired.push(`role search_path includes ${extSchema} (pg_stat_statements location)`);
|
|
25185
|
+
}
|
|
25186
|
+
}
|
|
25176
25187
|
}
|
|
25177
25188
|
}
|
|
25178
25189
|
const explainFnRes = await params.client.query("select has_function_privilege($1, 'postgres_ai.explain_generic(text, text, text)', 'EXECUTE') as ok", [role]);
|
|
@@ -27116,7 +27127,7 @@ function buildCheckInfoMap() {
|
|
|
27116
27127
|
}
|
|
27117
27128
|
|
|
27118
27129
|
// lib/checkup.ts
|
|
27119
|
-
var __dirname = "/builds/postgres-ai/
|
|
27130
|
+
var __dirname = "/builds/postgres-ai/postgresai/cli/lib";
|
|
27120
27131
|
var SECONDS_PER_DAY = 86400;
|
|
27121
27132
|
var SECONDS_PER_HOUR = 3600;
|
|
27122
27133
|
var SECONDS_PER_MINUTE = 60;
|
|
@@ -29487,19 +29498,42 @@ program2.command("unprepare-db [conn]").description("remove monitoring setup: dr
|
|
|
29487
29498
|
closeReadline();
|
|
29488
29499
|
}
|
|
29489
29500
|
});
|
|
29490
|
-
program2.command("checkup [conn]").description("generate health check reports directly from PostgreSQL (express mode)").option("--check-id <id>", `specific check to run (see list below), or ALL
|
|
29501
|
+
program2.command("checkup [checkIdOrConn] [conn]").description("generate health check reports directly from PostgreSQL (express mode)").option("--check-id <id>", `specific check to run (see list below), or ALL`).option("--node-name <name>", "node name for reports", "node-01").option("--output <path>", "output directory for JSON files").option("--upload", "upload JSON results to PostgresAI (requires API key)").option("--no-upload", "disable upload to PostgresAI").option("--project <project>", "project name or ID for remote upload (used with --upload; defaults to config defaultProject; auto-generated on first run)").option("--json", "output JSON to stdout").addHelpText("after", [
|
|
29491
29502
|
"",
|
|
29492
29503
|
"Available checks:",
|
|
29493
29504
|
...Object.entries(CHECK_INFO).map(([id, title]) => ` ${id}: ${title}`),
|
|
29494
29505
|
"",
|
|
29495
29506
|
"Examples:",
|
|
29496
29507
|
" postgresai checkup postgresql://user:pass@host:5432/db",
|
|
29497
|
-
" postgresai checkup postgresql://user:pass@host:5432/db
|
|
29508
|
+
" postgresai checkup H002 postgresql://user:pass@host:5432/db",
|
|
29509
|
+
" postgresai checkup postgresql://user:pass@host:5432/db --check-id H002",
|
|
29498
29510
|
" postgresai checkup postgresql://user:pass@host:5432/db --output ./reports",
|
|
29499
|
-
" postgresai checkup postgresql://user:pass@host:5432/db --project my_project",
|
|
29500
29511
|
" postgresai checkup postgresql://user:pass@host:5432/db --no-upload --json"
|
|
29501
29512
|
].join(`
|
|
29502
|
-
`)).action(async (
|
|
29513
|
+
`)).action(async (checkIdOrConn, connArg, opts, cmd) => {
|
|
29514
|
+
const checkIdPattern = /^[A-Z]\d{3}$/i;
|
|
29515
|
+
let conn;
|
|
29516
|
+
let checkId;
|
|
29517
|
+
if (!checkIdOrConn) {
|
|
29518
|
+
cmd.outputHelp();
|
|
29519
|
+
process.exitCode = 1;
|
|
29520
|
+
return;
|
|
29521
|
+
}
|
|
29522
|
+
if (checkIdPattern.test(checkIdOrConn)) {
|
|
29523
|
+
checkId = checkIdOrConn.toUpperCase();
|
|
29524
|
+
conn = connArg;
|
|
29525
|
+
if (!conn) {
|
|
29526
|
+
console.error(`Error: Connection string required when specifying check ID "${checkId}"`);
|
|
29527
|
+
console.error(`
|
|
29528
|
+
Usage: postgresai checkup ${checkId} postgresql://user@host:5432/dbname
|
|
29529
|
+
`);
|
|
29530
|
+
process.exitCode = 1;
|
|
29531
|
+
return;
|
|
29532
|
+
}
|
|
29533
|
+
} else {
|
|
29534
|
+
conn = checkIdOrConn;
|
|
29535
|
+
checkId = opts.checkId?.toUpperCase() || "ALL";
|
|
29536
|
+
}
|
|
29503
29537
|
if (!conn) {
|
|
29504
29538
|
cmd.outputHelp();
|
|
29505
29539
|
process.exitCode = 1;
|
|
@@ -29535,12 +29569,11 @@ program2.command("checkup [conn]").description("generate health check reports di
|
|
|
29535
29569
|
const connResult = await connectWithSslFallback(Client, adminConn);
|
|
29536
29570
|
client = connResult.client;
|
|
29537
29571
|
let reports;
|
|
29538
|
-
if (
|
|
29572
|
+
if (checkId === "ALL") {
|
|
29539
29573
|
reports = await generateAllReports(client, opts.nodeName, (p) => {
|
|
29540
29574
|
spinner.update(`Running ${p.checkId}: ${p.checkTitle} (${p.index}/${p.total})`);
|
|
29541
29575
|
});
|
|
29542
29576
|
} else {
|
|
29543
|
-
const checkId = opts.checkId.toUpperCase();
|
|
29544
29577
|
const generator = REPORT_GENERATORS[checkId];
|
|
29545
29578
|
if (!generator) {
|
|
29546
29579
|
spinner.stop();
|
|
@@ -29549,7 +29582,7 @@ program2.command("checkup [conn]").description("generate health check reports di
|
|
|
29549
29582
|
console.error(`Check ${checkId} (${dictEntry.title}) is not yet available in express mode.`);
|
|
29550
29583
|
console.error(`Express-mode checks: ${Object.keys(CHECK_INFO).join(", ")}`);
|
|
29551
29584
|
} else {
|
|
29552
|
-
console.error(`Unknown check ID: ${
|
|
29585
|
+
console.error(`Unknown check ID: ${checkId}`);
|
|
29553
29586
|
console.error(`See 'postgresai checkup --help' for available checks.`);
|
|
29554
29587
|
}
|
|
29555
29588
|
process.exitCode = 1;
|
|
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
|
|
|
32
32
|
-- Hardened clusters sometimes revoke PUBLIC on schema public
|
|
33
33
|
grant usage on schema public to {{ROLE_IDENT}};
|
|
34
34
|
|
|
35
|
-
--
|
|
36
|
-
|
|
35
|
+
-- Grant access to the schema where pg_stat_statements is installed.
|
|
36
|
+
-- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
|
|
37
|
+
-- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
|
|
38
|
+
do $$
|
|
39
|
+
declare
|
|
40
|
+
ext_schema text;
|
|
41
|
+
begin
|
|
42
|
+
select n.nspname into ext_schema
|
|
43
|
+
from pg_extension e
|
|
44
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
45
|
+
where e.extname = 'pg_stat_statements';
|
|
46
|
+
|
|
47
|
+
-- Only grant if extension exists and is in a non-standard schema
|
|
48
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
49
|
+
execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
|
|
50
|
+
end if;
|
|
51
|
+
end $$;
|
|
52
|
+
|
|
53
|
+
-- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
|
|
54
|
+
-- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
|
|
55
|
+
do $$
|
|
56
|
+
declare
|
|
57
|
+
ext_schema text;
|
|
58
|
+
sp text;
|
|
59
|
+
begin
|
|
60
|
+
-- Detect pg_stat_statements extension schema
|
|
61
|
+
select n.nspname into ext_schema
|
|
62
|
+
from pg_extension e
|
|
63
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
64
|
+
where e.extname = 'pg_stat_statements';
|
|
65
|
+
|
|
66
|
+
-- Build search_path: include extension schema if in non-standard location
|
|
67
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
68
|
+
sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
|
|
69
|
+
else
|
|
70
|
+
sp := 'postgres_ai, "$user", public, pg_catalog';
|
|
71
|
+
end if;
|
|
72
|
+
|
|
73
|
+
execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
|
|
74
|
+
end $$;
|
|
75
|
+
-- [SEARCH_PATH_BLOCK_END]
|
|
37
76
|
|
|
38
77
|
|
|
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
|
|
|
32
32
|
-- Hardened clusters sometimes revoke PUBLIC on schema public
|
|
33
33
|
grant usage on schema public to {{ROLE_IDENT}};
|
|
34
34
|
|
|
35
|
-
--
|
|
36
|
-
|
|
35
|
+
-- Grant access to the schema where pg_stat_statements is installed.
|
|
36
|
+
-- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
|
|
37
|
+
-- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
|
|
38
|
+
do $$
|
|
39
|
+
declare
|
|
40
|
+
ext_schema text;
|
|
41
|
+
begin
|
|
42
|
+
select n.nspname into ext_schema
|
|
43
|
+
from pg_extension e
|
|
44
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
45
|
+
where e.extname = 'pg_stat_statements';
|
|
46
|
+
|
|
47
|
+
-- Only grant if extension exists and is in a non-standard schema
|
|
48
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
49
|
+
execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
|
|
50
|
+
end if;
|
|
51
|
+
end $$;
|
|
52
|
+
|
|
53
|
+
-- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
|
|
54
|
+
-- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
|
|
55
|
+
do $$
|
|
56
|
+
declare
|
|
57
|
+
ext_schema text;
|
|
58
|
+
sp text;
|
|
59
|
+
begin
|
|
60
|
+
-- Detect pg_stat_statements extension schema
|
|
61
|
+
select n.nspname into ext_schema
|
|
62
|
+
from pg_extension e
|
|
63
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
64
|
+
where e.extname = 'pg_stat_statements';
|
|
65
|
+
|
|
66
|
+
-- Build search_path: include extension schema if in non-standard location
|
|
67
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
68
|
+
sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
|
|
69
|
+
else
|
|
70
|
+
sp := 'postgres_ai, "$user", public, pg_catalog';
|
|
71
|
+
end if;
|
|
72
|
+
|
|
73
|
+
execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
|
|
74
|
+
end $$;
|
|
75
|
+
-- [SEARCH_PATH_BLOCK_END]
|
|
37
76
|
|
|
38
77
|
|
package/lib/init.ts
CHANGED
|
@@ -538,16 +538,12 @@ end $$;`;
|
|
|
538
538
|
// Some providers restrict ALTER USER - remove those statements.
|
|
539
539
|
// TODO: Make this more flexible by allowing users to specify which statements to skip via config.
|
|
540
540
|
if (SKIP_ALTER_USER_PROVIDERS.includes(provider)) {
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
// Filter out ALTER USER statements (case-insensitive, flexible whitespace)
|
|
548
|
-
return !/^\s*alter\s+user\s+/i.test(line);
|
|
549
|
-
})
|
|
550
|
-
.join("\n");
|
|
541
|
+
// Remove the entire search_path DO block (marked with SEARCH_PATH_BLOCK_START/END)
|
|
542
|
+
// since it contains ALTER USER and can't be line-filtered without breaking the DO block.
|
|
543
|
+
permissionsSql = permissionsSql.replace(
|
|
544
|
+
/-- \[SEARCH_PATH_BLOCK_START\][\s\S]*?-- \[SEARCH_PATH_BLOCK_END\]\n?/,
|
|
545
|
+
""
|
|
546
|
+
);
|
|
551
547
|
}
|
|
552
548
|
|
|
553
549
|
steps.push({
|
|
@@ -838,6 +834,24 @@ export async function verifyInitSetup(params: {
|
|
|
838
834
|
missingRequired.push("USAGE on schema public");
|
|
839
835
|
}
|
|
840
836
|
|
|
837
|
+
// Check access to pg_stat_statements extension schema (may be 'extensions' on Supabase)
|
|
838
|
+
const extSchemaRes = await params.client.query(`
|
|
839
|
+
select n.nspname as schema
|
|
840
|
+
from pg_extension e
|
|
841
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
842
|
+
where e.extname = 'pg_stat_statements'
|
|
843
|
+
`);
|
|
844
|
+
const extSchema = extSchemaRes.rows?.[0]?.schema;
|
|
845
|
+
if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
|
|
846
|
+
const extSchemaUsageRes = await params.client.query(
|
|
847
|
+
"select has_schema_privilege($1, $2, 'USAGE') as ok",
|
|
848
|
+
[role, extSchema]
|
|
849
|
+
);
|
|
850
|
+
if (!extSchemaUsageRes.rows?.[0]?.ok) {
|
|
851
|
+
missingRequired.push(`USAGE on schema ${extSchema} (pg_stat_statements location)`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
841
855
|
// Some providers don't allow setting search_path via ALTER USER - skip this check.
|
|
842
856
|
// TODO: Make this more flexible by allowing users to specify which checks to skip via config.
|
|
843
857
|
if (!SKIP_SEARCH_PATH_CHECK_PROVIDERS.includes(provider)) {
|
|
@@ -848,10 +862,17 @@ export async function verifyInitSetup(params: {
|
|
|
848
862
|
missingRequired.push("role search_path is set");
|
|
849
863
|
} else {
|
|
850
864
|
// We accept any ordering as long as postgres_ai, public, and pg_catalog are included.
|
|
865
|
+
// Also verify search_path includes the pg_stat_statements schema if in a non-standard location.
|
|
851
866
|
const sp = spLine.toLowerCase();
|
|
852
867
|
if (!sp.includes("postgres_ai") || !sp.includes("public") || !sp.includes("pg_catalog")) {
|
|
853
868
|
missingRequired.push("role search_path includes postgres_ai, public and pg_catalog");
|
|
854
869
|
}
|
|
870
|
+
// If pg_stat_statements is in a non-standard schema (e.g., 'extensions' on Supabase), verify it's in search_path
|
|
871
|
+
if (extSchema && extSchema !== "pg_catalog" && extSchema !== "public") {
|
|
872
|
+
if (!sp.includes(extSchema.toLowerCase())) {
|
|
873
|
+
missingRequired.push(`role search_path includes ${extSchema} (pg_stat_statements location)`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
855
876
|
}
|
|
856
877
|
}
|
|
857
878
|
|
package/package.json
CHANGED
package/sql/03.permissions.sql
CHANGED
|
@@ -32,7 +32,46 @@ grant select on postgres_ai.pg_statistic to {{ROLE_IDENT}};
|
|
|
32
32
|
-- Hardened clusters sometimes revoke PUBLIC on schema public
|
|
33
33
|
grant usage on schema public to {{ROLE_IDENT}};
|
|
34
34
|
|
|
35
|
-
--
|
|
36
|
-
|
|
35
|
+
-- Grant access to the schema where pg_stat_statements is installed.
|
|
36
|
+
-- Some providers (e.g., Supabase) install extensions in a separate 'extensions' schema
|
|
37
|
+
-- rather than pg_catalog. This DO block detects the schema and grants USAGE if needed.
|
|
38
|
+
do $$
|
|
39
|
+
declare
|
|
40
|
+
ext_schema text;
|
|
41
|
+
begin
|
|
42
|
+
select n.nspname into ext_schema
|
|
43
|
+
from pg_extension e
|
|
44
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
45
|
+
where e.extname = 'pg_stat_statements';
|
|
46
|
+
|
|
47
|
+
-- Only grant if extension exists and is in a non-standard schema
|
|
48
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
49
|
+
execute format('grant usage on schema %I to {{ROLE_IDENT}}', ext_schema);
|
|
50
|
+
end if;
|
|
51
|
+
end $$;
|
|
52
|
+
|
|
53
|
+
-- [SEARCH_PATH_BLOCK_START] Keep search_path predictable; postgres_ai first so our objects are found.
|
|
54
|
+
-- Dynamically include the pg_stat_statements extension schema if it's in a non-standard location.
|
|
55
|
+
do $$
|
|
56
|
+
declare
|
|
57
|
+
ext_schema text;
|
|
58
|
+
sp text;
|
|
59
|
+
begin
|
|
60
|
+
-- Detect pg_stat_statements extension schema
|
|
61
|
+
select n.nspname into ext_schema
|
|
62
|
+
from pg_extension e
|
|
63
|
+
join pg_namespace n on e.extnamespace = n.oid
|
|
64
|
+
where e.extname = 'pg_stat_statements';
|
|
65
|
+
|
|
66
|
+
-- Build search_path: include extension schema if in non-standard location
|
|
67
|
+
if ext_schema is not null and ext_schema not in ('pg_catalog', 'public') then
|
|
68
|
+
sp := 'postgres_ai, ' || quote_ident(ext_schema) || ', "$user", public, pg_catalog';
|
|
69
|
+
else
|
|
70
|
+
sp := 'postgres_ai, "$user", public, pg_catalog';
|
|
71
|
+
end if;
|
|
72
|
+
|
|
73
|
+
execute format('alter user {{ROLE_IDENT}} set search_path = %s', sp);
|
|
74
|
+
end $$;
|
|
75
|
+
-- [SEARCH_PATH_BLOCK_END]
|
|
37
76
|
|
|
38
77
|
|
package/test/checkup.test.ts
CHANGED
|
@@ -1008,6 +1008,47 @@ describe("CLI tests", () => {
|
|
|
1008
1008
|
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload"], env);
|
|
1009
1009
|
expect(r.stderr).not.toMatch(/API key is required/i);
|
|
1010
1010
|
});
|
|
1011
|
+
|
|
1012
|
+
// Argument parsing tests for check ID / connection string recognition
|
|
1013
|
+
test("checkup with check ID but no connection shows specific error", () => {
|
|
1014
|
+
const r = runCli(["checkup", "H002"]);
|
|
1015
|
+
expect(r.status).not.toBe(0);
|
|
1016
|
+
expect(r.stderr).toMatch(/connection string required/i);
|
|
1017
|
+
expect(r.stderr).toMatch(/H002/);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
test("checkup recognizes valid check ID patterns", () => {
|
|
1021
|
+
// Valid check IDs: A002, H002, F004, etc.
|
|
1022
|
+
for (const checkId of ["A002", "H002", "F004", "K003", "a002", "h002"]) {
|
|
1023
|
+
const r = runCli(["checkup", checkId]);
|
|
1024
|
+
expect(r.status).not.toBe(0);
|
|
1025
|
+
expect(r.stderr).toMatch(/connection string required/i);
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test("checkup does not treat connection string as check ID", () => {
|
|
1030
|
+
// Connection strings should not be parsed as check IDs
|
|
1031
|
+
const r = runCli(["checkup", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
|
|
1032
|
+
// Should not show "connection string required" error
|
|
1033
|
+
expect(r.stderr).not.toMatch(/connection string required/i);
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
test("checkup with check ID and connection string works", () => {
|
|
1037
|
+
// pgai checkup H002 postgresql://...
|
|
1038
|
+
const r = runCli(["checkup", "H002", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
|
|
1039
|
+
// Should not show "connection string required" error
|
|
1040
|
+
expect(r.stderr).not.toMatch(/connection string required/i);
|
|
1041
|
+
// Connection will fail but argument parsing should succeed
|
|
1042
|
+
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
test("checkup with --check-id option works", () => {
|
|
1046
|
+
// pgai checkup --check-id H002 postgresql://...
|
|
1047
|
+
const r = runCli(["checkup", "--check-id", "H002", "postgresql://test:test@localhost:5432/test", "--no-upload"]);
|
|
1048
|
+
// Should not show "connection string required" error
|
|
1049
|
+
expect(r.stderr).not.toMatch(/connection string required/i);
|
|
1050
|
+
expect(r.stderr).not.toMatch(/unknown option/i);
|
|
1051
|
+
});
|
|
1011
1052
|
});
|
|
1012
1053
|
|
|
1013
1054
|
// Tests for checkup-api module
|
package/test/init.test.ts
CHANGED
|
@@ -281,7 +281,7 @@ describe("init module", () => {
|
|
|
281
281
|
return { rowCount: 1, rows: [] };
|
|
282
282
|
}
|
|
283
283
|
if (String(sql).includes("select rolconfig")) {
|
|
284
|
-
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
|
|
284
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
|
|
285
285
|
}
|
|
286
286
|
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
287
287
|
return { rowCount: 1, rows: [] };
|
|
@@ -307,6 +307,10 @@ describe("init module", () => {
|
|
|
307
307
|
if (String(sql).includes("has_schema_privilege")) {
|
|
308
308
|
return { rowCount: 1, rows: [{ ok: true }] };
|
|
309
309
|
}
|
|
310
|
+
// Query for pg_stat_statements extension schema location
|
|
311
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
312
|
+
return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
|
|
313
|
+
}
|
|
310
314
|
|
|
311
315
|
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
312
316
|
},
|
|
@@ -363,6 +367,10 @@ describe("init module", () => {
|
|
|
363
367
|
if (String(sql).includes("has_schema_privilege")) {
|
|
364
368
|
return { rowCount: 1, rows: [{ ok: true }] };
|
|
365
369
|
}
|
|
370
|
+
// Query for pg_stat_statements extension schema location (Supabase uses 'extensions' schema)
|
|
371
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
372
|
+
return { rowCount: 1, rows: [{ schema: "extensions" }] };
|
|
373
|
+
}
|
|
366
374
|
|
|
367
375
|
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
368
376
|
},
|
|
@@ -382,6 +390,335 @@ describe("init module", () => {
|
|
|
382
390
|
expect(calls.some((c) => c.includes("select rolconfig"))).toBe(false);
|
|
383
391
|
});
|
|
384
392
|
|
|
393
|
+
test("verifyInitSetup checks extensions schema when pg_stat_statements is there", async () => {
|
|
394
|
+
const calls: string[] = [];
|
|
395
|
+
const client = {
|
|
396
|
+
query: async (sql: string, params?: any) => {
|
|
397
|
+
calls.push(String(sql));
|
|
398
|
+
|
|
399
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
400
|
+
return { rowCount: 1, rows: [] };
|
|
401
|
+
}
|
|
402
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
403
|
+
return { rowCount: 1, rows: [] };
|
|
404
|
+
}
|
|
405
|
+
if (String(sql).includes("select rolconfig")) {
|
|
406
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
|
|
407
|
+
}
|
|
408
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
409
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
410
|
+
}
|
|
411
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
412
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
413
|
+
}
|
|
414
|
+
if (String(sql).includes("pg_has_role")) {
|
|
415
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
416
|
+
}
|
|
417
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
418
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
419
|
+
}
|
|
420
|
+
if (String(sql).includes("to_regclass")) {
|
|
421
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
422
|
+
}
|
|
423
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
424
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
425
|
+
}
|
|
426
|
+
// pg_stat_statements is in 'extensions' schema
|
|
427
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
428
|
+
return { rowCount: 1, rows: [{ schema: "extensions" }] };
|
|
429
|
+
}
|
|
430
|
+
// Check for USAGE on extensions schema
|
|
431
|
+
if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
|
|
432
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
433
|
+
}
|
|
434
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
435
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
439
|
+
},
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const r = await init.verifyInitSetup({
|
|
443
|
+
client: client as any,
|
|
444
|
+
database: "mydb",
|
|
445
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
446
|
+
includeOptionalPermissions: false,
|
|
447
|
+
});
|
|
448
|
+
expect(r.ok).toBe(true);
|
|
449
|
+
expect(r.missingRequired.length).toBe(0);
|
|
450
|
+
// Should have queried for pg_stat_statements schema location
|
|
451
|
+
expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("verifyInitSetup reports missing extensions schema access", async () => {
|
|
455
|
+
const client = {
|
|
456
|
+
query: async (sql: string, params?: any) => {
|
|
457
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
458
|
+
return { rowCount: 1, rows: [] };
|
|
459
|
+
}
|
|
460
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
461
|
+
return { rowCount: 1, rows: [] };
|
|
462
|
+
}
|
|
463
|
+
if (String(sql).includes("select rolconfig")) {
|
|
464
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
|
|
465
|
+
}
|
|
466
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
467
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
468
|
+
}
|
|
469
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
470
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
471
|
+
}
|
|
472
|
+
if (String(sql).includes("pg_has_role")) {
|
|
473
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
474
|
+
}
|
|
475
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
476
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
477
|
+
}
|
|
478
|
+
if (String(sql).includes("to_regclass")) {
|
|
479
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
480
|
+
}
|
|
481
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
482
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
483
|
+
}
|
|
484
|
+
// pg_stat_statements is in 'extensions' schema
|
|
485
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
486
|
+
return { rowCount: 1, rows: [{ schema: "extensions" }] };
|
|
487
|
+
}
|
|
488
|
+
// No USAGE on extensions schema
|
|
489
|
+
if (String(sql).includes("has_schema_privilege") && params?.[1] === "extensions") {
|
|
490
|
+
return { rowCount: 1, rows: [{ ok: false }] };
|
|
491
|
+
}
|
|
492
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
493
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
497
|
+
},
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const r = await init.verifyInitSetup({
|
|
501
|
+
client: client as any,
|
|
502
|
+
database: "mydb",
|
|
503
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
504
|
+
includeOptionalPermissions: false,
|
|
505
|
+
});
|
|
506
|
+
expect(r.ok).toBe(false);
|
|
507
|
+
// Should report missing USAGE on extensions schema
|
|
508
|
+
expect(r.missingRequired.some((m) => m.includes("extensions") && m.includes("pg_stat_statements"))).toBe(true);
|
|
509
|
+
// Should also report missing extensions in search_path
|
|
510
|
+
expect(r.missingRequired.some((m) => m.includes("search_path") && m.includes("extensions"))).toBe(true);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("buildInitPlan includes dynamic search_path with extension schema detection", async () => {
|
|
514
|
+
const plan = await init.buildInitPlan({
|
|
515
|
+
database: "mydb",
|
|
516
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
517
|
+
monitoringPassword: "pw",
|
|
518
|
+
includeOptionalPermissions: false,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const permStep = plan.steps.find((s) => s.name === "03.permissions");
|
|
522
|
+
expect(permStep).toBeTruthy();
|
|
523
|
+
// Should use dynamic DO block to set search_path based on detected extension schema
|
|
524
|
+
expect(permStep!.sql).toMatch(/alter\s+user.*set\s+search_path\s*=/i);
|
|
525
|
+
// Should detect pg_stat_statements extension schema dynamically
|
|
526
|
+
expect(permStep!.sql).toMatch(/quote_ident\(ext_schema\)/i);
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("buildInitPlan includes dynamic extension schema grant", async () => {
|
|
530
|
+
const plan = await init.buildInitPlan({
|
|
531
|
+
database: "mydb",
|
|
532
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
533
|
+
monitoringPassword: "pw",
|
|
534
|
+
includeOptionalPermissions: false,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const permStep = plan.steps.find((s) => s.name === "03.permissions");
|
|
538
|
+
expect(permStep).toBeTruthy();
|
|
539
|
+
// Should include DO block that grants USAGE on extension schema
|
|
540
|
+
expect(permStep!.sql).toMatch(/do\s+\$\$/i);
|
|
541
|
+
expect(permStep!.sql).toMatch(/pg_stat_statements/);
|
|
542
|
+
expect(permStep!.sql).toMatch(/grant usage on schema/i);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test("verifyInitSetup handles pg_stat_statements not installed", async () => {
|
|
546
|
+
const calls: string[] = [];
|
|
547
|
+
const client = {
|
|
548
|
+
query: async (sql: string, params?: any) => {
|
|
549
|
+
calls.push(String(sql));
|
|
550
|
+
|
|
551
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
552
|
+
return { rowCount: 1, rows: [] };
|
|
553
|
+
}
|
|
554
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
555
|
+
return { rowCount: 1, rows: [] };
|
|
556
|
+
}
|
|
557
|
+
if (String(sql).includes("select rolconfig")) {
|
|
558
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, extensions, "$user", public, pg_catalog'] }] };
|
|
559
|
+
}
|
|
560
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
561
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
562
|
+
}
|
|
563
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
564
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
565
|
+
}
|
|
566
|
+
if (String(sql).includes("pg_has_role")) {
|
|
567
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
568
|
+
}
|
|
569
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
570
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
571
|
+
}
|
|
572
|
+
if (String(sql).includes("to_regclass")) {
|
|
573
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
574
|
+
}
|
|
575
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
576
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
577
|
+
}
|
|
578
|
+
// pg_stat_statements is NOT installed - empty result
|
|
579
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
580
|
+
return { rowCount: 0, rows: [] };
|
|
581
|
+
}
|
|
582
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
583
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
587
|
+
},
|
|
588
|
+
};
|
|
589
|
+
|
|
590
|
+
const r = await init.verifyInitSetup({
|
|
591
|
+
client: client as any,
|
|
592
|
+
database: "mydb",
|
|
593
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
594
|
+
includeOptionalPermissions: false,
|
|
595
|
+
});
|
|
596
|
+
// Should pass without errors - missing extension shouldn't cause failure
|
|
597
|
+
expect(r.ok).toBe(true);
|
|
598
|
+
expect(r.missingRequired.length).toBe(0);
|
|
599
|
+
// Should have queried for pg_stat_statements schema location
|
|
600
|
+
expect(calls.some((c) => c.includes("pg_extension e") && c.includes("pg_stat_statements"))).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
test("verifyInitSetup skips extension schema check when in pg_catalog", async () => {
|
|
604
|
+
const calls: string[] = [];
|
|
605
|
+
const client = {
|
|
606
|
+
query: async (sql: string, params?: any) => {
|
|
607
|
+
calls.push(String(sql));
|
|
608
|
+
|
|
609
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
610
|
+
return { rowCount: 1, rows: [] };
|
|
611
|
+
}
|
|
612
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
613
|
+
return { rowCount: 1, rows: [] };
|
|
614
|
+
}
|
|
615
|
+
if (String(sql).includes("select rolconfig")) {
|
|
616
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
|
|
617
|
+
}
|
|
618
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
619
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
620
|
+
}
|
|
621
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
622
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
623
|
+
}
|
|
624
|
+
if (String(sql).includes("pg_has_role")) {
|
|
625
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
626
|
+
}
|
|
627
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
628
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
629
|
+
}
|
|
630
|
+
if (String(sql).includes("to_regclass")) {
|
|
631
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
632
|
+
}
|
|
633
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
634
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
635
|
+
}
|
|
636
|
+
// pg_stat_statements is in pg_catalog (standard location)
|
|
637
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
638
|
+
return { rowCount: 1, rows: [{ schema: "pg_catalog" }] };
|
|
639
|
+
}
|
|
640
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
641
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
645
|
+
},
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const r = await init.verifyInitSetup({
|
|
649
|
+
client: client as any,
|
|
650
|
+
database: "mydb",
|
|
651
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
652
|
+
includeOptionalPermissions: false,
|
|
653
|
+
});
|
|
654
|
+
// Should pass - pg_catalog doesn't need extra USAGE grant
|
|
655
|
+
expect(r.ok).toBe(true);
|
|
656
|
+
expect(r.missingRequired.length).toBe(0);
|
|
657
|
+
// Should NOT have queried for has_schema_privilege on pg_catalog specifically
|
|
658
|
+
// (the code skips the check for pg_catalog and public schemas)
|
|
659
|
+
const pgCatalogPrivCheck = calls.filter(
|
|
660
|
+
(c) => c.includes("has_schema_privilege") && c.includes("pg_catalog")
|
|
661
|
+
);
|
|
662
|
+
// Should only have the standard public schema check, not a pg_catalog check for extension
|
|
663
|
+
expect(pgCatalogPrivCheck.length).toBe(0);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("verifyInitSetup skips extension schema check when in public", async () => {
|
|
667
|
+
const calls: string[] = [];
|
|
668
|
+
const client = {
|
|
669
|
+
query: async (sql: string, params?: any) => {
|
|
670
|
+
calls.push(String(sql));
|
|
671
|
+
|
|
672
|
+
if (String(sql).toLowerCase().startsWith("begin isolation level repeatable read")) {
|
|
673
|
+
return { rowCount: 1, rows: [] };
|
|
674
|
+
}
|
|
675
|
+
if (String(sql).toLowerCase() === "rollback;") {
|
|
676
|
+
return { rowCount: 1, rows: [] };
|
|
677
|
+
}
|
|
678
|
+
if (String(sql).includes("select rolconfig")) {
|
|
679
|
+
return { rowCount: 1, rows: [{ rolconfig: ['search_path=postgres_ai, "$user", public, pg_catalog'] }] };
|
|
680
|
+
}
|
|
681
|
+
if (String(sql).includes("from pg_catalog.pg_roles")) {
|
|
682
|
+
return { rowCount: 1, rows: [{ rolname: DEFAULT_MONITORING_USER }] };
|
|
683
|
+
}
|
|
684
|
+
if (String(sql).includes("has_database_privilege")) {
|
|
685
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
686
|
+
}
|
|
687
|
+
if (String(sql).includes("pg_has_role")) {
|
|
688
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
689
|
+
}
|
|
690
|
+
if (String(sql).includes("has_table_privilege")) {
|
|
691
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
692
|
+
}
|
|
693
|
+
if (String(sql).includes("to_regclass")) {
|
|
694
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
695
|
+
}
|
|
696
|
+
if (String(sql).includes("has_function_privilege")) {
|
|
697
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
698
|
+
}
|
|
699
|
+
// pg_stat_statements is in public schema
|
|
700
|
+
if (String(sql).includes("pg_extension e") && String(sql).includes("pg_stat_statements")) {
|
|
701
|
+
return { rowCount: 1, rows: [{ schema: "public" }] };
|
|
702
|
+
}
|
|
703
|
+
if (String(sql).includes("has_schema_privilege")) {
|
|
704
|
+
return { rowCount: 1, rows: [{ ok: true }] };
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
throw new Error(`unexpected sql: ${sql} params=${JSON.stringify(params)}`);
|
|
708
|
+
},
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const r = await init.verifyInitSetup({
|
|
712
|
+
client: client as any,
|
|
713
|
+
database: "mydb",
|
|
714
|
+
monitoringUser: DEFAULT_MONITORING_USER,
|
|
715
|
+
includeOptionalPermissions: false,
|
|
716
|
+
});
|
|
717
|
+
// Should pass - public doesn't need extra USAGE grant for extension
|
|
718
|
+
expect(r.ok).toBe(true);
|
|
719
|
+
expect(r.missingRequired.length).toBe(0);
|
|
720
|
+
});
|
|
721
|
+
|
|
385
722
|
test("buildInitPlan preserves comments when filtering ALTER USER", async () => {
|
|
386
723
|
const plan = await init.buildInitPlan({
|
|
387
724
|
database: "mydb",
|