| 1 | import { adopt, Unowned } from "@/AdoptPolicy.ts"; |
| 2 | import { Auth, AuthProvider, removedAuthSettings } from "@/Neon/Auth.ts"; |
| 3 | import { |
| 4 | AuthOAuthProvider, |
| 5 | AuthOAuthProviderProvider, |
| 6 | } from "@/Neon/AuthOAuthProvider.ts"; |
| 7 | import { |
| 8 | AuthTrustedDomain, |
| 9 | AuthTrustedDomainProvider, |
| 10 | } from "@/Neon/AuthTrustedDomain.ts"; |
| 11 | import { DataApi, DataApiProvider } from "@/Neon/DataApi.ts"; |
| 12 | import * as Layer from "effect/Layer"; |
| 13 | import * as Output from "@/Output.ts"; |
| 14 | import { Branch } from "@/Neon/Branch.ts"; |
| 15 | import { Project } from "@/Neon/Project.ts"; |
| 16 | import { runSql, withPgClient } from "@/Neon/Migrations.ts"; |
| 17 | import { makePgMigrationExecutor } from "@/SQL/Migrations/index.ts"; |
| 18 | import { providers } from "@/Neon/Providers.ts"; |
| 19 | import * as Provider from "@/Provider.ts"; |
| 20 | import { Resource } from "@/Resource.ts"; |
| 21 | import { State } from "@/State/index.ts"; |
| 22 | import * as Test from "@/Test/Alchemy"; |
| 23 | import * as SDK from "@distilled.cloud/neon"; |
| 24 | import { expect } from "alchemy-test"; |
| 25 | import * as Effect from "effect/Effect"; |
| 26 | import * as Result from "effect/Result"; |
| 27 | import * as Redacted from "effect/Redacted"; |
| 28 | import * as HttpClient from "effect/http/HttpClient"; |
| 29 | import * as HttpClientResponse from "effect/http/HttpClientResponse"; |
| 30 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 31 | |
| 32 | const { test } = Test.make({ providers: providers() }); |
| 33 | |
| 34 | const context = { |
| 35 | id: "IdentityGuard", |
| 36 | fqn: "IdentityGuard", |
| 37 | instanceId: "identity-guard", |
| 38 | oldBindings: [], |
| 39 | newBindings: [], |
| 40 | bindings: [], |
| 41 | session: { |
| 42 | emit: () => Effect.void, |
| 43 | done: () => Effect.void, |
| 44 | note: () => Effect.void, |
| 45 | }, |
| 46 | }; |
| 47 | |
| 48 | test.provider( |
| 49 | "Auth recovery treats incomplete uncreated identities as absent and preserves existing ownership", |
| 50 | () => |
| 51 | Effect.gen(function* () { |
| 52 | const auth = yield* Auth.Provider; |
| 53 | const domain = yield* AuthTrustedDomain.Provider; |
| 54 | const scope = { |
| 55 | projectId: "recovery-project", |
| 56 | branchId: "recovery-branch", |
| 57 | }; |
| 58 | const origin = "https://recovery.example.com"; |
| 59 | const incompleteAuth = { branch: { ...scope } }; |
| 60 | const partialAuth = { branch: { ...scope } }; |
| 61 | const incompleteDomain = { auth: { ...scope }, domain: origin }; |
| 62 | const partialDomain = { auth: { ...scope }, domain: origin }; |
| 63 | const missingOrigin = { auth: { ...scope }, domain: origin }; |
| 64 | // Interrupted state can omit unresolved reference fields. |
| 65 | yield* Effect.sync(() => { |
| 66 | Reflect.deleteProperty(incompleteAuth, "branch"); |
| 67 | Reflect.deleteProperty(partialAuth.branch, "branchId"); |
| 68 | Reflect.deleteProperty(incompleteDomain, "auth"); |
| 69 | Reflect.deleteProperty(partialDomain.auth, "projectId"); |
| 70 | Reflect.deleteProperty(missingOrigin, "domain"); |
| 71 | }); |
| 72 | const requests: string[] = []; |
| 73 | const http = HttpClient.make((request) => |
| 74 | Effect.sync(() => { |
| 75 | requests.push(request.method); |
| 76 | expect(request.method).toBe("GET"); |
| 77 | return HttpClientResponse.fromWeb( |
| 78 | request, |
| 79 | Response.json( |
| 80 | request.url.endsWith("/domains") |
| 81 | ? { |
| 82 | domains: [{ domain: origin, auth_provider: "better_auth" }], |
| 83 | } |
| 84 | : { |
| 85 | auth_provider: "better_auth", |
| 86 | auth_provider_project_id: "recovery-auth", |
| 87 | branch_id: scope.branchId, |
| 88 | db_name: "neondb", |
| 89 | created_at: "2026-01-01T00:00:00Z", |
| 90 | owned_by: "neon", |
| 91 | jwks_url: "https://auth.example.com/jwks", |
| 92 | base_url: "https://auth.example.com", |
| 93 | }, |
| 94 | ), |
| 95 | ); |
| 96 | }), |
| 97 | ); |
| 98 | yield* Effect.gen(function* () { |
| 99 | for (const olds of [ |
| 100 | incompleteAuth, |
| 101 | partialAuth, |
| 102 | { branch: { projectId: scope.projectId, branchId: "" } }, |
| 103 | { project: { projectId: "" } }, |
| 104 | ]) { |
| 105 | expect( |
| 106 | yield* auth.read!({ ...context, olds, output: undefined }), |
| 107 | ).toBeUndefined(); |
| 108 | } |
| 109 | for (const olds of [ |
| 110 | incompleteDomain, |
| 111 | partialDomain, |
| 112 | missingOrigin, |
| 113 | { auth: { projectId: "", branchId: scope.branchId }, domain: origin }, |
| 114 | ]) { |
| 115 | expect( |
| 116 | yield* domain.read!({ ...context, olds, output: undefined }), |
| 117 | ).toBeUndefined(); |
| 118 | } |
| 119 | expect(requests).toEqual([]); |
| 120 | const authOutput = yield* auth.read!({ |
| 121 | ...context, |
| 122 | olds: { branch: scope }, |
| 123 | output: undefined, |
| 124 | }); |
| 125 | const domainOutput = yield* domain.read!({ |
| 126 | ...context, |
| 127 | olds: { auth: scope, domain: origin }, |
| 128 | output: undefined, |
| 129 | }); |
| 130 | expect(Unowned.is(authOutput)).toBe(true); |
| 131 | expect(Unowned.is(domainOutput)).toBe(true); |
| 132 | if (!authOutput || !domainOutput) |
| 133 | return yield* Effect.fail( |
| 134 | new Error("Expected observed Auth identities"), |
| 135 | ); |
| 136 | expect( |
| 137 | Unowned.is( |
| 138 | yield* auth.read!({ |
| 139 | ...context, |
| 140 | olds: incompleteAuth, |
| 141 | output: authOutput, |
| 142 | }), |
| 143 | ), |
| 144 | ).toBe(false); |
| 145 | expect( |
| 146 | Unowned.is( |
| 147 | yield* domain.read!({ |
| 148 | ...context, |
| 149 | olds: incompleteDomain, |
| 150 | output: domainOutput, |
| 151 | }), |
| 152 | ), |
| 153 | ).toBe(false); |
| 154 | expect( |
| 155 | yield* auth |
| 156 | .reconcile({ |
| 157 | ...context, |
| 158 | olds: undefined, |
| 159 | news: { branch: scope }, |
| 160 | output: undefined, |
| 161 | }) |
| 162 | .pipe( |
| 163 | Effect.as(false), |
| 164 | Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)), |
| 165 | ), |
| 166 | ).toBe(true); |
| 167 | expect( |
| 168 | yield* domain |
| 169 | .reconcile({ |
| 170 | ...context, |
| 171 | olds: undefined, |
| 172 | news: { auth: scope, domain: origin }, |
| 173 | output: undefined, |
| 174 | }) |
| 175 | .pipe( |
| 176 | Effect.as(false), |
| 177 | Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)), |
| 178 | ), |
| 179 | ).toBe(true); |
| 180 | expect(requests).toHaveLength(6); |
| 181 | }).pipe( |
| 182 | Effect.provideService(HttpClient.HttpClient, http), |
| 183 | Effect.provideService( |
| 184 | SDK.Credentials, |
| 185 | Effect.succeed({ |
| 186 | apiKey: Redacted.make("recovery-test"), |
| 187 | apiBaseUrl: "https://neon.example.com", |
| 188 | }), |
| 189 | ), |
| 190 | ); |
| 191 | }).pipe( |
| 192 | Effect.provide( |
| 193 | Layer.mergeAll(AuthProvider(), AuthTrustedDomainProvider()), |
| 194 | ), |
| 195 | ), |
| 196 | { |
| 197 | tags: [ |
| 198 | "provider:neon", |
| 199 | "provider:neon:auth", |
| 200 | "provider:neon:authtrusteddomain", |
| 201 | "live", |
| 202 | ], |
| 203 | }, |
| 204 | ); |
| 205 | |
| 206 | test.provider( |
| 207 | "Auth children plan unresolved identities as replacements and reject mismatched cached scope", |
| 208 | () => |
| 209 | Effect.gen(function* () { |
| 210 | const auth = yield* Auth.Provider; |
| 211 | const oauth = yield* AuthOAuthProvider.Provider; |
| 212 | const domain = yield* AuthTrustedDomain.Provider; |
| 213 | const data = yield* DataApi.Provider; |
| 214 | const scope = { projectId: "owned-project", branchId: "owned-branch" }; |
| 215 | const changed = { ...scope, branchId: "other-branch" }; |
| 216 | const unresolved = { ...scope, branchId: Output.literal("other-branch") }; |
| 217 | const authOutput = { |
| 218 | ...scope, |
| 219 | database: "neondb", |
| 220 | baseUrl: "https://auth.example.com", |
| 221 | jwksUrl: "https://auth.example.com/jwks", |
| 222 | name: undefined, |
| 223 | }; |
| 224 | const oauthOutput = { |
| 225 | ...scope, |
| 226 | provider: "github" as const, |
| 227 | type: "standard" as const, |
| 228 | clientId: "client", |
| 229 | }; |
| 230 | const domainOutput = { ...scope, domain: "https://app.example.com" }; |
| 231 | const dataOutput = { |
| 232 | ...scope, |
| 233 | database: "neondb", |
| 234 | url: "https://data.example.com", |
| 235 | status: "active", |
| 236 | settings: undefined, |
| 237 | }; |
| 238 | expect( |
| 239 | yield* auth.diff!({ |
| 240 | ...context, |
| 241 | olds: { branch: scope }, |
| 242 | news: { branch: unresolved }, |
| 243 | output: authOutput, |
| 244 | }), |
| 245 | ).toMatchObject({ action: "replace" }); |
| 246 | expect( |
| 247 | yield* oauth.diff!({ |
| 248 | ...context, |
| 249 | olds: { auth: scope, provider: "github" }, |
| 250 | news: { auth: unresolved, provider: "github" }, |
| 251 | output: oauthOutput, |
| 252 | }), |
| 253 | ).toMatchObject({ action: "replace" }); |
| 254 | expect( |
| 255 | yield* domain.diff!({ |
| 256 | ...context, |
| 257 | olds: { auth: scope, domain: domainOutput.domain }, |
| 258 | news: { auth: unresolved, domain: domainOutput.domain }, |
| 259 | output: domainOutput, |
| 260 | }), |
| 261 | ).toMatchObject({ action: "replace" }); |
| 262 | expect( |
| 263 | yield* data.diff!({ |
| 264 | ...context, |
| 265 | olds: { branch: scope, database: "neondb" }, |
| 266 | news: { branch: unresolved, database: "neondb" }, |
| 267 | output: dataOutput, |
| 268 | }), |
| 269 | ).toMatchObject({ action: "replace" }); |
| 270 | expect( |
| 271 | yield* auth.diff!({ |
| 272 | ...context, |
| 273 | olds: { branch: scope }, |
| 274 | news: { branch: scope, database: Output.literal("neondb") }, |
| 275 | output: authOutput, |
| 276 | }), |
| 277 | ).toBeUndefined(); |
| 278 | expect( |
| 279 | yield* auth |
| 280 | .reconcile({ |
| 281 | ...context, |
| 282 | olds: { branch: scope }, |
| 283 | news: { branch: changed }, |
| 284 | output: authOutput, |
| 285 | }) |
| 286 | .pipe( |
| 287 | Effect.as(false), |
| 288 | Effect.catchTag("InvalidManagedAuth", () => Effect.succeed(true)), |
| 289 | ), |
| 290 | ).toBe(true); |
| 291 | expect( |
| 292 | yield* oauth |
| 293 | .reconcile({ |
| 294 | ...context, |
| 295 | olds: { auth: scope, provider: "github" }, |
| 296 | news: { auth: changed, provider: "github" }, |
| 297 | output: oauthOutput, |
| 298 | }) |
| 299 | .pipe( |
| 300 | Effect.as(false), |
| 301 | Effect.catchTag("InvalidManagedAuth", () => Effect.succeed(true)), |
| 302 | ), |
| 303 | ).toBe(true); |
| 304 | expect( |
| 305 | yield* domain |
| 306 | .reconcile({ |
| 307 | ...context, |
| 308 | olds: { auth: scope, domain: domainOutput.domain }, |
| 309 | news: { auth: changed, domain: domainOutput.domain }, |
| 310 | output: domainOutput, |
| 311 | }) |
| 312 | .pipe( |
| 313 | Effect.as(false), |
| 314 | Effect.catchTag("InvalidManagedAuth", () => Effect.succeed(true)), |
| 315 | ), |
| 316 | ).toBe(true); |
| 317 | expect( |
| 318 | yield* data |
| 319 | .reconcile({ |
| 320 | ...context, |
| 321 | olds: { branch: scope, database: "neondb" }, |
| 322 | news: { branch: changed, database: "neondb" }, |
| 323 | output: dataOutput, |
| 324 | }) |
| 325 | .pipe( |
| 326 | Effect.as(false), |
| 327 | Effect.catchTag("InvalidDataApiConfiguration", () => |
| 328 | Effect.succeed(true), |
| 329 | ), |
| 330 | ), |
| 331 | ).toBe(true); |
| 332 | expect( |
| 333 | yield* auth |
| 334 | .reconcile({ |
| 335 | ...context, |
| 336 | olds: { branch: scope, allowLocalhost: false }, |
| 337 | news: { branch: scope, name: "must-not-apply" }, |
| 338 | output: authOutput, |
| 339 | }) |
| 340 | .pipe( |
| 341 | Effect.as(false), |
| 342 | Effect.catchTag("InvalidManagedAuth", () => Effect.succeed(true)), |
| 343 | ), |
| 344 | ).toBe(true); |
| 345 | expect( |
| 346 | yield* oauth |
| 347 | .reconcile({ |
| 348 | ...context, |
| 349 | olds: { auth: scope, provider: "github", clientId: "client" }, |
| 350 | news: { auth: scope, provider: "github" }, |
| 351 | output: oauthOutput, |
| 352 | }) |
| 353 | .pipe( |
| 354 | Effect.as(false), |
| 355 | Effect.catchTag("InvalidManagedAuth", () => Effect.succeed(true)), |
| 356 | ), |
| 357 | ).toBe(true); |
| 358 | expect( |
| 359 | yield* data |
| 360 | .reconcile({ |
| 361 | ...context, |
| 362 | olds: { |
| 363 | branch: scope, |
| 364 | database: "neondb", |
| 365 | settings: { db_max_rows: 10 }, |
| 366 | }, |
| 367 | news: { branch: scope, database: "neondb", settings: {} }, |
| 368 | output: dataOutput, |
| 369 | }) |
| 370 | .pipe( |
| 371 | Effect.as(false), |
| 372 | Effect.catchTag("InvalidDataApiConfiguration", () => |
| 373 | Effect.succeed(true), |
| 374 | ), |
| 375 | ), |
| 376 | ).toBe(true); |
| 377 | }).pipe( |
| 378 | Effect.provide( |
| 379 | Layer.mergeAll( |
| 380 | AuthProvider(), |
| 381 | AuthOAuthProviderProvider(), |
| 382 | AuthTrustedDomainProvider(), |
| 383 | DataApiProvider(), |
| 384 | ), |
| 385 | ), |
| 386 | ), |
| 387 | { |
| 388 | tags: [ |
| 389 | "provider:neon", |
| 390 | "provider:neon:auth", |
| 391 | "provider:neon:authoauthprovider", |
| 392 | "provider:neon:authtrusteddomain", |
| 393 | "provider:neon:dataapi", |
| 394 | "live", |
| 395 | ], |
| 396 | }, |
| 397 | ); |
| 398 | |
| 399 | test( |
| 400 | "managed field removal checks nested settings and permits an explicit email provider switch", |
| 401 | Effect.sync(() => { |
| 402 | expect( |
| 403 | removedAuthSettings( |
| 404 | { magicLink: { enabled: true, expires_in: 10 } }, |
| 405 | { magicLink: { enabled: false } }, |
| 406 | ["magicLink"], |
| 407 | ), |
| 408 | ).toEqual(["magicLink.expires_in"]); |
| 409 | expect( |
| 410 | removedAuthSettings( |
| 411 | { emailProvider: { type: "standard", host: "smtp.example.com" } }, |
| 412 | { emailProvider: { type: "shared" } }, |
| 413 | ["emailProvider"], |
| 414 | ), |
| 415 | ).toEqual([]); |
| 416 | }), |
| 417 | { tags: ["unit", "provider:neon", "provider:neon:auth", "local"] }, |
| 418 | ); |
| 419 | |
| 420 | test.provider( |
| 421 | "managed Auth requires adoption and preserves user data after external disable", |
| 422 | (stack) => |
| 423 | Effect.gen(function* () { |
| 424 | yield* stack.destroy(); |
| 425 | const base = Effect.gen(function* () { |
| 426 | const project = yield* Project("AuthOwnershipProject", { |
| 427 | region: "aws-us-east-2", |
| 428 | }); |
| 429 | const branch = yield* Branch("AuthOwnershipBranch", { project }); |
| 430 | return { project, branch }; |
| 431 | }); |
| 432 | const existing = yield* stack.deploy(base); |
| 433 | const request = { |
| 434 | project_id: existing.branch.projectId, |
| 435 | branch_id: existing.branch.branchId, |
| 436 | }; |
| 437 | yield* SDK.createNeonAuth({ ...request, auth_provider: "better_auth" }); |
| 438 | const application = (takeOwnership: boolean) => |
| 439 | Effect.gen(function* () { |
| 440 | const { branch } = yield* base; |
| 441 | const auth = yield* Auth("OwnedAuth", { |
| 442 | branch, |
| 443 | name: "Owned managed auth", |
| 444 | allowLocalhost: false, |
| 445 | }).pipe(adopt(takeOwnership)); |
| 446 | return { auth }; |
| 447 | }); |
| 448 | const refusal = yield* stack |
| 449 | .deploy(application(false)) |
| 450 | .pipe(Effect.result); |
| 451 | expect(Result.isFailure(refusal)).toBe(true); |
| 452 | yield* stack.deploy(application(true)); |
| 453 | expect((yield* SDK.getNeonAuth(request)).name).toBe("Owned managed auth"); |
| 454 | expect( |
| 455 | (yield* SDK.getNeonAuthAllowLocalhost(request)).allow_localhost, |
| 456 | ).toBe(false); |
| 457 | yield* SDK.disableNeonAuth(request); |
| 458 | const refused = yield* stack.deploy(application(false)).pipe( |
| 459 | Effect.as(undefined), |
| 460 | Effect.catchTag("Conflict", (error) => Effect.succeed(error.message)), |
| 461 | ); |
| 462 | expect(refused).toBe( |
| 463 | "The `neon_auth` schema already exists and cannot be automatically provisioned. Please drop the existing `neon_auth` schema before provisioning Neon Auth.", |
| 464 | ); |
| 465 | yield* stack.destroy(); |
| 466 | expect( |
| 467 | yield* SDK.getNeonAuth(request).pipe( |
| 468 | Effect.as(false), |
| 469 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 470 | ), |
| 471 | ).toBe(true); |
| 472 | yield* stack.destroy(); |
| 473 | }), |
| 474 | { |
| 475 | tags: [ |
| 476 | "provider:neon", |
| 477 | "provider:neon:auth", |
| 478 | "provider:neon:branch", |
| 479 | "provider:neon:project", |
| 480 | "live", |
| 481 | ], |
| 482 | timeout: 120_000, |
| 483 | }, |
| 484 | ); |
| 485 | |
| 486 | interface PreviewScope extends Resource< |
| 487 | "Test.AuthPreviewScope", |
| 488 | {}, |
| 489 | { projectId: string; branchId: string } |
| 490 | > {} |
| 491 | const PreviewScope = Resource<PreviewScope>("Test.AuthPreviewScope"); |
| 492 | const { test: retryTest } = Test.make({ |
| 493 | providers: Layer.mergeAll( |
| 494 | AuthProvider(), |
| 495 | Provider.succeed(PreviewScope, { |
| 496 | reconcile: () => |
| 497 | Effect.succeed({ projectId: "retry-project", branchId: "retry-child" }), |
| 498 | delete: () => Effect.void, |
| 499 | }), |
| 500 | ).pipe( |
| 501 | Layer.provideMerge( |
| 502 | Layer.succeed( |
| 503 | SDK.Credentials, |
| 504 | Effect.succeed({ |
| 505 | apiKey: Redacted.make("ownership-retry-test"), |
| 506 | apiBaseUrl: "https://neon.example.com", |
| 507 | }), |
| 508 | ), |
| 509 | ), |
| 510 | ), |
| 511 | }); |
| 512 | |
| 513 | retryTest.provider( |
| 514 | "actual Auth refusal retries scoped adoption with complete branch identity", |
| 515 | (stack) => { |
| 516 | const requests: string[] = []; |
| 517 | const http = HttpClient.make((request) => |
| 518 | Effect.sync(() => { |
| 519 | requests.push(request.method); |
| 520 | expect(request.url).toContain( |
| 521 | "/projects/retry-project/branches/retry-child/auth", |
| 522 | ); |
| 523 | expect(["GET", "DELETE"]).toContain(request.method); |
| 524 | return HttpClientResponse.fromWeb( |
| 525 | request, |
| 526 | Response.json( |
| 527 | request.method === "DELETE" |
| 528 | ? {} |
| 529 | : { |
| 530 | auth_provider: "better_auth", |
| 531 | auth_provider_project_id: "inherited-auth", |
| 532 | branch_id: "retry-child", |
| 533 | db_name: "neondb", |
| 534 | created_at: "2026-01-01T00:00:00Z", |
| 535 | owned_by: "neon", |
| 536 | jwks_url: "https://child.example.com/jwks", |
| 537 | base_url: "https://child.example.com", |
| 538 | }, |
| 539 | ), |
| 540 | ); |
| 541 | }), |
| 542 | ); |
| 543 | const app = (enabled: boolean) => |
| 544 | Effect.gen(function* () { |
| 545 | const branch = yield* PreviewScope("Branch", {}); |
| 546 | return yield* Auth("Auth", { branch }).pipe(adopt(enabled)); |
| 547 | }); |
| 548 | return Effect.gen(function* () { |
| 549 | yield* stack.destroy(); |
| 550 | const refusal = yield* stack.deploy(app(false)).pipe( |
| 551 | Effect.as(false), |
| 552 | Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)), |
| 553 | ); |
| 554 | expect(refusal).toBe(true); |
| 555 | expect(requests.every((method) => method === "GET")).toBe(true); |
| 556 | const state = yield* yield* State; |
| 557 | const refused = yield* state.get({ |
| 558 | stack: stack.name, |
| 559 | stage: stack.stage, |
| 560 | fqn: "Auth", |
| 561 | }); |
| 562 | if (!refused || refused.kind === "action") |
| 563 | return yield* Effect.fail( |
| 564 | new Error("Expected an Auth resource checkpoint"), |
| 565 | ); |
| 566 | expect(refused.attr).toBeUndefined(); |
| 567 | expect( |
| 568 | yield* stack.deploy(app(false)).pipe( |
| 569 | Effect.as(false), |
| 570 | Effect.catchTag("OwnedBySomeoneElse", () => Effect.succeed(true)), |
| 571 | ), |
| 572 | ).toBe(true); |
| 573 | const adopted = yield* stack.deploy(app(true)); |
| 574 | expect(adopted.branchId).toBe("retry-child"); |
| 575 | expect(adopted.baseUrl).toBe("https://child.example.com"); |
| 576 | expect(refused?.props).toEqual({ |
| 577 | branch: { projectId: "retry-project", branchId: "retry-child" }, |
| 578 | }); |
| 579 | expect(requests.every((method) => method === "GET")).toBe(true); |
| 580 | yield* stack.destroy(); |
| 581 | expect(requests.filter((method) => method === "DELETE")).toHaveLength(1); |
| 582 | }).pipe( |
| 583 | Effect.ensuring(stack.destroy().pipe(Effect.orDie)), |
| 584 | Effect.provideService(HttpClient.HttpClient, http), |
| 585 | ); |
| 586 | }, |
| 587 | { |
| 588 | tags: ["unit", "provider:neon", "provider:neon:auth", "local"], |
| 589 | timeout: 10_000, |
| 590 | }, |
| 591 | ); |
| 592 | |
| 593 | const { test: previewTest } = Test.make({ |
| 594 | providers: providers(), |
| 595 | stage: "test-neon-auth-deferred-adoption", |
| 596 | }); |
| 597 | |
| 598 | previewTest.provider( |
| 599 | "new child Auth explicitly adopts inherited integration and preserves parent identities and data", |
| 600 | (stack) => |
| 601 | Effect.gen(function* () { |
| 602 | yield* stack.destroy(); |
| 603 | const parent = Effect.gen(function* () { |
| 604 | const project = yield* Project("DeferredAuthProject", { |
| 605 | region: "aws-us-east-2", |
| 606 | }); |
| 607 | const auth = yield* Auth("ParentAuth", { |
| 608 | project, |
| 609 | name: "Deferred adoption parent", |
| 610 | allowLocalhost: true, |
| 611 | emailAndPassword: { |
| 612 | enabled: true, |
| 613 | require_email_verification: false, |
| 614 | send_verification_email_on_sign_up: false, |
| 615 | }, |
| 616 | }); |
| 617 | return { project, auth }; |
| 618 | }); |
| 619 | const first = yield* stack.deploy(parent); |
| 620 | const parentRequest = { |
| 621 | project_id: first.auth.projectId, |
| 622 | branch_id: first.auth.branchId, |
| 623 | }; |
| 624 | const parentUri = Redacted.make(first.project.connectionUri); |
| 625 | const query = (uri: Redacted.Redacted<string>, sql: string) => |
| 626 | withPgClient(uri, (client) => |
| 627 | makePgMigrationExecutor(client).query(sql), |
| 628 | ); |
| 629 | yield* runSql( |
| 630 | parentUri, |
| 631 | "CREATE TABLE auth_preview_probe (id integer PRIMARY KEY, value text NOT NULL); INSERT INTO auth_preview_probe VALUES (1, 'parent');", |
| 632 | ); |
| 633 | const http = yield* HttpClient.HttpClient; |
| 634 | const baseUrl = first.auth.baseUrl.replace(/\/$/, ""); |
| 635 | const authPath = baseUrl.endsWith("/auth") ? baseUrl : `${baseUrl}/auth`; |
| 636 | const signup = yield* http.execute( |
| 637 | HttpClientRequest.post(`${authPath}/sign-up/email`).pipe( |
| 638 | HttpClientRequest.setHeader("origin", "http://localhost:4318"), |
| 639 | HttpClientRequest.bodyJsonUnsafe({ |
| 640 | email: "parent-auth-preview@example.com", |
| 641 | password: "Alchemy-Auth-Preview-Test-Only-73!", |
| 642 | name: "Parent preview user", |
| 643 | }), |
| 644 | ), |
| 645 | ); |
| 646 | expect(signup.status).toBe(200); |
| 647 | const usersSql = 'SELECT id, email FROM neon_auth."user" ORDER BY id'; |
| 648 | const parentUsers = yield* query(parentUri, usersSql); |
| 649 | expect(parentUsers.length).toBe(1); |
| 650 | const parentAuth = yield* SDK.getNeonAuth(parentRequest); |
| 651 | const parentSettings = |
| 652 | yield* SDK.getNeonAuthEmailAndPasswordConfig(parentRequest); |
| 653 | const parentLocalhost = |
| 654 | yield* SDK.getNeonAuthAllowLocalhost(parentRequest); |
| 655 | const verifyParent = Effect.gen(function* () { |
| 656 | const survivingAuth = yield* SDK.getNeonAuth(parentRequest); |
| 657 | expect(survivingAuth.base_url).toBe(parentAuth.base_url); |
| 658 | expect(survivingAuth.name).toBe(parentAuth.name); |
| 659 | expect( |
| 660 | yield* SDK.getNeonAuthEmailAndPasswordConfig(parentRequest), |
| 661 | ).toEqual(parentSettings); |
| 662 | expect(yield* SDK.getNeonAuthAllowLocalhost(parentRequest)).toEqual( |
| 663 | parentLocalhost, |
| 664 | ); |
| 665 | expect(yield* query(parentUri, usersSql)).toEqual(parentUsers); |
| 666 | expect( |
| 667 | yield* query(parentUri, "SELECT * FROM auth_preview_probe"), |
| 668 | ).toEqual([{ id: 1, value: "parent" }]); |
| 669 | }); |
| 670 | const preview = Effect.gen(function* () { |
| 671 | const resources = yield* parent; |
| 672 | const branch = yield* Branch("AuthPreview", { |
| 673 | project: resources.project, |
| 674 | parentBranch: { branchId: resources.auth.branchId }, |
| 675 | initSource: "parent-data", |
| 676 | }); |
| 677 | const auth = yield* Auth("PreviewAuth", { |
| 678 | branch, |
| 679 | name: "Deferred adoption child", |
| 680 | allowLocalhost: false, |
| 681 | }).pipe(adopt()); |
| 682 | return { branch, auth }; |
| 683 | }); |
| 684 | const child = yield* stack.deploy(preview); |
| 685 | expect(child.branch.parentBranchId).toBe(first.auth.branchId); |
| 686 | expect(child.auth.branchId).toBe(child.branch.branchId); |
| 687 | expect(child.auth.branchId).not.toBe(first.auth.branchId); |
| 688 | expect(child.auth.baseUrl).not.toBe(first.auth.baseUrl); |
| 689 | const childRequest = { |
| 690 | project_id: child.auth.projectId, |
| 691 | branch_id: child.auth.branchId, |
| 692 | }; |
| 693 | expect((yield* SDK.getNeonAuth(childRequest)).name).toBe( |
| 694 | "Deferred adoption child", |
| 695 | ); |
| 696 | expect( |
| 697 | (yield* SDK.getNeonAuthAllowLocalhost(childRequest)).allow_localhost, |
| 698 | ).toBe(false); |
| 699 | const childUri = Redacted.make(child.branch.connectionUri); |
| 700 | expect(yield* query(childUri, usersSql)).toEqual(parentUsers); |
| 701 | expect( |
| 702 | yield* query(childUri, "SELECT * FROM auth_preview_probe"), |
| 703 | ).toEqual([{ id: 1, value: "parent" }]); |
| 704 | yield* runSql( |
| 705 | childUri, |
| 706 | "UPDATE auth_preview_probe SET value = 'child' WHERE id = 1", |
| 707 | ); |
| 708 | expect( |
| 709 | yield* query(childUri, "SELECT * FROM auth_preview_probe"), |
| 710 | ).toEqual([{ id: 1, value: "child" }]); |
| 711 | yield* verifyParent; |
| 712 | const unchanged = yield* stack.deploy(preview); |
| 713 | expect(unchanged.auth.baseUrl).toBe(child.auth.baseUrl); |
| 714 | yield* stack.deploy(parent); |
| 715 | expect( |
| 716 | yield* SDK.getNeonAuth(childRequest).pipe( |
| 717 | Effect.as(false), |
| 718 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 719 | ), |
| 720 | ).toBe(true); |
| 721 | expect( |
| 722 | yield* SDK.getProjectBranch(childRequest).pipe( |
| 723 | Effect.as(false), |
| 724 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 725 | ), |
| 726 | ).toBe(true); |
| 727 | yield* verifyParent; |
| 728 | yield* stack.destroy(); |
| 729 | expect( |
| 730 | yield* SDK.getNeonAuth(parentRequest).pipe( |
| 731 | Effect.as(false), |
| 732 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 733 | ), |
| 734 | ).toBe(true); |
| 735 | expect( |
| 736 | yield* SDK.getProject({ project_id: first.project.projectId }).pipe( |
| 737 | Effect.as(false), |
| 738 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 739 | ), |
| 740 | ).toBe(true); |
| 741 | }), |
| 742 | { |
| 743 | tags: [ |
| 744 | "provider:neon", |
| 745 | "provider:neon:auth", |
| 746 | "provider:neon:branch", |
| 747 | "provider:neon:project", |
| 748 | "live", |
| 749 | ], |
| 750 | timeout: 120_000, |
| 751 | }, |
| 752 | ); |
| 753 | |
| 754 | test.provider( |
| 755 | "Auth scope replacement retains both branches and leaves the old branch disabled", |
| 756 | (stack) => |
| 757 | Effect.gen(function* () { |
| 758 | yield* stack.destroy(); |
| 759 | const application = (second: boolean) => |
| 760 | Effect.gen(function* () { |
| 761 | const project = yield* Project("AuthReplacementProject", { |
| 762 | region: "aws-us-east-2", |
| 763 | }); |
| 764 | const a = yield* Branch("AuthBranchA", { project }); |
| 765 | const b = second |
| 766 | ? yield* Branch("AuthBranchB", { project }) |
| 767 | : undefined; |
| 768 | const auth = yield* Auth("ReplacingAuth", { branch: b ?? a }); |
| 769 | const domain = yield* AuthTrustedDomain("ScopeOrigin", { |
| 770 | auth, |
| 771 | domain: "https://scope.example.com", |
| 772 | }); |
| 773 | return { a, b, auth, domain }; |
| 774 | }); |
| 775 | const first = yield* stack.deploy(application(false)); |
| 776 | const replaced = yield* stack.deploy(application(true)); |
| 777 | expect(replaced.auth.branchId).toBe(replaced.b!.branchId); |
| 778 | expect(replaced.domain.branchId).toBe(replaced.b!.branchId); |
| 779 | expect(replaced.a.branchId).toBe(first.a.branchId); |
| 780 | expect( |
| 781 | yield* SDK.getNeonAuth({ |
| 782 | project_id: first.auth.projectId, |
| 783 | branch_id: first.auth.branchId, |
| 784 | }).pipe( |
| 785 | Effect.as(false), |
| 786 | Effect.catchTag("NotFound", () => Effect.succeed(true)), |
| 787 | ), |
| 788 | ).toBe(true); |
| 789 | yield* stack.destroy(); |
| 790 | }), |
| 791 | { |
| 792 | tags: [ |
| 793 | "provider:neon", |
| 794 | "provider:neon:auth", |
| 795 | "provider:neon:authtrusteddomain", |
| 796 | "provider:neon:branch", |
| 797 | "provider:neon:project", |
| 798 | "live", |
| 799 | ], |
| 800 | timeout: 120_000, |
| 801 | }, |
| 802 | ); |