diff --git a/src/__tests__/trpc/migrationAuth.test.ts b/src/__tests__/trpc/migrationAuth.test.ts new file mode 100644 index 00000000..42625ca4 --- /dev/null +++ b/src/__tests__/trpc/migrationAuth.test.ts @@ -0,0 +1,210 @@ +import { beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; + +import { makeAnonymousCtx, makeWalletCtx } from "./helpers"; + +jest.mock("superjson", () => ({ + __esModule: true, + default: { + serialize: (value: unknown) => value, + deserialize: (value: unknown) => value, + }, +})); + +jest.mock("@/server/auth", () => ({ + __esModule: true, + getServerAuthSession: jest.fn(), +})); + +let createCaller: typeof import("@/server/api/root").createCaller; + +const SIGNER = "addr_test1signer"; +const COSIGNER = "addr_test1cosigner"; +const STRANGER = "addr_test1stranger"; + +const walletRow = { + id: "wallet-1", + ownerAddress: SIGNER, + signersAddresses: [SIGNER, COSIGNER], +}; + +const activeMigration = { + id: "migration-1", + originalWalletId: walletRow.id, + ownerAddress: SIGNER, + status: "pending", + currentStep: 0, + migrationData: { plan: "secret" }, + createdAt: new Date("2026-11-01T00:00:00Z"), +}; + +const makeMockDb = (existing: typeof activeMigration | null = null) => ({ + wallet: { + findUnique: jest.fn(async ({ where }: { where: { id: string } }) => + where.id === walletRow.id ? walletRow : null, + ), + }, + migration: { + findFirst: jest.fn(async (_args: unknown) => existing), + create: jest.fn(async ({ data }: { data: Record }) => ({ + id: "migration-new", + ...data, + })), + }, +}); + +/** + * A wallet may have only one active migration. Whoever opens it therefore + * holds a lock on the wallet's migration flow, and the row records who that + * is and what they planned — so both opening and reading it are limited to + * the wallet's own signers, not to anyone with a session. + */ +describe("migration router wallet authorization", () => { + beforeAll(async () => { + ({ createCaller } = await import("@/server/api/root")); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("createMigration", () => { + it("refuses a signed-in stranger without creating the lock", async () => { + const db = makeMockDb(); + const caller = createCaller(makeWalletCtx(STRANGER, db) as any); + + await expect( + caller.migration.createMigration({ + originalWalletId: walletRow.id, + ownerAddress: STRANGER, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(db.migration.create).not.toHaveBeenCalled(); + }); + + it("does not tell a stranger who holds an existing migration", async () => { + const db = makeMockDb(activeMigration); + const caller = createCaller(makeWalletCtx(STRANGER, db) as any); + + const attempt = caller.migration.createMigration({ + originalWalletId: walletRow.id, + ownerAddress: STRANGER, + }); + await expect(attempt).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect(attempt).rejects.not.toThrow(SIGNER); + }); + + it("answers NOT_FOUND for a wallet id that does not exist", async () => { + const db = makeMockDb(); + const caller = createCaller(makeWalletCtx(SIGNER, db) as any); + + await expect( + caller.migration.createMigration({ + originalWalletId: "no-such-wallet", + ownerAddress: SIGNER, + }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + + expect(db.migration.create).not.toHaveBeenCalled(); + }); + + it("lets a signer open a migration for their wallet", async () => { + const db = makeMockDb(); + const caller = createCaller(makeWalletCtx(COSIGNER, db) as any); + + const created = await caller.migration.createMigration({ + originalWalletId: walletRow.id, + ownerAddress: COSIGNER, + }); + + expect(created).toMatchObject({ + originalWalletId: walletRow.id, + ownerAddress: COSIGNER, + status: "pending", + }); + }); + + it("still refuses an ownerAddress the session does not control", async () => { + const db = makeMockDb(); + const caller = createCaller(makeWalletCtx(COSIGNER, db) as any); + + await expect( + caller.migration.createMigration({ + originalWalletId: walletRow.id, + ownerAddress: SIGNER, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + // The address check comes first, so a mismatched caller learns nothing + // about whether the wallet id exists. + expect(db.wallet.findUnique).not.toHaveBeenCalled(); + }); + + it("records the lock only under an address that belongs to the wallet", async () => { + const db = makeMockDb(); + const ctx = makeWalletCtx(SIGNER, db); + // One session holding a signer address and an unrelated one. + ctx.sessionWallets = [SIGNER, STRANGER]; + const caller = createCaller(ctx as any); + + await expect( + caller.migration.createMigration({ + originalWalletId: walletRow.id, + ownerAddress: STRANGER, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(db.migration.create).not.toHaveBeenCalled(); + }); + }); + + describe("getMigrationByOriginalWallet", () => { + it("refuses an anonymous caller", async () => { + const db = makeMockDb(activeMigration); + const caller = createCaller(makeAnonymousCtx(db) as any); + + await expect( + caller.migration.getMigrationByOriginalWallet({ + originalWalletId: walletRow.id, + }), + ).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + + expect(db.migration.findFirst).not.toHaveBeenCalled(); + }); + + it("refuses a signed-in stranger without reading the migration", async () => { + const db = makeMockDb(activeMigration); + const caller = createCaller(makeWalletCtx(STRANGER, db) as any); + + await expect( + caller.migration.getMigrationByOriginalWallet({ + originalWalletId: walletRow.id, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + + expect(db.migration.findFirst).not.toHaveBeenCalled(); + }); + + it("shows a co-signer the migration another signer started", async () => { + const db = makeMockDb(activeMigration); + const caller = createCaller(makeWalletCtx(COSIGNER, db) as any); + + await expect( + caller.migration.getMigrationByOriginalWallet({ + originalWalletId: walletRow.id, + }), + ).resolves.toMatchObject({ id: activeMigration.id, ownerAddress: SIGNER }); + }); + + it("returns null to a signer when no migration is active", async () => { + const db = makeMockDb(null); + const caller = createCaller(makeWalletCtx(SIGNER, db) as any); + + await expect( + caller.migration.getMigrationByOriginalWallet({ + originalWalletId: walletRow.id, + }), + ).resolves.toBeNull(); + }); + }); +}); diff --git a/src/server/api/routers/migrations.ts b/src/server/api/routers/migrations.ts index 512d017f..288033a7 100644 --- a/src/server/api/routers/migrations.ts +++ b/src/server/api/routers/migrations.ts @@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import type { AuthCtx } from "@/server/api/trpc"; +import { assertWalletAccess } from "@/server/api/auth"; const requireSessionAddress = (ctx: AuthCtx) => { const address = ctx.session?.user?.id ?? ctx.sessionAddress; @@ -80,6 +81,27 @@ export const migrationRouter = createTRPCRouter({ if (!addresses.includes(input.ownerAddress)) { throw new TRPCError({ code: "FORBIDDEN", message: "Address mismatch" }); } + // Proving control of an address says nothing about the wallet being + // migrated. Without this, any signed-in user could open a migration for + // someone else's wallet id — and because only one active migration is + // allowed per wallet, that blocks the real signers from migrating until + // the stranger cancels it. + // The initiator itself must belong to the wallet: assertWalletAccess + // would also accept any other address in a multi-wallet session, which + // would record the lock under an address that is not a signer. + const wallet = await ctx.db.wallet.findUnique({ + where: { id: input.originalWalletId }, + }); + if (!wallet) { + throw new TRPCError({ code: "NOT_FOUND", message: "Wallet not found" }); + } + const initiatorBelongs = + wallet.ownerAddress === input.ownerAddress || + (Array.isArray(wallet.signersAddresses) && + wallet.signersAddresses.includes(input.ownerAddress)); + if (!initiatorBelongs) { + throw new TRPCError({ code: "FORBIDDEN", message: "Not authorized for this wallet" }); + } // Check if there's already an active migration for this wallet const existingMigration = await ctx.db.migration.findFirst({ @@ -225,7 +247,12 @@ export const migrationRouter = createTRPCRouter({ getMigrationByOriginalWallet: protectedProcedure .input(z.object({ originalWalletId: z.string() })) .query(async ({ ctx, input }) => { - const sessionAddress = requireSessionAddress(ctx); + // Deliberately not owner-scoped: every signer must see a migration + // another signer started, so they don't start a second one. It is still + // wallet-scoped — the row carries the initiator's address and the + // migration plan, which are nobody else's business. + requireSessionAddress(ctx); + await assertWalletAccess(ctx, input.originalWalletId); return ctx.db.migration.findFirst({ where: { originalWalletId: input.originalWalletId,