import { describe, expect, test, beforeEach } from "bun:test"; import { makeFunctionReference } from "convex/server"; import { convexTest } from "convex-test"; import schema from "../src/convex/schema"; import type { Asset } from "../src/lib/domain/model"; process.env.CONVEX_BRIDGE_SECRET = "test-bridge-secret"; process.env.APP_CAPABILITY_ALLOWLIST = "asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions"; const modules = { "src/convex/functions.ts": () => import("../src/convex/functions"), "src/convex/status.ts": () => import("../src/convex/status"), // convex-test uses this marker to resolve a custom functions directory. These // generated modules are not imported by our generic wrappers. "src/convex/_generated/server.ts": async () => ({}), }; const query = (name: string) => makeFunctionReference<"query", any, any>(`functions:${name}`); const statusQuery = (name: string) => makeFunctionReference<"query", any, any>(`status:${name}`); const mutation = (name: string) => makeFunctionReference<"mutation", any, any>(`functions:${name}`); const bridge = { bridgeSecret: "test-bridge-secret" }; const asset = (characterId: number, itemId: string): Asset => ({ itemId, characterId, typeId: 34, quantity: 1, locationId: "60003760", locationType: "station", locationFlag: "Hangar", }); async function login(t: ReturnType, input: Partial = {}) { return await t.mutation(mutation("bridgeCompleteLogin"), { ...bridge, characterId: 100, name: "Capsuleer", ownerHash: "owner-a", scopes: ["esi-assets.read_assets.v1"], intent: "initial_login", capability: "asset_search", requestedScopes: ["esi-assets.read_assets.v1"], approvedExistingScopes: [], encryptedTokens: "encrypted", tokenExpiresAt: Date.now() + 60_000, sessionHash: "session-hash", sessionExpiresAt: Date.now() + 60_000, ...input, }); } describe("Convex persistence", () => { beforeEach(() => { process.env.CONVEX_BRIDGE_SECRET = "test-bridge-secret"; process.env.APP_CAPABILITY_ALLOWLIST = "asset_search,asset_valuation,asset_sync,assistant_dispatch,session_token,identity,subscriptions"; }); test("rejects unauthenticated bridge calls and keeps tenants separate", async () => { const t = convexTest({ schema, modules }); await expect( t.mutation(mutation("bridgeCompleteLogin"), { ...bridge, characterId: 100, name: "Capsuleer", ownerHash: "owner-a", scopes: [], intent: "initial_login", capability: "asset_search", requestedScopes: ["esi-assets.read_assets.v1"], approvedExistingScopes: [], encryptedTokens: "secret", tokenExpiresAt: Date.now() + 60_000, sessionHash: "hash", sessionExpiresAt: Date.now() + 60_000, }), ).resolves.toMatchObject({ userId: expect.any(String) }); await expect( t.query(query("bridgeCharacters"), { bridgeSecret: "wrong", userId: "other" }), ).rejects.toThrow(); await expect( t.query(query("bridgeTokenRecord"), { ...bridge, userId: "other", characterId: 100 }), ).resolves.toBeNull(); }); test("owner hashes stay per-character and explicit linking requires an existing user", async () => { const t = convexTest({ schema, modules }); const first = await login(t, { sessionHash: "first-session" }); const separate = await login(t, { characterId: 101, sessionHash: "second-session", ownerHash: "owner-a", }); expect(separate.userId).not.toBe(first.userId); const linked = await login(t, { characterId: 102, sessionHash: "linked-session", ownerHash: "owner-a", userId: first.userId, intent: "link_character", initiatingSessionHash: "first-session", }); expect(linked.userId).toBe(first.userId); await expect( login(t, { characterId: 103, sessionHash: "missing-user-session", userId: "missing-user", }), ).rejects.toThrow("User does not exist"); }); test("a verified wallet grant authorizes balance, journal and transaction dataset states", async () => { const t = convexTest({ schema, modules }); const loggedIn = await login(t, { characterId: 105, sessionHash: "wallet-session", scopes: ["esi-wallet.read_character_wallet.v1"], capability: "wallet_balance", requestedScopes: ["esi-wallet.read_character_wallet.v1"], }); const characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: loggedIn.userId }); const walletStates = characters[0].datasetStatuses.filter((entry: any) => entry.dataset.startsWith("wallet_")); expect(walletStates).toHaveLength(3); expect(walletStates.every((entry: any) => entry.status === "never")).toBe(true); }); test("linking revalidates the initiating session in the persistence transaction", async () => { const t = convexTest({ schema, modules }); const first = await login(t, { characterId: 110, sessionHash: "stale-link-session" }); await t.mutation(mutation("bridgeDeleteSession"), { ...bridge, sessionHash: "stale-link-session" }); await expect(login(t, { userId: first.userId, characterId: 111, ownerHash: "owner-alt", sessionHash: "new-link-session", intent: "link_character", initiatingSessionHash: "stale-link-session", })).rejects.toThrow("Initiating session is no longer valid"); }); test("two populated tenants cannot borrow character selectors", async () => { const t = convexTest({ schema, modules }); const first = await login(t, { characterId: 120, sessionHash: "tenant-a-session", ownerHash: "tenant-a-owner" }); const second = await login(t, { characterId: 121, sessionHash: "tenant-b-session", ownerHash: "tenant-b-owner" }); for (const [account, characterId] of [[first, 120], [second, 121]] as const) { const generation = await t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: account.userId, characterId, }); await t.mutation(mutation("bridgeFinishSync"), { ...bridge, userId: account.userId, characterId, generation, chunkCount: 0, syncedAt: Date.now(), expiresAt: Date.now() + 60_000, }); } await expect(t.query(query("bridgeAssetsPage"), { ...bridge, userId: first.userId, characterId: 121, paginationOpts: { numItems: 10, cursor: null }, })).resolves.toMatchObject({ page: [], generation: null }); await expect(t.mutation(mutation("bridgeSetPrimary"), { ...bridge, userId: first.userId, characterId: 121, initiatingSessionHash: "tenant-a-session", })).rejects.toThrow("not eligible"); }); test("asset lease binds refreshed authority revisions, scopes, and credential version", async () => { const t = convexTest({ schema, modules }); const account = await login(t, { characterId: 122, sessionHash: "authority-session", ownerHash: "authority-owner" }); const committed = await t.mutation(mutation("bridgeSaveTokens"), { ...bridge, userId: account.userId, characterId: 122, encryptedTokens: "refreshed", tokenExpiresAt: Date.now() + 60_000, scopes: ["esi-assets.read_assets.v1"], expectedVersion: 1, returnAuthority: true, }); expect(committed).toMatchObject({ saved: true, credentialVersion: 2, grantRevision: 1, authorizationEpoch: 1 }); await expect(t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: account.userId, characterId: 122, expectedScopes: ["esi-assets.read_assets.v1"], expectedGrantRevision: 999, expectedAuthorizationEpoch: 1, expectedCredentialVersion: 2, })).rejects.toThrow("authorization changed before lease"); await expect(t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: account.userId, characterId: 122, expectedScopes: ["other.scope"], expectedGrantRevision: 1, expectedAuthorizationEpoch: 1, expectedCredentialVersion: 2, })).rejects.toThrow("scopes changed before lease"); const generation = await t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: account.userId, characterId: 122, expectedScopes: ["esi-assets.read_assets.v1"], expectedGrantRevision: 1, expectedAuthorizationEpoch: 1, expectedCredentialVersion: 2, }); expect(generation).toEqual(expect.any(String)); }); test("OAuth state is one-time and token writes use CAS", async () => { const t = convexTest({ schema, modules }); await expect( t.mutation(mutation("bridgeCreateOAuth"), { ...bridge, stateHash: "expired-state", bindingHash: "binding", encryptedVerifier: "verifier", expiresAt: Date.now() - 1, returnTo: "/assets", intent: "initial_login", capability: "asset_search", requestedScopes: ["esi-assets.read_assets.v1"], approvedExistingScopes: [], }), ).rejects.toThrow("Invalid OAuth state expiry"); await expect( t.mutation(mutation("bridgeCreateOAuth"), { ...bridge, stateHash: "long-state", bindingHash: "binding", encryptedVerifier: "verifier", expiresAt: Date.now() + 660_000, // safely beyond the limit, not a 1ms wall-clock race returnTo: "/assets", intent: "initial_login", capability: "asset_search", requestedScopes: ["esi-assets.read_assets.v1"], approvedExistingScopes: [], }), ).rejects.toThrow("Invalid OAuth state expiry"); await t.mutation(mutation("bridgeCreateOAuth"), { ...bridge, stateHash: "state", bindingHash: "binding", encryptedVerifier: "verifier", expiresAt: Date.now() + 60_000, returnTo: "/assets", intent: "initial_login", capability: "asset_search", requestedScopes: ["esi-assets.read_assets.v1"], approvedExistingScopes: [], }); const consumed = await t.mutation(mutation("bridgeConsumeOAuth"), { ...bridge, stateHash: "state", bindingHash: "binding", }); expect(consumed).toMatchObject({ encryptedVerifier: "verifier" }); await expect( t.mutation(mutation("bridgeConsumeOAuth"), { ...bridge, stateHash: "state", bindingHash: "binding" }), ).resolves.toBeNull(); const loggedIn = await login(t); const record = await t.query(query("bridgeTokenRecord"), { ...bridge, userId: loggedIn.userId, characterId: 100, }); expect(record).toMatchObject({ version: 1, encryptedTokens: "encrypted" }); await expect( t.mutation(mutation("bridgeSaveTokens"), { ...bridge, userId: loggedIn.userId, characterId: 100, encryptedTokens: "new", tokenExpiresAt: Date.now() + 60_000, scopes: [], expectedVersion: 0, }), ).resolves.toBe(false); await expect( t.mutation(mutation("bridgeSaveTokens"), { ...bridge, userId: loggedIn.userId, characterId: 100, encryptedTokens: "new", tokenExpiresAt: Date.now() + 60_000, scopes: [], expectedVersion: 1, }), ).resolves.toBe(true); }); test("failed snapshot does not replace a complete snapshot", async () => { const t = convexTest({ schema, modules }); const loggedIn = await login(t); const emptyGeneration = await t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, }); await t.mutation(mutation("bridgeFinishSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation: emptyGeneration, chunkCount: 0, syncedAt: Date.now(), expiresAt: Date.now() + 60_000, }); const emptyPage = await t.query(query("bridgeAssetsPage"), { ...bridge, userId: loggedIn.userId, characterId: 100, paginationOpts: { numItems: 10, cursor: null }, }); expect(emptyPage.page).toEqual([]); const generation = await t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, }); await t.mutation(mutation("bridgeStageAssets"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation, index: 0, assets: [asset(100, "old")], }); await t.mutation(mutation("bridgeFinishSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation, chunkCount: 1, syncedAt: Date.now(), expiresAt: Date.now() + 60_000, }); const failedGeneration = await t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, }); await t.mutation(mutation("bridgeStageAssets"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation: failedGeneration, index: 0, assets: [asset(100, "partial")], }); await t.mutation(mutation("bridgeFailSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation: failedGeneration, error: "ESI failed", }); const page = await t.query(query("bridgeAssetsPage"), { ...bridge, userId: loggedIn.userId, characterId: 100, paginationOpts: { numItems: 10, cursor: null }, }); expect(page.page.map((item: Asset) => item.itemId)).toEqual(["old"]); expect(page.generation).toBe(generation); const promotedGeneration = await t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, }); await t.mutation(mutation("bridgeStageAssets"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation: promotedGeneration, index: 0, assets: [asset(100, "new")], }); await t.mutation(mutation("bridgeFinishSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation: promotedGeneration, chunkCount: 1, syncedAt: Date.now(), expiresAt: Date.now() + 60_000, }); const changed = await t.query(query("bridgeAssetsPage"), { ...bridge, userId: loggedIn.userId, characterId: 100, expectedGeneration: generation, paginationOpts: { numItems: 10, cursor: null }, }); expect(changed.generation).toBe(promotedGeneration); expect(changed.page).toEqual([]); }); test("credential rotation fences an in-flight asset publication", async () => { const t = convexTest({ schema, modules }); const loggedIn = await login(t, { characterId: 150, sessionHash: "credential-fence-session" }); const generation = await t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 150, }); await expect(t.mutation(mutation("bridgeSaveTokens"), { ...bridge, userId: loggedIn.userId, characterId: 150, encryptedTokens: "rotated", tokenExpiresAt: Date.now() + 60_000, scopes: ["esi-assets.read_assets.v1"], expectedVersion: 1, })).resolves.toBe(true); await expect(t.mutation(mutation("bridgeFinishSync"), { ...bridge, userId: loggedIn.userId, characterId: 150, generation, chunkCount: 0, syncedAt: Date.now(), expiresAt: Date.now() + 60_000, })).rejects.toThrow("authorization is stale"); const beforeFailure = await t.query(query("bridgeCharacters"), { ...bridge, userId: loggedIn.userId }); await expect(t.mutation(mutation("bridgeFailSync"), { ...bridge, userId: loggedIn.userId, characterId: 150, generation, error: "stale worker", })).rejects.toThrow("failure publication is stale"); const afterFailure = await t.query(query("bridgeCharacters"), { ...bridge, userId: loggedIn.userId }); expect(afterFailure).toEqual(beforeFailure); }); test("invalid snapshot timestamps never promote", async () => { const t = convexTest({ schema, modules }); const timestamp = Date.now(); const cases = [ { syncedAt: timestamp + 60_000, expiresAt: timestamp + 120_000 }, { syncedAt: -1, expiresAt: timestamp + 2_000 }, { syncedAt: timestamp - 2_000, expiresAt: timestamp - 1 }, { syncedAt: timestamp - 2_000, expiresAt: timestamp - 2_000 }, { syncedAt: timestamp - 1_000, expiresAt: timestamp + 3_600_001 }, ]; for (const [index, values] of cases.entries()) { const loggedIn = await login(t, { characterId: 200 + index, sessionHash: `timestamp-session-${index}`, }); const generation = await t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 200 + index, }); await expect( t.mutation(mutation("bridgeFinishSync"), { ...bridge, userId: loggedIn.userId, characterId: 200 + index, generation, chunkCount: 0, ...values, }), ).rejects.toThrow("Invalid snapshot timestamps"); const page = await t.query(query("bridgeAssetsPage"), { ...bridge, userId: loggedIn.userId, characterId: 200 + index, paginationOpts: { numItems: 10, cursor: null }, }); expect(page.generation).toBeNull(); await t.mutation(mutation("bridgeFailSync"), { ...bridge, userId: loggedIn.userId, characterId: 200 + index, generation, error: "invalid test timestamp", }); } }); test("snapshot staging requires contiguous immutable chunks", async () => { const t = convexTest({ schema, modules }); const loggedIn = await login(t); const generation = await t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, }); await expect( t.mutation(mutation("bridgeStageAssets"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation, index: 1, assets: [asset(100, "gap")], }), ).rejects.toThrow(); await t.mutation(mutation("bridgeStageAssets"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation, index: 0, assets: [asset(100, "first")], }); await expect( t.mutation(mutation("bridgeStageAssets"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation, index: 0, assets: [asset(100, "duplicate")], }), ).rejects.toThrow(); await expect( t.mutation(mutation("bridgeFinishSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation, chunkCount: 2, syncedAt: Date.now(), expiresAt: Date.now() + 60_000, }), ).rejects.toThrow(); await t.mutation(mutation("bridgeStageAssets"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation, index: 1, assets: [asset(100, "second")], }); await t.mutation(mutation("bridgeFinishSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, generation, chunkCount: 2, syncedAt: Date.now(), expiresAt: Date.now() + 60_000, }); const page = await t.query(query("bridgeAssetsPage"), { ...bridge, userId: loggedIn.userId, characterId: 100, paginationOpts: { numItems: 10, cursor: null }, }); expect(page.page.map((item: Asset) => item.itemId)).toEqual(["first", "second"]); }); test("interaction idempotency permits same-message resume", async () => { const t = convexTest({ schema, modules }); const loggedIn = await login(t); const blocked = await t.mutation(mutation("bridgeSaveInteraction"), { ...bridge, userId: loggedIn.userId, requestId: "request-1", message: "show my assets", result: { reason: "authentication_required" }, status: "requirements_missing", }); const resumed = await t.mutation(mutation("bridgeSaveInteraction"), { ...bridge, userId: loggedIn.userId, requestId: "request-1", message: "show my assets", result: { total: "42" }, status: "ready", }); expect(blocked.created).toBe(true); expect(resumed).toMatchObject({ created: false, status: "ready", result: { total: "42" } }); await expect( t.mutation(mutation("bridgeSaveInteraction"), { ...bridge, userId: loggedIn.userId, requestId: "request-1", message: "show my characters", result: {}, status: "ready", }), ).rejects.toThrow(); }); test("browser status is safe and honors session revocation", async () => { process.env.APP_AUTH_ISSUER = "https://auth.example.test"; const t = convexTest({ schema, modules }); const loggedIn = await login(t); const browser = t.withIdentity({ subject: loggedIn.userId, issuer: "https://auth.example.test", sid: loggedIn.sessionId, } as any); const safe = await browser.query(statusQuery("characters"), {}); expect(safe).toMatchObject([{ id: 100, userId: loggedIn.userId, assets: { status: "never" } }]); expect(JSON.stringify(safe)).not.toContain("encrypted"); await t.mutation(mutation("bridgeDeleteSession"), { ...bridge, sessionHash: "session-hash" }); await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull(); }); test("backend rollout gate blocks status subscriptions and sync mutations", async () => { const previous = process.env.APP_CAPABILITY_ALLOWLIST; process.env.APP_CAPABILITY_ALLOWLIST = ""; try { process.env.APP_AUTH_ISSUER = "https://auth.example.test"; const t = convexTest({ schema, modules }); const loggedIn = await login(t, { sessionHash: "gated-session" }); const browser = t.withIdentity({ subject: loggedIn.userId, issuer: "https://auth.example.test", sid: loggedIn.sessionId, } as any); await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull(); await expect(t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 100, })).rejects.toThrow("asset_sync"); } finally { if (previous === undefined) delete process.env.APP_CAPABILITY_ALLOWLIST; else process.env.APP_CAPABILITY_ALLOWLIST = previous; } }); test("single-primary linking, consent fencing, cancellation and owner quarantine are explicit", async () => { const t = convexTest({ schema, modules }); const first = await login(t, { characterId: 300, sessionHash: "primary-session", ownerHash: "owner-primary" }); const linked = await login(t, { userId: first.userId, characterId: 301, sessionHash: "link-session", initiatingSessionHash: "primary-session", intent: "link_character", capability: "asset_search", requestedScopes: ["esi-assets.read_assets.v1"], approvedExistingScopes: [], ownerHash: "owner-alt", }); let characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId }); expect(characters).toEqual(expect.arrayContaining([ expect.objectContaining({ characterId: 300, loginRole: "primary", authorizationStatus: "active" }), expect.objectContaining({ characterId: 301, loginRole: "data_only", authorizationStatus: "active" }), ])); await expect(t.query(query("bridgeSession"), { ...bridge, sessionHash: "primary-session" })).resolves.toBeNull(); await expect(t.query(query("bridgeSession"), { ...bridge, sessionHash: "link-session" })).resolves.toMatchObject({ userId: first.userId }); await expect(login(t, { characterId: 301, ownerHash: "owner-alt", sessionHash: "signed-out-alt" })).rejects.toThrow("Data-only"); await expect(login(t, { userId: first.userId, characterId: 301, ownerHash: "owner-alt", sessionHash: "duplicate-link-session", intent: "link_character", initiatingSessionHash: "link-session", })).rejects.toThrow("already linked"); const other = await login(t, { characterId: 302, ownerHash: "owner-other", sessionHash: "other-session" }); await expect(t.mutation(mutation("bridgeSetPrimary"), { ...bridge, userId: first.userId, characterId: 302, initiatingSessionHash: "link-session", })).rejects.toThrow("not eligible"); expect(other.userId).not.toBe(first.userId); await expect(t.mutation(mutation("bridgeInvalidateAuthorization"), { ...bridge, userId: first.userId, characterId: 300, initiatingSessionHash: "link-session", })).rejects.toThrow("Switch the primary character"); await t.mutation(mutation("bridgeSetPrimary"), { ...bridge, userId: first.userId, characterId: 301, initiatingSessionHash: "link-session", }); await t.mutation(mutation("bridgeInvalidateAuthorization"), { ...bridge, userId: first.userId, characterId: 300, initiatingSessionHash: "link-session", }); characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId }); expect(characters).toEqual(expect.arrayContaining([ expect.objectContaining({ characterId: 300, loginRole: "data_only", authorizationStatus: "revoked" }), expect.objectContaining({ characterId: 301, loginRole: "primary" }), ])); await expect(t.query(query("bridgeTokenRecord"), { ...bridge, userId: first.userId, characterId: 300 })).resolves.toBeNull(); await expect(t.query(query("bridgeAssetsPage"), { ...bridge, userId: first.userId, characterId: 300, paginationOpts: { numItems: 10, cursor: null }, })).resolves.toMatchObject({ page: [], generation: null }); const switched = await login(t, { characterId: 301, ownerHash: "owner-alt", sessionHash: "signed-in-primary" }); expect(switched.userId).toBe(first.userId); const reduced = await login(t, { userId: first.userId, characterId: 301, sessionHash: "reduced-session", initiatingSessionHash: "signed-in-primary", intent: "reconnect", capability: "asset_search", targetCharacterId: 301, requestedScopes: ["esi-assets.read_assets.v1"], scopes: [], ownerHash: "owner-alt", }); expect(reduced.userId).toBe(first.userId); characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId }); expect(characters.find((character: any) => character.characterId === 301)).toMatchObject({ authorizationStatus: "active", grantRevision: 2, datasetStatuses: expect.arrayContaining([expect.objectContaining({ dataset: "assets", status: "blocked" })]), }); await expect(t.query(query("bridgeAssetsPage"), { ...bridge, userId: first.userId, characterId: 301, paginationOpts: { numItems: 10, cursor: null }, })).resolves.toMatchObject({ page: [], generation: null }); const conflict = await t.mutation(mutation("bridgeCompleteLogin"), { ...bridge, characterId: 300, name: "Borrowed", ownerHash: "different-owner", scopes: [], encryptedTokens: "borrowed", tokenExpiresAt: Date.now() + 60_000, sessionHash: "borrowed-session", sessionExpiresAt: Date.now() + 60_000, intent: "initial_login", capability: "asset_search", requestedScopes: ["esi-assets.read_assets.v1"], approvedExistingScopes: [], }); expect(conflict).toMatchObject({ rejected: "ownership_conflict" }); characters = await t.query(query("bridgeCharacters"), { ...bridge, userId: first.userId }); expect(characters.find((character: any) => character.characterId === 300)).toMatchObject({ authorizationStatus: "quarantined", datasetStatuses: expect.arrayContaining([expect.objectContaining({ dataset: "assets", status: "quarantined" })]), }); await expect(t.query(query("bridgeTokenRecord"), { ...bridge, userId: first.userId, characterId: 300 })).resolves.toBeNull(); await expect(t.query(query("bridgeAssetsPage"), { ...bridge, userId: first.userId, characterId: 300, paginationOpts: { numItems: 10, cursor: null }, })).resolves.toMatchObject({ page: [], generation: null }); }); test("backend rollout gate fails closed when propagation is missing or malformed", async () => { const previous = process.env.APP_CAPABILITY_ALLOWLIST; try { for (const value of [undefined, "not-a-capability"]) { if (value === undefined) delete process.env.APP_CAPABILITY_ALLOWLIST; else process.env.APP_CAPABILITY_ALLOWLIST = value; process.env.APP_AUTH_ISSUER = "https://auth.example.test"; const t = convexTest({ schema, modules }); const loggedIn = await login(t, { sessionHash: `missing-gate-${value ?? 'unset'}` }); const browser = t.withIdentity({ subject: loggedIn.userId, issuer: "https://auth.example.test", sid: loggedIn.sessionId } as any); await expect(browser.query(statusQuery("characters"), {})).resolves.toBeNull(); await expect(t.mutation(mutation("bridgeBeginSync"), { ...bridge, userId: loggedIn.userId, characterId: 100 })).rejects.toThrow("asset_sync"); } } finally { if (previous === undefined) delete process.env.APP_CAPABILITY_ALLOWLIST; else process.env.APP_CAPABILITY_ALLOWLIST = previous; } }); });