729 lines
29 KiB
TypeScript
729 lines
29 KiB
TypeScript
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<typeof convexTest>, input: Partial<any> = {}) {
|
|
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;
|
|
}
|
|
});
|
|
});
|