From 9a120e6d459e459752a843bc387421248810f714 Mon Sep 17 00:00:00 2001 From: donghyeon-ka Date: Sat, 25 Jul 2026 23:42:39 +0900 Subject: [PATCH] feat: compose executable frontend runtime --- public/config.json | 2 +- src/adapters/auth/external-session-adapter.js | 79 +++++++++++++ src/application/create-application.js | 20 ++-- src/application/ports/auth-session-port.js | 3 + src/bootstrap/composition-root.js | 19 +++- src/bootstrap/create-runtime-composition.js | 32 ++++++ src/bootstrap/load-release-manifest.js | 107 ++++++++++++++++++ src/bootstrap/main.jsx | 13 ++- src/bootstrap/runtime-adapters.js | 94 +++++++++++++++ src/bootstrap/runtime-config-schema.js | 9 +- tests/e2e/accessibility.spec.js | 3 +- tests/runtime-schema/release-manifest.test.js | 51 +++++++++ tests/runtime-schema/runtime-config.test.js | 14 +++ tests/unit/auth-session-adapter.test.js | 23 ++++ tests/unit/runtime-adapters.test.js | 57 ++++++++++ 15 files changed, 505 insertions(+), 21 deletions(-) create mode 100644 src/bootstrap/create-runtime-composition.js create mode 100644 src/bootstrap/load-release-manifest.js create mode 100644 src/bootstrap/runtime-adapters.js create mode 100644 tests/runtime-schema/release-manifest.test.js create mode 100644 tests/unit/runtime-adapters.test.js diff --git a/public/config.json b/public/config.json index 9cd3dbe..d589523 100644 --- a/public/config.json +++ b/public/config.json @@ -4,7 +4,7 @@ "REQUEST_TIMEOUT_MS": 10000, "MAX_RETRY_ATTEMPTS": 2, "TELEMETRY_ENABLED": false, - "AUTH_MODE": "external", + "AUTH_MODE": "demo", "CONFIG_SCHEMA_VERSION": "1", "API_CONTRACT_VERSION": "1", "RELEASE_MANIFEST_URL": "/release-manifest.json", diff --git a/src/adapters/auth/external-session-adapter.js b/src/adapters/auth/external-session-adapter.js index 00b3f99..c990868 100644 --- a/src/adapters/auth/external-session-adapter.js +++ b/src/adapters/auth/external-session-adapter.js @@ -4,6 +4,9 @@ * * @param {{ * readState(): import("../../application/ports/auth-session-port.js").SessionState, + * subscribe(listener: () => void): () => void, + * beginSignIn(returnTo?: string): Promise, + * signOut(): Promise, * attachCredential(request: Request): Promise, * recoverSession(): Promise<"restored" | "no-session">, * notifyUnauthenticated(): void @@ -15,6 +18,16 @@ export function createExternalAuthSessionAdapter(owner) { getState() { return owner.readState(); }, + subscribe(listener) { + return owner.subscribe(listener); + }, + async beginSignIn(returnTo) { + await owner.beginSignIn(returnTo); + }, + async signOut() { + await owner.signOut(); + }, + /** @param {Request} request */ async attach(request) { const attached = await owner.attachCredential(request); if (!(attached instanceof Request)) { @@ -38,8 +51,74 @@ export function createExternalAuthSessionAdapter(owner) { export function createAnonymousSessionAdapter() { return createExternalAuthSessionAdapter({ readState: () => "unauthenticated", + subscribe: () => () => {}, + beginSignIn: async () => {}, + signOut: async () => {}, attachCredential: async (request) => request, recoverSession: async () => "no-session", notifyUnauthenticated: () => {}, }); } + +/** + * Local/test-only session seam. It never creates or stores credentials. + * + * @param {import("../../application/ports/auth-session-port.js").SessionState} [initialState] + */ +export function createDemoSessionAdapter(initialState = "unauthenticated") { + let state = initialState; + const listeners = new Set(); + + function notify() { + for (const listener of listeners) listener(); + } + + /** @param {import("../../application/ports/auth-session-port.js").SessionState} next */ + function setState(next) { + state = next; + notify(); + } + + return Object.freeze({ + getState: () => state, + /** @param {() => void} listener */ + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + async beginSignIn() { + setState("authenticated"); + }, + async signOut() { + setState("unauthenticated"); + }, + /** @param {Request} request */ + async attach(request) { + return request; + }, + async recover() { + if (state === "recovery-pending") { + setState("authenticated"); + return /** @type {const} */ ("restored"); + } + return /** @type {const} */ ("no-session"); + }, + onUnauthenticated() { + setState("unauthenticated"); + }, + setState, + }); +} + +export function createUnavailableSessionAdapter() { + return Object.freeze({ + getState: () => /** @type {const} */ ("integration-failed"), + subscribe: () => () => {}, + beginSignIn: async () => {}, + signOut: async () => {}, + /** @param {Request} request */ + attach: async (request) => request, + recover: async () => /** @type {const} */ ("no-session"), + onUnauthenticated: () => {}, + }); +} diff --git a/src/application/create-application.js b/src/application/create-application.js index 7634731..8d2b633 100644 --- a/src/application/create-application.js +++ b/src/application/create-application.js @@ -2,7 +2,7 @@ * Application facade factory. Concrete dependencies are supplied by bootstrap. * * @param {{ - * resources: { + * resources?: { * query: import("./ports/resource-ports.js").ResourceQueryPort, * command: import("./ports/resource-ports.js").ResourceCommandPort * }, @@ -17,7 +17,9 @@ export function createApplication(ports) { * @param {import("./ports/resource-ports.js").RequestContext} [context] */ function queryResources(query, context) { - return ports.resources.query.execute(query, context); + return /** @type {NonNullable} */ ( + ports.resources + ).query.execute(query, context); } /** @@ -25,14 +27,18 @@ export function createApplication(ports) { * @param {import("./ports/resource-ports.js").RequestContext} [context] */ function commandResources(command, context) { - return ports.resources.command.execute(command, context); + return /** @type {NonNullable} */ ( + ports.resources + ).command.execute(command, context); } return Object.freeze({ - resources: Object.freeze({ - query: queryResources, - command: commandResources, - }), + resources: ports.resources + ? Object.freeze({ + query: queryResources, + command: commandResources, + }) + : null, cache: ports.cache, storage: ports.storage, telemetry: ports.telemetry, diff --git a/src/application/ports/auth-session-port.js b/src/application/ports/auth-session-port.js index 9173476..645f349 100644 --- a/src/application/ports/auth-session-port.js +++ b/src/application/ports/auth-session-port.js @@ -7,6 +7,9 @@ * * @typedef {{ * getState(): SessionState, + * subscribe(listener: () => void): () => void, + * beginSignIn(returnTo?: string): Promise, + * signOut(): Promise, * attach(request: Request): Promise, * recover(): Promise<"restored" | "no-session">, * onUnauthenticated(): void diff --git a/src/bootstrap/composition-root.js b/src/bootstrap/composition-root.js index 0bbee54..45e130a 100644 --- a/src/bootstrap/composition-root.js +++ b/src/bootstrap/composition-root.js @@ -4,14 +4,23 @@ import { createApplication } from "../application/create-application.js"; * This is the only module allowed to join concrete adapters to application * ports. Boot phases are explicit so failures can stop before product mount. * + * @template Config + * @template Release + * @template {Parameters[0]} Ports * @param {{ - * loadConfig(): Promise>, - * loadRelease(config: Record): Promise>, + * loadConfig(): Promise, + * loadRelease(config: Config): Promise, * createAdapters(context: { - * config: Record, - * release: Record - * }): Promise[0]> + * config: Config, + * release: Release + * }): Promise * }} factories + * @returns {Promise + * }>>} */ export async function createCompositionRoot(factories) { const config = await factories.loadConfig(); diff --git a/src/bootstrap/create-runtime-composition.js b/src/bootstrap/create-runtime-composition.js new file mode 100644 index 0000000..b58f385 --- /dev/null +++ b/src/bootstrap/create-runtime-composition.js @@ -0,0 +1,32 @@ +import { createCompositionRoot } from "./composition-root.js"; +import { loadReleaseManifest } from "./load-release-manifest.js"; +import { loadRuntimeConfig } from "./load-runtime-config.js"; +import { createRuntimeAdapters } from "./runtime-adapters.js"; + +/** + * @param {{ + * fetcher?: typeof fetch, + * host?: Record + * }} [dependencies] + */ +export function createRuntimeComposition(dependencies = {}) { + return createCompositionRoot({ + loadConfig: () => loadRuntimeConfig({ fetcher: dependencies.fetcher }), + loadRelease: (runtime) => + loadReleaseManifest( + /** @type {Awaited>} */ (runtime), + { fetcher: dependencies.fetcher }, + ), + createAdapters: ({ config: runtime, release }) => + createRuntimeAdapters({ + runtime: + /** @type {Awaited>} */ (runtime), + release: + /** @type {Awaited>} */ ( + release + ), + fetcher: dependencies.fetcher, + host: dependencies.host, + }), + }); +} diff --git a/src/bootstrap/load-release-manifest.js b/src/bootstrap/load-release-manifest.js new file mode 100644 index 0000000..aea1dcd --- /dev/null +++ b/src/bootstrap/load-release-manifest.js @@ -0,0 +1,107 @@ +import { z } from "zod"; + +const version = z.string().regex(/^\d+(?:\.\d+){0,2}$/); +const releaseManifestSchema = z + .object({ + schemaVersion: z.literal(1), + appVersion: z.string().min(1), + buildId: z.string().min(1), + commitSha: z.string().min(1), + configSchemaVersion: version, + apiContractVersion: version, + assetManifestHash: z.string().min(1), + releaseId: z.string().min(1), + builtAt: z.string().min(1), + }) + .strict(); + +export class ReleaseManifestError extends Error { + /** @param {string} code @param {{buildId: string, releaseId?: string}} safe */ + constructor(code, safe) { + super("Release manifest could not be loaded"); + this.name = "ReleaseManifestError"; + this.kind = "RELEASE_MANIFEST_FAILURE"; + this.code = code; + this.safe = Object.freeze({ + kind: this.kind, + code, + buildId: safe.buildId, + releaseId: safe.releaseId, + supportReference: `${safe.buildId}:${code}`, + }); + } +} + +/** + * @param {Awaited>} runtime + * @param {{fetcher?: typeof fetch}} [options] + */ +export async function loadReleaseManifest(runtime, options = {}) { + const fetcher = options.fetcher ?? fetch; + let response; + try { + response = await fetcher(runtime.config.RELEASE_MANIFEST_URL, { + cache: "no-store", + headers: { Accept: "application/json" }, + }); + } catch { + throw new ReleaseManifestError("MANIFEST_FETCH_FAILED", { + buildId: runtime.build.buildId, + releaseId: runtime.config.RELEASE_ID, + }); + } + if (!response.ok) { + throw new ReleaseManifestError("MANIFEST_HTTP_FAILED", { + buildId: runtime.build.buildId, + releaseId: runtime.config.RELEASE_ID, + }); + } + + let raw; + try { + raw = await response.json(); + } catch { + throw new ReleaseManifestError("MANIFEST_JSON_INVALID", { + buildId: runtime.build.buildId, + releaseId: runtime.config.RELEASE_ID, + }); + } + const parsed = releaseManifestSchema.safeParse(raw); + if (!parsed.success) { + throw new ReleaseManifestError("MANIFEST_SCHEMA_INVALID", { + buildId: runtime.build.buildId, + releaseId: runtime.config.RELEASE_ID, + }); + } + + const manifest = parsed.data; + const mismatches = []; + if (manifest.buildId !== runtime.build.buildId) mismatches.push("buildId"); + if ( + runtime.config.BUILD_ID && + manifest.buildId !== runtime.config.BUILD_ID + ) { + mismatches.push("runtimeBuildId"); + } + if ( + manifest.configSchemaVersion !== runtime.config.CONFIG_SCHEMA_VERSION + ) { + mismatches.push("configSchemaVersion"); + } + if (manifest.apiContractVersion !== runtime.config.API_CONTRACT_VERSION) { + mismatches.push("apiContractVersion"); + } + if ( + runtime.config.RELEASE_ID && + manifest.releaseId !== runtime.config.RELEASE_ID + ) { + mismatches.push("releaseId"); + } + if (mismatches.length > 0) { + throw new ReleaseManifestError("MANIFEST_RUNTIME_MISMATCH", { + buildId: runtime.build.buildId, + releaseId: runtime.config.RELEASE_ID, + }); + } + return Object.freeze(structuredClone(manifest)); +} diff --git a/src/bootstrap/main.jsx b/src/bootstrap/main.jsx index d238eb4..b8e5e38 100644 --- a/src/bootstrap/main.jsx +++ b/src/bootstrap/main.jsx @@ -1,10 +1,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { createAnonymousSessionAdapter } from "../adapters/auth/external-session-adapter.js"; import { BootErrorShell } from "../presentation/boundaries/boot-error-shell.jsx"; import { AppRouter } from "../presentation/routes/app-router.jsx"; -import { BootConfigError, loadRuntimeConfig } from "./load-runtime-config.js"; +import { createRuntimeComposition } from "./create-runtime-composition.js"; +import { BootConfigError } from "./load-runtime-config.js"; +import { ReleaseManifestError } from "./load-release-manifest.js"; import "../presentation/styles/theme.css"; const rootElement = document.getElementById("root"); @@ -17,18 +18,18 @@ const root = createRoot(rootElement); async function boot() { try { - const runtime = await loadRuntimeConfig(); + const composition = await createRuntimeComposition(); root.render( , ); } catch (error) { const safe = - error instanceof BootConfigError + error instanceof BootConfigError || error instanceof ReleaseManifestError ? error.safe : { supportReference: "boot:unknown" }; diff --git a/src/bootstrap/runtime-adapters.js b/src/bootstrap/runtime-adapters.js new file mode 100644 index 0000000..d5b6162 --- /dev/null +++ b/src/bootstrap/runtime-adapters.js @@ -0,0 +1,94 @@ +import { + createDemoSessionAdapter, + createExternalAuthSessionAdapter, + createUnavailableSessionAdapter, +} from "../adapters/auth/external-session-adapter.js"; +import { createHttpClient } from "../adapters/http/client.js"; +import { + createQueryCacheAdapter, + createQueryClient, +} from "../adapters/query-cache/tanstack-query-cache.js"; +import { createBrowserStorageAdapter } from "../adapters/storage/browser-storage-adapter.js"; +import { createTelemetryAdapter } from "../adapters/telemetry/best-effort-telemetry.js"; + +/** + * @param {Record} host + * @returns {Parameters[0] | null} + */ +function externalOwnerFrom(host) { + const candidate = host.__CA_FRONTEND_AUTH_OWNER__; + if (!candidate || typeof candidate !== "object") return null; + const owner = /** @type {Record} */ (candidate); + const required = [ + "readState", + "subscribe", + "beginSignIn", + "signOut", + "attachCredential", + "recoverSession", + "notifyUnauthenticated", + ]; + return required.every((name) => typeof owner[name] === "function") + ? /** @type {Parameters[0]} */ ( + candidate + ) + : null; +} + +/** @param {unknown} value */ +function storageOrUndefined(value) { + return typeof Storage !== "undefined" && value instanceof Storage + ? value + : undefined; +} + +/** + * @param {{ + * runtime: Awaited>, + * release: Awaited>, + * host?: Record, + * fetcher?: typeof fetch + * }} context + */ +export async function createRuntimeAdapters(context) { + const host = context.host ?? /** @type {Record} */ (globalThis); + const config = context.runtime.config; + const externalOwner = externalOwnerFrom(host); + const authSession = + config.AUTH_MODE === "demo" + ? createDemoSessionAdapter() + : externalOwner + ? createExternalAuthSessionAdapter(externalOwner) + : createUnavailableSessionAdapter(); + const queryClient = createQueryClient(); + const cache = createQueryCacheAdapter(queryClient); + const storage = createBrowserStorageAdapter({ + localStorage: storageOrUndefined(host.localStorage), + sessionStorage: storageOrUndefined(host.sessionStorage), + }); + const telemetry = createTelemetryAdapter({ + enabled: config.TELEMETRY_ENABLED, + endpoint: config.TELEMETRY_ENDPOINT, + fetcher: context.fetcher, + }); + const http = createHttpClient({ + baseUrl: config.API_BASE_URL, + authSession, + fetcher: context.fetcher, + }); + const releaseInfo = Object.freeze({ + async getCurrent() { + return structuredClone(context.release); + }, + }); + + return Object.freeze({ + authSession, + cache, + http, + queryClient, + releaseInfo, + storage, + telemetry, + }); +} diff --git a/src/bootstrap/runtime-config-schema.js b/src/bootstrap/runtime-config-schema.js index 9de4cb7..c96431c 100644 --- a/src/bootstrap/runtime-config-schema.js +++ b/src/bootstrap/runtime-config-schema.js @@ -10,7 +10,7 @@ export const runtimeConfigSchema = z MAX_RETRY_ATTEMPTS: z.int().min(0).max(2).default(2), TELEMETRY_ENABLED: z.boolean(), TELEMETRY_ENDPOINT: z.url().optional(), - AUTH_MODE: z.literal("external"), + AUTH_MODE: z.enum(["external", "demo"]), CONFIG_SCHEMA_VERSION: version, API_CONTRACT_VERSION: version, RELEASE_MANIFEST_URL: z.string().min(1).default("/release-manifest.json"), @@ -28,6 +28,13 @@ export const runtimeConfigSchema = z } const local = config.APP_ENV === "local" || config.APP_ENV === "development"; + if (!local && config.AUTH_MODE === "demo") { + context.addIssue({ + code: "custom", + path: ["AUTH_MODE"], + message: "demo authentication is limited to local environments", + }); + } const endpointEntries = /** @type {Array<[string, string | undefined]>} */ ([ ["API_BASE_URL", config.API_BASE_URL], diff --git a/tests/e2e/accessibility.spec.js b/tests/e2e/accessibility.spec.js index 307485c..ede6e1e 100644 --- a/tests/e2e/accessibility.spec.js +++ b/tests/e2e/accessibility.spec.js @@ -21,8 +21,9 @@ test("@a11y keyboard reaches the primary route action with visible focus", async page, }) => { await page.goto("/"); - await page.keyboard.press("Tab"); const action = page.getByRole("link", { name: "샘플 리소스" }); + await expect(action).toBeVisible(); + await page.keyboard.press("Tab"); await expect(action).toBeFocused(); await expect(action).toHaveCSS("outline-style", "solid"); }); diff --git a/tests/runtime-schema/release-manifest.test.js b/tests/runtime-schema/release-manifest.test.js new file mode 100644 index 0000000..900cdbd --- /dev/null +++ b/tests/runtime-schema/release-manifest.test.js @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; + +import { + loadReleaseManifest, + ReleaseManifestError, +} from "../../src/bootstrap/load-release-manifest.js"; + +const runtime = { + build: { buildId: "build-a" }, + config: { + RELEASE_MANIFEST_URL: "/release-manifest.json", + BUILD_ID: "build-a", + RELEASE_ID: "release-a", + CONFIG_SCHEMA_VERSION: "1", + API_CONTRACT_VERSION: "1", + }, +}; +const manifest = { + schemaVersion: 1, + appVersion: "0.1.0", + buildId: "build-a", + commitSha: "abc123", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "hash-a", + releaseId: "release-a", + builtAt: "2026-07-25T00:00:00Z", +}; + +describe("release manifest boot boundary", () => { + it("loads a coherent release tuple", async () => { + await expect( + loadReleaseManifest( + /** @type {Parameters[0]} */ (runtime), + { fetcher: async () => new Response(JSON.stringify(manifest)) }, + ), + ).resolves.toMatchObject({ releaseId: "release-a" }); + }); + + it("fails before mount when release and runtime differ", async () => { + await expect( + loadReleaseManifest( + /** @type {Parameters[0]} */ (runtime), + { + fetcher: async () => + new Response(JSON.stringify({ ...manifest, buildId: "build-b" })), + }, + ), + ).rejects.toBeInstanceOf(ReleaseManifestError); + }); +}); diff --git a/tests/runtime-schema/runtime-config.test.js b/tests/runtime-schema/runtime-config.test.js index 114694e..d94b105 100644 --- a/tests/runtime-schema/runtime-config.test.js +++ b/tests/runtime-schema/runtime-config.test.js @@ -35,6 +35,20 @@ describe("runtime configuration boundary", () => { ); }); + it("allows demo authentication only for local runtime configuration", () => { + expect( + validateRuntimeConfig({ ...validConfig, AUTH_MODE: "demo" }).success, + ).toBe(true); + expect( + validateRuntimeConfig({ + ...validConfig, + APP_ENV: "production", + API_BASE_URL: "https://api.example.test", + AUTH_MODE: "demo", + }).success, + ).toBe(false); + }); + it("validates a fetched config under the 500ms budget excluding network", async () => { let current = 100; const result = await loadRuntimeConfig({ diff --git a/tests/unit/auth-session-adapter.test.js b/tests/unit/auth-session-adapter.test.js index d1221e9..f138e5e 100644 --- a/tests/unit/auth-session-adapter.test.js +++ b/tests/unit/auth-session-adapter.test.js @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import { createAnonymousSessionAdapter, + createDemoSessionAdapter, createExternalAuthSessionAdapter, } from "../../src/adapters/auth/external-session-adapter.js"; @@ -9,6 +10,9 @@ describe("external AuthSessionPort adapter", () => { it("attaches opaque credentials without exposing a token-shaped session", async () => { const adapter = createExternalAuthSessionAdapter({ readState: () => "authenticated", + subscribe: () => () => {}, + beginSignIn: async () => {}, + signOut: async () => {}, attachCredential: async (request) => { const headers = new Headers(request.headers); headers.set("X-Session-Attached", "true"); @@ -28,6 +32,9 @@ describe("external AuthSessionPort adapter", () => { it("fails invalid recovery states closed", async () => { const adapter = createExternalAuthSessionAdapter({ readState: () => "authenticated", + subscribe: () => () => {}, + beginSignIn: async () => {}, + signOut: async () => {}, attachCredential: async (request) => request, recoverSession: async () => "unexpected", notifyUnauthenticated: vi.fn(), @@ -41,4 +48,20 @@ describe("external AuthSessionPort adapter", () => { expect(adapter.getState()).toBe("unauthenticated"); await expect(adapter.recover()).resolves.toBe("no-session"); }); + + it("provides a reactive credential-free demo seam", async () => { + const adapter = createDemoSessionAdapter(); + let notifications = 0; + const unsubscribe = adapter.subscribe(() => { + notifications += 1; + }); + + expect(adapter.getState()).toBe("unauthenticated"); + await adapter.beginSignIn("/"); + expect(adapter.getState()).toBe("authenticated"); + await adapter.signOut(); + expect(adapter.getState()).toBe("unauthenticated"); + expect(notifications).toBe(2); + unsubscribe(); + }); }); diff --git a/tests/unit/runtime-adapters.test.js b/tests/unit/runtime-adapters.test.js new file mode 100644 index 0000000..9ba653f --- /dev/null +++ b/tests/unit/runtime-adapters.test.js @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; + +import { createRuntimeAdapters } from "../../src/bootstrap/runtime-adapters.js"; + +const runtime = { + config: { + APP_ENV: "local", + API_BASE_URL: "http://localhost:8080", + TELEMETRY_ENABLED: false, + AUTH_MODE: "demo", + }, +}; +const release = { + schemaVersion: 1, + appVersion: "0.1.0", + buildId: "build-a", + commitSha: "abc123", + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: "hash-a", + releaseId: "release-a", + builtAt: "2026-07-25T00:00:00Z", +}; + +describe("runtime adapter composition", () => { + it("constructs the local demo seam and infrastructure adapters", async () => { + const adapters = await createRuntimeAdapters({ + runtime: + /** @type {Parameters[0]["runtime"]} */ ( + runtime + ), + release, + host: {}, + }); + + expect(adapters.authSession.getState()).toBe("unauthenticated"); + expect(adapters.cache.read(["missing"])).toEqual({ + ok: true, + value: undefined, + }); + await expect(adapters.releaseInfo.getCurrent()).resolves.toMatchObject({ + releaseId: "release-a", + }); + }); + + it("fails closed when an external auth owner was not installed", async () => { + const adapters = await createRuntimeAdapters({ + runtime: + /** @type {Parameters[0]["runtime"]} */ ({ + config: { ...runtime.config, AUTH_MODE: "external" }, + }), + release, + host: {}, + }); + expect(adapters.authSession.getState()).toBe("integration-failed"); + }); +});