postgresai 0.15.0-dev.10 → 0.15.0-dev.11

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.
@@ -2,6 +2,20 @@ import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
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";
5
19
 
6
20
  /**
7
21
  * Test updatePgwatchConfig function behavior.
@@ -337,3 +351,266 @@ describe("demo mode instances.demo.yml", () => {
337
351
  }
338
352
  });
339
353
  });
354
+
355
+ describe("docker-compose: default network has IPv6 enabled", () => {
356
+ const repoRoot = path.resolve(import.meta.dir, "..", "..");
357
+
358
+ test("root docker-compose.yml declares networks.default with enable_ipv6", () => {
359
+ const composePath = path.join(repoRoot, "docker-compose.yml");
360
+ const content = fs.readFileSync(composePath, "utf8");
361
+
362
+ // Use string match so we also catch the env-overridable form
363
+ // `enable_ipv6: ${PGAI_ENABLE_IPV6:-true}`, which Compose interpolates
364
+ // before the YAML strict-bool check.
365
+ expect(content).toMatch(/^networks:/m);
366
+ expect(content).toMatch(/^\s*default:/m);
367
+ expect(content).toMatch(/enable_ipv6:\s*(true|\$\{PGAI_ENABLE_IPV6:-true\})/);
368
+
369
+ // Also assert the YAML is parseable (catches indentation regressions).
370
+ const parsed = yaml.load(content) as any;
371
+ expect(parsed?.networks?.default).toBeDefined();
372
+ });
373
+
374
+ test("env override default resolves to 'true' when PGAI_ENABLE_IPV6 is unset", () => {
375
+ // Mirror Compose's `${VAR:-default}` interpolation rule. Verifies that the
376
+ // template's default value matches the documented one.
377
+ const composePath = path.join(repoRoot, "docker-compose.yml");
378
+ const content = fs.readFileSync(composePath, "utf8");
379
+ const m = content.match(/enable_ipv6:\s*\$\{PGAI_ENABLE_IPV6:-(\w+)\}/);
380
+ expect(m).not.toBeNull();
381
+ expect(m![1]).toBe("true");
382
+ });
383
+ });
384
+
385
+ describe("addInstanceToFile / removeInstanceFromFile round-trip", () => {
386
+ let tempDir: string;
387
+ let instancesFile: string;
388
+
389
+ beforeEach(() => {
390
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "instances-test-"));
391
+ instancesFile = path.join(tempDir, "instances.yml");
392
+ });
393
+
394
+ afterEach(() => {
395
+ if (tempDir && fs.existsSync(tempDir)) {
396
+ fs.rmSync(tempDir, { recursive: true, force: true });
397
+ }
398
+ });
399
+
400
+ test("add to empty file produces a valid YAML list", () => {
401
+ addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
402
+ const list = loadInstances(instancesFile);
403
+ expect(list.length).toBe(1);
404
+ expect(list[0].name).toBe("t1");
405
+ });
406
+
407
+ test("add → remove → add cycle keeps file parseable (regression)", () => {
408
+ // The previous bug: after `remove` left `[]` in the file, `add` appended a
409
+ // list-item next to it, producing two YAML documents in one file.
410
+ addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
411
+ removeInstanceFromFile(instancesFile, "t1");
412
+ expect(loadInstances(instancesFile)).toEqual([]);
413
+
414
+ addInstanceToFile(instancesFile, buildInstance("t2", "postgresql://u:p@h:5432/db2"));
415
+
416
+ // Must NOT throw "end of the stream or a document separator is expected".
417
+ const list = loadInstances(instancesFile);
418
+ expect(list.length).toBe(1);
419
+ expect(list[0].name).toBe("t2");
420
+ });
421
+
422
+ test("sink_type placeholder survives the round-trip", () => {
423
+ addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
424
+ const content = fs.readFileSync(instancesFile, "utf8");
425
+ // js-yaml emits ~sink_type~ unquoted (it's only special when standalone);
426
+ // sed s/~sink_type~/.../g still hits it as raw text regardless.
427
+ expect(content).toContain("~sink_type~");
428
+ });
429
+
430
+ test("add throws InstancesParseError on a corrupted file (no silent overwrite)", () => {
431
+ // Silent overwrite would discard credentials in conn_str values. Refuse.
432
+ fs.writeFileSync(instancesFile, "key: [unclosed\nfoo: bar\n", "utf8");
433
+
434
+ expect(() =>
435
+ addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db")),
436
+ ).toThrow(InstancesParseError);
437
+
438
+ // File contents are unchanged.
439
+ expect(fs.readFileSync(instancesFile, "utf8")).toBe("key: [unclosed\nfoo: bar\n");
440
+ });
441
+
442
+ test("add rejects duplicate name", () => {
443
+ addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
444
+ expect(() =>
445
+ addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db")),
446
+ ).toThrow(/already exists/);
447
+ });
448
+
449
+ test("add replaces a directory at the target path (Docker bind-mount artifact)", () => {
450
+ fs.mkdirSync(instancesFile);
451
+ expect(fs.statSync(instancesFile).isDirectory()).toBe(true);
452
+ addInstanceToFile(instancesFile, buildInstance("t1", "postgresql://u:p@h:5432/db"));
453
+ expect(fs.statSync(instancesFile).isFile()).toBe(true);
454
+ expect(loadInstances(instancesFile).length).toBe(1);
455
+ });
456
+ });
457
+
458
+ describe("sslOptionFromConnString — libpq semantics", () => {
459
+ test("sslmode=require → SSL without chain verification", () => {
460
+ expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=require"))
461
+ .toEqual({ rejectUnauthorized: false });
462
+ });
463
+
464
+ test("sslmode unset → SSL without chain verification", () => {
465
+ expect(sslOptionFromConnString("postgresql://u:p@h/db"))
466
+ .toEqual({ rejectUnauthorized: false });
467
+ });
468
+
469
+ test("sslmode=disable → no SSL", () => {
470
+ expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=disable")).toBe(false);
471
+ });
472
+
473
+ test("sslmode=verify-ca → chain verification, no hostname check", () => {
474
+ const opt = sslOptionFromConnString("postgresql://u:p@h/db?sslmode=verify-ca");
475
+ expect(opt).toMatchObject({ rejectUnauthorized: true });
476
+ expect(typeof (opt as any).checkServerIdentity).toBe("function");
477
+ });
478
+
479
+ test("sslmode=verify-full → chain + hostname verification", () => {
480
+ expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=verify-full"))
481
+ .toEqual({ rejectUnauthorized: true });
482
+ });
483
+
484
+ test("sslmode=prefer → SSL without chain verification", () => {
485
+ expect(sslOptionFromConnString("postgresql://u:p@h/db?sslmode=prefer"))
486
+ .toEqual({ rejectUnauthorized: false });
487
+ });
488
+
489
+ test("malformed connection string → safe default (no chain verification)", () => {
490
+ expect(sslOptionFromConnString("not-a-url")).toEqual({ rejectUnauthorized: false });
491
+ });
492
+ });
493
+
494
+ describe("buildClientConfig — actual node-postgres Client gets the intended ssl (regression)", () => {
495
+ // The previous code passed `{ connectionString, ssl }` to `new Client(...)`.
496
+ // node-postgres' ConnectionParameters internally does
497
+ // `Object.assign({}, config, parse(connectionString))`, so the parsed
498
+ // `connectionString.ssl` REPLACES the explicit `ssl`. Net effect:
499
+ // `?sslmode=require` + `ssl: { rejectUnauthorized: false }` → `ssl: {}`
500
+ // (chain verified)
501
+ // — exactly the bug the MR claims to fix. This integration test asserts
502
+ // against `client.connectionParameters.ssl`, so the bug cannot return.
503
+
504
+ test("require: actual Client.connectionParameters.ssl has rejectUnauthorized:false", () => {
505
+ const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=require"));
506
+ expect(c.connectionParameters.ssl).toEqual({ rejectUnauthorized: false });
507
+ });
508
+
509
+ test("verify-full: actual Client.connectionParameters.ssl has rejectUnauthorized:true", () => {
510
+ const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=verify-full"));
511
+ expect(c.connectionParameters.ssl).toEqual({ rejectUnauthorized: true });
512
+ });
513
+
514
+ test("disable: actual Client.connectionParameters.ssl is false", () => {
515
+ const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=disable"));
516
+ expect(c.connectionParameters.ssl).toBe(false);
517
+ });
518
+
519
+ test("unset: actual Client.connectionParameters.ssl has rejectUnauthorized:false", () => {
520
+ const c = new Client(buildClientConfig("postgresql://u:p@h/db"));
521
+ expect(c.connectionParameters.ssl).toEqual({ rejectUnauthorized: false });
522
+ });
523
+
524
+ test("connectionTimeoutMillis is forwarded (exact value, not just truthy)", () => {
525
+ const c = new Client(buildClientConfig("postgresql://u:p@h/db?sslmode=require", { connectionTimeoutMillis: 5000 }));
526
+ // node-postgres v8 stores it on `_connectionTimeoutMillis`. Asserting the
527
+ // exact value catches regressions that would silently swap in the default.
528
+ expect((c as any)._connectionTimeoutMillis).toBe(5000);
529
+ });
530
+ });
531
+
532
+ describe("warnIfLaxSslmode — UX warning for lax sslmode", () => {
533
+ let stderrSpy: ReturnType<typeof spyOn>;
534
+
535
+ beforeEach(() => {
536
+ stderrSpy = spyOn(console, "error").mockImplementation(() => {});
537
+ });
538
+
539
+ afterEach(() => {
540
+ stderrSpy.mockRestore();
541
+ });
542
+
543
+ for (const sslmode of ["require", "prefer", "allow"]) {
544
+ test(`warns when sslmode=${sslmode}`, () => {
545
+ warnIfLaxSslmode(`postgresql://u:p@h/db?sslmode=${sslmode}`);
546
+ expect(stderrSpy).toHaveBeenCalledTimes(1);
547
+ const msg = String(stderrSpy.mock.calls[0][0]);
548
+ expect(msg).toContain(`sslmode=${sslmode}`);
549
+ expect(msg).toContain("NOT verified");
550
+ expect(msg).toContain("verify-full");
551
+ });
552
+ }
553
+
554
+ test("warns when sslmode is unset (uses '(unset)' label)", () => {
555
+ warnIfLaxSslmode("postgresql://u:p@h/db");
556
+ expect(stderrSpy).toHaveBeenCalledTimes(1);
557
+ expect(String(stderrSpy.mock.calls[0][0])).toContain("sslmode=(unset)");
558
+ });
559
+
560
+ test("does NOT warn when sslmode=verify-full or verify-ca or disable", () => {
561
+ warnIfLaxSslmode("postgresql://u:p@h/db?sslmode=verify-full");
562
+ warnIfLaxSslmode("postgresql://u:p@h/db?sslmode=verify-ca");
563
+ warnIfLaxSslmode("postgresql://u:p@h/db?sslmode=disable");
564
+ expect(stderrSpy).not.toHaveBeenCalled();
565
+ });
566
+ });
567
+
568
+ describe("buildClientConfig — silences pg-connection-string deprecation warning", () => {
569
+ // pg-connection-string v2.x prints `process.emitWarning("SECURITY WARNING:
570
+ // ... 'prefer'/'require'/'verify-ca' ...")` whenever a recognised lax
571
+ // sslmode appears. Without our `uselibpqcompat=true` shim, this would fire
572
+ // on every CLI invocation against a Supabase-shaped URL. Assert it doesn't.
573
+
574
+ let warnings: string[];
575
+ let origEmitWarning: typeof process.emitWarning;
576
+
577
+ beforeEach(() => {
578
+ warnings = [];
579
+ origEmitWarning = process.emitWarning;
580
+ (process as any).emitWarning = (warning: any) => {
581
+ warnings.push(typeof warning === "string" ? warning : String(warning));
582
+ };
583
+ });
584
+
585
+ afterEach(() => {
586
+ (process as any).emitWarning = origEmitWarning;
587
+ });
588
+
589
+ for (const sslmode of ["require", "prefer", "verify-ca"]) {
590
+ test(`no SECURITY WARNING for sslmode=${sslmode}`, () => {
591
+ buildClientConfig(`postgresql://u:p@h/db?sslmode=${sslmode}`);
592
+ const security = warnings.filter((w) => w.includes("SECURITY"));
593
+ expect(security).toEqual([]);
594
+ });
595
+ }
596
+ });
597
+
598
+ describe("extractSslmode / isLaxSslmode", () => {
599
+ test("extractSslmode returns lowercase", () => {
600
+ expect(extractSslmode("postgresql://u:p@h/db?sslmode=REQUIRE")).toBe("require");
601
+ });
602
+
603
+ test("extractSslmode returns '' for unparseable URLs", () => {
604
+ expect(extractSslmode("not-a-url")).toBe("");
605
+ });
606
+
607
+ test("isLaxSslmode covers the full set", () => {
608
+ expect(isLaxSslmode("")).toBe(true);
609
+ expect(isLaxSslmode("require")).toBe(true);
610
+ expect(isLaxSslmode("prefer")).toBe(true);
611
+ expect(isLaxSslmode("allow")).toBe(true);
612
+ expect(isLaxSslmode("verify-ca")).toBe(false);
613
+ expect(isLaxSslmode("verify-full")).toBe(false);
614
+ expect(isLaxSslmode("disable")).toBe(false);
615
+ });
616
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect, mock, afterEach, beforeEach } from "bun:test";
2
- import { uploadFile, downloadFile, buildMarkdownLink } from "../lib/storage";
2
+ import { uploadFile, downloadFile, buildMarkdownLink, uploadAttachments, appendAttachmentsToContent } from "../lib/storage";
3
3
  import * as fs from "fs";
4
4
  import * as path from "path";
5
5
  import * as os from "os";
@@ -759,3 +759,177 @@ describe("buildMarkdownLink escaping", () => {
759
759
  expect(result).toBe("![shot \\[v2\\] \\(draft\\).png](https://postgres.ai/storage/files/123/abc.png)");
760
760
  });
761
761
  });
762
+
763
+ describe("appendAttachmentsToContent", () => {
764
+ const mkAttachment = (markdown: string) => ({
765
+ path: "/tmp/x",
766
+ url: "/files/1/x",
767
+ markdown,
768
+ metadata: { originalName: "x", size: 0, mimeType: "x", uploadedAt: "", duration: 0 },
769
+ });
770
+
771
+ test("returns content unchanged when attachments empty", () => {
772
+ expect(appendAttachmentsToContent("hello", [])).toBe("hello");
773
+ });
774
+
775
+ test("returns content unchanged when attachments missing (undefined-safe)", () => {
776
+ // Defensive: callers may pass undefined for cleaner sites — we tolerate it.
777
+ // (TS prevents this at compile time but runtime data can disagree.)
778
+ expect(appendAttachmentsToContent("hello", undefined as unknown as never[])).toBe("hello");
779
+ });
780
+
781
+ test("returns just the link(s) when content is empty string", () => {
782
+ const out = appendAttachmentsToContent("", [mkAttachment("![a](u)")]);
783
+ expect(out).toBe("![a](u)");
784
+ });
785
+
786
+ test("returns just the link(s) when content is whitespace", () => {
787
+ const out = appendAttachmentsToContent(" \n ", [mkAttachment("![a](u)")]);
788
+ expect(out).toBe("![a](u)");
789
+ });
790
+
791
+ test("appends a single link with two-newline separator", () => {
792
+ const out = appendAttachmentsToContent("hello", [mkAttachment("![a](u)")]);
793
+ expect(out).toBe("hello\n\n![a](u)");
794
+ });
795
+
796
+ test("appends multiple links one per line, preserving order", () => {
797
+ const out = appendAttachmentsToContent("hello", [
798
+ mkAttachment("![first](u1)"),
799
+ mkAttachment("[second](u2)"),
800
+ mkAttachment("![third](u3)"),
801
+ ]);
802
+ expect(out).toBe("hello\n\n![first](u1)\n[second](u2)\n![third](u3)");
803
+ });
804
+
805
+ test("does not strip user-provided trailing newlines", () => {
806
+ // The user may have a meaningful trailing newline (e.g. for code blocks).
807
+ // We should not normalize content beyond appending.
808
+ const out = appendAttachmentsToContent("hello\n", [mkAttachment("![a](u)")]);
809
+ expect(out).toBe("hello\n\n\n![a](u)");
810
+ });
811
+ });
812
+
813
+ describe("uploadAttachments", () => {
814
+ afterEach(() => {
815
+ globalThis.fetch = originalFetch;
816
+ });
817
+
818
+ test("returns empty array when input is empty", async () => {
819
+ const out = await uploadAttachments({
820
+ apiKey: "k",
821
+ storageBaseUrl: "https://postgres.ai/storage",
822
+ attachmentPaths: [],
823
+ });
824
+ expect(out).toEqual([]);
825
+ });
826
+
827
+ test("returns empty array when input is undefined (defensive)", async () => {
828
+ const out = await uploadAttachments({
829
+ apiKey: "k",
830
+ storageBaseUrl: "https://postgres.ai/storage",
831
+ attachmentPaths: undefined as unknown as string[],
832
+ });
833
+ expect(out).toEqual([]);
834
+ });
835
+
836
+ test("uploads each file in order and returns metadata + markdown link per upload", async () => {
837
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ua-"));
838
+ const f1 = path.join(tmpDir, "shot.png");
839
+ const f2 = path.join(tmpDir, "trace.log");
840
+ fs.writeFileSync(f1, "fake-png-bytes");
841
+ fs.writeFileSync(f2, "log line 1\nlog line 2\n");
842
+
843
+ const fakeResponses = [
844
+ { url: "/files/9/aaa.png", originalName: "shot.png" },
845
+ { url: "/files/9/bbb.log", originalName: "trace.log" },
846
+ ];
847
+ const calls: Array<{ url: string; mime: string }> = [];
848
+
849
+ globalThis.fetch = mock((url: string, init?: RequestInit) => {
850
+ const body = init?.body as FormData;
851
+ const file = body?.get("file") as Blob;
852
+ const next = fakeResponses[calls.length];
853
+ calls.push({ url: String(url), mime: file?.type ?? "" });
854
+ return Promise.resolve(
855
+ new Response(
856
+ JSON.stringify({
857
+ success: true,
858
+ url: next.url,
859
+ metadata: {
860
+ originalName: next.originalName,
861
+ size: 0,
862
+ mimeType: file?.type ?? "application/octet-stream",
863
+ uploadedAt: "",
864
+ duration: 0,
865
+ },
866
+ requestId: `r-${calls.length}`,
867
+ }),
868
+ { status: 200, headers: { "Content-Type": "application/json" } }
869
+ )
870
+ );
871
+ }) as unknown as typeof fetch;
872
+
873
+ try {
874
+ const out = await uploadAttachments({
875
+ apiKey: "k",
876
+ storageBaseUrl: "https://postgres.ai/storage",
877
+ attachmentPaths: [f1, f2],
878
+ });
879
+
880
+ // Both uploads happened, in order.
881
+ expect(calls).toHaveLength(2);
882
+ expect(calls[0].url).toBe("https://postgres.ai/storage/upload");
883
+ expect(calls[1].url).toBe("https://postgres.ai/storage/upload");
884
+ // Mime types from extensions (image/png, text/plain).
885
+ // Blob may append `;charset=utf-8` for text MIME types — accept either.
886
+ expect(calls[0].mime).toBe("image/png");
887
+ expect(calls[1].mime.startsWith("text/plain")).toBe(true);
888
+
889
+ expect(out).toHaveLength(2);
890
+ expect(out[0].path).toBe(f1);
891
+ expect(out[0].url).toBe("/files/9/aaa.png");
892
+ // Image extension renders inline.
893
+ expect(out[0].markdown).toBe("![shot.png](https://postgres.ai/storage/files/9/aaa.png)");
894
+ expect(out[0].metadata.mimeType).toBe("image/png");
895
+
896
+ expect(out[1].path).toBe(f2);
897
+ expect(out[1].url).toBe("/files/9/bbb.log");
898
+ // Non-image renders as plain link.
899
+ expect(out[1].markdown).toBe("[trace.log](https://postgres.ai/storage/files/9/bbb.log)");
900
+ } finally {
901
+ fs.rmSync(tmpDir, { recursive: true, force: true });
902
+ }
903
+ });
904
+
905
+ test("error on file N surfaces the path so the user can retry", async () => {
906
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ua-err-"));
907
+ const f1 = path.join(tmpDir, "ok.txt");
908
+ fs.writeFileSync(f1, "ok");
909
+ const missing = path.join(tmpDir, "definitely-not-here.png");
910
+
911
+ let callCount = 0;
912
+ globalThis.fetch = mock(() => {
913
+ callCount++;
914
+ return Promise.resolve(
915
+ new Response(JSON.stringify({ success: true, url: "/files/1/a", metadata: { originalName: "ok.txt", size: 2, mimeType: "text/plain", uploadedAt: "", duration: 0 }, requestId: "r" }), { status: 200 })
916
+ );
917
+ }) as unknown as typeof fetch;
918
+
919
+ try {
920
+ await expect(
921
+ uploadAttachments({
922
+ apiKey: "k",
923
+ storageBaseUrl: "https://postgres.ai/storage",
924
+ attachmentPaths: [f1, missing],
925
+ })
926
+ ).rejects.toThrow(/File not found.*definitely-not-here/);
927
+ // The first file was uploaded before the failure; we don't retry it.
928
+ // (Documenting current behavior — caller is responsible if mid-failure
929
+ // partial uploads matter.)
930
+ expect(callCount).toBe(1);
931
+ } finally {
932
+ fs.rmSync(tmpDir, { recursive: true, force: true });
933
+ }
934
+ });
935
+ });