Files
eden/tests/convex.test.ts
T
2026-09-18 23:15:05 -04:00

449 lines
14 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";
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"],
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";
});
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: [],
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,
});
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("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",
}),
).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",
}),
).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",
});
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("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();
});
});