1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as redshift from "@distilled.cloud/aws/redshift";
5import { describe, expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import RedshiftConnectFunctionLive, {
10 RedshiftConnectFunction,
11} from "./fixtures/connect-handler";
12
13const testOptions = { providers: AWS.providers() };
14const { test, beforeAll, afterAll } = Test.make(testOptions);
15const sharedStack = Core.scratchStack(testOptions, "RedshiftConnect");
16
17// Ungated typed-error probes: prove the distilled error unions carry the
18// not-found tag both Connect credential strategies can surface. Run in every
19// CI pass at near-zero cost.
20test.provider(
21 "getClusterCredentialsWithIAM on a nonexistent cluster fails with ClusterNotFoundFault",
22 () =>
23 Effect.gen(function* () {
24 const error = yield* Effect.flip(
25 redshift.getClusterCredentialsWithIAM({
26 ClusterIdentifier: "alchemy-nonexistent-redshift-connect-probe",
27 }),
28 );
29 expect(error._tag).toBe("ClusterNotFoundFault");
30 }),
31 { tags: ["provider:aws", "provider:aws:redshift", "live"] },
32);
33
34test.provider(
35 "getClusterCredentials on a nonexistent cluster fails with ClusterNotFoundFault",
36 () =>
37 Effect.gen(function* () {
38 const error = yield* Effect.flip(
39 redshift.getClusterCredentials({
40 ClusterIdentifier: "alchemy-nonexistent-redshift-connect-probe",
41 DbUser: "probe",
42 }),
43 );
44 expect(error._tag).toBe("ClusterNotFoundFault");
45 }),
46 { tags: ["provider:aws", "provider:aws:redshift", "live"] },
47);
48
49let baseUrl: string;
50
51const getInfo = (path: string) =>
52 HttpClient.get(`${baseUrl}${path}`).pipe(
53 Effect.flatMap((res) =>
54 res.status === 200
55 ? Effect.succeed(res)
56 : Effect.fail(new Error(`${path} returned ${res.status}`)),
57 ),
58 Effect.retry({
59 schedule: Schedule.max([
60 Schedule.exponential("1 second"),
61 Schedule.recurs(10),
62 ]),
63 }),
64 Effect.flatMap((res) => res.json),
65 Effect.map(
66 (body) =>
67 body as {
68 host: string;
69 port: number;
70 database: string;
71 username: string | undefined;
72 hasPassword: boolean;
73 ssl: boolean;
74 urlScheme: string;
75 expiresInFuture: boolean;
76 },
77 ),
78 );
79
80// The full lifecycle deploys a provisioned Redshift cluster (~5-10 min to
81// reach `available`, bills hourly per node while it exists) plus a Lambda,
82// so it is gated behind AWS_TEST_REDSHIFT=1 and destroys everything in
83// afterAll.
84describe.skipIf(!process.env.AWS_TEST_REDSHIFT)(
85 "Redshift.Connect",
86 {
87 tags: [
88 "provider:aws",
89 "provider:aws:lambda",
90 "provider:aws:redshift",
91 "live",
92 ],
93 },
94 () => {
95 beforeAll(
96 Effect.gen(function* () {
97 yield* Effect.logInfo(
98 "Redshift.Connect setup: destroying previous run",
99 );
100 yield* sharedStack.destroy();
101
102 yield* Effect.logInfo("Redshift.Connect setup: deploying fixture");
103 const { functionUrl } = yield* sharedStack.deploy(
104 Effect.gen(function* () {
105 return yield* RedshiftConnectFunction;
106 }).pipe(Effect.provide(RedshiftConnectFunctionLive)),
107 );
108
109 expect(functionUrl).toBeTruthy();
110 baseUrl = functionUrl!.replace(/\/+$/, "");
111 yield* Effect.logInfo(
112 `Redshift.Connect setup: function URL ready (${functionUrl})`,
113 );
114 }),
115 // cluster create (~5-10 min) + Lambda deploy.
116 { timeout: 1_200_000 },
117 );
118
119 afterAll(sharedStack.destroy(), { timeout: 900_000 });
120
121 describe("Connect", () => {
122 test.provider(
123 "mints IAM-mapped temporary credentials (GetClusterCredentialsWithIAM)",
124 (_stack) =>
125 Effect.gen(function* () {
126 const body = yield* getInfo("/info");
127 expect(body.host).toContain("redshift");
128 expect(body.port).toBe(5439);
129 expect(body.database).toBe("dev");
130 // WithIAM maps the caller's IAM identity to a database user
131 // prefixed with `IAM:`/`IAMR:`.
132 expect(body.username).toMatch(/^IAM/);
133 expect(body.hasPassword).toBe(true);
134 expect(body.ssl).toBe(true);
135 expect(body.urlScheme).toBe("postgresql");
136 expect(body.expiresInFuture).toBe(true);
137 }),
138 { timeout: 240_000 },
139 );
140
141 test.provider(
142 "mints named-user temporary credentials (GetClusterCredentials + AutoCreate)",
143 (_stack) =>
144 Effect.gen(function* () {
145 const body = yield* getInfo("/info-dbuser");
146 expect(body.port).toBe(5439);
147 // AutoCreate=true prefixes the user with `IAMA:`.
148 expect(body.username).toBe("IAMA:alchemy_etl");
149 expect(body.hasPassword).toBe(true);
150 expect(body.urlScheme).toBe("postgresql");
151 expect(body.expiresInFuture).toBe(true);
152 }),
153 { timeout: 240_000 },
154 );
155 });
156 },
157);