1import * as AWS from "@/AWS";
2import * as Core from "@/Test/Core";
3import * as Test from "@/Test/Alchemy";
4import { describe, expect } from "alchemy-test";
5import * as Data from "effect/Data";
6import * as Effect from "effect/Effect";
7import * as Schedule from "effect/Schedule";
8import * as HttpClient from "effect/http/HttpClient";
9import * as HttpClientRequest from "effect/http/HttpClientRequest";
10import VerifiedPermissionsTestFunctionLive, {
11 VerifiedPermissionsTestFunction,
12} from "./handler";
13
14const testOptions = { providers: AWS.providers() };
15const { test, beforeAll, afterAll } = Test.make(testOptions);
16const sharedStack = Core.scratchStack(
17 testOptions,
18 "VerifiedPermissionsBindings",
19);
20
21const readinessPolicy = Schedule.max([
22 Schedule.exponential("500 millis"),
23 Schedule.recurs(10),
24]);
25
26let baseUrl: string;
27
28class TransientUpstream extends Data.TaggedError("TransientUpstream")<{
29 readonly status: number;
30 readonly body: string;
31}> {}
32
33const send = (request: HttpClientRequest.HttpClientRequest) =>
34 HttpClient.execute(request).pipe(
35 Effect.flatMap((response) =>
36 response.status >= 500
37 ? response.text.pipe(
38 Effect.flatMap((body) =>
39 Effect.fail(
40 new TransientUpstream({ status: response.status, body }),
41 ),
42 ),
43 )
44 : Effect.succeed(response),
45 ),
46 Effect.retry({
47 while: (e) => e._tag === "TransientUpstream",
48 schedule: Schedule.max([
49 Schedule.exponential("500 millis"),
50 Schedule.recurs(6),
51 ]),
52 }),
53 );
54
55describe(
56 "VerifiedPermissions Bindings",
57 {
58 tags: [
59 "provider:aws",
60 "provider:aws:lambda",
61 "provider:aws:verifiedpermissions",
62 "live",
63 ],
64 },
65 () => {
66 beforeAll(
67 Effect.gen(function* () {
68 yield* sharedStack.destroy();
69 const { functionUrl } = yield* sharedStack.deploy(
70 Effect.gen(function* () {
71 return yield* VerifiedPermissionsTestFunction;
72 }).pipe(Effect.provide(VerifiedPermissionsTestFunctionLive)),
73 );
74 baseUrl = functionUrl!.replace(/\/+$/, "");
75 // wait for the fresh function URL to serve (AVP is eventually consistent
76 // — policies take a moment to be evaluatable after CreatePolicy)
77 yield* send(HttpClientRequest.get(`${baseUrl}/info`)).pipe(
78 Effect.retry({ schedule: readinessPolicy }),
79 );
80 }),
81 { timeout: 240_000 },
82 );
83 afterAll(sharedStack.destroy(), { timeout: 60_000 });
84
85 describe("IsAuthorized", () => {
86 test.provider("allows alice to view a photo", (_stack) =>
87 Effect.gen(function* () {
88 const response = yield* send(
89 HttpClientRequest.get(`${baseUrl}/authorize?user=alice`),
90 ).pipe(
91 Effect.retry({
92 schedule: Schedule.max([
93 Schedule.spaced("3 seconds"),
94 Schedule.recurs(10),
95 ]),
96 }),
97 );
98 const body = (yield* response.json) as { decision: string };
99 expect(body.decision).toBe("ALLOW");
100 }),
101 );
102
103 test.provider("denies bob (no matching permit policy)", (_stack) =>
104 Effect.gen(function* () {
105 const response = yield* send(
106 HttpClientRequest.get(`${baseUrl}/authorize?user=bob`),
107 );
108 const body = (yield* response.json) as { decision: string };
109 expect(body.decision).toBe("DENY");
110 }),
111 );
112 });
113
114 describe("BatchIsAuthorized", () => {
115 test.provider("returns ALLOW for alice and DENY for bob", (_stack) =>
116 Effect.gen(function* () {
117 const response = yield* send(
118 HttpClientRequest.get(`${baseUrl}/batch`),
119 ).pipe(
120 Effect.retry({
121 schedule: Schedule.max([
122 Schedule.spaced("3 seconds"),
123 Schedule.recurs(10),
124 ]),
125 }),
126 );
127 const body = (yield* response.json) as { decisions: string[] };
128 expect(body.decisions).toEqual(["ALLOW", "DENY"]);
129 }),
130 );
131 });
132
133 describe("BatchIsAuthorizedWithToken", () => {
134 test.provider(
135 "rejects a malformed token with a typed ValidationException",
136 (_stack) =>
137 Effect.gen(function* () {
138 const response = yield* send(
139 HttpClientRequest.get(`${baseUrl}/batch-token`),
140 );
141 const body = (yield* response.json) as { tag: string };
142 // the request reaches AVP (IAM allowed) and fails Cedar-side token
143 // validation — proving the binding + IAM wiring end-to-end
144 expect(body.tag).toBe("ValidationException");
145 }),
146 );
147 });
148
149 describe("GetPolicies", () => {
150 test.provider("batchGetPolicy returns the AllowAlice policy", (_stack) =>
151 Effect.gen(function* () {
152 const response = yield* send(
153 HttpClientRequest.get(`${baseUrl}/policies`),
154 );
155 const body = (yield* response.json) as {
156 ids: string[];
157 types: string[];
158 errors: number;
159 };
160 expect(body.ids).toHaveLength(1);
161 expect(body.types).toEqual(["STATIC"]);
162 expect(body.errors).toBe(0);
163 }),
164 );
165 });
166 },
167);