|
1 | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
2 | 2 | import { ConnectorManager } from "../manager.js"; |
3 | | -import { ConnectorRegistry } from "../interface.js"; |
| 3 | +import { ConnectorRegistry, type Connector, type ConnectorConfig } from "../interface.js"; |
4 | 4 | import { SSHTunnel } from "../../utils/ssh-tunnel.js"; |
5 | 5 | import type { SourceConfig } from "../../types/config.js"; |
6 | 6 | import { homedir } from "os"; |
@@ -278,7 +278,7 @@ describe("ConnectorManager IAM DSN rewrite", () => { |
278 | 278 | dsn: "mysql://dbuser%40example.com:ignored@mydb.abc123.eu-west-1.rds.amazonaws.com:3306/mydb?connectTimeout=5000&sslmode=disable", |
279 | 279 | }; |
280 | 280 |
|
281 | | - const dsn = await (manager as any).buildConnectionDSN(source); |
| 281 | + const dsn = await (manager as any).buildConnectionDSN(source, {}); |
282 | 282 |
|
283 | 283 | expect(mocks.generateRdsAuthToken).toHaveBeenCalledWith({ |
284 | 284 | hostname: "mydb.abc123.eu-west-1.rds.amazonaws.com", |
@@ -481,3 +481,113 @@ describe("ConnectorManager IAM refresh recovery", () => { |
481 | 481 | ); |
482 | 482 | }); |
483 | 483 | }); |
| 484 | + |
| 485 | +describe("PostgreSQL IAM authentication on demand", () => { |
| 486 | + const source: SourceConfig = { |
| 487 | + id: "production", type: "postgres", host: "db.example.com", port: 5432, |
| 488 | + database: "db", user: "db_user", aws_iam_auth: true, |
| 489 | + aws_region: "us-east-1", aws_profile: "production", lazy: true, |
| 490 | + }; |
| 491 | + let manager: ConnectorManager; |
| 492 | + let config: ConnectorConfig; |
| 493 | + let dsn: string; |
| 494 | + const disconnect = vi.fn(); |
| 495 | + const connect = vi.fn(); |
| 496 | + |
| 497 | + beforeEach(() => { |
| 498 | + vi.useFakeTimers(); |
| 499 | + vi.clearAllMocks(); |
| 500 | + mocks.generateRdsAuthToken.mockReset().mockResolvedValue("token"); |
| 501 | + connect.mockReset().mockImplementation(async (value: string, _init: string, options: ConnectorConfig) => { |
| 502 | + dsn = value; |
| 503 | + config = options; |
| 504 | + await options.password?.(); |
| 505 | + }); |
| 506 | + manager = new ConnectorManager(); |
| 507 | + vi.spyOn(console, "error").mockImplementation(() => {}); |
| 508 | + vi.spyOn(ConnectorRegistry, "getConnectorForDSN").mockReturnValue({ |
| 509 | + clone: () => ({ |
| 510 | + id: "postgres", disconnect, connect, |
| 511 | + }), |
| 512 | + } as unknown as Connector); |
| 513 | + }); |
| 514 | + |
| 515 | + afterEach(async () => { |
| 516 | + await manager.disconnect(); |
| 517 | + vi.useRealTimers(); |
| 518 | + vi.restoreAllMocks(); |
| 519 | + }); |
| 520 | + |
| 521 | + it("authenticates new connections, never refreshes an idle source on a timer", async () => { |
| 522 | + await manager.connectWithSources([source]); |
| 523 | + expect(mocks.generateRdsAuthToken).not.toHaveBeenCalled(); |
| 524 | + await manager.ensureConnected(source.id); |
| 525 | + expect(config.password).toBeTypeOf("function"); |
| 526 | + expect(dsn).not.toContain("token"); |
| 527 | + expect(dsn).toContain("sslmode=require"); |
| 528 | + expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(1); |
| 529 | + |
| 530 | + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); |
| 531 | + expect(disconnect).not.toHaveBeenCalled(); |
| 532 | + expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(1); |
| 533 | + |
| 534 | + // A pool opening several sockets shares only the in-flight token request. |
| 535 | + const password = config.password!; |
| 536 | + await Promise.all(Array.from({ length: 8 }, () => password())); |
| 537 | + expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(2); |
| 538 | + mocks.generateRdsAuthToken.mockRejectedValueOnce(new Error("SSO session expired")); |
| 539 | + await expect(password()).rejects.toThrow("SSO session expired"); |
| 540 | + await expect(password()).resolves.toBe("token"); |
| 541 | + expect(manager.getConnector(source.id)).toBeDefined(); |
| 542 | + expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(4); |
| 543 | + }); |
| 544 | + |
| 545 | + it("retries failed initial authentication only when another request arrives", async () => { |
| 546 | + mocks.generateRdsAuthToken.mockRejectedValueOnce(new Error("SSO session expired")); |
| 547 | + await manager.connectWithSources([source]); |
| 548 | + await expect(manager.ensureConnected(source.id)).rejects.toThrow("SSO session expired"); |
| 549 | + await vi.advanceTimersByTimeAsync(24 * 60 * 60 * 1000); |
| 550 | + expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(1); |
| 551 | + await Promise.all(Array.from({ length: 8 }, () => manager.ensureConnected(source.id))); |
| 552 | + expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(2); |
| 553 | + expect(manager.getSourceIds()).toEqual([source.id]); |
| 554 | + expect(manager.getConnector(source.id)).toBeDefined(); |
| 555 | + }); |
| 556 | + |
| 557 | + it("signs the original endpoint through SSH and cleans up a failed authentication", async () => { |
| 558 | + mocks.looksLikeSSHAlias.mockReturnValue(false); |
| 559 | + vi.spyOn(SSHTunnel.prototype, "establish").mockResolvedValue({ |
| 560 | + localPort: 15432, localHost: "127.0.0.1", |
| 561 | + } as any); |
| 562 | + const close = vi.spyOn(SSHTunnel.prototype, "close").mockResolvedValue(); |
| 563 | + mocks.generateRdsAuthToken.mockRejectedValueOnce(new Error("SSO session expired")); |
| 564 | + await manager.connectWithSources([{ ...source, sslmode: "verify-full", |
| 565 | + ssh_host: "bastion.example.com", ssh_user: "user", ssh_key: "/fake/key" }]); |
| 566 | + await expect(manager.ensureConnected(source.id)).rejects.toThrow("SSO session expired"); |
| 567 | + expect(close).toHaveBeenCalledTimes(1); |
| 568 | + await manager.ensureConnected(source.id); |
| 569 | + expect(dsn).toContain("127.0.0.1:15432"); |
| 570 | + expect(dsn).toContain("sslmode=verify-full"); |
| 571 | + expect(mocks.generateRdsAuthToken).toHaveBeenLastCalledWith({ |
| 572 | + hostname: "db.example.com", port: 5432, username: "db_user", |
| 573 | + region: "us-east-1", profile: "production", |
| 574 | + }); |
| 575 | + }); |
| 576 | + |
| 577 | + it("shares a still-running credential helper after an initial socket timeout", async () => { |
| 578 | + let finishLogin!: (token: string) => void; |
| 579 | + mocks.generateRdsAuthToken.mockReturnValueOnce(new Promise<string>(resolve => { finishLogin = resolve; })); |
| 580 | + connect.mockImplementationOnce(async (_dsn, _init, options: ConnectorConfig) => { |
| 581 | + void options.password!().catch(() => {}); |
| 582 | + throw new Error("Connection timeout"); |
| 583 | + }); |
| 584 | + await manager.connectWithSources([source]); |
| 585 | + await expect(manager.ensureConnected(source.id)).rejects.toThrow("Connection timeout"); |
| 586 | + const retry = manager.ensureConnected(source.id); |
| 587 | + await vi.waitFor(() => expect(connect).toHaveBeenCalledTimes(2)); |
| 588 | + expect(mocks.generateRdsAuthToken).toHaveBeenCalledTimes(1); |
| 589 | + finishLogin("token"); |
| 590 | + await retry; |
| 591 | + expect(manager.getConnector(source.id)).toBeDefined(); |
| 592 | + }); |
| 593 | +}); |
0 commit comments