1import * as GitHub from "@/GitHub";
2import { Octokit } from "@/GitHub/Octokit.ts";
3import * as Provider from "@/Provider";
4import { destroy } from "@/RemovalPolicy";
5import * as Test from "@/Test/Alchemy";
6import { expect } from "alchemy-test";
7import * as Config from "effect/Config";
8import * as Effect from "effect/Effect";
9import * as Redacted from "effect/Redacted";
10import { MinimumLogLevel } from "effect/References";
11
12const { test } = Test.make({ providers: GitHub.providers() });
13
14const logLevel = Effect.provideService(
15 MinimumLogLevel,
16 process.env.DEBUG ? "Debug" : "Info",
17);
18
19// Deploying a secret needs an owner + repository the token can write to —
20// the dedicated test org (never a real one). Set GITHUB_TEST_OWNER="" to
21// skip.
22const owner = process.env.GITHUB_TEST_OWNER ?? "alchemy-run-test";
23const repository = process.env.GITHUB_TEST_REPOSITORY ?? "test-repo";
24
25// GitHub never returns a secret's value, only its metadata — so `getRepoSecret`
26// succeeding (vs. 404) is how we assert presence/absence out-of-band.
27const secretExists = (name: string) =>
28 Effect.gen(function* () {
29 const octokit = yield* Octokit;
30 return yield* Effect.tryPromise(async () => {
31 try {
32 await octokit.rest.actions.getRepoSecret({
33 owner,
34 repo: repository,
35 secret_name: name,
36 });
37 return true;
38 } catch (error: any) {
39 if (error.status === 404) return false;
40 throw error;
41 }
42 });
43 });
44
45test.provider.skipIf(!owner)(
46 "Secrets resolves Config values before wrapping them as Redacted",
47 (stack) =>
48 Effect.gen(function* () {
49 yield* stack.destroy();
50
51 const [secret] = yield* stack.deploy(
52 Effect.gen(function* () {
53 // `Repository` defaults to `retain`: ensure the host repo exists
54 // (another suite may have deleted/recreated it mid-run) without
55 // ever deleting it on destroy. Reconcile is idempotent.
56 yield* GitHub.Repository("Repo", {
57 owner,
58 name: repository,
59 description: "alchemy-effect secret test host",
60 visibility: "private",
61 autoInit: true,
62 });
63
64 return yield* GitHub.Secrets({
65 owner,
66 repository,
67 secrets: {
68 ALCHEMY_CONFIG_SECRET: Config.succeed("hunter2"),
69 },
70 });
71 }),
72 );
73
74 // A Config value resolves to "hunter2", gets wrapped as Redacted, and is
75 // encrypted + uploaded successfully — proven by a fresh `updatedAt`.
76 expect(secret.updatedAt).toEqual(expect.any(String));
77
78 // The secret really lives in GitHub now.
79 expect(yield* secretExists("ALCHEMY_CONFIG_SECRET")).toBe(true);
80
81 yield* stack.destroy();
82
83 // ...and is gone after destroy.
84 expect(yield* secretExists("ALCHEMY_CONFIG_SECRET")).toBe(false);
85 }).pipe(logLevel),
86 {
87 tags: [
88 "provider:github",
89 "provider:github:repository",
90 "provider:github:secret",
91 "live",
92 ],
93 timeout: 120_000,
94 },
95);
96
97// `list()` for GitHub.Secret is non-listable (pattern (e) in
98// processes/list-support.md): secrets are keyed by their parent
99// (owner/repository[, environment]/name) which arrive as props, there is no
100// ambient owner/repo scope, and GitHub exposes no account-wide enumeration —
101// only list-secrets *within* a specific repo. So `list()` always returns `[]`,
102// even when a secret exists in the cloud.
103test.provider.skipIf(!owner)(
104 "list returns an empty array for non-enumerable GitHub secrets",
105 (stack) =>
106 Effect.gen(function* () {
107 yield* stack.destroy();
108
109 // Prove `list()` still returns `[]` even with a real secret deployed
110 // (it is not enumerable without scope).
111 yield* stack.deploy(
112 Effect.gen(function* () {
113 return yield* GitHub.Secret("ListSecret", {
114 owner,
115 repository,
116 name: "ALCHEMY_LIST_TEST",
117 value: Redacted.make("list-test-value"),
118 }).pipe(destroy());
119 }),
120 );
121
122 // The secret is really in GitHub even though `list()` can't see it.
123 expect(yield* secretExists("ALCHEMY_LIST_TEST")).toBe(true);
124
125 const provider = yield* Provider.findProvider(GitHub.Secret);
126 const all = yield* provider.list();
127
128 expect(all).toEqual([]);
129
130 yield* stack.destroy();
131
132 // ...and is gone after destroy.
133 expect(yield* secretExists("ALCHEMY_LIST_TEST")).toBe(false);
134 }).pipe(logLevel),
135 {
136 tags: ["provider:github", "provider:github:secret", "live"],
137 timeout: 120_000,
138 },
139);