feat: add dataset consent and primary character model

This commit is contained in:
jb
2026-09-19 12:12:10 -04:00
parent a3dc8637f0
commit 4d3c7a7521
23 changed files with 1068 additions and 138 deletions
+208
View File
@@ -37,6 +37,10 @@ async function login(t: ReturnType<typeof convexTest>, input: Partial<any> = {})
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",
@@ -60,6 +64,10 @@ describe("Convex persistence", () => {
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",
@@ -89,6 +97,8 @@ describe("Convex persistence", () => {
sessionHash: "linked-session",
ownerHash: "owner-a",
userId: first.userId,
intent: "link_character",
initiatingSessionHash: "first-session",
});
expect(linked.userId).toBe(first.userId);
@@ -101,6 +111,57 @@ describe("Convex persistence", () => {
).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("OAuth state is one-time and token writes use CAS", async () => {
const t = convexTest({ schema, modules });
await expect(
@@ -111,6 +172,10 @@ describe("Convex persistence", () => {
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(
@@ -121,6 +186,10 @@ describe("Convex persistence", () => {
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"), {
@@ -130,6 +199,10 @@ describe("Convex persistence", () => {
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,
@@ -279,6 +352,38 @@ describe("Convex persistence", () => {
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();
@@ -472,6 +577,109 @@ describe("Convex persistence", () => {
}
});
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 {
+6
View File
@@ -18,6 +18,12 @@ function character(id: number, overrides: Partial<Character> = {}): Character {
userId: 'user-a',
name: `Pilot ${id}`,
scopes: [ESI_ASSETS_SCOPE],
loginRole: 'primary',
authorizationStatus: 'active',
grantRevision: 1,
credentialVersion: 1,
authorizationEpoch: 1,
datasets: [{ dataset: 'assets', status: 'ready', grantRevision: 1, authorizationEpoch: 1, credentialVersion: 1, generation: 'fixture', syncedAt: 900 }],
assets: { status: 'ready', syncedAt: 900, expiresAt: 2_000 },
...overrides,
};
+18
View File
@@ -101,6 +101,24 @@ describe('EVE SSO adapter', () => {
expect(fixture.calls[0]!.init?.signal).toBeInstanceOf(AbortSignal);
});
test('uses the server-selected capability scope list without expanding it', async () => {
const fixture = mockFetch({});
const url = new URL(await sso(fixture).authorizationUrl('state', 'challenge', [
'esi-assets.read_assets.v1',
'esi-wallet.read_character_wallet.v1',
]));
expect(url.searchParams.get('scope')).toBe('esi-assets.read_assets.v1 esi-wallet.read_character_wallet.v1');
});
test('rejects duplicate, empty and non-allowlisted authorization scopes', async () => {
const fixture = mockFetch({});
const client = sso(fixture);
await expect(client.authorizationUrl('state', 'challenge', [])).rejects.toThrow('Invalid EVE SSO requested scopes');
await expect(client.authorizationUrl('state', 'challenge', ['esi-assets.read_assets.v1', 'esi-assets.read_assets.v1'])).rejects.toThrow('Invalid EVE SSO requested scopes');
await expect(client.authorizationUrl('state', 'challenge', ['esi-evil.read_everything.v1'])).rejects.toThrow('Invalid EVE SSO requested scopes');
expect(fixture.calls).toHaveLength(0);
});
test('refreshes cached discovery metadata after its bounded TTL', async () => {
let clock = NOW;
const fixture = mockFetch({});