| 1 | import * as Lambda from "@/AWS/Lambda"; |
| 2 | import * as Shield from "@/AWS/Shield"; |
| 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 ShieldTestFunction extends Lambda.Function<Lambda.Function>()( |
| 14 | "ShieldTestFunction", |
| 15 | ) {} |
| 16 | |
| 17 | /** |
| 18 | * Routes answer `{ …fields }` on success or `{ errorTag }` when the operation |
| 19 | * fails with a TYPED error — the test asserts the tag is in a route-specific |
| 20 | * allowlist, which proves both the binding wiring and the IAM grant. An |
| 21 | * 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 | // The testing account is NOT (and must never be) subscribed to Shield |
| 39 | // Advanced ($3,000/month, 1-year commitment). GetSubscriptionState and |
| 40 | // DescribeAttackStatistics succeed on any account; the subscription-gated |
| 41 | // operations answer with their typed entitlement/not-found tags, which the |
| 42 | // test asserts — proving the binding + IAM wiring end-to-end either way. |
| 43 | export default ShieldTestFunction.make( |
| 44 | { |
| 45 | main, |
| 46 | functionUrl: true, |
| 47 | timeout: Duration.seconds(30), |
| 48 | }, |
| 49 | Effect.gen(function* () { |
| 50 | // Event source: subscribe the host to Shield Advanced attack events |
| 51 | // (AWS Health, source `aws.health`, service SHIELD). The deploy proves |
| 52 | // the EventBridge rule + invoke permission wiring; events only fire on |
| 53 | // subscribed accounts under active attack. |
| 54 | yield* Shield.consumeAttackEvents({}, (events) => |
| 55 | Stream.runForEach(events, (event) => |
| 56 | Effect.log(`shield attack event: ${event.detail.eventTypeCode}`), |
| 57 | ), |
| 58 | ); |
| 59 | |
| 60 | const getSubscriptionState = yield* Shield.GetSubscriptionState(); |
| 61 | const describeAttackStatistics = yield* Shield.DescribeAttackStatistics(); |
| 62 | const listAttacks = yield* Shield.ListAttacks(); |
| 63 | const describeAttack = yield* Shield.DescribeAttack(); |
| 64 | const describeDRTAccess = yield* Shield.DescribeDRTAccess(); |
| 65 | const listResourcesInProtectionGroup = |
| 66 | yield* Shield.ListResourcesInProtectionGroup(); |
| 67 | |
| 68 | const bound = { |
| 69 | getSubscriptionState, |
| 70 | describeAttackStatistics, |
| 71 | listAttacks, |
| 72 | describeAttack, |
| 73 | describeDRTAccess, |
| 74 | listResourcesInProtectionGroup, |
| 75 | }; |
| 76 | |
| 77 | return { |
| 78 | fetch: Effect.gen(function* () { |
| 79 | const request = yield* HttpServerRequest; |
| 80 | const url = new URL(request.originalUrl); |
| 81 | const pathname = url.pathname; |
| 82 | |
| 83 | if (request.method === "GET" && pathname === "/bindings") { |
| 84 | return yield* HttpServerResponse.json({ bound: Object.keys(bound) }); |
| 85 | } |
| 86 | |
| 87 | // Available to every account, subscribed or not. |
| 88 | if (request.method === "GET" && pathname === "/subscription-state") { |
| 89 | const { SubscriptionState } = yield* getSubscriptionState(); |
| 90 | return yield* HttpServerResponse.json({ state: SubscriptionState }); |
| 91 | } |
| 92 | |
| 93 | // Available to Standard and Advanced customers alike. |
| 94 | if (request.method === "GET" && pathname === "/attack-stats") { |
| 95 | const result = yield* errorTagged( |
| 96 | describeAttackStatistics().pipe( |
| 97 | Effect.map((r) => ({ dataItems: r.DataItems.length })), |
| 98 | ), |
| 99 | ); |
| 100 | return yield* HttpServerResponse.json(result); |
| 101 | } |
| 102 | |
| 103 | // Subscription-gated — a non-subscribed account answers with a typed |
| 104 | // error tag. |
| 105 | if (request.method === "GET" && pathname === "/attacks") { |
| 106 | const result = yield* errorTagged( |
| 107 | listAttacks().pipe( |
| 108 | Effect.map((r) => ({ |
| 109 | count: (r.AttackSummaries ?? []).length, |
| 110 | })), |
| 111 | ), |
| 112 | ); |
| 113 | return yield* HttpServerResponse.json(result); |
| 114 | } |
| 115 | |
| 116 | // A nonexistent attack id answers with an empty document (observed |
| 117 | // live) or the documented typed AccessDeniedException. |
| 118 | if (request.method === "GET" && pathname === "/attack-detail") { |
| 119 | const result = yield* errorTagged( |
| 120 | describeAttack({ |
| 121 | AttackId: "00000000-0000-0000-0000-000000000000", |
| 122 | }).pipe(Effect.map((r) => ({ attackId: r.Attack?.AttackId }))), |
| 123 | ); |
| 124 | return yield* HttpServerResponse.json(result); |
| 125 | } |
| 126 | |
| 127 | // Subscription-gated — a non-subscribed account answers with the |
| 128 | // typed ResourceNotFoundException. |
| 129 | if (request.method === "GET" && pathname === "/drt-access") { |
| 130 | const result = yield* errorTagged( |
| 131 | describeDRTAccess().pipe( |
| 132 | Effect.map((r) => ({ |
| 133 | roleArn: r.RoleArn ?? null, |
| 134 | logBuckets: (r.LogBucketList ?? []).length, |
| 135 | })), |
| 136 | ), |
| 137 | ); |
| 138 | return yield* HttpServerResponse.json(result); |
| 139 | } |
| 140 | |
| 141 | // A nonexistent group answers with the typed |
| 142 | // ResourceNotFoundException (even without a subscription). |
| 143 | if (request.method === "GET" && pathname === "/group-members") { |
| 144 | const result = yield* errorTagged( |
| 145 | listResourcesInProtectionGroup({ |
| 146 | ProtectionGroupId: "alchemy-shield-bindings-nonexistent", |
| 147 | }).pipe( |
| 148 | Effect.map((r) => ({ |
| 149 | count: (r.ResourceArns ?? []).length, |
| 150 | })), |
| 151 | ), |
| 152 | ); |
| 153 | return yield* HttpServerResponse.json(result); |
| 154 | } |
| 155 | |
| 156 | return yield* HttpServerResponse.json( |
| 157 | { error: "Not found", method: request.method, pathname }, |
| 158 | { status: 404 }, |
| 159 | ); |
| 160 | }).pipe( |
| 161 | // Surface typed operation errors as JSON so live-test failures carry |
| 162 | // the tag + message instead of an opaque 500 (routes that expect a |
| 163 | // typed error use `errorTagged` above and never reach this). |
| 164 | Effect.catch((e) => |
| 165 | HttpServerResponse.json( |
| 166 | { |
| 167 | errorTag: (e as { _tag?: string })._tag ?? "UnknownError", |
| 168 | errorMessage: |
| 169 | (e as { message?: string }).message ?? |
| 170 | (e as { Message?: string }).Message, |
| 171 | }, |
| 172 | { status: 500 }, |
| 173 | ), |
| 174 | ), |
| 175 | Effect.orDie, |
| 176 | ), |
| 177 | }; |
| 178 | }).pipe( |
| 179 | Effect.provide( |
| 180 | Layer.mergeAll( |
| 181 | Lambda.EventSource, |
| 182 | Shield.GetSubscriptionStateHttp, |
| 183 | Shield.DescribeAttackStatisticsHttp, |
| 184 | Shield.ListAttacksHttp, |
| 185 | Shield.DescribeAttackHttp, |
| 186 | Shield.DescribeDRTAccessHttp, |
| 187 | Shield.ListResourcesInProtectionGroupHttp, |
| 188 | ), |
| 189 | ), |
| 190 | ), |
| 191 | ); |