| 1 | import * as Config from "@/AWS/Config"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Stream from "effect/Stream"; |
| 7 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 9 | import path from "pathe"; |
| 10 | |
| 11 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 12 | |
| 13 | export class ConfigTestFunction extends Lambda.Function<Lambda.Function>()( |
| 14 | "ConfigTestFunction", |
| 15 | ) {} |
| 16 | |
| 17 | /** |
| 18 | * Every route answers `{ …fields }` on success or `{ errorTag }` when the |
| 19 | * operation fails with a TYPED error — the test asserts the tag is in a |
| 20 | * route-specific allowlist, which proves both the binding wiring and the |
| 21 | * IAM grant. An untyped error crashes into a 500 instead. |
| 22 | */ |
| 23 | const errorTagged = <A, E extends { _tag: string }, R>( |
| 24 | effect: Effect.Effect<A, E, R>, |
| 25 | ): Effect.Effect<A | { errorTag: string; errorMessage?: string }, never, R> => |
| 26 | effect.pipe( |
| 27 | Effect.map((a): A | { errorTag: string; errorMessage?: string } => a), |
| 28 | Effect.catch((e) => |
| 29 | Effect.succeed({ |
| 30 | errorTag: e._tag, |
| 31 | errorMessage: |
| 32 | (e as { Message?: string }).Message ?? |
| 33 | (e as { message?: string }).message, |
| 34 | }), |
| 35 | ), |
| 36 | ); |
| 37 | |
| 38 | // One binding per Config capability. DeliverConfigSnapshot has no route |
| 39 | // here: it binds to the account's singleton DeliveryChannel, whose |
| 40 | // lifecycle is gated behind AWS_TEST_CONFIG_RECORDER (see |
| 41 | // ConfigurationRecorder.test.ts) — deploying a second channel ungated |
| 42 | // would clobber the account setup. |
| 43 | export default ConfigTestFunction.make( |
| 44 | { |
| 45 | main, |
| 46 | functionUrl: true, |
| 47 | // Several routes fan out Config API calls — AWS's 3s default |
| 48 | // intermittently times out under cold starts. |
| 49 | timeout: Duration.seconds(30), |
| 50 | }, |
| 51 | Effect.gen(function* () { |
| 52 | // A managed rule the rule-scoped bindings are bound to. Requires a |
| 53 | // configuration recorder in the account/region — the test's beforeAll |
| 54 | // stands one up out-of-band before deploying this fixture. |
| 55 | const rule = yield* Config.ConfigRule("BindingRule", { |
| 56 | description: "alchemy config-bindings fixture rule", |
| 57 | source: { |
| 58 | owner: "AWS", |
| 59 | sourceIdentifier: "S3_BUCKET_VERSIONING_ENABLED", |
| 60 | }, |
| 61 | scope: { complianceResourceTypes: ["AWS::S3::Bucket"] }, |
| 62 | }); |
| 63 | |
| 64 | // Event source: subscribe the host to Config compliance-change events. |
| 65 | // The deploy proves the EventBridge rule + invoke permission wiring. |
| 66 | yield* Config.consumeConfigEvents({ kinds: ["compliance"] }, (events) => |
| 67 | Stream.runForEach(events, (event) => |
| 68 | Effect.log( |
| 69 | `config compliance: ${event.detail.configRuleName} -> ${event.detail.resourceId}`, |
| 70 | ), |
| 71 | ), |
| 72 | ); |
| 73 | |
| 74 | // Account-level bindings. |
| 75 | const batchGetResourceConfig = yield* Config.BatchGetResourceConfig(); |
| 76 | const getResourceConfigHistory = yield* Config.GetResourceConfigHistory(); |
| 77 | const listDiscoveredResources = yield* Config.ListDiscoveredResources(); |
| 78 | const getDiscoveredResourceCounts = |
| 79 | yield* Config.GetDiscoveredResourceCounts(); |
| 80 | const selectResourceConfig = yield* Config.SelectResourceConfig(); |
| 81 | const describeComplianceByConfigRule = |
| 82 | yield* Config.DescribeComplianceByConfigRule(); |
| 83 | const describeComplianceByResource = |
| 84 | yield* Config.DescribeComplianceByResource(); |
| 85 | const getComplianceDetailsByResource = |
| 86 | yield* Config.GetComplianceDetailsByResource(); |
| 87 | const getComplianceSummaryByConfigRule = |
| 88 | yield* Config.GetComplianceSummaryByConfigRule(); |
| 89 | const getComplianceSummaryByResourceType = |
| 90 | yield* Config.GetComplianceSummaryByResourceType(); |
| 91 | const describeConfigRuleEvaluationStatus = |
| 92 | yield* Config.DescribeConfigRuleEvaluationStatus(); |
| 93 | const putEvaluations = yield* Config.PutEvaluations(); |
| 94 | const putResourceConfig = yield* Config.PutResourceConfig(); |
| 95 | const deleteResourceConfig = yield* Config.DeleteResourceConfig(); |
| 96 | const startResourceEvaluation = yield* Config.StartResourceEvaluation(); |
| 97 | const getResourceEvaluationSummary = |
| 98 | yield* Config.GetResourceEvaluationSummary(); |
| 99 | const listResourceEvaluations = yield* Config.ListResourceEvaluations(); |
| 100 | |
| 101 | // Rule-scoped bindings (the rule name is injected automatically). |
| 102 | const getComplianceDetailsByConfigRule = |
| 103 | yield* Config.GetComplianceDetailsByConfigRule(rule); |
| 104 | const startConfigRulesEvaluation = |
| 105 | yield* Config.StartConfigRulesEvaluation(rule); |
| 106 | const putExternalEvaluation = yield* Config.PutExternalEvaluation(rule); |
| 107 | |
| 108 | const RuleName = yield* rule.configRuleName; |
| 109 | |
| 110 | return { |
| 111 | fetch: Effect.gen(function* () { |
| 112 | const request = yield* HttpServerRequest; |
| 113 | const url = new URL(request.originalUrl); |
| 114 | const route = `${request.method} ${url.pathname}`; |
| 115 | |
| 116 | switch (route) { |
| 117 | case "GET /health": |
| 118 | return HttpServerResponse.text("ok"); |
| 119 | |
| 120 | // ── Discovering resources / querying ────────────────────────── |
| 121 | |
| 122 | case "GET /select-resource-config": { |
| 123 | const result = yield* selectResourceConfig({ |
| 124 | Expression: |
| 125 | "SELECT resourceId WHERE resourceType = 'AWS::S3::Bucket'", |
| 126 | }); |
| 127 | return yield* HttpServerResponse.json({ |
| 128 | results: result.Results ?? [], |
| 129 | }); |
| 130 | } |
| 131 | case "GET /list-discovered-resources": { |
| 132 | const result = yield* listDiscoveredResources({ |
| 133 | resourceType: "AWS::S3::Bucket", |
| 134 | }); |
| 135 | return yield* HttpServerResponse.json({ |
| 136 | identifiers: result.resourceIdentifiers ?? [], |
| 137 | }); |
| 138 | } |
| 139 | case "GET /get-discovered-resource-counts": { |
| 140 | const result = yield* getDiscoveredResourceCounts(); |
| 141 | return yield* HttpServerResponse.json({ |
| 142 | total: result.totalDiscoveredResources ?? 0, |
| 143 | counts: result.resourceCounts ?? [], |
| 144 | }); |
| 145 | } |
| 146 | case "POST /batch-get-resource-config": { |
| 147 | const result = yield* batchGetResourceConfig({ |
| 148 | resourceKeys: [ |
| 149 | { |
| 150 | resourceType: "AWS::S3::Bucket", |
| 151 | resourceId: "alchemy-nonexistent-bucket-probe", |
| 152 | }, |
| 153 | ], |
| 154 | }); |
| 155 | return yield* HttpServerResponse.json({ |
| 156 | items: result.baseConfigurationItems ?? [], |
| 157 | unprocessed: result.unprocessedResourceKeys ?? [], |
| 158 | }); |
| 159 | } |
| 160 | case "GET /get-resource-config-history": { |
| 161 | // The probe resource is never discovered — the typed |
| 162 | // ResourceNotDiscoveredException proves binding + IAM. |
| 163 | const result = yield* errorTagged( |
| 164 | getResourceConfigHistory({ |
| 165 | resourceType: "AWS::S3::Bucket", |
| 166 | resourceId: "alchemy-nonexistent-bucket-probe", |
| 167 | }), |
| 168 | ); |
| 169 | return yield* HttpServerResponse.json( |
| 170 | "errorTag" in result |
| 171 | ? result |
| 172 | : { items: result.configurationItems ?? [] }, |
| 173 | ); |
| 174 | } |
| 175 | |
| 176 | // ── Compliance reads ─────────────────────────────────────────── |
| 177 | |
| 178 | case "GET /describe-compliance-by-config-rule": { |
| 179 | const ruleName = yield* RuleName; |
| 180 | const result = yield* describeComplianceByConfigRule({ |
| 181 | ConfigRuleNames: [ruleName], |
| 182 | }); |
| 183 | return yield* HttpServerResponse.json({ |
| 184 | ruleName, |
| 185 | compliances: result.ComplianceByConfigRules ?? [], |
| 186 | }); |
| 187 | } |
| 188 | case "GET /describe-compliance-by-resource": { |
| 189 | const result = yield* describeComplianceByResource({ |
| 190 | ResourceType: "AWS::S3::Bucket", |
| 191 | }); |
| 192 | return yield* HttpServerResponse.json({ |
| 193 | compliances: result.ComplianceByResources ?? [], |
| 194 | }); |
| 195 | } |
| 196 | case "GET /get-compliance-details-by-resource": { |
| 197 | const result = yield* getComplianceDetailsByResource({ |
| 198 | ResourceType: "AWS::S3::Bucket", |
| 199 | ResourceId: "alchemy-nonexistent-bucket-probe", |
| 200 | }); |
| 201 | return yield* HttpServerResponse.json({ |
| 202 | evaluations: result.EvaluationResults ?? [], |
| 203 | }); |
| 204 | } |
| 205 | case "GET /get-compliance-summary-by-config-rule": { |
| 206 | const result = yield* getComplianceSummaryByConfigRule(); |
| 207 | return yield* HttpServerResponse.json({ |
| 208 | summary: result.ComplianceSummary ?? null, |
| 209 | }); |
| 210 | } |
| 211 | case "GET /get-compliance-summary-by-resource-type": { |
| 212 | const result = yield* getComplianceSummaryByResourceType(); |
| 213 | return yield* HttpServerResponse.json({ |
| 214 | summaries: result.ComplianceSummariesByResourceType ?? [], |
| 215 | }); |
| 216 | } |
| 217 | case "GET /get-compliance-details-by-config-rule": { |
| 218 | const result = yield* getComplianceDetailsByConfigRule(); |
| 219 | return yield* HttpServerResponse.json({ |
| 220 | ruleName: yield* RuleName, |
| 221 | evaluations: result.EvaluationResults ?? [], |
| 222 | }); |
| 223 | } |
| 224 | |
| 225 | // ── Rule evaluation ──────────────────────────────────────────── |
| 226 | |
| 227 | case "GET /describe-config-rule-evaluation-status": { |
| 228 | const ruleName = yield* RuleName; |
| 229 | const result = yield* describeConfigRuleEvaluationStatus({ |
| 230 | ConfigRuleNames: [ruleName], |
| 231 | }); |
| 232 | return yield* HttpServerResponse.json({ |
| 233 | ruleName, |
| 234 | statuses: (result.ConfigRulesEvaluationStatus ?? []).map( |
| 235 | (s) => s.ConfigRuleName, |
| 236 | ), |
| 237 | }); |
| 238 | } |
| 239 | case "POST /start-config-rules-evaluation": { |
| 240 | // Back-to-back runs can reject with ResourceInUseException / |
| 241 | // LimitExceededException — typed either way. |
| 242 | const result = yield* errorTagged(startConfigRulesEvaluation()); |
| 243 | return yield* HttpServerResponse.json( |
| 244 | "errorTag" in result ? result : { ok: true }, |
| 245 | ); |
| 246 | } |
| 247 | case "POST /put-evaluations": { |
| 248 | // TestMode validates the evaluations without pushing them; an |
| 249 | // out-of-band token can still reject with the typed |
| 250 | // InvalidResultTokenException. |
| 251 | const result = yield* errorTagged( |
| 252 | putEvaluations({ |
| 253 | ResultToken: "alchemy-config-bindings-test-token", |
| 254 | TestMode: true, |
| 255 | Evaluations: [ |
| 256 | { |
| 257 | ComplianceResourceType: "AWS::S3::Bucket", |
| 258 | ComplianceResourceId: "alchemy-nonexistent-bucket-probe", |
| 259 | ComplianceType: "COMPLIANT", |
| 260 | OrderingTimestamp: new Date(), |
| 261 | }, |
| 262 | ], |
| 263 | }), |
| 264 | ); |
| 265 | return yield* HttpServerResponse.json( |
| 266 | "errorTag" in result |
| 267 | ? result |
| 268 | : { failed: result.FailedEvaluations ?? [] }, |
| 269 | ); |
| 270 | } |
| 271 | case "POST /put-external-evaluation": { |
| 272 | // The fixture rule is a managed rule, not an external-evaluation |
| 273 | // rule — the typed InvalidParameterValueException proves binding |
| 274 | // + IAM. |
| 275 | const result = yield* errorTagged( |
| 276 | putExternalEvaluation({ |
| 277 | ExternalEvaluation: { |
| 278 | ComplianceResourceType: "AWS::S3::Bucket", |
| 279 | ComplianceResourceId: "alchemy-nonexistent-bucket-probe", |
| 280 | ComplianceType: "NOT_APPLICABLE", |
| 281 | OrderingTimestamp: new Date(), |
| 282 | }, |
| 283 | }), |
| 284 | ); |
| 285 | return yield* HttpServerResponse.json( |
| 286 | "errorTag" in result ? result : { ok: true }, |
| 287 | ); |
| 288 | } |
| 289 | |
| 290 | // ── Custom resource recording ────────────────────────────────── |
| 291 | |
| 292 | case "POST /put-resource-config": { |
| 293 | // Recording a third-party type requires a running recorder — |
| 294 | // NoRunningConfigurationRecorderException is typed and proves |
| 295 | // binding + IAM when the fixture recorder is stopped. |
| 296 | const result = yield* errorTagged( |
| 297 | putResourceConfig({ |
| 298 | ResourceType: "MyCompany::Alchemy::Fixture", |
| 299 | SchemaVersionId: "1.0", |
| 300 | ResourceId: "alchemy-config-bindings-fixture", |
| 301 | Configuration: JSON.stringify({ fixture: true }), |
| 302 | }), |
| 303 | ); |
| 304 | return yield* HttpServerResponse.json( |
| 305 | "errorTag" in result ? result : { ok: true }, |
| 306 | ); |
| 307 | } |
| 308 | case "POST /delete-resource-config": { |
| 309 | const result = yield* errorTagged( |
| 310 | deleteResourceConfig({ |
| 311 | ResourceType: "MyCompany::Alchemy::Fixture", |
| 312 | ResourceId: "alchemy-config-bindings-fixture", |
| 313 | }), |
| 314 | ); |
| 315 | return yield* HttpServerResponse.json( |
| 316 | "errorTag" in result ? result : { ok: true }, |
| 317 | ); |
| 318 | } |
| 319 | |
| 320 | // ── Proactive resource evaluation ────────────────────────────── |
| 321 | |
| 322 | case "POST /start-resource-evaluation": { |
| 323 | const result = yield* errorTagged( |
| 324 | startResourceEvaluation({ |
| 325 | EvaluationMode: "PROACTIVE", |
| 326 | ResourceDetails: { |
| 327 | ResourceId: "alchemy-config-bindings-proactive", |
| 328 | ResourceType: "AWS::S3::Bucket", |
| 329 | ResourceConfiguration: JSON.stringify({ |
| 330 | BucketName: "alchemy-config-bindings-proactive", |
| 331 | }), |
| 332 | ResourceConfigurationSchemaType: "CFN_RESOURCE_SCHEMA", |
| 333 | }, |
| 334 | }), |
| 335 | ); |
| 336 | return yield* HttpServerResponse.json( |
| 337 | "errorTag" in result |
| 338 | ? result |
| 339 | : { id: result.ResourceEvaluationId }, |
| 340 | ); |
| 341 | } |
| 342 | case "GET /get-resource-evaluation-summary": { |
| 343 | const id = url.searchParams.get("id") ?? ""; |
| 344 | const result = yield* errorTagged( |
| 345 | getResourceEvaluationSummary({ ResourceEvaluationId: id }), |
| 346 | ); |
| 347 | return yield* HttpServerResponse.json( |
| 348 | "errorTag" in result |
| 349 | ? result |
| 350 | : { |
| 351 | id: result.ResourceEvaluationId, |
| 352 | status: result.EvaluationStatus?.Status, |
| 353 | }, |
| 354 | ); |
| 355 | } |
| 356 | case "GET /list-resource-evaluations": { |
| 357 | const result = yield* listResourceEvaluations({ |
| 358 | Filters: { EvaluationMode: "PROACTIVE" }, |
| 359 | }); |
| 360 | return yield* HttpServerResponse.json({ |
| 361 | evaluations: result.ResourceEvaluations ?? [], |
| 362 | }); |
| 363 | } |
| 364 | |
| 365 | default: |
| 366 | return yield* HttpServerResponse.json( |
| 367 | { error: "Not found", route }, |
| 368 | { status: 404 }, |
| 369 | ); |
| 370 | } |
| 371 | }).pipe(Effect.orDie), |
| 372 | }; |
| 373 | }).pipe( |
| 374 | Effect.provide( |
| 375 | Layer.mergeAll( |
| 376 | Lambda.EventSource, |
| 377 | Config.BatchGetResourceConfigHttp, |
| 378 | Config.DeleteResourceConfigHttp, |
| 379 | Config.DescribeComplianceByConfigRuleHttp, |
| 380 | Config.DescribeComplianceByResourceHttp, |
| 381 | Config.DescribeConfigRuleEvaluationStatusHttp, |
| 382 | Config.GetComplianceDetailsByConfigRuleHttp, |
| 383 | Config.GetComplianceDetailsByResourceHttp, |
| 384 | Config.GetComplianceSummaryByConfigRuleHttp, |
| 385 | Config.GetComplianceSummaryByResourceTypeHttp, |
| 386 | Config.GetDiscoveredResourceCountsHttp, |
| 387 | Config.GetResourceConfigHistoryHttp, |
| 388 | Config.GetResourceEvaluationSummaryHttp, |
| 389 | Config.ListDiscoveredResourcesHttp, |
| 390 | Config.ListResourceEvaluationsHttp, |
| 391 | Config.PutEvaluationsHttp, |
| 392 | Config.PutExternalEvaluationHttp, |
| 393 | Config.PutResourceConfigHttp, |
| 394 | Config.SelectResourceConfigHttp, |
| 395 | Config.StartConfigRulesEvaluationHttp, |
| 396 | Config.StartResourceEvaluationHttp, |
| 397 | ), |
| 398 | ), |
| 399 | ), |
| 400 | ); |