1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import * as qapps from "@distilled.cloud/aws/qapps";
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 QAppsTestFunctionLive, {
10 QAppsTestFunction,
11} from "./bindings-handler.ts";
12
13const testOptions = { providers: AWS.providers() };
14const { test } = Test.make(testOptions);
15
16// A well-formed-but-nonexistent Q Business application environment instance
17// the ungated probes are driven against.
18const NONEXISTENT_INSTANCE = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
19const 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.
26const 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
42describe(
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
106const sharedStack = Core.scratchStack(testOptions, "QAppsBindings");
107
108test.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);