postgresai 0.16.0-rc.4 → 0.16.0
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 +154 -0
- package/dist/bin/postgres-ai.js +2911 -255
- package/package.json +12 -3
- package/schemas/A002.schema.json +63 -0
- package/schemas/A003.schema.json +73 -0
- package/schemas/A004.schema.json +81 -0
- package/schemas/A007.schema.json +71 -0
- package/schemas/A013.schema.json +61 -0
- package/schemas/D001.schema.json +71 -0
- package/schemas/D004.schema.json +136 -0
- package/schemas/F001.schema.json +73 -0
- package/schemas/F002.schema.json +108 -0
- package/schemas/F003.schema.json +138 -0
- package/schemas/F004.schema.json +125 -0
- package/schemas/F005.schema.json +131 -0
- package/schemas/F009.schema.json +155 -0
- package/schemas/G001.schema.json +135 -0
- package/schemas/G003.schema.json +90 -0
- package/schemas/H001.schema.json +141 -0
- package/schemas/H002.schema.json +129 -0
- package/schemas/H004.schema.json +128 -0
- package/schemas/I001.schema.json +149 -0
- package/schemas/K001.schema.json +161 -0
- package/schemas/K003.schema.json +163 -0
- package/schemas/K004.schema.json +110 -0
- package/schemas/K005.schema.json +110 -0
- package/schemas/K006.schema.json +110 -0
- package/schemas/K007.schema.json +110 -0
- package/schemas/K008.schema.json +110 -0
- package/schemas/M001.schema.json +119 -0
- package/schemas/M002.schema.json +110 -0
- package/schemas/M003.schema.json +128 -0
- package/schemas/N001.schema.json +161 -0
- package/schemas/query.schema.json +62 -0
- package/CHANGELOG.md +0 -11
- package/bin/postgres-ai.ts +0 -5578
- package/bun.lock +0 -258
- package/bunfig.toml +0 -20
- package/lib/aas-onboard.ts +0 -251
- package/lib/auth-server.ts +0 -285
- package/lib/checkup-api.ts +0 -526
- package/lib/checkup-dictionary.ts +0 -103
- package/lib/checkup-summary.ts +0 -338
- package/lib/checkup.ts +0 -2261
- package/lib/config.ts +0 -171
- package/lib/init.ts +0 -1152
- package/lib/instances.ts +0 -245
- package/lib/issues.ts +0 -1060
- package/lib/mcp-server.ts +0 -667
- package/lib/metrics-loader.ts +0 -134
- package/lib/pkce.ts +0 -79
- package/lib/reports.ts +0 -373
- package/lib/storage.ts +0 -367
- package/lib/supabase.ts +0 -826
- package/lib/util.ts +0 -134
- package/packages/postgres-ai/README.md +0 -26
- package/packages/postgres-ai/bin/postgres-ai.js +0 -27
- package/packages/postgres-ai/package.json +0 -27
- package/scripts/embed-checkup-dictionary.ts +0 -115
- package/scripts/embed-metrics.ts +0 -160
- package/scripts/generate-release-notes.ts +0 -668
- package/test/PERMISSION_CHECK_TEST_SUMMARY.md +0 -139
- package/test/aas-onboard.test.ts +0 -301
- package/test/auth.test.ts +0 -287
- package/test/checkup.integration.test.ts +0 -413
- package/test/checkup.test.ts +0 -3626
- package/test/compose-cmd.test.ts +0 -120
- package/test/config-consistency.test.ts +0 -352
- package/test/init.integration.test.ts +0 -438
- package/test/init.test.ts +0 -1816
- package/test/issues.cli.test.ts +0 -1162
- package/test/issues.test.ts +0 -456
- package/test/mcp-server.test.ts +0 -2530
- package/test/monitoring.test.ts +0 -746
- package/test/permission-check-sql.test.ts +0 -116
- package/test/reports.cli.test.ts +0 -793
- package/test/reports.test.ts +0 -977
- package/test/schema-validation.test.ts +0 -231
- package/test/storage.test.ts +0 -935
- package/test/supabase.test.ts +0 -709
- package/test/targets-add-config.test.ts +0 -28
- package/test/test-utils.ts +0 -190
- package/test/upgrade.test.ts +0 -1056
- package/test/util.test.ts +0 -44
- package/tsconfig.json +0 -20
package/test/monitoring.test.ts
DELETED
|
@@ -1,746 +0,0 @@
|
|
|
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
|
-
import * as yaml from "js-yaml";
|
|
6
|
-
import { Client } from "pg";
|
|
7
|
-
import {
|
|
8
|
-
addInstanceToFile,
|
|
9
|
-
removeInstanceFromFile,
|
|
10
|
-
loadInstances,
|
|
11
|
-
buildInstance,
|
|
12
|
-
buildClientConfig,
|
|
13
|
-
sslOptionFromConnString,
|
|
14
|
-
warnIfLaxSslmode,
|
|
15
|
-
isLaxSslmode,
|
|
16
|
-
extractSslmode,
|
|
17
|
-
InstancesParseError,
|
|
18
|
-
} from "../lib/instances";
|
|
19
|
-
import {
|
|
20
|
-
registerMonitoringInstance,
|
|
21
|
-
resolveAdoptedProject,
|
|
22
|
-
planMonitoringRegistration,
|
|
23
|
-
} from "../bin/postgres-ai";
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Test updatePgwatchConfig function behavior.
|
|
27
|
-
* Since the function is internal to postgres-ai.ts, we test it via file system operations.
|
|
28
|
-
*/
|
|
29
|
-
function updatePgwatchConfig(configPath: string, updates: Record<string, string>): void {
|
|
30
|
-
let lines: string[] = [];
|
|
31
|
-
|
|
32
|
-
// Read existing config if it exists
|
|
33
|
-
if (fs.existsSync(configPath)) {
|
|
34
|
-
const stats = fs.statSync(configPath);
|
|
35
|
-
if (!stats.isDirectory()) {
|
|
36
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
37
|
-
lines = content.split(/\r?\n/).filter(l => l.trim() !== "");
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Update or add each key
|
|
42
|
-
for (const [key, value] of Object.entries(updates)) {
|
|
43
|
-
const existingIndex = lines.findIndex(l => l.startsWith(key + "="));
|
|
44
|
-
if (existingIndex >= 0) {
|
|
45
|
-
lines[existingIndex] = `${key}=${value}`;
|
|
46
|
-
} else {
|
|
47
|
-
lines.push(`${key}=${value}`);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
fs.writeFileSync(configPath, lines.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
describe("updatePgwatchConfig", () => {
|
|
55
|
-
let tempDir: string;
|
|
56
|
-
let configPath: string;
|
|
57
|
-
|
|
58
|
-
beforeEach(() => {
|
|
59
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pgwatch-test-"));
|
|
60
|
-
configPath = path.join(tempDir, ".pgwatch-config");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
afterEach(() => {
|
|
64
|
-
if (tempDir && fs.existsSync(tempDir)) {
|
|
65
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
66
|
-
}
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("creates new file with updates", () => {
|
|
70
|
-
updatePgwatchConfig(configPath, {
|
|
71
|
-
api_key: "test-key-123",
|
|
72
|
-
project_name: "my-project",
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
expect(fs.existsSync(configPath)).toBe(true);
|
|
76
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
77
|
-
expect(content).toContain("api_key=test-key-123");
|
|
78
|
-
expect(content).toContain("project_name=my-project");
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test("updates existing keys", () => {
|
|
82
|
-
fs.writeFileSync(configPath, "api_key=old-key\nproject_name=old-project\n");
|
|
83
|
-
|
|
84
|
-
updatePgwatchConfig(configPath, {
|
|
85
|
-
api_key: "new-key",
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
89
|
-
expect(content).toContain("api_key=new-key");
|
|
90
|
-
expect(content).toContain("project_name=old-project");
|
|
91
|
-
expect(content).not.toContain("api_key=old-key");
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("adds new keys to existing file", () => {
|
|
95
|
-
fs.writeFileSync(configPath, "api_key=existing-key\n");
|
|
96
|
-
|
|
97
|
-
updatePgwatchConfig(configPath, {
|
|
98
|
-
project_name: "new-project",
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
102
|
-
expect(content).toContain("api_key=existing-key");
|
|
103
|
-
expect(content).toContain("project_name=new-project");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
test("preserves existing keys not being updated", () => {
|
|
107
|
-
fs.writeFileSync(configPath, "api_key=key1\nproject_name=proj1\nother_setting=value1\n");
|
|
108
|
-
|
|
109
|
-
updatePgwatchConfig(configPath, {
|
|
110
|
-
project_name: "proj2",
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
114
|
-
expect(content).toContain("api_key=key1");
|
|
115
|
-
expect(content).toContain("project_name=proj2");
|
|
116
|
-
expect(content).toContain("other_setting=value1");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
test("handles values with equals sign", () => {
|
|
120
|
-
updatePgwatchConfig(configPath, {
|
|
121
|
-
api_key: "key=with=equals",
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
125
|
-
expect(content).toContain("api_key=key=with=equals");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("handles empty file", () => {
|
|
129
|
-
fs.writeFileSync(configPath, "");
|
|
130
|
-
|
|
131
|
-
updatePgwatchConfig(configPath, {
|
|
132
|
-
api_key: "new-key",
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
136
|
-
expect(content).toContain("api_key=new-key");
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
test("handles file with blank lines", () => {
|
|
140
|
-
fs.writeFileSync(configPath, "api_key=key1\n\n\nproject_name=proj1\n\n");
|
|
141
|
-
|
|
142
|
-
updatePgwatchConfig(configPath, {
|
|
143
|
-
new_key: "new-value",
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
147
|
-
expect(content).toContain("api_key=key1");
|
|
148
|
-
expect(content).toContain("project_name=proj1");
|
|
149
|
-
expect(content).toContain("new_key=new-value");
|
|
150
|
-
// Blank lines in the middle should be filtered out (no consecutive newlines)
|
|
151
|
-
expect(content).not.toContain("\n\n");
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("handles multiple updates in one call", () => {
|
|
155
|
-
fs.writeFileSync(configPath, "api_key=old-key\n");
|
|
156
|
-
|
|
157
|
-
updatePgwatchConfig(configPath, {
|
|
158
|
-
api_key: "new-key",
|
|
159
|
-
project_name: "my-project",
|
|
160
|
-
another_setting: "another-value",
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
164
|
-
expect(content).toContain("api_key=new-key");
|
|
165
|
-
expect(content).toContain("project_name=my-project");
|
|
166
|
-
expect(content).toContain("another_setting=another-value");
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test("uses startsWith for key matching (not regex)", () => {
|
|
170
|
-
// This tests that we use startsWith, not regex, to avoid ReDoS
|
|
171
|
-
// A key like "api_key" should not match "other_api_key"
|
|
172
|
-
fs.writeFileSync(configPath, "other_api_key=other-value\napi_key=original-key\n");
|
|
173
|
-
|
|
174
|
-
updatePgwatchConfig(configPath, {
|
|
175
|
-
api_key: "updated-key",
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
const content = fs.readFileSync(configPath, "utf8");
|
|
179
|
-
expect(content).toContain("api_key=updated-key");
|
|
180
|
-
expect(content).toContain("other_api_key=other-value");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
test("sets restrictive file permissions", () => {
|
|
184
|
-
updatePgwatchConfig(configPath, {
|
|
185
|
-
api_key: "secret-key",
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
const stats = fs.statSync(configPath);
|
|
189
|
-
// Check that file is only readable/writable by owner (mode 0o600)
|
|
190
|
-
const mode = stats.mode & 0o777;
|
|
191
|
-
expect(mode).toBe(0o600);
|
|
192
|
-
});
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
describe("registerMonitoringInstance", () => {
|
|
196
|
-
let originalFetch: typeof global.fetch;
|
|
197
|
-
let fetchCalls: Array<{ url: string; options: RequestInit }>;
|
|
198
|
-
// Each test sets `respond(call) => Response`; defaults to a 200 with no body.
|
|
199
|
-
let respond: (call: { url: string; options: RequestInit }) => Response;
|
|
200
|
-
const apiBaseUrl = "https://api.example.com";
|
|
201
|
-
const registerUrl = `${apiBaseUrl}/rpc/monitoring_instance_register`;
|
|
202
|
-
|
|
203
|
-
beforeEach(() => {
|
|
204
|
-
originalFetch = global.fetch;
|
|
205
|
-
fetchCalls = [];
|
|
206
|
-
respond = () => new Response(JSON.stringify({ project_id: 7 }), { status: 200 });
|
|
207
|
-
// Mock fetch to capture calls and return the test-configured response.
|
|
208
|
-
global.fetch = async (url: RequestInfo | URL, options?: RequestInit) => {
|
|
209
|
-
const call = { url: url.toString(), options: options || {} };
|
|
210
|
-
fetchCalls.push(call);
|
|
211
|
-
return respond(call);
|
|
212
|
-
};
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
afterEach(() => {
|
|
216
|
-
global.fetch = originalFetch;
|
|
217
|
-
});
|
|
218
|
-
|
|
219
|
-
// retryDelayMs: 0 keeps the retry path instant under test.
|
|
220
|
-
const opts = (extra?: Record<string, unknown>) => ({ apiBaseUrl, retryDelayMs: 0, ...extra });
|
|
221
|
-
|
|
222
|
-
test("posts api_token + project_name in the body (never in headers), legacy mode", async () => {
|
|
223
|
-
await registerMonitoringInstance("secret-key-12345", "my-project", opts());
|
|
224
|
-
|
|
225
|
-
expect(fetchCalls.length).toBe(1);
|
|
226
|
-
expect(fetchCalls[0].url).toBe(registerUrl);
|
|
227
|
-
expect(fetchCalls[0].options.method).toBe("POST");
|
|
228
|
-
|
|
229
|
-
const headers = fetchCalls[0].options.headers as Record<string, string>;
|
|
230
|
-
expect(headers["Content-Type"]).toBe("application/json");
|
|
231
|
-
// API key only in body, never in an access-token header (security review).
|
|
232
|
-
expect(headers["access-token"]).toBeUndefined();
|
|
233
|
-
|
|
234
|
-
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
235
|
-
expect(body.api_token).toBe("secret-key-12345");
|
|
236
|
-
expect(body.project_name).toBe("my-project");
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
test("honors a custom apiBaseUrl for the endpoint path", async () => {
|
|
240
|
-
await registerMonitoringInstance("key", "proj", opts({ apiBaseUrl: "https://custom.api.com/v2" }));
|
|
241
|
-
expect(fetchCalls[0].url).toBe("https://custom.api.com/v2/rpc/monitoring_instance_register");
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
// Issue platform-all#311: console-provisioned installs pass instance_id so
|
|
245
|
-
// the platform adopts the provisioned instance and returns its real project
|
|
246
|
-
// — the CLI sends NO project_name on the adopt path (the hardcoded
|
|
247
|
-
// "postgres-ai-monitoring" default was removed).
|
|
248
|
-
test("includes instance_id in body when adopting a provisioned instance", async () => {
|
|
249
|
-
const instanceId = "019eb300-3f2a-7a75-b54d-4f10572b25b8";
|
|
250
|
-
|
|
251
|
-
await registerMonitoringInstance("key", undefined, opts({ instanceId }));
|
|
252
|
-
|
|
253
|
-
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
254
|
-
expect(body.instance_id).toBe(instanceId);
|
|
255
|
-
// instance_id rides in the body next to api_token — never in headers.
|
|
256
|
-
const headers = fetchCalls[0].options.headers as Record<string, string>;
|
|
257
|
-
expect(headers["instance-id"]).toBeUndefined();
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test("omits project_name from the body when undefined (adopt path)", async () => {
|
|
261
|
-
await registerMonitoringInstance("key", undefined, opts({ instanceId: "i" }));
|
|
262
|
-
|
|
263
|
-
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
264
|
-
// The adopt path sends no name; the platform returns the real project.
|
|
265
|
-
expect("project_name" in body).toBe(false);
|
|
266
|
-
expect(body.api_token).toBe("key");
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
test("omits project_name from the body when empty/whitespace", async () => {
|
|
270
|
-
await registerMonitoringInstance("key", " ", opts({ instanceId: "i" }));
|
|
271
|
-
|
|
272
|
-
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
273
|
-
expect("project_name" in body).toBe(false);
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("omits instance_id from the body for legacy self-registration", async () => {
|
|
277
|
-
await registerMonitoringInstance("key", "my-project", opts());
|
|
278
|
-
|
|
279
|
-
const body = JSON.parse(fetchCalls[0].options.body as string);
|
|
280
|
-
// PostgREST matches the 3-arg function via its default — the key must be
|
|
281
|
-
// ABSENT (not null) so legacy CLIs and the new one hit the same overload.
|
|
282
|
-
expect("instance_id" in body).toBe(false);
|
|
283
|
-
// A real project name still rides in the body for legacy registration.
|
|
284
|
-
expect(body.project_name).toBe("my-project");
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
test("a 200 with {project_id, project_name} returns a populated result", async () => {
|
|
288
|
-
respond = () => new Response(JSON.stringify({ project_id: 42, project_name: "prod-db", created: false }), { status: 200 });
|
|
289
|
-
|
|
290
|
-
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
291
|
-
|
|
292
|
-
expect(reg).toEqual({ instanceId: undefined, projectId: 42, projectName: "prod-db", created: false });
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
test("a non-JSON 200 returns {} (success, but no fields) — not null", async () => {
|
|
296
|
-
respond = () => new Response("<html>oops</html>", { status: 200 });
|
|
297
|
-
|
|
298
|
-
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
299
|
-
|
|
300
|
-
expect(reg).toEqual({});
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
test("mistyped fields are dropped at runtime (project_id must be a number)", async () => {
|
|
304
|
-
// A spoofed/older platform returning a string id must not poison the
|
|
305
|
-
// persistence decision, which prefers a numeric project_id.
|
|
306
|
-
respond = () => new Response(JSON.stringify({ project_id: "13", project_name: "ok-name", created: "yes" }), { status: 200 });
|
|
307
|
-
|
|
308
|
-
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
309
|
-
|
|
310
|
-
expect(reg).toEqual({ instanceId: undefined, projectId: undefined, projectName: "ok-name", created: undefined });
|
|
311
|
-
});
|
|
312
|
-
|
|
313
|
-
test("when adopting, a first non-OK response triggers one retry that succeeds", async () => {
|
|
314
|
-
respond = (call) => {
|
|
315
|
-
// First attempt 503, second 200.
|
|
316
|
-
return fetchCalls.length === 1
|
|
317
|
-
? new Response("upstream down", { status: 503 })
|
|
318
|
-
: new Response(JSON.stringify({ project_id: 9 }), { status: 200 });
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
322
|
-
|
|
323
|
-
expect(fetchCalls.length).toBe(2);
|
|
324
|
-
expect(reg).toEqual({ instanceId: undefined, projectId: 9, projectName: undefined, created: undefined });
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
test("when adopting, two failures exhaust the retry and return null", async () => {
|
|
328
|
-
respond = () => new Response("upstream down", { status: 503 });
|
|
329
|
-
|
|
330
|
-
const reg = await registerMonitoringInstance("key", "p", opts({ instanceId: "i" }));
|
|
331
|
-
|
|
332
|
-
expect(fetchCalls.length).toBe(2); // one initial + one retry
|
|
333
|
-
expect(reg).toBeNull();
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test("legacy mode does NOT retry — a single failure returns null after one attempt", async () => {
|
|
337
|
-
respond = () => new Response("upstream down", { status: 503 });
|
|
338
|
-
|
|
339
|
-
const reg = await registerMonitoringInstance("key", "p", opts());
|
|
340
|
-
|
|
341
|
-
expect(fetchCalls.length).toBe(1);
|
|
342
|
-
expect(reg).toBeNull();
|
|
343
|
-
});
|
|
344
|
-
});
|
|
345
|
-
|
|
346
|
-
describe("planMonitoringRegistration — mon local-install registration decision", () => {
|
|
347
|
-
test("instance id present → adopt, no project name required", () => {
|
|
348
|
-
const plan = planMonitoringRegistration({ instanceId: "i-123" });
|
|
349
|
-
expect(plan.kind).toBe("adopt");
|
|
350
|
-
expect(plan.projectName).toBeUndefined();
|
|
351
|
-
});
|
|
352
|
-
|
|
353
|
-
test("instance id present + project → adopt, carries the trimmed name", () => {
|
|
354
|
-
const plan = planMonitoringRegistration({ instanceId: "i-123", project: " prod-db " });
|
|
355
|
-
expect(plan.kind).toBe("adopt");
|
|
356
|
-
expect(plan.projectName).toBe("prod-db");
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test("no instance id + no project → error-missing-project (exit 1 path)", () => {
|
|
360
|
-
const plan = planMonitoringRegistration({});
|
|
361
|
-
expect(plan.kind).toBe("error-missing-project");
|
|
362
|
-
expect(plan.projectName).toBeUndefined();
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
test("no instance id + empty/whitespace project → error-missing-project", () => {
|
|
366
|
-
expect(planMonitoringRegistration({ project: "" }).kind).toBe("error-missing-project");
|
|
367
|
-
expect(planMonitoringRegistration({ project: " " }).kind).toBe("error-missing-project");
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test("no instance id + real project → legacy self-register with the trimmed name", () => {
|
|
371
|
-
const plan = planMonitoringRegistration({ project: " my-project " });
|
|
372
|
-
expect(plan.kind).toBe("self-register");
|
|
373
|
-
expect(plan.projectName).toBe("my-project");
|
|
374
|
-
});
|
|
375
|
-
});
|
|
376
|
-
|
|
377
|
-
describe("resolveAdoptedProject — what gets persisted to .pgwatch-config", () => {
|
|
378
|
-
test("prefers the numeric project_id over the name (survives renames)", () => {
|
|
379
|
-
expect(resolveAdoptedProject({ projectId: 42, projectName: "prod-db" })).toBe("42");
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
test("project_id === 0 is a valid id and is honored", () => {
|
|
383
|
-
expect(resolveAdoptedProject({ projectId: 0, projectName: "prod-db" })).toBe("0");
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
test("falls back to project_name when there is no id", () => {
|
|
387
|
-
expect(resolveAdoptedProject({ projectName: "prod-db" })).toBe("prod-db");
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
test("returns null for a null response", () => {
|
|
391
|
-
expect(resolveAdoptedProject(null)).toBeNull();
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
test("returns null for a fieldless (succeeded-but-empty) response", () => {
|
|
395
|
-
expect(resolveAdoptedProject({})).toBeNull();
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
test("rejects a project_name with a newline (config-file injection, CWE-93)", () => {
|
|
399
|
-
expect(resolveAdoptedProject({ projectName: "prod\ninjected=evil" })).toBeNull();
|
|
400
|
-
});
|
|
401
|
-
|
|
402
|
-
test("rejects a project_name containing '='", () => {
|
|
403
|
-
expect(resolveAdoptedProject({ projectName: "a=b" })).toBeNull();
|
|
404
|
-
});
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
describe("demo mode instances.demo.yml", () => {
|
|
408
|
-
const repoRoot = path.resolve(import.meta.dir, "..", "..");
|
|
409
|
-
|
|
410
|
-
test("instances.demo.yml exists in repo root", () => {
|
|
411
|
-
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
412
|
-
expect(fs.existsSync(demoFile)).toBe(true);
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
test("instances.demo.yml contains demo target connection", () => {
|
|
416
|
-
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
417
|
-
const content = fs.readFileSync(demoFile, "utf8");
|
|
418
|
-
expect(content).toContain("name: target_database");
|
|
419
|
-
expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
|
|
420
|
-
expect(content).toContain("is_enabled: true");
|
|
421
|
-
expect(content).toContain("preset_metrics: full");
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
test("instances.demo.yml has required YAML structure", () => {
|
|
425
|
-
const demoFile = path.join(repoRoot, "instances.demo.yml");
|
|
426
|
-
const content = fs.readFileSync(demoFile, "utf8");
|
|
427
|
-
// Verify it's a YAML list (starts with "- name:")
|
|
428
|
-
expect(content).toMatch(/^- name: target_database/m);
|
|
429
|
-
// Verify required fields are present with correct indentation
|
|
430
|
-
expect(content).toMatch(/^\s+conn_str:/m);
|
|
431
|
-
expect(content).toMatch(/^\s+preset_metrics: full/m);
|
|
432
|
-
expect(content).toMatch(/^\s+is_enabled: true/m);
|
|
433
|
-
// ~sink_type~ is a sed token substituted by generate-pgwatch-sources.sh; values: postgres, prometheus
|
|
434
|
-
expect(content).toMatch(/^\s+sink_type: ~sink_type~/m);
|
|
435
|
-
});
|
|
436
|
-
|
|
437
|
-
test("instances.yml is gitignored (not tracked)", () => {
|
|
438
|
-
const gitignore = fs.readFileSync(path.join(repoRoot, ".gitignore"), "utf8");
|
|
439
|
-
expect(gitignore).toMatch(/^instances\.yml$/m);
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
test("demo config can be copied to instances.yml in temp dir", () => {
|
|
443
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-install-test-"));
|
|
444
|
-
try {
|
|
445
|
-
const demoSrc = path.join(repoRoot, "instances.demo.yml");
|
|
446
|
-
const instancesDest = path.join(tempDir, "instances.yml");
|
|
447
|
-
|
|
448
|
-
fs.copyFileSync(demoSrc, instancesDest);
|
|
449
|
-
|
|
450
|
-
expect(fs.existsSync(instancesDest)).toBe(true);
|
|
451
|
-
const content = fs.readFileSync(instancesDest, "utf8");
|
|
452
|
-
expect(content).toContain("name: target_database");
|
|
453
|
-
expect(content).toContain("conn_str: postgresql://monitor:monitor_pass@target-db:5432/target_database");
|
|
454
|
-
} finally {
|
|
455
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
456
|
-
}
|
|
457
|
-
});
|
|
458
|
-
|
|
459
|
-
test("demo config copy overwrites directory at instances.yml path", () => {
|
|
460
|
-
// Docker bind-mounts create missing paths as directories; the copy must handle this
|
|
461
|
-
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "demo-eisdir-test-"));
|
|
462
|
-
try {
|
|
463
|
-
const demoSrc = path.join(repoRoot, "instances.demo.yml");
|
|
464
|
-
const instancesDest = path.join(tempDir, "instances.yml");
|
|
465
|
-
|
|
466
|
-
// Simulate Docker creating a directory at instances.yml path
|
|
467
|
-
fs.mkdirSync(instancesDest);
|
|
468
|
-
expect(fs.statSync(instancesDest).isDirectory()).toBe(true);
|
|
469
|
-
|
|
470
|
-
// The fix: remove directory then copy
|
|
471
|
-
if (fs.statSync(instancesDest).isDirectory()) {
|
|
472
|
-
fs.rmSync(instancesDest, { recursive: true, force: true });
|
|
473
|
-
}
|
|
474
|
-
fs.copyFileSync(demoSrc, instancesDest);
|
|
475
|
-
|
|
476
|
-
expect(fs.statSync(instancesDest).isFile()).toBe(true);
|
|
477
|
-
const content = fs.readFileSync(instancesDest, "utf8");
|
|
478
|
-
expect(content).toContain("name: target_database");
|
|
479
|
-
} finally {
|
|
480
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
481
|
-
}
|
|
482
|
-
});
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
describe("docker-compose: default network has IPv6 enabled", () => {
|
|
486
|
-
const repoRoot = path.resolve(import.meta.dir, "..", "..");
|
|
487
|
-
|
|
488
|
-
test("root docker-compose.yml declares networks.default with enable_ipv6", () => {
|
|
489
|
-
const composePath = path.join(repoRoot, "docker-compose.yml");
|
|
490
|
-
const content = fs.readFileSync(composePath, "utf8");
|
|
491
|
-
|
|
492
|
-
// Use string match so we also catch the env-overridable form
|
|
493
|
-
// `enable_ipv6: ${PGAI_ENABLE_IPV6:-true}`, which Compose interpolates
|
|
494
|
-
// before the YAML strict-bool check.
|
|
495
|
-
expect(content).toMatch(/^networks:/m);
|
|
496
|
-
expect(content).toMatch(/^\s*default:/m);
|
|
497
|
-
expect(content).toMatch(/enable_ipv6:\s*(true|\$\{PGAI_ENABLE_IPV6:-true\})/);
|
|
498
|
-
|
|
499
|
-
// Also assert the YAML is parseable (catches indentation regressions).
|
|
500
|
-
const parsed = yaml.load(content) as any;
|
|
501
|
-
expect(parsed?.networks?.default).toBeDefined();
|
|
502
|
-
});
|
|
503
|
-
|
|
504
|
-
test("env override default resolves to 'true' when PGAI_ENABLE_IPV6 is unset", () => {
|
|
505
|
-
// Mirror Compose's `${VAR:-default}` interpolation rule. Verifies that the
|
|
506
|
-
// template's default value matches the documented one.
|
|
507
|
-
const composePath = path.join(repoRoot, "docker-compose.yml");
|
|
508
|
-
const content = fs.readFileSync(composePath, "utf8");
|
|
509
|
-
const m = content.match(/enable_ipv6:\s*\$\{PGAI_ENABLE_IPV6:-(\w+)\}/);
|
|
510
|
-
expect(m).not.toBeNull();
|
|
511
|
-
expect(m![1]).toBe("true");
|
|
512
|
-
});
|
|
513
|
-
});
|
|
514
|
-
|
|
515
|
-
describe("addInstanceToFile / removeInstanceFromFile round-trip", () => {
|
|
516
|
-
let tempDir: string;
|
|
517
|
-
let instancesFile: string;
|
|
518
|
-
|
|
519
|
-
beforeEach(() => {
|
|
520
|
-
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "instances-test-"));
|
|
521
|
-
instancesFile = path.join(tempDir, "instances.yml");
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
afterEach(() => {
|
|
525
|
-
if (tempDir && fs.existsSync(tempDir)) {
|
|
526
|
-
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
527
|
-
}
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
test("add to empty file produces a valid YAML list", () => {
|
|
531
|
-
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
532
|
-
const list = loadInstances(instancesFile);
|
|
533
|
-
expect(list.length).toBe(1);
|
|
534
|
-
expect(list[0].name).toBe("t1");
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
test("add → remove → add cycle keeps file parseable (regression)", () => {
|
|
538
|
-
// The previous bug: after `remove` left `[]` in the file, `add` appended a
|
|
539
|
-
// list-item next to it, producing two YAML documents in one file.
|
|
540
|
-
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
541
|
-
removeInstanceFromFile(instancesFile, "t1");
|
|
542
|
-
expect(loadInstances(instancesFile)).toEqual([]);
|
|
543
|
-
|
|
544
|
-
addInstanceToFile(instancesFile, buildInstance("t2", "postgresql://u:p@h:5432/db2"));
|
|
545
|
-
|
|
546
|
-
// Must NOT throw "end of the stream or a document separator is expected".
|
|
547
|
-
const list = loadInstances(instancesFile);
|
|
548
|
-
expect(list.length).toBe(1);
|
|
549
|
-
expect(list[0].name).toBe("t2");
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
test("sink_type placeholder survives the round-trip", () => {
|
|
553
|
-
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
554
|
-
const content = fs.readFileSync(instancesFile, "utf8");
|
|
555
|
-
// js-yaml emits ~sink_type~ unquoted (it's only special when standalone);
|
|
556
|
-
// sed s/~sink_type~/.../g still hits it as raw text regardless.
|
|
557
|
-
expect(content).toContain("~sink_type~");
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
test("add throws InstancesParseError on a corrupted file (no silent overwrite)", () => {
|
|
561
|
-
// Silent overwrite would discard credentials in conn_str values. Refuse.
|
|
562
|
-
fs.writeFileSync(instancesFile, "key: [unclosed\nfoo: bar\n", "utf8");
|
|
563
|
-
|
|
564
|
-
expect(() =>
|
|
565
|
-
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db")),
|
|
566
|
-
).toThrow(InstancesParseError);
|
|
567
|
-
|
|
568
|
-
// File contents are unchanged.
|
|
569
|
-
expect(fs.readFileSync(instancesFile, "utf8")).toBe("key: [unclosed\nfoo: bar\n");
|
|
570
|
-
});
|
|
571
|
-
|
|
572
|
-
test("add rejects duplicate name", () => {
|
|
573
|
-
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
574
|
-
expect(() =>
|
|
575
|
-
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db")),
|
|
576
|
-
).toThrow(/already exists/);
|
|
577
|
-
});
|
|
578
|
-
|
|
579
|
-
test("add replaces a directory at the target path (Docker bind-mount artifact)", () => {
|
|
580
|
-
fs.mkdirSync(instancesFile);
|
|
581
|
-
expect(fs.statSync(instancesFile).isDirectory()).toBe(true);
|
|
582
|
-
addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
|
|
583
|
-
expect(fs.statSync(instancesFile).isFile()).toBe(true);
|
|
584
|
-
expect(loadInstances(instancesFile).length).toBe(1);
|
|
585
|
-
});
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
describe("sslOptionFromConnString — libpq semantics", () => {
|
|
589
|
-
test("sslmode=require → SSL without chain verification", () => {
|
|
590
|
-
expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=require"))
|
|
591
|
-
.toEqual({ rejectUnauthorized: false });
|
|
592
|
-
});
|
|
593
|
-
|
|
594
|
-
test("sslmode unset → SSL without chain verification", () => {
|
|
595
|
-
expect(sslOptionFromConnString("postgresql://u:p@h/db"))
|
|
596
|
-
.toEqual({ rejectUnauthorized: false });
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
test("sslmode=disable → no SSL", () => {
|
|
600
|
-
expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=disable")).toBe(false);
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
test("sslmode=verify-ca → chain verification, no hostname check", () => {
|
|
604
|
-
const opt = sslOptionFromConnString("postgresql://u:p@h/db?sslmode=verify-ca");
|
|
605
|
-
expect(opt).toMatchObject({ rejectUnauthorized: true });
|
|
606
|
-
expect(typeof (opt as any).checkServerIdentity).toBe("function");
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
test("sslmode=verify-full → chain + hostname verification", () => {
|
|
610
|
-
expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=verify-full"))
|
|
611
|
-
.toEqual({ rejectUnauthorized: true });
|
|
612
|
-
});
|
|
613
|
-
|
|
614
|
-
test("sslmode=prefer → SSL without chain verification", () => {
|
|
615
|
-
expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=prefer"))
|
|
616
|
-
.toEqual({ rejectUnauthorized: false });
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
test("malformed connection string → safe default (no chain verification)", () => {
|
|
620
|
-
expect(sslOptionFromConnString("not-a-url")).toEqual({ rejectUnauthorized: false });
|
|
621
|
-
});
|
|
622
|
-
});
|
|
623
|
-
|
|
624
|
-
describe("buildClientConfig — actual node-postgres Client gets the intended ssl (regression)", () => {
|
|
625
|
-
// The previous code passed `{ connectionString, ssl }` to `new Client(...)`.
|
|
626
|
-
// node-postgres' ConnectionParameters internally does
|
|
627
|
-
// `Object.assign({}, config, parse(connectionString))`, so the parsed
|
|
628
|
-
// `connectionString.ssl` REPLACES the explicit `ssl`. Net effect:
|
|
629
|
-
// `?sslmode=require` + `ssl: { rejectUnauthorized: false }` → `ssl: {}`
|
|
630
|
-
// (chain verified)
|
|
631
|
-
// — exactly the bug the MR claims to fix. This integration test asserts
|
|
632
|
-
// against `client.connectionParameters.ssl`, so the bug cannot return.
|
|
633
|
-
|
|
634
|
-
test("require: actual Client.connectionParameters.ssl has rejectUnauthorized:false", () => {
|
|
635
|
-
const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=require"));
|
|
636
|
-
expect(c.connectionParameters.ssl).toEqual({ rejectUnauthorized: false });
|
|
637
|
-
});
|
|
638
|
-
|
|
639
|
-
test("verify-full: actual Client.connectionParameters.ssl has rejectUnauthorized:true", () => {
|
|
640
|
-
const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=verify-full"));
|
|
641
|
-
expect(c.connectionParameters.ssl).toEqual({ rejectUnauthorized: true });
|
|
642
|
-
});
|
|
643
|
-
|
|
644
|
-
test("disable: actual Client.connectionParameters.ssl is false", () => {
|
|
645
|
-
const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=disable"));
|
|
646
|
-
expect(c.connectionParameters.ssl).toBe(false);
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
test("unset: actual Client.connectionParameters.ssl has rejectUnauthorized:false", () => {
|
|
650
|
-
const c = new Client(buildClientConfig("postgresql://u:p@h/db"));
|
|
651
|
-
expect(c.connectionParameters.ssl).toEqual({ rejectUnauthorized: false });
|
|
652
|
-
});
|
|
653
|
-
|
|
654
|
-
test("connectionTimeoutMillis is forwarded (exact value, not just truthy)", () => {
|
|
655
|
-
const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=require", { connectionTimeoutMillis: 5000 }));
|
|
656
|
-
// node-postgres v8 stores it on `_connectionTimeoutMillis`. Asserting the
|
|
657
|
-
// exact value catches regressions that would silently swap in the default.
|
|
658
|
-
expect((c as any)._connectionTimeoutMillis).toBe(5000);
|
|
659
|
-
});
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
describe("warnIfLaxSslmode — UX warning for lax sslmode", () => {
|
|
663
|
-
let stderrSpy: ReturnType<typeof spyOn>;
|
|
664
|
-
|
|
665
|
-
beforeEach(() => {
|
|
666
|
-
stderrSpy = spyOn(console, "error").mockImplementation(() => {});
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
afterEach(() => {
|
|
670
|
-
stderrSpy.mockRestore();
|
|
671
|
-
});
|
|
672
|
-
|
|
673
|
-
for (const sslmode of ["require", "prefer", "allow"]) {
|
|
674
|
-
test(`warns when sslmode=${sslmode}`, () => {
|
|
675
|
-
warnIfLaxSslmode(`postgresql://u:p@h/db?sslmode=${sslmode}`);
|
|
676
|
-
expect(stderrSpy).toHaveBeenCalledTimes(1);
|
|
677
|
-
const msg = String(stderrSpy.mock.calls[0][0]);
|
|
678
|
-
expect(msg).toContain(`sslmode=${sslmode}`);
|
|
679
|
-
expect(msg).toContain("NOT verified");
|
|
680
|
-
expect(msg).toContain("verify-full");
|
|
681
|
-
});
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
test("warns when sslmode is unset (uses '(unset)' label)", () => {
|
|
685
|
-
warnIfLaxSslmode("postgresql://u:p@h/db");
|
|
686
|
-
expect(stderrSpy).toHaveBeenCalledTimes(1);
|
|
687
|
-
expect(String(stderrSpy.mock.calls[0][0])).toContain("sslmode=(unset)");
|
|
688
|
-
});
|
|
689
|
-
|
|
690
|
-
test("does NOT warn when sslmode=verify-full or verify-ca or disable", () => {
|
|
691
|
-
warnIfLaxSslmode("postgresql://u:p@h/db?sslmode=verify-full");
|
|
692
|
-
warnIfLaxSslmode("postgresql://u:p@h/db?sslmode=verify-ca");
|
|
693
|
-
warnIfLaxSslmode("postgresql://u:p@h/db?sslmode=disable");
|
|
694
|
-
expect(stderrSpy).not.toHaveBeenCalled();
|
|
695
|
-
});
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
describe("buildClientConfig — silences pg-connection-string deprecation warning", () => {
|
|
699
|
-
// pg-connection-string v2.x prints `process.emitWarning("SECURITY WARNING:
|
|
700
|
-
// ... 'prefer'/'require'/'verify-ca' ...")` whenever a recognised lax
|
|
701
|
-
// sslmode appears. Without our `uselibpqcompat=true` shim, this would fire
|
|
702
|
-
// on every CLI invocation against a Supabase-shaped URL. Assert it doesn't.
|
|
703
|
-
|
|
704
|
-
let warnings: string[];
|
|
705
|
-
let origEmitWarning: typeof process.emitWarning;
|
|
706
|
-
|
|
707
|
-
beforeEach(() => {
|
|
708
|
-
warnings = [];
|
|
709
|
-
origEmitWarning = process.emitWarning;
|
|
710
|
-
(process as any).emitWarning = (warning: any) => {
|
|
711
|
-
warnings.push(typeof warning === "string" ? warning : String(warning));
|
|
712
|
-
};
|
|
713
|
-
});
|
|
714
|
-
|
|
715
|
-
afterEach(() => {
|
|
716
|
-
(process as any).emitWarning = origEmitWarning;
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
for (const sslmode of ["require", "prefer", "verify-ca"]) {
|
|
720
|
-
test(`no SECURITY WARNING for sslmode=${sslmode}`, () => {
|
|
721
|
-
buildClientConfig(`postgresql://u:p@h/db?sslmode=${sslmode}`);
|
|
722
|
-
const security = warnings.filter((w) => w.includes("SECURITY"));
|
|
723
|
-
expect(security).toEqual([]);
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
describe("extractSslmode / isLaxSslmode", () => {
|
|
729
|
-
test("extractSslmode returns lowercase", () => {
|
|
730
|
-
expect(extractSslmode("postgresql://u:p@h/db?sslmode=REQUIRE")).toBe("require");
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
test("extractSslmode returns '' for unparseable URLs", () => {
|
|
734
|
-
expect(extractSslmode("not-a-url")).toBe("");
|
|
735
|
-
});
|
|
736
|
-
|
|
737
|
-
test("isLaxSslmode covers the full set", () => {
|
|
738
|
-
expect(isLaxSslmode("")).toBe(true);
|
|
739
|
-
expect(isLaxSslmode("require")).toBe(true);
|
|
740
|
-
expect(isLaxSslmode("prefer")).toBe(true);
|
|
741
|
-
expect(isLaxSslmode("allow")).toBe(true);
|
|
742
|
-
expect(isLaxSslmode("verify-ca")).toBe(false);
|
|
743
|
-
expect(isLaxSslmode("verify-full")).toBe(false);
|
|
744
|
-
expect(isLaxSslmode("disable")).toBe(false);
|
|
745
|
-
});
|
|
746
|
-
});
|