t3-code-android-nightly/.repos/alchemy-effect/packages/alchemy/test/AWS/IAM/Role.test.ts
Julius Marminge 6f9cea00ae
chore(refs): sync Effect and Alchemy references to 4.0.1 and beta.80 (#16170)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:22:30 -07:00

382 lines
11 KiB
TypeScript

import { adopt } from "@/AdoptPolicy";
import * as AWS from "@/AWS";
import { Role } from "@/AWS/IAM";
import * as Provider from "@/Provider";
import { State } from "@/State";
import * as Test from "@/Test/Alchemy";
import * as IAM from "@distilled.cloud/aws/iam";
import { expect } from "alchemy-test";
import * as Effect from "effect/Effect";
const { test } = Test.make({ providers: AWS.providers() });
const assumeRolePolicy = {
Version: "2012-10-17" as const,
Statement: [
{
Effect: "Allow" as const,
Principal: {
Service: "lambda.amazonaws.com",
},
Action: ["sts:AssumeRole"],
},
],
};
test.provider(
"create, update, and delete role",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const role = yield* stack.deploy(
Effect.gen(function* () {
return yield* Role("IamRole", {
assumeRolePolicyDocument: assumeRolePolicy,
maxSessionDuration: "2 hours",
managedPolicyArns: [
"arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole",
],
inlinePolicies: {
AllowLogs: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: ["logs:CreateLogGroup"],
Resource: "*",
},
],
},
},
tags: {
env: "test",
},
});
}),
);
const created = yield* IAM.getRole({
RoleName: role.roleName,
});
expect(created.Role.RoleName).toBe(role.roleName);
// Duration.Input prop converts to whole wire seconds.
expect(created.Role.MaxSessionDuration).toBe(7200);
yield* stack.deploy(
Effect.gen(function* () {
return yield* Role("IamRole", {
assumeRolePolicyDocument: assumeRolePolicy,
maxSessionDuration: "1 hour",
managedPolicyArns: [],
inlinePolicies: {
AllowLogs: {
Version: "2012-10-17",
Statement: [
{
Effect: "Allow",
Action: ["logs:CreateLogStream"],
Resource: "*",
},
],
},
},
tags: {
env: "prod",
},
});
}),
);
const updatedTags = yield* IAM.listRoleTags({
RoleName: role.roleName,
});
expect(
Object.fromEntries(
(updatedTags.Tags ?? []).map((tag) => [tag.Key, tag.Value]),
),
).toMatchObject({
env: "prod",
});
// The reconcile sync path applies the changed Duration prop.
const updated = yield* IAM.getRole({ RoleName: role.roleName });
expect(updated.Role.MaxSessionDuration).toBe(3600);
yield* stack.destroy();
const deleted = yield* IAM.getRole({
RoleName: role.roleName,
}).pipe(Effect.option);
expect(deleted._tag).toBe("None");
}),
{ tags: ["provider:aws", "provider:aws:iam", "live"] },
);
test.provider(
"folds binding-supplied policy statements into an inline policy",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
// Phase 1: a consumer binds policy statements to the role; the provider
// folds them into the synthesized `alchemy-bindings` inline policy.
const role = yield* stack.deploy(
Effect.gen(function* () {
const role = yield* Role("BoundRole", {
assumeRolePolicyDocument: assumeRolePolicy,
});
yield* role.bind`Allow(test, s3:GetObject)`({
policyStatements: [
{
Effect: "Allow",
Action: ["s3:GetObject"],
Resource: ["arn:aws:s3:::example-bucket/*"],
},
],
});
return role;
}),
);
const bound = yield* IAM.getRolePolicy({
RoleName: role.roleName,
PolicyName: "alchemy-bindings",
});
const boundDoc = JSON.parse(decodeURIComponent(bound.PolicyDocument)) as {
Statement: Array<{ Action: string[]; Resource: string[] }>;
};
expect(boundDoc.Statement).toEqual(
expect.arrayContaining([
expect.objectContaining({
Action: ["s3:GetObject"],
Resource: ["arn:aws:s3:::example-bucket/*"],
}),
]),
);
// Phase 2: re-deploy without the binding; the inline policy is removed.
yield* stack.deploy(
Effect.gen(function* () {
return yield* Role("BoundRole", {
assumeRolePolicyDocument: assumeRolePolicy,
});
}),
);
const afterUnbind = yield* IAM.getRolePolicy({
RoleName: role.roleName,
PolicyName: "alchemy-bindings",
}).pipe(Effect.option);
expect(afterUnbind._tag).toBe("None");
yield* stack.destroy();
}),
{ tags: ["provider:aws", "provider:aws:iam", "live"] },
);
test.provider(
"bindings can supply the role's trust policy",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
// A bare role with no `assumeRolePolicyDocument`; the binding supplies the
// trust statement instead.
const role = yield* stack.deploy(
Effect.gen(function* () {
const role = yield* Role("TrustBoundRole", {});
yield* role.bind`Trust(test, lambda)`({
assumeRolePolicyStatements: [
{
Effect: "Allow",
Principal: { Service: "lambda.amazonaws.com" },
Action: ["sts:AssumeRole"],
},
],
});
return role;
}),
);
const live = yield* IAM.getRole({ RoleName: role.roleName });
const trust = JSON.parse(
decodeURIComponent(live.Role.AssumeRolePolicyDocument!),
) as { Statement: Array<{ Principal?: { Service?: string } }> };
expect(trust.Statement).toEqual(
expect.arrayContaining([
expect.objectContaining({
Principal: { Service: "lambda.amazonaws.com" },
}),
]),
);
yield* stack.destroy();
}),
{ tags: ["provider:aws", "provider:aws:iam", "live"] },
);
test.provider(
"list enumerates the deployed role",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const role = yield* stack.deploy(
Effect.gen(function* () {
return yield* Role("ListRole", {
assumeRolePolicyDocument: assumeRolePolicy,
tags: {
env: "test",
},
});
}),
);
const provider = yield* Provider.findProvider(Role);
const all = yield* provider.list();
const found = all.find((r) => r.roleName === role.roleName);
expect(found).toBeDefined();
expect(found?.roleArn).toBe(role.roleArn);
yield* stack.destroy();
const deleted = yield* IAM.getRole({
RoleName: role.roleName,
}).pipe(Effect.option);
expect(deleted._tag).toBe("None");
}),
{ tags: ["provider:aws", "provider:aws:iam", "live"] },
);
// Engine-level adoption tests for IAM Role. Note: the user must supply an
// explicit `roleName` for adoption to work across a state-store wipe —
// without one, `createPhysicalName` derives a fresh name from a per-deploy
// random `instanceId`, so the cold-start `read` lookup would never find
// the original role.
//
// These tests wipe the state store mid-test, so the framework's automatic
// scratch-stack teardown cannot always see the role. Each test therefore
// uses a DETERMINISTIC role name, pre-cleans any leftover from a previously
// killed run, and guarantees an idempotent out-of-band delete via
// `Effect.ensuring` so a pass (or interruption) never orphans the role.
const deleteRoleIfExists = (roleName: string) =>
IAM.deleteRole({ RoleName: roleName }).pipe(
Effect.catchTag("NoSuchEntityException", () => Effect.void),
Effect.asVoid,
// Any non-not-found failure is a real cleanup bug — surface it loudly.
Effect.orDie,
);
test.provider(
"owned role (matching alchemy tags) is silently adopted without --adopt",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
const roleName = "alchemy-test-role-adopt";
// Reclaim a leftover from a previously killed run.
yield* deleteRoleIfExists(roleName);
const initial = yield* stack.deploy(
Effect.gen(function* () {
return yield* Role("AdoptableRole", {
roleName,
assumeRolePolicyDocument: assumeRolePolicy,
});
}),
);
// Wipe state — the role stays in IAM.
yield* Effect.gen(function* () {
const state = yield* yield* State;
yield* state.delete({
stack: stack.name,
stage: stack.stage,
fqn: "AdoptableRole",
});
}).pipe(Effect.provide(stack.state));
const adopted = yield* stack.deploy(
Effect.gen(function* () {
return yield* Role("AdoptableRole", {
roleName,
assumeRolePolicyDocument: assumeRolePolicy,
});
}),
);
expect(adopted.roleArn).toEqual(initial.roleArn);
expect(adopted.roleName).toEqual(roleName);
yield* stack.destroy();
const deleted = yield* IAM.getRole({ RoleName: roleName }).pipe(
Effect.option,
);
expect(deleted._tag).toBe("None");
}).pipe(Effect.ensuring(deleteRoleIfExists("alchemy-test-role-adopt"))),
{ tags: ["provider:aws", "provider:aws:iam", "live"] },
);
test.provider(
"foreign-tagged role requires adopt(true) to take over",
(stack) =>
Effect.gen(function* () {
yield* stack.destroy();
// Use a deterministic shared physical name for both deploys.
const sharedRoleName = "alchemy-test-role-takeover";
// Reclaim a leftover from a previously killed run.
yield* deleteRoleIfExists(sharedRoleName);
const original = yield* stack.deploy(
Effect.gen(function* () {
return yield* Role("Original", {
roleName: sharedRoleName,
assumeRolePolicyDocument: assumeRolePolicy,
});
}),
);
yield* Effect.gen(function* () {
const state = yield* yield* State;
yield* state.delete({
stack: stack.name,
stage: stack.stage,
fqn: "Original",
});
}).pipe(Effect.provide(stack.state));
const takenOver = yield* stack
.deploy(
Effect.gen(function* () {
return yield* Role("Different", {
roleName: sharedRoleName,
assumeRolePolicyDocument: assumeRolePolicy,
});
}),
)
.pipe(adopt(true));
expect(takenOver.roleArn).toEqual(original.roleArn);
// After adoption, tags should now identify this stack/stage/id.
const tagsResp = yield* IAM.listRoleTags({ RoleName: sharedRoleName });
const tagMap = Object.fromEntries(
(tagsResp.Tags ?? []).map((t) => [t.Key, t.Value]),
);
expect(tagMap["alchemy::id"]).toEqual("Different");
yield* stack.destroy();
const deleted = yield* IAM.getRole({ RoleName: sharedRoleName }).pipe(
Effect.option,
);
expect(deleted._tag).toBe("None");
}).pipe(Effect.ensuring(deleteRoleIfExists("alchemy-test-role-takeover"))),
{ tags: ["provider:aws", "provider:aws:iam", "live"] },
);