1import { cacheUntilExpiry } from "@/Cloudflare/Credentials";
2import {
3 apiTokenCredentials,
4 oauthCredentials,
5 type ResolvedCredentials,
6} from "@distilled.cloud/cloudflare/Credentials";
7import { describe, expect, it } from "alchemy-test";
8import * as Clock from "effect/Clock";
9import * as Duration from "effect/Duration";
10import * as Effect from "effect/Effect";
11import * as TestClock from "effect/testing/TestClock";
12
13/**
14 * Regression spec for "alchemy dev breaks once the Cloudflare OAuth access
15 * token expires": `fromAuthProvider` used to memoize the first credential
16 * resolution forever (`Effect.cached`), so when the remote-bindings preview
17 * session needed refreshing hours into a dev session, the preview-session
18 * API was called with the long-dead access token and every remote binding
19 * failed until the process was restarted.
20 *
21 * `cacheUntilExpiry` is the replacement: cache while valid, re-resolve
22 * (which refreshes + persists the token) once the refresh window is
23 * reached, single-flight under concurrency.
24 */
25
26const MINUTE_MS = 60_000;
27
28const makeOAuthResolver = () => {
29 let resolutions = 0;
30 const resolve = Effect.gen(function* () {
31 resolutions++;
32 const now = yield* Clock.currentTimeMillis;
33 return oauthCredentials({
34 accessToken: `token-${resolutions}`,
35 // each freshly resolved token is valid for 1 hour from "now"
36 expiresAt: now + 60 * MINUTE_MS,
37 }) as ResolvedCredentials;
38 });
39 return { resolve, count: () => resolutions };
40};
41
42describe(
43 "Cloudflare Credentials cacheUntilExpiry",
44 { tags: ["unit", "provider:cloudflare", "local"] },
45 () => {
46 it.effect("caches OAuth credentials while they are valid", () =>
47 Effect.gen(function* () {
48 const resolver = makeOAuthResolver();
49 const credentials = yield* cacheUntilExpiry(resolver.resolve);
50
51 const first = yield* credentials;
52 yield* TestClock.adjust(Duration.minutes(10));
53 const second = yield* credentials;
54
55 expect(resolver.count()).toBe(1);
56 expect(second).toBe(first);
57 }),
58 );
59
60 it.effect(
61 "re-resolves OAuth credentials once the refresh window is reached",
62 () =>
63 Effect.gen(function* () {
64 const resolver = makeOAuthResolver();
65 const credentials = yield* cacheUntilExpiry(resolver.resolve);
66
67 const first = yield* credentials;
68 expect(first.type).toBe("oauth");
69
70 // 56 minutes in: inside the 5-minute refresh window of the 1h token.
71 yield* TestClock.adjust(Duration.minutes(56));
72 const second = yield* credentials;
73
74 expect(resolver.count()).toBe(2);
75 expect(second).not.toBe(first);
76
77 // The re-resolved token is cached again in turn.
78 yield* TestClock.adjust(Duration.minutes(10));
79 const third = yield* credentials;
80 expect(resolver.count()).toBe(2);
81 expect(third).toBe(second);
82 }),
83 );
84
85 it.effect(
86 "re-resolves OAuth credentials that are already fully expired",
87 () =>
88 Effect.gen(function* () {
89 const resolver = makeOAuthResolver();
90 const credentials = yield* cacheUntilExpiry(resolver.resolve);
91
92 yield* credentials;
93 // The machine slept through the token's entire lifetime.
94 yield* TestClock.adjust(Duration.hours(6));
95 yield* credentials;
96
97 expect(resolver.count()).toBe(2);
98 }),
99 );
100
101 it.effect("caches non-expiring credentials (api tokens) forever", () =>
102 Effect.gen(function* () {
103 let resolutions = 0;
104 const resolve = Effect.sync(() => {
105 resolutions++;
106 return apiTokenCredentials({
107 apiToken: "static",
108 }) as ResolvedCredentials;
109 });
110 const credentials = yield* cacheUntilExpiry(resolve);
111
112 yield* credentials;
113 yield* TestClock.adjust(Duration.days(365));
114 yield* credentials;
115
116 expect(resolutions).toBe(1);
117 }),
118 );
119
120 it.live("concurrent cold-cache resolutions are single-flight", () =>
121 Effect.gen(function* () {
122 let resolutions = 0;
123 const resolve = Effect.sleep("20 millis").pipe(
124 Effect.map(() => {
125 resolutions++;
126 return oauthCredentials({
127 accessToken: `token-${resolutions}`,
128 expiresAt: Date.now() + 60 * MINUTE_MS,
129 }) as ResolvedCredentials;
130 }),
131 );
132 const credentials = yield* cacheUntilExpiry(resolve);
133
134 const results = yield* Effect.all(
135 [credentials, credentials, credentials, credentials],
136 { concurrency: "unbounded" },
137 );
138
139 expect(resolutions).toBe(1);
140 for (const result of results) {
141 expect(result).toBe(results[0]);
142 }
143 }),
144 );
145 },
146);