| 1 | import { AuthProviders } from "@/Auth/AuthProvider.ts"; |
| 2 | import { |
| 3 | PROFILE_FORMAT, |
| 4 | profileProviderFilePath, |
| 5 | ProfileStore, |
| 6 | ProfileStoreLive, |
| 7 | } from "@/Auth/Profile.ts"; |
| 8 | import { GitHubAuthConfigSchema } from "@/GitHub/AuthProvider.ts"; |
| 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 10 | import { expect, it } from "alchemy-test"; |
| 11 | import * as ConfigProvider from "effect/ConfigProvider"; |
| 12 | import * as Effect from "effect/Effect"; |
| 13 | import * as FileSystem from "effect/FileSystem"; |
| 14 | import * as Layer from "effect/Layer"; |
| 15 | import * as Schema from "effect/Schema"; |
| 16 | import path from "pathe"; |
| 17 | |
| 18 | const FIXTURE_HOME = path.join(import.meta.dirname, "fixtures/v0-home"); |
| 19 | |
| 20 | const testLayer = () => |
| 21 | ProfileStoreLive.pipe( |
| 22 | Layer.provideMerge( |
| 23 | Layer.mergeAll( |
| 24 | Layer.succeed(AuthProviders, {}), |
| 25 | ConfigProvider.layer(ConfigProvider.fromUnknown({})), |
| 26 | NodeServices.layer, |
| 27 | ), |
| 28 | ), |
| 29 | ); |
| 30 | |
| 31 | /** |
| 32 | * Point `ALCHEMY_HOME` at a scoped temp directory seeded from a fixture |
| 33 | * tree, so store operations never touch the developer's real `~/.alchemy`. |
| 34 | * Tests using this must be `exclusive` — the env var is process-global. |
| 35 | */ |
| 36 | const withFixtureHome = <A, E, R>( |
| 37 | fixture: string | undefined, |
| 38 | effect: (home: string) => Effect.Effect<A, E, R>, |
| 39 | ) => |
| 40 | Effect.gen(function* () { |
| 41 | const fs = yield* FileSystem.FileSystem; |
| 42 | const dir = yield* fs.makeTempDirectoryScoped({ |
| 43 | prefix: "alchemy-migrate-", |
| 44 | }); |
| 45 | if (fixture !== undefined) { |
| 46 | yield* fs.copyFile( |
| 47 | path.join(fixture, "profiles.json"), |
| 48 | path.join(dir, "profiles.json"), |
| 49 | ); |
| 50 | yield* fs.copy( |
| 51 | path.join(fixture, "credentials"), |
| 52 | path.join(dir, "credentials"), |
| 53 | ); |
| 54 | } |
| 55 | const previous = process.env.ALCHEMY_HOME; |
| 56 | yield* Effect.acquireRelease( |
| 57 | Effect.sync(() => { |
| 58 | process.env.ALCHEMY_HOME = dir; |
| 59 | }), |
| 60 | () => |
| 61 | Effect.sync(() => { |
| 62 | if (previous === undefined) delete process.env.ALCHEMY_HOME; |
| 63 | else process.env.ALCHEMY_HOME = previous; |
| 64 | }), |
| 65 | ); |
| 66 | return yield* effect(dir).pipe(Effect.provide(testLayer())); |
| 67 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); |
| 68 | |
| 69 | it.live( |
| 70 | "migrates a full v0 store: entries kept, credentials dropped into a backup", |
| 71 | () => |
| 72 | withFixtureHome(FIXTURE_HOME, (home) => |
| 73 | Effect.gen(function* () { |
| 74 | const fs = yield* FileSystem.FileSystem; |
| 75 | const store = yield* ProfileStore; |
| 76 | |
| 77 | // The first read performs the on-disk migration. |
| 78 | const manifest = yield* store.readManifest; |
| 79 | |
| 80 | // Every profile and provider entry survives (ids backfilled from the |
| 81 | // profile name), except legacy `method: "env"` entries. |
| 82 | expect(Object.keys(manifest.profiles).sort()).toEqual([ |
| 83 | "default", |
| 84 | "work", |
| 85 | ]); |
| 86 | expect(manifest.profiles.default?.id).toBe("default"); |
| 87 | expect(manifest.profiles.work?.id).toBe("work"); |
| 88 | expect(manifest.profiles.default?.providers.Cloudflare).toEqual({ |
| 89 | method: "oauth", |
| 90 | }); |
| 91 | expect(manifest.profiles.default?.providers.AWS?.method).toBe("sso"); |
| 92 | expect(manifest.profiles.default?.providers.GitHub).toEqual({ |
| 93 | method: "gh-cli", |
| 94 | }); |
| 95 | expect( |
| 96 | Schema.decodeUnknownSync(GitHubAuthConfigSchema)( |
| 97 | manifest.profiles.default?.providers.GitHub, |
| 98 | ), |
| 99 | ).toEqual({ method: "gh-cli" }); |
| 100 | expect(manifest.profiles.default?.providers.Neon).toBeUndefined(); |
| 101 | expect(manifest.profiles.work?.providers.GitHub?.method).toBe("stored"); |
| 102 | expect(manifest.profiles.work?.providers.Axiom?.method).toBe("stored"); |
| 103 | expect(manifest.profiles.work?.providers.Cloudflare).toEqual({ |
| 104 | method: "stored", |
| 105 | credentialType: "apiKey", |
| 106 | apiKey: "v0-cf-global-api-key", |
| 107 | email: "worker@example.com", |
| 108 | accountId: "0123456789abcdef0123456789abcdef", |
| 109 | }); |
| 110 | expect(manifest.profiles.work?.providers.GitHub?.token).toBe( |
| 111 | "ghp_v0token", |
| 112 | ); |
| 113 | expect(manifest.profiles.work?.providers.Axiom).toEqual({ |
| 114 | method: "stored", |
| 115 | token: "xaat-v0-token", |
| 116 | apiBaseUrl: "https://api.axiom.co", |
| 117 | }); |
| 118 | |
| 119 | // Each provider is now its own document. Compatible provider values |
| 120 | // survive. Cloudflare OAuth is deliberately emptied because its old |
| 121 | // grant is obsolete; stored API key credentials remain compatible. |
| 122 | const aws = JSON.parse( |
| 123 | yield* fs.readFileString(profileProviderFilePath("default", "AWS")), |
| 124 | ); |
| 125 | expect(aws).toEqual({ |
| 126 | format: PROFILE_FORMAT, |
| 127 | provider: "AWS", |
| 128 | metadata: {}, |
| 129 | values: { method: "sso", ssoProfile: "dev" }, |
| 130 | }); |
| 131 | const oauthCloudflare = JSON.parse( |
| 132 | yield* fs.readFileString( |
| 133 | profileProviderFilePath("default", "Cloudflare"), |
| 134 | ), |
| 135 | ); |
| 136 | expect(oauthCloudflare.values).toEqual({ method: "oauth" }); |
| 137 | const storedCloudflare = JSON.parse( |
| 138 | yield* fs.readFileString( |
| 139 | profileProviderFilePath("work", "Cloudflare"), |
| 140 | ), |
| 141 | ); |
| 142 | expect(storedCloudflare.values).toEqual( |
| 143 | manifest.profiles.work?.providers.Cloudflare, |
| 144 | ); |
| 145 | expect( |
| 146 | (yield* fs.readDirectory(path.join(home, "profiles/default"))).sort(), |
| 147 | ).toEqual(["aws.json", "cloudflare.json", "github.json"]); |
| 148 | expect( |
| 149 | yield* fs.exists(profileProviderFilePath("default", "Neon")), |
| 150 | ).toBe(false); |
| 151 | expect(yield* fs.exists(path.join(home, "profiles.json"))).toBe(false); |
| 152 | |
| 153 | // The original manifest and every credential file are preserved in |
| 154 | // a timestamped backup for manual recovery. |
| 155 | const backups = (yield* fs.readDirectory(home)).filter((entry) => |
| 156 | entry.startsWith(".profiles-v0-"), |
| 157 | ); |
| 158 | expect(backups).toHaveLength(1); |
| 159 | const backupDir = path.join(home, backups[0]!); |
| 160 | const backedUp = JSON.parse( |
| 161 | yield* fs.readFileString(path.join(backupDir, "profiles.json")), |
| 162 | ); |
| 163 | expect(backedUp.version).toBe(0); |
| 164 | // v0 profiles are flat provider maps (no `providers` wrapper). |
| 165 | expect(backedUp.profiles.default.Neon.method).toBe("env"); |
| 166 | const oauth = JSON.parse( |
| 167 | yield* fs.readFileString( |
| 168 | path.join(backupDir, "credentials/default/cf-oauth.json"), |
| 169 | ), |
| 170 | ); |
| 171 | expect(oauth.refresh).toBe("v0-refresh-token"); |
| 172 | for (const file of [ |
| 173 | "credentials/default/cloudflare-state-store.json", |
| 174 | "credentials/work/gh-stored.json", |
| 175 | "credentials/work/axiom-stored.json", |
| 176 | "credentials/work/cf-stored.json", |
| 177 | ]) { |
| 178 | expect(yield* fs.exists(path.join(backupDir, file))).toBe(true); |
| 179 | } |
| 180 | |
| 181 | // The live credential store is gone; all obsolete sidecars live only |
| 182 | // in the recoverable v0 backup. |
| 183 | expect(yield* fs.exists(path.join(home, "credentials"))).toBe(false); |
| 184 | |
| 185 | // Idempotent: a second read is a plain current-version read — no new |
| 186 | // backup dir, and the existing one is untouched. |
| 187 | const again = yield* store.readManifest; |
| 188 | expect(again.profiles.work?.providers.GitHub?.method).toBe("stored"); |
| 189 | expect(again.profiles.work?.providers.Cloudflare).toEqual( |
| 190 | manifest.profiles.work?.providers.Cloudflare, |
| 191 | ); |
| 192 | expect( |
| 193 | (yield* fs.readDirectory(home)).filter((entry) => |
| 194 | entry.startsWith(".profiles-v0-"), |
| 195 | ), |
| 196 | ).toHaveLength(1); |
| 197 | expect( |
| 198 | JSON.parse( |
| 199 | yield* fs.readFileString(path.join(backupDir, "profiles.json")), |
| 200 | ).version, |
| 201 | ).toBe(0); |
| 202 | }), |
| 203 | ), |
| 204 | { tags: ["unit", "provider:github", "local"], exclusive: true }, |
| 205 | ); |
| 206 | |
| 207 | it.live( |
| 208 | "leaves the directory-backed store untouched", |
| 209 | () => |
| 210 | withFixtureHome(undefined, (home) => |
| 211 | Effect.gen(function* () { |
| 212 | const fs = yield* FileSystem.FileSystem; |
| 213 | const providerFile = profileProviderFilePath("work", "Neon"); |
| 214 | yield* fs.makeDirectory(path.dirname(providerFile), { |
| 215 | recursive: true, |
| 216 | }); |
| 217 | yield* fs.writeFileString( |
| 218 | providerFile, |
| 219 | JSON.stringify({ |
| 220 | format: PROFILE_FORMAT, |
| 221 | provider: "Neon", |
| 222 | metadata: { label: "production" }, |
| 223 | values: { method: "stored", apiKey: "current-key" }, |
| 224 | }), |
| 225 | ); |
| 226 | |
| 227 | const store = yield* ProfileStore; |
| 228 | const manifest = yield* store.readManifest; |
| 229 | |
| 230 | expect(manifest.profiles.work?.id).toBe("work"); |
| 231 | expect(manifest.profiles.work?.providers.Neon).toEqual({ |
| 232 | method: "stored", |
| 233 | apiKey: "current-key", |
| 234 | }); |
| 235 | expect( |
| 236 | (yield* fs.readDirectory(home)).filter((entry) => |
| 237 | entry.startsWith(".profiles-v0-"), |
| 238 | ), |
| 239 | ).toHaveLength(0); |
| 240 | expect( |
| 241 | JSON.parse(yield* fs.readFileString(providerFile)).metadata, |
| 242 | ).toEqual({ label: "production" }); |
| 243 | }), |
| 244 | ), |
| 245 | { tags: ["unit", "local"], exclusive: true }, |
| 246 | ); |
| 247 | |
| 248 | it.live( |
| 249 | "salvages valid v0 providers when another entry or sidecar is invalid", |
| 250 | () => |
| 251 | withFixtureHome(FIXTURE_HOME, (home) => |
| 252 | Effect.gen(function* () { |
| 253 | const fs = yield* FileSystem.FileSystem; |
| 254 | const legacyFile = path.join(home, "profiles.json"); |
| 255 | const legacy = JSON.parse(yield* fs.readFileString(legacyFile)); |
| 256 | legacy.profiles.work.Broken = "not-an-object"; |
| 257 | yield* fs.writeFileString(legacyFile, JSON.stringify(legacy)); |
| 258 | yield* fs.writeFileString( |
| 259 | path.join(home, "credentials/work/axiom-stored.json"), |
| 260 | "{not-json", |
| 261 | ); |
| 262 | |
| 263 | const manifest = yield* (yield* ProfileStore).readManifest; |
| 264 | expect(manifest.profiles.default?.providers.AWS).toEqual({ |
| 265 | method: "sso", |
| 266 | ssoProfile: "dev", |
| 267 | }); |
| 268 | expect(manifest.profiles.work?.providers.GitHub?.token).toBe( |
| 269 | "ghp_v0token", |
| 270 | ); |
| 271 | expect(manifest.profiles.work?.providers.Axiom).toEqual({ |
| 272 | method: "stored", |
| 273 | }); |
| 274 | expect(manifest.profiles.work?.providers.Broken).toBeUndefined(); |
| 275 | |
| 276 | const backup = (yield* fs.readDirectory(home)).find((entry) => |
| 277 | entry.startsWith(".profiles-v0-"), |
| 278 | ); |
| 279 | expect(backup).toBeDefined(); |
| 280 | expect( |
| 281 | yield* fs.readFileString( |
| 282 | path.join(home, backup!, "credentials/work/axiom-stored.json"), |
| 283 | ), |
| 284 | ).toBe("{not-json"); |
| 285 | }), |
| 286 | ), |
| 287 | { tags: ["unit", "local"], exclusive: true }, |
| 288 | ); |
| 289 | |
| 290 | it.live( |
| 291 | "inlines every recognized v0 credential sidecar", |
| 292 | () => |
| 293 | withFixtureHome(undefined, (home) => |
| 294 | Effect.gen(function* () { |
| 295 | const fs = yield* FileSystem.FileSystem; |
| 296 | const staticCredentials = { |
| 297 | "aws-stored": { |
| 298 | accessKeyId: "AKIAFIXTURE", |
| 299 | secretAccessKey: "aws-secret", |
| 300 | region: "us-east-1", |
| 301 | }, |
| 302 | "axiom-stored": { |
| 303 | type: "apiToken", |
| 304 | apiToken: "axiom-token", |
| 305 | orgId: "axiom-org", |
| 306 | }, |
| 307 | "fly-stored": { apiKey: "fly-token" }, |
| 308 | "github-stored": { type: "pat", token: "github-token" }, |
| 309 | "hetzner-stored": { token: "hetzner-token" }, |
| 310 | "neon-stored": { apiKey: "neon-key" }, |
| 311 | "planetscale-stored": { |
| 312 | type: "apiToken", |
| 313 | tokenId: "ps-token-id", |
| 314 | token: "ps-token", |
| 315 | organization: "ps-org", |
| 316 | }, |
| 317 | "prisma-stored": { serviceToken: "prisma-token" }, |
| 318 | "railway-stored": { type: "token", token: "railway-token" }, |
| 319 | }; |
| 320 | const oauthCredentials = { |
| 321 | "planetscale-oauth": { |
| 322 | type: "oauth", |
| 323 | clientId: "ps-client", |
| 324 | access: "ps-access", |
| 325 | refresh: "ps-refresh", |
| 326 | expires: 4_102_444_800_000, |
| 327 | scopes: ["read_organizations"], |
| 328 | }, |
| 329 | "railway-oauth": { type: "token", token: "railway-oauth-token" }, |
| 330 | }; |
| 331 | yield* fs.writeFileString( |
| 332 | path.join(home, "profiles.json"), |
| 333 | JSON.stringify({ |
| 334 | version: 0, |
| 335 | profiles: { |
| 336 | static: { |
| 337 | AWS: { method: "stored" }, |
| 338 | Axiom: { method: "stored" }, |
| 339 | Fly: { method: "stored" }, |
| 340 | GitHub: { method: "stored" }, |
| 341 | Hetzner: { method: "stored" }, |
| 342 | Neon: { method: "stored" }, |
| 343 | Planetscale: { method: "stored" }, |
| 344 | Prisma: { method: "stored" }, |
| 345 | Railway: { method: "stored" }, |
| 346 | }, |
| 347 | oauth: { |
| 348 | Planetscale: { method: "oauth", organization: "ps-org" }, |
| 349 | Railway: { method: "oauth" }, |
| 350 | }, |
| 351 | }, |
| 352 | }), |
| 353 | ); |
| 354 | for (const [profile, credentials] of Object.entries({ |
| 355 | static: staticCredentials, |
| 356 | oauth: oauthCredentials, |
| 357 | })) { |
| 358 | const dir = path.join(home, "credentials", profile); |
| 359 | yield* fs.makeDirectory(dir, { recursive: true }); |
| 360 | for (const [key, values] of Object.entries(credentials)) { |
| 361 | yield* fs.writeFileString( |
| 362 | path.join(dir, `${key}.json`), |
| 363 | JSON.stringify(values), |
| 364 | ); |
| 365 | } |
| 366 | } |
| 367 | |
| 368 | const manifest = yield* (yield* ProfileStore).readManifest; |
| 369 | expect(manifest.profiles.static?.providers).toEqual({ |
| 370 | AWS: { |
| 371 | method: "stored", |
| 372 | accessKeyId: "AKIAFIXTURE", |
| 373 | secretAccessKey: "aws-secret", |
| 374 | region: "us-east-1", |
| 375 | }, |
| 376 | Axiom: { |
| 377 | method: "stored", |
| 378 | token: "axiom-token", |
| 379 | orgId: "axiom-org", |
| 380 | }, |
| 381 | Fly: { method: "stored", apiKey: "fly-token" }, |
| 382 | GitHub: { method: "stored", token: "github-token" }, |
| 383 | Hetzner: { method: "stored", token: "hetzner-token" }, |
| 384 | Neon: { method: "stored", apiKey: "neon-key" }, |
| 385 | Planetscale: { |
| 386 | method: "stored", |
| 387 | tokenId: "ps-token-id", |
| 388 | token: "ps-token", |
| 389 | organization: "ps-org", |
| 390 | }, |
| 391 | Prisma: { method: "stored", serviceToken: "prisma-token" }, |
| 392 | Railway: { method: "stored", token: "railway-token" }, |
| 393 | }); |
| 394 | expect(manifest.profiles.oauth?.providers).toEqual({ |
| 395 | Planetscale: { |
| 396 | method: "oauth", |
| 397 | organization: "ps-org", |
| 398 | clientId: "ps-client", |
| 399 | access: "ps-access", |
| 400 | refresh: "ps-refresh", |
| 401 | expires: 4_102_444_800_000, |
| 402 | scopes: ["read_organizations"], |
| 403 | }, |
| 404 | Railway: { method: "oauth", token: "railway-oauth-token" }, |
| 405 | }); |
| 406 | }), |
| 407 | ), |
| 408 | { tags: ["unit", "local"], exclusive: true }, |
| 409 | ); |