postgresai 0.14.0 → 0.15.0-dev.10
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/README.md +3 -1
- package/bin/postgres-ai.ts +712 -108
- package/bun.lock +4 -4
- package/dist/bin/postgres-ai.js +2755 -572
- package/instances.demo.yml +14 -0
- package/lib/checkup-api.ts +25 -6
- package/lib/checkup.ts +465 -8
- package/lib/config.ts +7 -0
- package/lib/init.ts +196 -4
- package/lib/issues.ts +72 -72
- package/lib/mcp-server.ts +90 -0
- package/lib/metrics-loader.ts +6 -1
- package/lib/reports.ts +373 -0
- package/lib/storage.ts +291 -0
- package/lib/supabase.ts +8 -1
- package/lib/util.ts +7 -1
- package/package.json +4 -4
- package/scripts/embed-checkup-dictionary.ts +9 -0
- package/scripts/embed-metrics.ts +5 -0
- package/scripts/generate-release-notes.ts +283 -48
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +139 -0
- package/test/checkup.test.ts +1316 -2
- package/test/compose-cmd.test.ts +120 -0
- package/test/config-consistency.test.ts +321 -5
- package/test/init.integration.test.ts +27 -28
- package/test/init.test.ts +534 -6
- package/test/issues.cli.test.ts +230 -1
- package/test/mcp-server.test.ts +516 -0
- package/test/monitoring.test.ts +339 -0
- package/test/permission-check-sql.test.ts +116 -0
- package/test/reports.cli.test.ts +793 -0
- package/test/reports.test.ts +977 -0
- package/test/schema-validation.test.ts +81 -0
- package/test/storage.test.ts +761 -0
- package/test/test-utils.ts +51 -2
- package/test/upgrade.test.ts +422 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Test updatePgwatchConfig function behavior.
|
|
8
|
+
* Since the function is internal to postgres-ai.ts, we test it via file system operations.
|
|
9
|
+
*/
|
|
10
|
+
function updatePgwatchConfig(configPath: string, updates: Record<string, string>): void {
|
|
11
|
+
let lines: string[] = [];
|
|
12
|
+
|
|
13
|
+
// Read existing config if it exists
|
|
14
|
+
if (fs.existsSync(configPath)) {
|
|
15
|
+
const stats = fs.statSync(configPath);
|
|
16
|
+
if (!stats.isDirectory()) {
|
|
17
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
18
|
+
lines = content.split(/\r?\n/).filter(l => l.trim() !== "");
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Update or add each key
|
|
23
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
24
|
+
const existingIndex = lines.findIndex(l => l.startsWith(key + "="));
|
|
25
|
+
if (existingIndex >= 0) {
|
|
26
|
+
lines[existingIndex] = `${key}=${value}`;
|
|
27
|
+
} else {
|
|
28
|
+
lines.push(`${key}=${value}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
fs.writeFileSync(configPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("updatePgwatchConfig", () => {
|
|
36
|
+
let tempDir: string;
|
|
37
|
+
let configPath: string;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pgwatch-test-"));
|
|
41
|
+
configPath = path.join(tempDir, ".pgwatch-config");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
46
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("creates new file with updates", () => {
|
|
51
|
+
updatePgwatchConfig(configPath, {
|
|
52
|
+
api_key: "test-key-123",
|
|
53
|
+
project_name: "my-project",
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(fs.existsSync(configPath)).toBe(true);
|
|
57
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
58
|
+
expect(content).toContain("api_key=test-key-123");
|
|
59
|
+
expect(content).toContain("project_name=my-project");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("updates existing keys", () => {
|
|
63
|
+
fs.writeFileSync(configPath, "api_key=old-key\nproject_name=old-project\n");
|
|
64
|
+
|
|
65
|
+
updatePgwatchConfig(configPath, {
|
|
66
|
+
api_key: "new-key",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
70
|
+
expect(content).toContain("api_key=new-key");
|
|
71
|
+
expect(content).toContain("project_name=old-project");
|
|
72
|
+
expect(content).not.toContain("api_key=old-key");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("adds new keys to existing file", () => {
|
|
76
|
+
fs.writeFileSync(configPath, "api_key=existing-key\n");
|
|
77
|
+
|
|
78
|
+
updatePgwatchConfig(configPath, {
|
|
79
|
+
project_name: "new-project",
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
83
|
+
expect(content).toContain("api_key=existing-key");
|
|
84
|
+
expect(content).toContain("project_name=new-project");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("preserves existing keys not being updated", () => {
|
|
88
|
+
fs.writeFileSync(configPath, "api_key=key1\nproject_name=proj1\nother_setting=value1\n");
|
|
89
|
+
|
|
90
|
+
updatePgwatchConfig(configPath, {
|
|
91
|
+
project_name: "proj2",
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
95
|
+
expect(content).toContain("api_key=key1");
|
|
96
|
+
expect(content).toContain("project_name=proj2");
|
|
97
|
+
expect(content).toContain("other_setting=value1");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("handles values with equals sign", () => {
|
|
101
|
+
updatePgwatchConfig(configPath, {
|
|
102
|
+
api_key: "key=with=equals",
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
106
|
+
expect(content).toContain("api_key=key=with=equals");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("handles empty file", () => {
|
|
110
|
+
fs.writeFileSync(configPath, "");
|
|
111
|
+
|
|
112
|
+
updatePgwatchConfig(configPath, {
|
|
113
|
+
api_key: "new-key",
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
117
|
+
expect(content).toContain("api_key=new-key");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("handles file with blank lines", () => {
|
|
121
|
+
fs.writeFileSync(configPath, "api_key=key1\n\n\nproject_name=proj1\n\n");
|
|
122
|
+
|
|
123
|
+
updatePgwatchConfig(configPath, {
|
|
124
|
+
new_key: "new-value",
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
128
|
+
expect(content).toContain("api_key=key1");
|
|
129
|
+
expect(content).toContain("project_name=proj1");
|
|
130
|
+
expect(content).toContain("new_key=new-value");
|
|
131
|
+
// Blank lines in the middle should be filtered out (no consecutive newlines)
|
|
132
|
+
expect(content).not.toContain("\n\n");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("handles multiple updates in one call", () => {
|
|
136
|
+
fs.writeFileSync(configPath, "api_key=old-key\n");
|
|
137
|
+
|
|
138
|
+
updatePgwatchConfig(configPath, {
|
|
139
|
+
api_key: "new-key",
|
|
140
|
+
project_name: "my-project",
|
|
141
|
+
another_setting: "another-value",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
145
|
+
expect(content).toContain("api_key=new-key");
|
|
146
|
+
expect(content).toContain("project_name=my-project");
|
|
147
|
+
expect(content).toContain("another_setting=another-value");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("uses startsWith for key matching (not regex)", () => {
|
|
151
|
+
// This tests that we use startsWith, not regex, to avoid ReDoS
|
|
152
|
+
// A key like "api_key" should not match "other_api_key"
|
|
153
|
+
fs.writeFileSync(configPath, "other_api_key=other-value\napi_key=original-key\n");
|
|
154
|
+
|
|
155
|
+
updatePgwatchConfig(configPath, {
|
|
156
|
+
api_key: "updated-key",
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const content = fs.readFileSync(configPath, "utf8");
|
|
160
|
+
expect(content).toContain("api_key=updated-key");
|
|
161
|
+
expect(content).toContain("other_api_key=other-value");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("sets restrictive file permissions", () => {
|
|
165
|
+
updatePgwatchConfig(configPath, {
|
|
166
|
+
api_key: "secret-key",
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const stats = fs.statSync(configPath);
|
|
170
|
+
// Check that file is only readable/writable by owner (mode 0o600)
|
|
171
|
+
const mode = stats.mode & 0o777;
|
|
172
|
+
expect(mode).toBe(0o600);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("registerMonitoringInstance", () => {
|
|
177
|
+
let originalFetch: typeof global.fetch;
|
|
178
|
+
let fetchCalls: Array<{ url: string; options: RequestInit }>;
|
|
179
|
+
|
|
180
|
+
beforeEach(() => {
|
|
181
|
+
originalFetch = global.fetch;
|
|
182
|
+
fetchCalls = [];
|
|
183
|
+
// Mock fetch to capture calls
|
|
184
|
+
global.fetch = async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
185
|
+
fetchCalls.push({ url: url.toString(), options: options || {} });
|
|
186
|
+
return new Response(JSON.stringify({ success: true }), { status: 200 });
|
|
187
|
+
};
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
afterEach(() => {
|
|
191
|
+
global.fetch = originalFetch;
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("sends POST request with correct URL and body", async () => {
|
|
195
|
+
// Simulate what registerMonitoringInstance does
|
|
196
|
+
const apiKey = "test-api-key";
|
|
197
|
+
const projectName = "my-project";
|
|
198
|
+
const apiBaseUrl = "https://api.example.com";
|
|
199
|
+
|
|
200
|
+
await fetch(`${apiBaseUrl}/rpc/monitoring_instance_register`, {
|
|
201
|
+
method: "POST",
|
|
202
|
+
headers: {
|
|
203
|
+
"Content-Type": "application/json",
|
|
204
|
+
},
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
api_token: apiKey,
|
|
207
|
+
project_name: projectName,
|
|
208
|
+
}),
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(fetchCalls.length).toBe(1);
|
|
212
|
+
expect(fetchCalls[0].url).toBe("https://api.example.com/rpc/monitoring_instance_register");
|
|
213
|
+
expect(fetchCalls[0].options.method).toBe("POST");
|
|
214
|
+
|
|
215
|
+
const headers = fetchCalls[0].options.headers as Record<string, string>;
|
|
216
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
217
|
+
// Verify API key is NOT in headers (only in body per security review)
|
|
218
|
+
expect(headers["access-token"]).toBeUndefined();
|
|
219
|
+
|
|
220
|
+
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
221
|
+
expect(body.api_token).toBe("test-api-key");
|
|
222
|
+
expect(body.project_name).toBe("my-project");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("includes api_token in body, not in header", async () => {
|
|
226
|
+
const apiKey = "secret-key-12345";
|
|
227
|
+
const projectName = "test-project";
|
|
228
|
+
const apiBaseUrl = "https://postgres.ai/api/general";
|
|
229
|
+
|
|
230
|
+
await fetch(`${apiBaseUrl}/rpc/monitoring_instance_register`, {
|
|
231
|
+
method: "POST",
|
|
232
|
+
headers: {
|
|
233
|
+
"Content-Type": "application/json",
|
|
234
|
+
},
|
|
235
|
+
body: JSON.stringify({
|
|
236
|
+
api_token: apiKey,
|
|
237
|
+
project_name: projectName,
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
const headers = fetchCalls[0].options.headers as Record<string, string>;
|
|
242
|
+
// Verify no access-token header
|
|
243
|
+
expect(Object.keys(headers)).not.toContain("access-token");
|
|
244
|
+
|
|
245
|
+
// Verify token is in body
|
|
246
|
+
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
247
|
+
expect(body.api_token).toBe("secret-key-12345");
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("uses correct endpoint path", async () => {
|
|
251
|
+
const apiBaseUrl = "https://custom.api.com/v2";
|
|
252
|
+
|
|
253
|
+
await fetch(`${apiBaseUrl}/rpc/monitoring_instance_register`, {
|
|
254
|
+
method: "POST",
|
|
255
|
+
headers: { "Content-Type": "application/json" },
|
|
256
|
+
body: JSON.stringify({ api_token: "key", project_name: "proj" }),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(fetchCalls[0].url).toBe("https://custom.api.com/v2/rpc/monitoring_instance_register");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("demo mode instances.demo.yml", () => {
|
|
264
|
+
const repoRoot = path.resolve(import.meta.dir, "..", "..");
|
|
265
|
+
|
|
266
|
+
test("instances.demo.yml exists in repo root", () => {
|
|
267
|
+
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
268
|
+
expect(fs.existsSync(demoFile)).toBe(true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("instances.demo.yml contains demo target connection", () => {
|
|
272
|
+
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
273
|
+
const content = fs.readFileSync(demoFile, "utf8");
|
|
274
|
+
expect(content).toContain("name: target_database");
|
|
275
|
+
expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
|
|
276
|
+
expect(content).toContain("is_enabled: true");
|
|
277
|
+
expect(content).toContain("preset_metrics: full");
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("instances.demo.yml has required YAML structure", () => {
|
|
281
|
+
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
282
|
+
const content = fs.readFileSync(demoFile, "utf8");
|
|
283
|
+
// Verify it's a YAML list (starts with "- name:")
|
|
284
|
+
expect(content).toMatch(/^- name: target_database/m);
|
|
285
|
+
// Verify required fields are present with correct indentation
|
|
286
|
+
expect(content).toMatch(/^\s+conn_str:/m);
|
|
287
|
+
expect(content).toMatch(/^\s+preset_metrics: full/m);
|
|
288
|
+
expect(content).toMatch(/^\s+is_enabled: true/m);
|
|
289
|
+
// ~sink_type~ is a sed token substituted by generate-pgwatch-sources.sh; values: postgres, prometheus
|
|
290
|
+
expect(content).toMatch(/^\s+sink_type: ~sink_type~/m);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("instances.yml is gitignored (not tracked)", () => {
|
|
294
|
+
const gitignore = fs.readFileSync(path.join(repoRoot, ".gitignore"), "utf8");
|
|
295
|
+
expect(gitignore).toMatch(/^instances\.yml$/m);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("demo config can be copied to instances.yml in temp dir", () => {
|
|
299
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-install-test-"));
|
|
300
|
+
try {
|
|
301
|
+
const demoSrc = path.join(repoRoot, "instances.demo.yml");
|
|
302
|
+
const instancesDest = path.join(tempDir, "instances.yml");
|
|
303
|
+
|
|
304
|
+
fs.copyFileSync(demoSrc, instancesDest);
|
|
305
|
+
|
|
306
|
+
expect(fs.existsSync(instancesDest)).toBe(true);
|
|
307
|
+
const content = fs.readFileSync(instancesDest, "utf8");
|
|
308
|
+
expect(content).toContain("name: target_database");
|
|
309
|
+
expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
|
|
310
|
+
} finally {
|
|
311
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("demo config copy overwrites directory at instances.yml path", () => {
|
|
316
|
+
// Docker bind-mounts create missing paths as directories; the copy must handle this
|
|
317
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-eisdir-test-"));
|
|
318
|
+
try {
|
|
319
|
+
const demoSrc = path.join(repoRoot, "instances.demo.yml");
|
|
320
|
+
const instancesDest = path.join(tempDir, "instances.yml");
|
|
321
|
+
|
|
322
|
+
// Simulate Docker creating a directory at instances.yml path
|
|
323
|
+
fs.mkdirSync(instancesDest);
|
|
324
|
+
expect(fs.statSync(instancesDest).isDirectory()).toBe(true);
|
|
325
|
+
|
|
326
|
+
// The fix: remove directory then copy
|
|
327
|
+
if (fs.statSync(instancesDest).isDirectory()) {
|
|
328
|
+
fs.rmSync(instancesDest, { recursive: true, force: true });
|
|
329
|
+
}
|
|
330
|
+
fs.copyFileSync(demoSrc, instancesDest);
|
|
331
|
+
|
|
332
|
+
expect(fs.statSync(instancesDest).isFile()).toBe(true);
|
|
333
|
+
const content = fs.readFileSync(instancesDest, "utf8");
|
|
334
|
+
expect(content).toContain("name: target_database");
|
|
335
|
+
} finally {
|
|
336
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test the SQL logic for checking postgres_ai.pg_statistic view existence
|
|
3
|
+
* across different permission scenarios.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, test, expect } from "bun:test";
|
|
6
|
+
|
|
7
|
+
describe("postgres_ai.pg_statistic permission check SQL", () => {
|
|
8
|
+
test("to_regclass() returns NULL when schema doesn't exist", () => {
|
|
9
|
+
// Simulate the SQL check behavior
|
|
10
|
+
const viewExists = null; // to_regclass('postgres_ai.pg_statistic') when schema doesn't exist
|
|
11
|
+
const granted = viewExists !== null;
|
|
12
|
+
|
|
13
|
+
expect(granted).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("to_regclass() returns NULL when user lacks USAGE on schema", () => {
|
|
17
|
+
// When user lacks USAGE on postgres_ai schema, to_regclass() returns NULL
|
|
18
|
+
// even if the schema and view exist
|
|
19
|
+
const viewExists = null; // to_regclass('postgres_ai.pg_statistic') when no USAGE
|
|
20
|
+
const granted = viewExists !== null;
|
|
21
|
+
|
|
22
|
+
expect(granted).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("to_regclass() returns oid when view exists and user has access", () => {
|
|
26
|
+
// When user has USAGE on schema and view exists
|
|
27
|
+
const viewExists = 12345; // to_regclass('postgres_ai.pg_statistic') returns oid
|
|
28
|
+
const granted = viewExists !== null;
|
|
29
|
+
|
|
30
|
+
expect(granted).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("has_table_privilege is skipped (returns null) when view doesn't exist", () => {
|
|
34
|
+
const viewExists = null;
|
|
35
|
+
const selectGranted = viewExists === null ? null : true; // skipped
|
|
36
|
+
|
|
37
|
+
expect(selectGranted).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("has_table_privilege is checked when view exists", () => {
|
|
41
|
+
const viewExists = 12345;
|
|
42
|
+
const userHasSelect = true;
|
|
43
|
+
const selectGranted = viewExists === null ? null : userHasSelect;
|
|
44
|
+
|
|
45
|
+
expect(selectGranted).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("Expected behavior per scenario", () => {
|
|
50
|
+
test("Scenario 1: Superuser with postgres_ai.pg_statistic", () => {
|
|
51
|
+
// to_regclass returns oid, has_table_privilege returns true
|
|
52
|
+
const checkViewExists = true; // to_regclass('postgres_ai.pg_statistic') is not null
|
|
53
|
+
const checkSelectPrivilege = true; // has_table_privilege returns true
|
|
54
|
+
|
|
55
|
+
const missingOptional: string[] = [];
|
|
56
|
+
if (!checkViewExists) {
|
|
57
|
+
missingOptional.push("postgres_ai.pg_statistic view exists");
|
|
58
|
+
}
|
|
59
|
+
if (checkSelectPrivilege === false) {
|
|
60
|
+
missingOptional.push("select on postgres_ai.pg_statistic");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
expect(missingOptional).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("Scenario 2: pg_monitor, no postgres_ai schema access (before prepare-db)", () => {
|
|
67
|
+
// to_regclass returns NULL because user lacks USAGE on postgres_ai schema
|
|
68
|
+
const checkViewExists = false; // to_regclass('postgres_ai.pg_statistic') is null
|
|
69
|
+
const checkSelectPrivilege = null; // skipped because view doesn't exist
|
|
70
|
+
|
|
71
|
+
const missingOptional: string[] = [];
|
|
72
|
+
if (!checkViewExists) {
|
|
73
|
+
missingOptional.push("postgres_ai.pg_statistic view exists");
|
|
74
|
+
}
|
|
75
|
+
if (checkSelectPrivilege === false) {
|
|
76
|
+
missingOptional.push("select on postgres_ai.pg_statistic");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Should show warning about missing view but NOT crash
|
|
80
|
+
expect(missingOptional).toEqual(["postgres_ai.pg_statistic view exists"]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("Scenario 3: No pg_monitor (before prepare-db)", () => {
|
|
84
|
+
// to_regclass returns NULL because schema doesn't exist yet
|
|
85
|
+
const checkViewExists = false; // to_regclass('postgres_ai.pg_statistic') is null
|
|
86
|
+
const checkSelectPrivilege = null; // skipped
|
|
87
|
+
|
|
88
|
+
const missingOptional: string[] = [];
|
|
89
|
+
if (!checkViewExists) {
|
|
90
|
+
missingOptional.push("postgres_ai.pg_statistic view exists");
|
|
91
|
+
}
|
|
92
|
+
if (checkSelectPrivilege === false) {
|
|
93
|
+
missingOptional.push("select on postgres_ai.pg_statistic");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Should show warning but NOT crash
|
|
97
|
+
expect(missingOptional).toEqual(["postgres_ai.pg_statistic view exists"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("Scenario 8: After prepare-db with schema grants", () => {
|
|
101
|
+
// to_regclass returns oid, has_table_privilege returns true
|
|
102
|
+
const checkViewExists = true; // to_regclass('postgres_ai.pg_statistic') is not null
|
|
103
|
+
const checkSelectPrivilege = true; // has_table_privilege returns true
|
|
104
|
+
|
|
105
|
+
const missingOptional: string[] = [];
|
|
106
|
+
if (!checkViewExists) {
|
|
107
|
+
missingOptional.push("postgres_ai.pg_statistic view exists");
|
|
108
|
+
}
|
|
109
|
+
if (checkSelectPrivilege === false) {
|
|
110
|
+
missingOptional.push("select on postgres_ai.pg_statistic");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Should be clean, no warnings
|
|
114
|
+
expect(missingOptional).toHaveLength(0);
|
|
115
|
+
});
|
|
116
|
+
});
|