| 1 | import * as AWS from "@/AWS"; |
| 2 | import * as Core from "@/Test/Core"; |
| 3 | import * as Test from "@/Test/Alchemy"; |
| 4 | import * as qapps from "@distilled.cloud/aws/qapps"; |
| 5 | import { describe, expect } from "alchemy-test"; |
| 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Schedule from "effect/Schedule"; |
| 8 | import * as HttpClient from "effect/http/HttpClient"; |
| 9 | import QAppsTestFunctionLive, { |
| 10 | QAppsTestFunction, |
| 11 | } from "./bindings-handler.ts"; |
| 12 | |
| 13 | const testOptions = { providers: AWS.providers() }; |
| 14 | const { test } = Test.make(testOptions); |
| 15 | |
| 16 | // A well-formed-but-nonexistent Q Business application environment instance |
| 17 | // the ungated probes are driven against. |
| 18 | const NONEXISTENT_INSTANCE = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; |
| 19 | const NONEXISTENT_ID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; |
| 20 | |
| 21 | // Without a Q Business instance, Amazon Q Apps rejects at the door with a |
| 22 | // typed UnauthorizedException ("Unauthorized") — the caller is not an IAM |
| 23 | // Identity Center user of any instance. An entitled account with bogus ids |
| 24 | // surfaces ResourceNotFoundException / AccessDeniedException / |
| 25 | // ValidationException instead; all are typed tags in the distilled unions. |
| 26 | const TYPED_REJECTIONS = [ |
| 27 | "UnauthorizedException", |
| 28 | "ResourceNotFoundException", |
| 29 | "AccessDeniedException", |
| 30 | "ValidationException", |
| 31 | ]; |
| 32 | |
| 33 | // --------------------------------------------------------------------------- |
| 34 | // Ungated typed-error probes: representative operations behind each binding |
| 35 | // group are exercised directly through distilled against a nonexistent |
| 36 | // instance and must answer with a typed tag. These prove the distilled error |
| 37 | // unions and request serialization (instance-id header, query/body shapes) |
| 38 | // at near-zero cost on every CI pass; the full runtime fixture below is |
| 39 | // gated behind an entitled Q Business instance. |
| 40 | // --------------------------------------------------------------------------- |
| 41 | |
| 42 | describe( |
| 43 | "QApps data-plane operations (typed-error probes)", |
| 44 | { tags: ["provider:aws", "provider:aws:qapps", "live"] }, |
| 45 | () => { |
| 46 | const probes: Record< |
| 47 | string, |
| 48 | Effect.Effect<unknown, { _tag: string }, any> |
| 49 | > = { |
| 50 | startQAppSession: qapps.startQAppSession({ |
| 51 | instanceId: NONEXISTENT_INSTANCE, |
| 52 | appId: NONEXISTENT_ID, |
| 53 | appVersion: 1, |
| 54 | }), |
| 55 | getQAppSession: qapps.getQAppSession({ |
| 56 | instanceId: NONEXISTENT_INSTANCE, |
| 57 | sessionId: NONEXISTENT_ID, |
| 58 | }), |
| 59 | listQApps: qapps.listQApps({ instanceId: NONEXISTENT_INSTANCE }), |
| 60 | listCategories: qapps.listCategories({ |
| 61 | instanceId: NONEXISTENT_INSTANCE, |
| 62 | }), |
| 63 | batchCreateCategory: qapps.batchCreateCategory({ |
| 64 | instanceId: NONEXISTENT_INSTANCE, |
| 65 | categories: [{ title: "alchemy-probe" }], |
| 66 | }), |
| 67 | createLibraryItem: qapps.createLibraryItem({ |
| 68 | instanceId: NONEXISTENT_INSTANCE, |
| 69 | appId: NONEXISTENT_ID, |
| 70 | appVersion: 1, |
| 71 | categories: [], |
| 72 | }), |
| 73 | describeQAppPermissions: qapps.describeQAppPermissions({ |
| 74 | instanceId: NONEXISTENT_INSTANCE, |
| 75 | appId: NONEXISTENT_ID, |
| 76 | }), |
| 77 | predictQApp: qapps.predictQApp({ instanceId: NONEXISTENT_INSTANCE }), |
| 78 | }; |
| 79 | |
| 80 | for (const [name, probe] of Object.entries(probes)) { |
| 81 | test.provider( |
| 82 | `${name} against a nonexistent instance yields a typed error`, |
| 83 | () => |
| 84 | Effect.gen(function* () { |
| 85 | const error = yield* Effect.flip(probe); |
| 86 | expect(TYPED_REJECTIONS).toContain(error._tag); |
| 87 | }), |
| 88 | { timeout: 60_000 }, |
| 89 | ); |
| 90 | } |
| 91 | }, |
| 92 | ); |
| 93 | |
| 94 | // --------------------------------------------------------------------------- |
| 95 | // Full runtime fixture: a Q App + a Lambda bound to six Q Apps bindings |
| 96 | // (sessions, permissions, inventory, categories — covering both the |
| 97 | // app/session-scoped and the instance-level builders). Amazon Q Apps lives |
| 98 | // inside a Q Business application environment instance (IAM Identity |
| 99 | // Center required), which the shared testing account does not have, so this |
| 100 | // is gated behind AWS_TEST_QAPPS=1 + QAPPS_INSTANCE_ID and always destroys |
| 101 | // what it created. Note: the Q Apps data plane authorizes the calling |
| 102 | // identity as an Identity Center user of the instance — an entitled runner |
| 103 | // must grant the Lambda's role access to the instance. |
| 104 | // --------------------------------------------------------------------------- |
| 105 | |
| 106 | const sharedStack = Core.scratchStack(testOptions, "QAppsBindings"); |
| 107 | |
| 108 | test.provider.skipIf(!process.env.AWS_TEST_QAPPS)( |
| 109 | "session, inventory, category, and permission bindings against a live instance", |
| 110 | () => |
| 111 | Effect.gen(function* () { |
| 112 | yield* sharedStack.destroy(); |
| 113 | |
| 114 | yield* Effect.gen(function* () { |
| 115 | const { functionUrl } = yield* sharedStack.deploy( |
| 116 | Effect.gen(function* () { |
| 117 | return yield* QAppsTestFunction; |
| 118 | }).pipe(Effect.provide(QAppsTestFunctionLive)), |
| 119 | ); |
| 120 | expect(functionUrl).toBeTruthy(); |
| 121 | const baseUrl = functionUrl!.replace(/\/+$/, ""); |
| 122 | |
| 123 | const getJson = (path: string) => |
| 124 | HttpClient.get(`${baseUrl}${path}`).pipe( |
| 125 | Effect.flatMap((response) => |
| 126 | response.status >= 500 |
| 127 | ? Effect.fail( |
| 128 | new Error(`transient upstream ${response.status}`), |
| 129 | ) |
| 130 | : Effect.succeed(response), |
| 131 | ), |
| 132 | Effect.retry({ |
| 133 | schedule: Schedule.max([ |
| 134 | Schedule.exponential("500 millis"), |
| 135 | Schedule.recurs(10), |
| 136 | ]), |
| 137 | }), |
| 138 | Effect.flatMap((r) => r.json), |
| 139 | ); |
| 140 | |
| 141 | // All six capabilities initialized in the runtime. |
| 142 | const bindings = (yield* getJson("/bindings")) as { bound: string[] }; |
| 143 | expect(bindings.bound).toHaveLength(6); |
| 144 | |
| 145 | // ListQApps — instance-level builder, instance-id header injection. |
| 146 | const apps = (yield* getJson("/apps")) as { |
| 147 | count?: number; |
| 148 | errorTag?: string; |
| 149 | }; |
| 150 | expect(apps.errorTag).toBeUndefined(); |
| 151 | expect(apps.count).toBeGreaterThanOrEqual(1); |
| 152 | |
| 153 | // ListCategories — instance-level builder. |
| 154 | const categories = (yield* getJson("/categories")) as { |
| 155 | count?: number; |
| 156 | errorTag?: string; |
| 157 | }; |
| 158 | expect(categories.errorTag).toBeUndefined(); |
| 159 | |
| 160 | // DescribeQAppPermissions — app-scoped builder, app-id injection. |
| 161 | const permissions = (yield* getJson("/permissions")) as { |
| 162 | resourceArn?: string; |
| 163 | errorTag?: string; |
| 164 | }; |
| 165 | expect(permissions.errorTag).toBeUndefined(); |
| 166 | |
| 167 | // Session flow — start → get → stop against the deployed app. |
| 168 | const session = (yield* getJson("/session")) as { |
| 169 | sessionId?: string; |
| 170 | status?: string; |
| 171 | errorTag?: string; |
| 172 | }; |
| 173 | expect(session.errorTag).toBeUndefined(); |
| 174 | expect(session.sessionId).toBeTruthy(); |
| 175 | }).pipe(Effect.ensuring(sharedStack.destroy().pipe(Effect.orDie))); |
| 176 | }), |
| 177 | { |
| 178 | tags: ["provider:aws", "provider:aws:lambda", "provider:aws:qapps", "live"], |
| 179 | timeout: 600_000, |
| 180 | }, |
| 181 | ); |