pgserve 1.2.0 → 2.0.1
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/.genie/brainstorms/pgserve-v2/DESIGN.md +174 -0
- package/.genie/wishes/pgserve-v2/BRIEF-from-genie-pgserve.md +99 -0
- package/.genie/wishes/pgserve-v2/WISH.md +442 -0
- package/.genie/wishes/release-system-genie-pattern/WISH.md +9 -9
- package/.genie/wishes/release-system-genie-pattern/validation.md +43 -10
- package/.github/workflows/ci.yml +10 -6
- package/.github/workflows/release.yml +1 -1
- package/.github/workflows/version.yml +4 -4
- package/CHANGELOG.md +150 -0
- package/Makefile +12 -12
- package/README.md +216 -10
- package/bin/pgserve-wrapper.cjs +3 -3
- package/bin/{pglite-server.js → postgres-server.js} +258 -1
- package/bun.lock +0 -3
- package/ecosystem.config.cjs +3 -3
- package/eslint.config.js +2 -0
- package/knip.json +1 -1
- package/package.json +4 -5
- package/scripts/test-bun-self-heal.sh +10 -10
- package/src/admin-client.js +171 -0
- package/src/audit.js +168 -0
- package/src/control-db.js +313 -0
- package/src/daemon-control.js +408 -0
- package/src/daemon-shared.js +18 -0
- package/src/daemon-tcp.js +296 -0
- package/src/daemon.js +629 -0
- package/src/fingerprint.js +453 -0
- package/src/gc.js +351 -0
- package/src/index.js +31 -0
- package/src/protocol.js +131 -0
- package/src/router.js +8 -0
- package/src/sdk.js +137 -0
- package/src/tenancy.js +75 -0
- package/src/tokens.js +102 -0
- package/tests/audit.test.js +189 -0
- package/tests/benchmarks/runner.js +430 -754
- package/tests/control-db.test.js +285 -0
- package/tests/daemon-fingerprint-integration.test.js +111 -0
- package/tests/daemon-pr24-regression.test.js +198 -0
- package/tests/fingerprint.test.js +249 -0
- package/tests/fixtures/240-orphan-seed.sql +30 -0
- package/tests/orphan-cleanup.test.js +390 -0
- package/tests/sdk.test.js +71 -0
- package/tests/tcp-listen.test.js +368 -0
- package/tests/tenancy.test.js +403 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Group 4 — database-per-fingerprint + enforcement + kill switch.
|
|
3
|
+
*
|
|
4
|
+
* Boots a real pgserve daemon with isolated control socket + audit log,
|
|
5
|
+
* stubs SO_PEERCRED to return synthetic creds, and overrides the
|
|
6
|
+
* fingerprint-derivation cwd per-accept so a single test process can
|
|
7
|
+
* masquerade as several different "projects" connecting to the daemon.
|
|
8
|
+
*
|
|
9
|
+
* Coverage (mirrors WISH §Group 4 acceptance bullets):
|
|
10
|
+
* 1. Two peers with different fingerprints get different DBs
|
|
11
|
+
* 2. Same peer reconnecting reaches its existing DB
|
|
12
|
+
* 3. Cross-fingerprint connection denied with SQLSTATE 28P01
|
|
13
|
+
* 4. Kill-switch env: cross-fingerprint succeeds + audit event emitted
|
|
14
|
+
* 5. Sanitizer: name "@scope/foo bar" → "_scope_foo_bar"
|
|
15
|
+
*
|
|
16
|
+
* Plus unit tests on `sanitizeName` and `resolveTenantDatabaseName` and a
|
|
17
|
+
* boot-time deprecation warning check.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
describe,
|
|
22
|
+
test,
|
|
23
|
+
expect,
|
|
24
|
+
beforeAll,
|
|
25
|
+
afterAll,
|
|
26
|
+
beforeEach,
|
|
27
|
+
afterEach,
|
|
28
|
+
} from 'bun:test';
|
|
29
|
+
import fs from 'fs';
|
|
30
|
+
import os from 'os';
|
|
31
|
+
import path from 'path';
|
|
32
|
+
import pg from 'pg';
|
|
33
|
+
|
|
34
|
+
import {
|
|
35
|
+
PgserveDaemon,
|
|
36
|
+
resolveControlSocketPath,
|
|
37
|
+
resolvePidLockPath,
|
|
38
|
+
resolveLibpqCompatPath,
|
|
39
|
+
} from '../src/daemon.js';
|
|
40
|
+
import { _setPeerCredImpl, initFingerprintFfi } from '../src/fingerprint.js';
|
|
41
|
+
import { configureAudit, AUDIT_EVENTS } from '../src/audit.js';
|
|
42
|
+
import {
|
|
43
|
+
sanitizeName,
|
|
44
|
+
resolveTenantDatabaseName,
|
|
45
|
+
KILL_SWITCH_ENV,
|
|
46
|
+
} from '../src/tenancy.js';
|
|
47
|
+
import { createLogger } from '../src/logger.js';
|
|
48
|
+
|
|
49
|
+
const { Client } = pg;
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Pure-function unit tests
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
describe('sanitizeName', () => {
|
|
56
|
+
test('collapses non-[a-z0-9] runs to a single underscore', () => {
|
|
57
|
+
expect(sanitizeName('hello-world')).toBe('hello_world');
|
|
58
|
+
expect(sanitizeName('hello---world')).toBe('hello_world');
|
|
59
|
+
expect(sanitizeName('a..b..c')).toBe('a_b_c');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('lowercases', () => {
|
|
63
|
+
expect(sanitizeName('UPPER-CASE')).toBe('upper_case');
|
|
64
|
+
expect(sanitizeName('MixedCase')).toBe('mixedcase');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('preserves alphanumerics', () => {
|
|
68
|
+
expect(sanitizeName('foo123')).toBe('foo123');
|
|
69
|
+
expect(sanitizeName('1to1')).toBe('1to1');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('truncates to 30 chars', () => {
|
|
73
|
+
const long = 'a'.repeat(50);
|
|
74
|
+
expect(sanitizeName(long).length).toBe(30);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('handles the wish-spec example', () => {
|
|
78
|
+
expect(sanitizeName('@scope/foo bar')).toBe('_scope_foo_bar');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('falls back to "anon" for empty or pure-non-alphanumeric input', () => {
|
|
82
|
+
expect(sanitizeName('')).toBe('anon');
|
|
83
|
+
expect(sanitizeName(null)).toBe('anon');
|
|
84
|
+
expect(sanitizeName(undefined)).toBe('anon');
|
|
85
|
+
expect(sanitizeName('@@@')).toBe('anon');
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe('resolveTenantDatabaseName', () => {
|
|
90
|
+
test('builds canonical app_<sanitized>_<fingerprint>', () => {
|
|
91
|
+
expect(resolveTenantDatabaseName({ name: 'demo', fingerprint: 'abcdef012345' }))
|
|
92
|
+
.toBe('app_demo_abcdef012345');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('applies sanitization', () => {
|
|
96
|
+
expect(resolveTenantDatabaseName({ name: '@scope/foo bar', fingerprint: 'abcdef012345' }))
|
|
97
|
+
.toBe('app__scope_foo_bar_abcdef012345');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('rejects malformed fingerprints', () => {
|
|
101
|
+
expect(() => resolveTenantDatabaseName({ name: 'x', fingerprint: 'TOO-SHORT' }))
|
|
102
|
+
.toThrow(/12 hex chars/);
|
|
103
|
+
expect(() => resolveTenantDatabaseName({ name: 'x', fingerprint: 'GHIJKL012345' }))
|
|
104
|
+
.toThrow(/12 hex chars/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('result fits in PG identifier limit (≤63 chars)', () => {
|
|
108
|
+
const longName = 'a'.repeat(80);
|
|
109
|
+
const ident = resolveTenantDatabaseName({ name: longName, fingerprint: 'abcdef012345' });
|
|
110
|
+
expect(ident.length).toBeLessThanOrEqual(63);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
// Daemon integration tests
|
|
116
|
+
//
|
|
117
|
+
// One daemon shared across the integration suite — PG startup is slow and
|
|
118
|
+
// the tests are independent at the pgserve_meta level (each clears its
|
|
119
|
+
// state). Per-accept fingerprint behaviour is driven by an override queue
|
|
120
|
+
// the test pushes into before each connect.
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
describe('daemon tenancy enforcement', () => {
|
|
124
|
+
let daemon;
|
|
125
|
+
let scratch;
|
|
126
|
+
let controlSocketDir;
|
|
127
|
+
let auditFile;
|
|
128
|
+
let overridesQueue;
|
|
129
|
+
let savedAuditDefaults;
|
|
130
|
+
|
|
131
|
+
beforeAll(async () => {
|
|
132
|
+
await initFingerprintFfi();
|
|
133
|
+
// Stub peer creds: every accept on the test daemon's control socket
|
|
134
|
+
// appears to come from this process. The real creds matter only for
|
|
135
|
+
// uid (used in fingerprint hashing); pid is ignored once we override
|
|
136
|
+
// cwd via `_fingerprintAcceptOpts`.
|
|
137
|
+
_setPeerCredImpl(() => ({
|
|
138
|
+
pid: process.pid,
|
|
139
|
+
uid: process.getuid(),
|
|
140
|
+
gid: process.getgid(),
|
|
141
|
+
}));
|
|
142
|
+
|
|
143
|
+
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-tenancy-test-'));
|
|
144
|
+
controlSocketDir = path.join(scratch, 'sock');
|
|
145
|
+
fs.mkdirSync(controlSocketDir, { recursive: true });
|
|
146
|
+
auditFile = path.join(scratch, 'audit.log');
|
|
147
|
+
|
|
148
|
+
// Save the audit module's mutable globals so we can restore them after
|
|
149
|
+
// the suite (other tests rely on the defaults).
|
|
150
|
+
savedAuditDefaults = {
|
|
151
|
+
logFile: path.join(os.homedir(), '.pgserve', 'audit.log'),
|
|
152
|
+
target: process.env.PGSERVE_AUDIT_TARGET || 'file',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
overridesQueue = [];
|
|
156
|
+
|
|
157
|
+
daemon = new PgserveDaemon({
|
|
158
|
+
controlSocketDir,
|
|
159
|
+
controlSocketPath: resolveControlSocketPath(controlSocketDir),
|
|
160
|
+
pidLockPath: resolvePidLockPath(controlSocketDir),
|
|
161
|
+
libpqCompatPath: resolveLibpqCompatPath(controlSocketDir, 5432),
|
|
162
|
+
auditLogFile: auditFile,
|
|
163
|
+
auditTarget: 'file',
|
|
164
|
+
pgPort: 16700,
|
|
165
|
+
logger: createLogger({ level: process.env.LOG_LEVEL || 'warn' }),
|
|
166
|
+
_fingerprintAcceptOpts: () => overridesQueue.shift() || {},
|
|
167
|
+
});
|
|
168
|
+
await daemon.start();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
afterAll(async () => {
|
|
172
|
+
try { await daemon.stop(); } catch { /* swallow */ }
|
|
173
|
+
_setPeerCredImpl(null);
|
|
174
|
+
if (savedAuditDefaults) configureAudit(savedAuditDefaults);
|
|
175
|
+
try { fs.rmSync(scratch, { recursive: true, force: true }); } catch { /* swallow */ }
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
beforeEach(async () => {
|
|
179
|
+
overridesQueue.length = 0;
|
|
180
|
+
// Clear pgserve_meta and drop any user DBs from prior tests so each
|
|
181
|
+
// test starts from a clean slate.
|
|
182
|
+
if (daemon._adminClient) {
|
|
183
|
+
try { await daemon._adminClient.query('TRUNCATE pgserve_meta'); } catch { /* schema not yet created in odd cases */ }
|
|
184
|
+
const r = await daemon._adminClient.query(`
|
|
185
|
+
SELECT datname FROM pg_database
|
|
186
|
+
WHERE datname LIKE 'app_%' AND datistemplate = false
|
|
187
|
+
`);
|
|
188
|
+
for (const row of r.rows) {
|
|
189
|
+
try { await daemon._adminClient.query(`DROP DATABASE "${row.datname}"`); } catch { /* swallow */ }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Reset audit log so each test reads only its own events.
|
|
193
|
+
try { fs.writeFileSync(auditFile, '', { mode: 0o600 }); } catch { /* swallow */ }
|
|
194
|
+
daemon.enforcementDisabled = false;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
function makeProject(name, dirName = name) {
|
|
198
|
+
const dir = path.join(scratch, dirName);
|
|
199
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
200
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name }));
|
|
201
|
+
return dir;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function pushOverride(projDir, scriptArgv1 = 'index.js') {
|
|
205
|
+
overridesQueue.push({
|
|
206
|
+
cwdOverride: projDir,
|
|
207
|
+
cmdlineOverride: ['bun', scriptArgv1],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function makeClient({ database, expectError = false } = {}) {
|
|
212
|
+
const client = new Client({
|
|
213
|
+
host: controlSocketDir,
|
|
214
|
+
port: 5432,
|
|
215
|
+
database: database || 'postgres',
|
|
216
|
+
user: 'postgres',
|
|
217
|
+
password: 'postgres',
|
|
218
|
+
});
|
|
219
|
+
// pg.Client's end() can hang after a FATAL connect failure (it tries
|
|
220
|
+
// to send a Terminate message on a closed socket), so on the deny path
|
|
221
|
+
// we swallow connect's rejection and return immediately. The TCP
|
|
222
|
+
// socket is already FIN'd by the daemon.
|
|
223
|
+
if (expectError) {
|
|
224
|
+
// Suppress unhandled-error events on the underlying socket.
|
|
225
|
+
client.on('error', () => { /* swallow */ });
|
|
226
|
+
let err;
|
|
227
|
+
try { await client.connect(); } catch (e) { err = e; }
|
|
228
|
+
return { error: err };
|
|
229
|
+
}
|
|
230
|
+
await client.connect();
|
|
231
|
+
return { client };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function readAudit() {
|
|
235
|
+
if (!fs.existsSync(auditFile)) return [];
|
|
236
|
+
return fs.readFileSync(auditFile, 'utf8')
|
|
237
|
+
.split('\n')
|
|
238
|
+
.filter(Boolean)
|
|
239
|
+
.map((l) => JSON.parse(l));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
test('two peers with different fingerprints get different DBs', async () => {
|
|
243
|
+
const projA = makeProject('proj-a');
|
|
244
|
+
const projB = makeProject('proj-b');
|
|
245
|
+
|
|
246
|
+
pushOverride(projA);
|
|
247
|
+
const { client: ca } = await makeClient();
|
|
248
|
+
const ra = await ca.query('SELECT current_database() AS db');
|
|
249
|
+
await ca.end();
|
|
250
|
+
|
|
251
|
+
pushOverride(projB);
|
|
252
|
+
const { client: cb } = await makeClient();
|
|
253
|
+
const rb = await cb.query('SELECT current_database() AS db');
|
|
254
|
+
await cb.end();
|
|
255
|
+
|
|
256
|
+
expect(ra.rows[0].db).toMatch(/^app_proj_a_[0-9a-f]{12}$/);
|
|
257
|
+
expect(rb.rows[0].db).toMatch(/^app_proj_b_[0-9a-f]{12}$/);
|
|
258
|
+
expect(ra.rows[0].db).not.toBe(rb.rows[0].db);
|
|
259
|
+
|
|
260
|
+
const events = readAudit();
|
|
261
|
+
const created = events.filter((e) => e.event === AUDIT_EVENTS.DB_CREATED);
|
|
262
|
+
expect(created.length).toBe(2);
|
|
263
|
+
expect(created.map((e) => e.database).sort()).toEqual(
|
|
264
|
+
[ra.rows[0].db, rb.rows[0].db].sort(),
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('same peer reconnecting reaches its existing DB (no second db_created)', async () => {
|
|
269
|
+
const projA = makeProject('reconnect-app');
|
|
270
|
+
|
|
271
|
+
pushOverride(projA);
|
|
272
|
+
const { client: c1 } = await makeClient();
|
|
273
|
+
const r1 = await c1.query('SELECT current_database() AS db');
|
|
274
|
+
await c1.end();
|
|
275
|
+
|
|
276
|
+
pushOverride(projA);
|
|
277
|
+
const { client: c2 } = await makeClient();
|
|
278
|
+
const r2 = await c2.query('SELECT current_database() AS db');
|
|
279
|
+
await c2.end();
|
|
280
|
+
|
|
281
|
+
expect(r2.rows[0].db).toBe(r1.rows[0].db);
|
|
282
|
+
|
|
283
|
+
const created = readAudit().filter((e) => e.event === AUDIT_EVENTS.DB_CREATED);
|
|
284
|
+
expect(created.length).toBe(1);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('cross-fingerprint connection denied with SQLSTATE 28P01', async () => {
|
|
288
|
+
const projA = makeProject('tenant-a');
|
|
289
|
+
const projB = makeProject('tenant-b');
|
|
290
|
+
|
|
291
|
+
// Provision tenant A's DB first.
|
|
292
|
+
pushOverride(projA);
|
|
293
|
+
const { client: ca } = await makeClient();
|
|
294
|
+
const ra = await ca.query('SELECT current_database() AS db');
|
|
295
|
+
await ca.end();
|
|
296
|
+
const tenantADb = ra.rows[0].db;
|
|
297
|
+
|
|
298
|
+
// Now have tenant B try to connect explicitly into tenant A's DB.
|
|
299
|
+
pushOverride(projB);
|
|
300
|
+
const { error } = await makeClient({ database: tenantADb, expectError: true });
|
|
301
|
+
|
|
302
|
+
expect(error).toBeDefined();
|
|
303
|
+
expect(error.code).toBe('28P01');
|
|
304
|
+
|
|
305
|
+
const denied = readAudit().filter(
|
|
306
|
+
(e) => e.event === AUDIT_EVENTS.CONNECTION_DENIED_FINGERPRINT_MISMATCH,
|
|
307
|
+
);
|
|
308
|
+
expect(denied.length).toBe(1);
|
|
309
|
+
expect(denied[0].requested_database).toBe(tenantADb);
|
|
310
|
+
expect(denied[0].owned_database).toMatch(/^app_tenant_b_[0-9a-f]{12}$/);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test('kill-switch env: cross-fingerprint succeeds and emits audit event', async () => {
|
|
314
|
+
const projA = makeProject('killswitch-a');
|
|
315
|
+
const projB = makeProject('killswitch-b');
|
|
316
|
+
|
|
317
|
+
// Provision tenant A.
|
|
318
|
+
pushOverride(projA);
|
|
319
|
+
const { client: ca } = await makeClient();
|
|
320
|
+
const ra = await ca.query('SELECT current_database() AS db');
|
|
321
|
+
await ca.end();
|
|
322
|
+
const tenantADb = ra.rows[0].db;
|
|
323
|
+
|
|
324
|
+
// Flip the live kill-switch flag on the daemon (the env var is read
|
|
325
|
+
// once at construction; this is the test seam for the same effect).
|
|
326
|
+
daemon.enforcementDisabled = true;
|
|
327
|
+
|
|
328
|
+
pushOverride(projB);
|
|
329
|
+
const { client: cb } = await makeClient({ database: tenantADb });
|
|
330
|
+
const rb = await cb.query('SELECT current_database() AS db');
|
|
331
|
+
await cb.end();
|
|
332
|
+
|
|
333
|
+
// Bypass succeeded: tenant B's session reached tenant A's DB.
|
|
334
|
+
expect(rb.rows[0].db).toBe(tenantADb);
|
|
335
|
+
|
|
336
|
+
const events = readAudit();
|
|
337
|
+
const bypass = events.filter(
|
|
338
|
+
(e) => e.event === AUDIT_EVENTS.ENFORCEMENT_KILL_SWITCH_USED,
|
|
339
|
+
);
|
|
340
|
+
expect(bypass.length).toBe(1);
|
|
341
|
+
expect(bypass[0].owned_database).toMatch(/^app_killswitch_b_[0-9a-f]{12}$/);
|
|
342
|
+
expect(bypass[0].requested_database).toBe(tenantADb);
|
|
343
|
+
|
|
344
|
+
// No deny event should fire while the kill switch is active.
|
|
345
|
+
const denied = events.filter(
|
|
346
|
+
(e) => e.event === AUDIT_EVENTS.CONNECTION_DENIED_FINGERPRINT_MISMATCH,
|
|
347
|
+
);
|
|
348
|
+
expect(denied.length).toBe(0);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('sanitizer: name "@scope/foo bar" produces app__scope_foo_bar_<hex>', async () => {
|
|
352
|
+
const projScoped = makeProject('@scope/foo bar', 'scoped-pkg');
|
|
353
|
+
|
|
354
|
+
pushOverride(projScoped);
|
|
355
|
+
const { client } = await makeClient();
|
|
356
|
+
const r = await client.query('SELECT current_database() AS db');
|
|
357
|
+
await client.end();
|
|
358
|
+
|
|
359
|
+
expect(r.rows[0].db).toMatch(/^app__scope_foo_bar_[0-9a-f]{12}$/);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ---------------------------------------------------------------------------
|
|
364
|
+
// Boot-time deprecation warning
|
|
365
|
+
// ---------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
describe('boot deprecation warning when kill switch is set', () => {
|
|
368
|
+
test('writes a deprecation message to stderr at start()', async () => {
|
|
369
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pgserve-killswitch-boot-'));
|
|
370
|
+
const controlDir = path.join(dir, 'sock');
|
|
371
|
+
fs.mkdirSync(controlDir, { recursive: true });
|
|
372
|
+
|
|
373
|
+
const captured = [];
|
|
374
|
+
const origWrite = process.stderr.write.bind(process.stderr);
|
|
375
|
+
process.stderr.write = (chunk, ...rest) => {
|
|
376
|
+
captured.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
|
|
377
|
+
return origWrite(chunk, ...rest);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
let d;
|
|
381
|
+
try {
|
|
382
|
+
d = new PgserveDaemon({
|
|
383
|
+
controlSocketDir: controlDir,
|
|
384
|
+
controlSocketPath: resolveControlSocketPath(controlDir),
|
|
385
|
+
pidLockPath: resolvePidLockPath(controlDir),
|
|
386
|
+
libpqCompatPath: resolveLibpqCompatPath(controlDir, 5432),
|
|
387
|
+
pgPort: 16780,
|
|
388
|
+
enforcementDisabled: true,
|
|
389
|
+
logger: createLogger({ level: 'warn' }),
|
|
390
|
+
});
|
|
391
|
+
await d.start();
|
|
392
|
+
|
|
393
|
+
const merged = captured.join('');
|
|
394
|
+
expect(merged).toContain(KILL_SWITCH_ENV);
|
|
395
|
+
expect(merged).toContain('DISABLED');
|
|
396
|
+
expect(merged).toContain('deprecated');
|
|
397
|
+
} finally {
|
|
398
|
+
process.stderr.write = origWrite;
|
|
399
|
+
try { if (d) await d.stop(); } catch { /* swallow */ }
|
|
400
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* swallow */ }
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
});
|