diff --git a/jest.config.mjs b/jest.config.mjs index 3cb3583f..a5409725 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -16,7 +16,11 @@ export default { }, moduleNameMapper: { '^@/(.*)$': '/src/$1', + '\\.(css|less|scss|sass)$': '/src/__tests__/__mocks__/styleMock.cjs', }, + transformIgnorePatterns: [ + '/node_modules/(?!(superjson|copy-anything|is-what|@trpc|@meshsdk|@noble|@sidan-lab|nanoid|jose|uuid)/)', + ], collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', @@ -28,6 +32,7 @@ export default { coverageProvider: 'v8', coverageDirectory: 'coverage', coverageReporters: ['text', 'lcov', 'html'], + setupFiles: ['/src/__tests__/setupEnv.cjs'], setupFilesAfterEnv: ['/src/__tests__/setup.ts'], testTimeout: 10000, verbose: true, diff --git a/package-lock.json b/package-lock.json index a7a0c437..9ee7a9b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "hasInstallScript": true, "dependencies": { "@auth/prisma-adapter": "^2.11.1", - "@hookform/resolvers": "^3.9.0", "@jinglescode/nostr-chat-plugin": "^0.0.11", "@meshsdk/core": "^1.9.0-beta.87", "@meshsdk/core-csl": "^1.9.0-beta.87", @@ -61,7 +60,6 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", - "react-hook-form": "^7.53.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "superjson": "^2.2.1", @@ -72,7 +70,6 @@ "three": "^0.168.0", "three-globe": "^2.31.1", "uuid": "^11.1.0", - "yaml": "^2.8.2", "zod": "^3.23.8", "zustand": "^4.5.5" }, @@ -1399,14 +1396,6 @@ "@harmoniclabs/plutus-data": "^1.2.4" } }, - "node_modules/@hookform/resolvers": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", - "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", - "peerDependencies": { - "react-hook-form": "^7.0.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -20800,21 +20789,6 @@ "react": ">= 16.8 || 18.0.0" } }, - "node_modules/react-hook-form": { - "version": "7.72.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", - "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", - "engines": { - "node": ">=18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/react-hook-form" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18 || ^19" - } - }, "node_modules/react-immutable-proptypes": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/react-immutable-proptypes/-/react-immutable-proptypes-2.2.0.tgz", @@ -24556,20 +24530,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, - "node_modules/yaml": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", - "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index bc6e55f3..f72acf6f 100644 --- a/package.json +++ b/package.json @@ -19,16 +19,15 @@ "format:check": "prettier --check .", "prestart": "prisma migrate deploy", "start": "next start", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "test:ci": "jest --ci --coverage --watchAll=false", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", + "test:watch": "node --experimental-vm-modules node_modules/jest/bin/jest.js --watch", + "test:coverage": "node --experimental-vm-modules node_modules/jest/bin/jest.js --coverage", + "test:ci": "node --experimental-vm-modules node_modules/jest/bin/jest.js --ci --coverage --watchAll=false", "analyze": "ANALYZE=true npm run build", "apply-project": "node scripts/apply-project-to-github.mjs" }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", - "@hookform/resolvers": "^3.9.0", "@jinglescode/nostr-chat-plugin": "^0.0.11", "@meshsdk/core": "^1.9.0-beta.87", "@meshsdk/core-csl": "^1.9.0-beta.87", @@ -79,7 +78,6 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", - "react-hook-form": "^7.53.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "superjson": "^2.2.1", @@ -90,7 +88,6 @@ "three": "^0.168.0", "three-globe": "^2.31.1", "uuid": "^11.1.0", - "yaml": "^2.8.2", "zod": "^3.23.8", "zustand": "^4.5.5" }, diff --git a/prisma/migrations/20260510160404_audit_log_and_indexes/migration.sql b/prisma/migrations/20260510160404_audit_log_and_indexes/migration.sql new file mode 100644 index 00000000..058fae30 --- /dev/null +++ b/prisma/migrations/20260510160404_audit_log_and_indexes/migration.sql @@ -0,0 +1,88 @@ +-- AlterTable +ALTER TABLE "Ballot" ALTER COLUMN "anchorUrls" SET DEFAULT ARRAY[]::TEXT[], +ALTER COLUMN "anchorHashes" SET DEFAULT ARRAY[]::TEXT[]; + +-- CreateTable +CREATE TABLE "AuditLog" ( + "id" TEXT NOT NULL, + "actorAddress" TEXT, + "actorType" TEXT NOT NULL, + "action" TEXT NOT NULL, + "resourceType" TEXT, + "resourceId" TEXT, + "ip" TEXT, + "userAgent" TEXT, + "outcome" TEXT NOT NULL, + "reason" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "AuditLog_actorAddress_idx" ON "AuditLog"("actorAddress"); + +-- CreateIndex +CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action"); + +-- CreateIndex +CREATE INDEX "AuditLog_resourceType_resourceId_idx" ON "AuditLog"("resourceType", "resourceId"); + +-- CreateIndex +CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt"); + +-- CreateIndex +CREATE INDEX "AuditLog_actorAddress_createdAt_idx" ON "AuditLog"("actorAddress", "createdAt"); + +-- CreateIndex +CREATE INDEX "BalanceSnapshot_walletId_idx" ON "BalanceSnapshot"("walletId"); + +-- CreateIndex +CREATE INDEX "BalanceSnapshot_walletId_snapshotDate_idx" ON "BalanceSnapshot"("walletId", "snapshotDate"); + +-- CreateIndex +CREATE INDEX "Ballot_walletId_idx" ON "Ballot"("walletId"); + +-- CreateIndex +CREATE INDEX "NewWallet_ownerAddress_idx" ON "NewWallet"("ownerAddress"); + +-- CreateIndex +CREATE INDEX "NewWallet_signersAddresses_idx" ON "NewWallet" USING GIN ("signersAddresses" array_ops); + +-- CreateIndex +CREATE INDEX "Proxy_walletId_idx" ON "Proxy"("walletId"); + +-- CreateIndex +CREATE INDEX "Proxy_userId_idx" ON "Proxy"("userId"); + +-- CreateIndex +CREATE INDEX "Proxy_walletId_isActive_idx" ON "Proxy"("walletId", "isActive"); + +-- CreateIndex +CREATE INDEX "Proxy_userId_isActive_idx" ON "Proxy"("userId", "isActive"); + +-- CreateIndex +CREATE INDEX "Signable_walletId_idx" ON "Signable"("walletId"); + +-- CreateIndex +CREATE INDEX "Signable_state_idx" ON "Signable"("state"); + +-- CreateIndex +CREATE INDEX "Signable_walletId_state_idx" ON "Signable"("walletId", "state"); + +-- CreateIndex +CREATE INDEX "Transaction_walletId_idx" ON "Transaction"("walletId"); + +-- CreateIndex +CREATE INDEX "Transaction_state_idx" ON "Transaction"("state"); + +-- CreateIndex +CREATE INDEX "Transaction_walletId_state_idx" ON "Transaction"("walletId", "state"); + +-- CreateIndex +CREATE INDEX "Wallet_ownerAddress_idx" ON "Wallet"("ownerAddress"); + +-- CreateIndex +CREATE INDEX "Wallet_signersAddresses_idx" ON "Wallet" USING GIN ("signersAddresses" array_ops); + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 23d45677..92387e35 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -43,6 +43,9 @@ model Wallet { migrationTargetWalletId String? profileImageIpfsUrl String? ownerAddress String? + + @@index([ownerAddress]) + @@index([signersAddresses(ops: ArrayOps)], type: Gin) } model Transaction { @@ -77,6 +80,10 @@ model Signable { updatedAt DateTime @updatedAt callbackUrl String? remoteOrigin String? + + @@index([walletId]) + @@index([state]) + @@index([walletId, state]) } model NewWallet { @@ -95,6 +102,9 @@ model NewWallet { paymentCbor String? stakeCbor String? rawImportBodies Json? + + @@index([ownerAddress]) + @@index([signersAddresses(ops: ArrayOps)], type: Gin) } model Nonce { @@ -105,18 +115,36 @@ model Nonce { } model Ballot { - id String @id @default(cuid()) - walletId String - description String? - items String[] - itemDescriptions String[] - choices String[] - anchorUrls String[] @default([]) - anchorHashes String[] @default([]) + id String @id @default(cuid()) + walletId String + description String? + items String[] + itemDescriptions String[] + choices String[] + anchorUrls String[] @default([]) + anchorHashes String[] @default([]) rationaleComments String[] @default([]) - type Int - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + type Int + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + @@index([walletId]) +} + +// Retained to preserve the existing production table; feature is currently unused +// in app code. Do not drop without an explicit data-migration plan. +model Crowdfund { + id String @id @default(cuid()) + name String + description String? + proposerKeyHashR0 String + authTokenId String? + datum String? + address String? + paramUtxo String? + govDatum String? + govAddress String? + createdAt DateTime @default(now()) } model Proxy { @@ -146,6 +174,9 @@ model BalanceSnapshot { assetBalances Json isArchived Boolean snapshotDate DateTime @default(now()) + + @@index([walletId]) + @@index([walletId, snapshotDate]) } model Migration { @@ -195,13 +226,13 @@ model BotKey { } model BotUser { - id String @id @default(cuid()) - botKeyId String @unique - paymentAddress String @unique // One bot, one address - stakeAddress String? - displayName String? - createdAt DateTime @default(now()) - botKey BotKey @relation(fields: [botKeyId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + botKeyId String @unique + paymentAddress String @unique // One bot, one address + stakeAddress String? + displayName String? + createdAt DateTime @default(now()) + botKey BotKey @relation(fields: [botKeyId], references: [id], onDelete: Cascade) @@index([paymentAddress]) } @@ -213,11 +244,11 @@ enum BotWalletRole { model WalletBotAccess { walletId String - botId String - role BotWalletRole - createdAt DateTime @default(now()) + botId String + role BotWalletRole + createdAt DateTime @default(now()) - @@unique([walletId, botId]) + @@id([walletId, botId]) @@index([walletId]) @@index([botId]) } @@ -232,10 +263,10 @@ model PendingBot { name String paymentAddress String stakeAddress String? - requestedScopes String // JSON array of requested scopes + requestedScopes String // JSON array of requested scopes status PendingBotStatus @default(UNCLAIMED) - claimedBy String? // ownerAddress of the claiming human - secretCipher String? // Encrypted secret (set on claim, cleared on pickup) + claimedBy String? // ownerAddress of the claiming human + secretCipher String? // Encrypted secret (set on claim, cleared on pickup) pickedUp Boolean @default(false) expiresAt DateTime createdAt DateTime @default(now()) @@ -249,7 +280,7 @@ model BotClaimToken { id String @id @default(cuid()) pendingBotId String @unique pendingBot PendingBot @relation(fields: [pendingBotId], references: [id], onDelete: Cascade) - tokenHash String // SHA-256 hash of the claim code + tokenHash String // SHA-256 hash of the claim code attempts Int @default(0) expiresAt DateTime consumedAt DateTime? @@ -257,3 +288,27 @@ model BotClaimToken { @@index([tokenHash]) } + +// Append-only security audit trail. Writers should never UPDATE existing rows. +// Events are emitted from auth flows, wallet/transaction mutations, and +// privilege-changing actions (bot grants, signer changes, ownership transfers). +model AuditLog { + id String @id @default(cuid()) + actorAddress String? // Wallet address that performed the action (null for system/anonymous) + actorType String // "user" | "bot" | "system" + action String // e.g. "wallet.create", "tx.sign", "bot.grant", "auth.login" + resourceType String? // "wallet" | "transaction" | "bot" | "ballot" | etc. + resourceId String? + ip String? + userAgent String? + outcome String // "success" | "denied" | "error" + reason String? // Short reason on denied/error + metadata Json? // Additional context (no secrets, redacted) + createdAt DateTime @default(now()) + + @@index([actorAddress]) + @@index([action]) + @@index([resourceType, resourceId]) + @@index([createdAt]) + @@index([actorAddress, createdAt]) +} diff --git a/src/__tests__/__mocks__/styleMock.cjs b/src/__tests__/__mocks__/styleMock.cjs new file mode 100644 index 00000000..f053ebf7 --- /dev/null +++ b/src/__tests__/__mocks__/styleMock.cjs @@ -0,0 +1 @@ +module.exports = {}; diff --git a/src/__tests__/apiSecurity.test.ts b/src/__tests__/apiSecurity.test.ts index 667c6959..891567a8 100644 --- a/src/__tests__/apiSecurity.test.ts +++ b/src/__tests__/apiSecurity.test.ts @@ -1,3 +1,4 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; import { TRPCError } from "@trpc/server"; import { applyRateLimit, enforceBodySize } from "@/lib/security/requestGuards"; @@ -67,6 +68,8 @@ describe("wallet router authorization", () => { db: baseDb as any, session: null, sessionAddress: null, + sessionWallets: [], + primaryWallet: null, ip: "3.3.3.3", }); @@ -93,12 +96,14 @@ describe("wallet router authorization", () => { isArchived: false, verified: [], migrationTargetWalletId: null, - }); + } as never); const caller = createCaller({ db: baseDb as any, session: { user: { id: "addr1" }, expires: new Date().toISOString() } as any, sessionAddress: "addr1", + sessionWallets: ["addr1"], + primaryWallet: "addr1", ip: "4.4.4.4", }); @@ -126,12 +131,14 @@ describe("wallet router authorization", () => { verified: [], migrationTargetWalletId: null, }; - baseDb.wallet.findUnique.mockResolvedValueOnce(wallet); + baseDb.wallet.findUnique.mockResolvedValueOnce(wallet as never); const caller = createCaller({ db: baseDb as any, session: { user: { id: "addr1" }, expires: new Date().toISOString() } as any, sessionAddress: "addr1", + sessionWallets: ["addr1"], + primaryWallet: "addr1", ip: "5.5.5.5", }); diff --git a/src/__tests__/botBallotsUpsert.test.ts b/src/__tests__/botBallotsUpsert.test.ts index 1d487b77..b2423af9 100644 --- a/src/__tests__/botBallotsUpsert.test.ts +++ b/src/__tests__/botBallotsUpsert.test.ts @@ -6,36 +6,35 @@ const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise< const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>(); const enforceBodySizeMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean>(); -const verifyJwtMock = jest.fn(); -const isBotJwtMock = jest.fn(); -const assertBotWalletAccessMock = jest.fn(); -const findBotUserMock = jest.fn(); -const transactionMock = jest.fn(); -const parseScopeMock = jest.fn(); -const scopeIncludesMock = jest.fn(); -const isValidChoiceMock = jest.fn(); -const parseProposalIdMock = jest.fn(); +const verifyJwtMock = jest.fn<(...args: any[]) => any>(); +const isBotJwtMock = jest.fn<(...args: any[]) => any>(); +const assertBotWalletAccessMock = jest.fn<(...args: any[]) => any>(); +const findBotUserMock = jest.fn<(...args: any[]) => any>(); +const transactionMock = jest.fn<(...args: any[]) => any>(); +const parseScopeMock = jest.fn<(...args: any[]) => any>(); +const scopeIncludesMock = jest.fn<(...args: any[]) => any>(); +const isValidChoiceMock = jest.fn<(...args: any[]) => any>(); +const parseProposalIdMock = jest.fn<(...args: any[]) => any>(); const txMock = { ballot: { - findUnique: jest.fn(), - findMany: jest.fn(), - create: jest.fn(), - updateMany: jest.fn(), + findUnique: jest.fn<(...args: any[]) => any>(), + findMany: jest.fn<(...args: any[]) => any>(), + create: jest.fn<(...args: any[]) => any>(), + updateMany: jest.fn<(...args: any[]) => any>(), }, }; -jest.mock( +jest.unstable_mockModule( "@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/security/requestGuards", () => ({ __esModule: true, @@ -43,49 +42,44 @@ jest.mock( applyBotRateLimit: applyBotRateLimitMock, enforceBodySize: enforceBodySizeMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/verifyJwt", () => ({ __esModule: true, verifyJwt: verifyJwtMock, isBotJwt: isBotJwtMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/governance", () => ({ __esModule: true, isValidChoice: isValidChoiceMock, parseProposalId: parseProposalIdMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/auth/botKey", () => ({ __esModule: true, parseScope: parseScopeMock, scopeIncludes: scopeIncludesMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/auth/botAccess", () => ({ __esModule: true, assertBotWalletAccess: assertBotWalletAccessMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/server/db", () => ({ __esModule: true, @@ -96,7 +90,6 @@ jest.mock( $transaction: transactionMock, }, }), - { virtual: true }, ); type ResponseMock = NextApiResponse & { statusCode?: number }; diff --git a/src/__tests__/governanceActiveProposals.test.ts b/src/__tests__/governanceActiveProposals.test.ts index 3fdfb895..7b1558f3 100644 --- a/src/__tests__/governanceActiveProposals.test.ts +++ b/src/__tests__/governanceActiveProposals.test.ts @@ -5,64 +5,59 @@ const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>() const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); const applyRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => boolean>(); const applyBotRateLimitMock = jest.fn<(req: NextApiRequest, res: NextApiResponse, botId: string) => boolean>(); -const verifyJwtMock = jest.fn(); -const isBotJwtMock = jest.fn(); -const findBotUserMock = jest.fn(); -const providerGetMock = jest.fn(); -const parseScopeMock = jest.fn(); -const scopeIncludesMock = jest.fn(); -const getProposalStatusMock = jest.fn(); - -jest.mock( +const verifyJwtMock = jest.fn<(...args: any[]) => any>(); +const isBotJwtMock = jest.fn<(...args: any[]) => any>(); +const findBotUserMock = jest.fn<(...args: any[]) => any>(); +const providerGetMock = jest.fn<(...args: any[]) => any>(); +const parseScopeMock = jest.fn<(...args: any[]) => any>(); +const scopeIncludesMock = jest.fn<(...args: any[]) => any>(); +const getProposalStatusMock = jest.fn<(...args: any[]) => any>(); + +jest.unstable_mockModule( "@/lib/cors", () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/security/requestGuards", () => ({ __esModule: true, applyRateLimit: applyRateLimitMock, applyBotRateLimit: applyBotRateLimitMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/verifyJwt", () => ({ __esModule: true, verifyJwt: verifyJwtMock, isBotJwt: isBotJwtMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/governance", () => ({ __esModule: true, getProposalStatus: getProposalStatusMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/lib/auth/botKey", () => ({ __esModule: true, parseScope: parseScopeMock, scopeIncludes: scopeIncludesMock, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/server/db", () => ({ __esModule: true, @@ -72,10 +67,9 @@ jest.mock( }, }, }), - { virtual: true }, ); -jest.mock( +jest.unstable_mockModule( "@/utils/get-provider", () => ({ __esModule: true, @@ -83,7 +77,6 @@ jest.mock( get: providerGetMock, }), }), - { virtual: true }, ); type ResponseMock = NextApiResponse & { statusCode?: number }; @@ -214,7 +207,7 @@ describe("governanceActiveProposals API", () => { await handler(req, res); expect(res.status).toHaveBeenCalledWith(200); - const payload = res.json.mock.calls[0]?.[0] as any; + const payload = (res.json as unknown as jest.Mock).mock.calls[0]?.[0] as any; expect(Array.isArray(payload.proposals)).toBe(true); expect(payload.proposals).toHaveLength(1); expect(payload.proposals[0]).toMatchObject({ diff --git a/src/__tests__/multisigSDK.test.ts b/src/__tests__/multisigSDK.test.ts index dbef91da..487e8cc9 100644 --- a/src/__tests__/multisigSDK.test.ts +++ b/src/__tests__/multisigSDK.test.ts @@ -39,9 +39,9 @@ describe('MultisigWallet', () => { ]; const testWallet = new MultisigWallet('Test', unsortedKeys); - expect(testWallet.keys[0].keyHash).toBe('aaaa'); - expect(testWallet.keys[1].keyHash).toBe('mmmm'); - expect(testWallet.keys[2].keyHash).toBe('zzzz'); + expect(testWallet.keys[0]!.keyHash).toBe('aaaa'); + expect(testWallet.keys[1]!.keyHash).toBe('mmmm'); + expect(testWallet.keys[2]!.keyHash).toBe('zzzz'); }); it('should filter out invalid keys', () => { @@ -54,7 +54,7 @@ describe('MultisigWallet', () => { const testWallet = new MultisigWallet('Test', keysWithInvalid); expect(testWallet.keys).toHaveLength(1); - expect(testWallet.keys[0].keyHash).toBe(mockKeyHashes.payment1); + expect(testWallet.keys[0]!.keyHash).toBe(mockKeyHashes.payment1); }); it('should use default values when optional parameters are not provided', () => { @@ -86,7 +86,7 @@ describe('MultisigWallet', () => { it('should return drep keys (role 3)', () => { const drepKeys = wallet.getKeysByRole(3); expect(drepKeys).toHaveLength(1); - expect(drepKeys?.[0].role).toBe(3); + expect(drepKeys?.[0]!.role).toBe(3); }); it('should return undefined for non-existent role', () => { diff --git a/src/__tests__/og.test.ts b/src/__tests__/og.test.ts new file mode 100644 index 00000000..afa0a471 --- /dev/null +++ b/src/__tests__/og.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, jest, beforeEach, afterEach } from "@jest/globals"; +import type { NextApiRequest, NextApiResponse } from "next"; + +// SSRF tripwire suite for /api/v1/og +// +// The handler must reject: +// - non-https URLs +// - hosts not on the allowlist +// - hosts that resolve to private / loopback / link-local addresses +// - upstream redirects (no auto-follow) +// +// The most important regression is the IMDS URL case: +// http://169.254.169.254/latest/meta-data/ (AWS instance metadata) +// — historically the canonical SSRF target. If this ever returns 200, an +// attacker who can hit our public OG endpoint can pivot into cloud metadata. + +const dnsLookupMock = jest.fn() as jest.MockedFunction< + (host: string, opts?: unknown) => Promise> +>; + +jest.unstable_mockModule("dns", () => ({ + __esModule: true, + default: { promises: { lookup: dnsLookupMock } }, + promises: { lookup: dnsLookupMock }, +})); + +const envState: { OG_ALLOWED_HOSTS?: string } = {}; +jest.unstable_mockModule("@/env", () => ({ + __esModule: true, + env: new Proxy({}, { + get(_t, key: string) { + if (key === "OG_ALLOWED_HOSTS") return envState.OG_ALLOWED_HOSTS; + return undefined; + }, + }), +})); + +const fetchMock = jest.fn() as jest.MockedFunction; +const realFetch = global.fetch; + +function makeRes() { + const status = jest.fn(); + const json = jest.fn(); + const setHeader = jest.fn(); + const res = { + status: status.mockImplementation(() => res), + json: json.mockImplementation(() => res), + setHeader, + } as unknown as NextApiResponse; + return { res, status, json }; +} + +function makeReq(url: string | undefined): NextApiRequest { + return { + query: url === undefined ? {} : { url }, + method: "GET", + headers: {}, + } as unknown as NextApiRequest; +} + +const handlerPromise = import("../pages/api/v1/og"); + +beforeEach(() => { + dnsLookupMock.mockReset(); + fetchMock.mockReset(); + global.fetch = fetchMock as unknown as typeof fetch; + envState.OG_ALLOWED_HOSTS = undefined; +}); + +afterEach(() => { + global.fetch = realFetch; +}); + +describe("og handler — SSRF defense", () => { + it("rejects missing url with 400", async () => { + const { default: handler } = await handlerPromise; + const { res, status, json } = makeRes(); + await handler(makeReq(undefined), res); + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringMatching(/missing/i) })); + }); + + it("rejects http:// URLs with 400", async () => { + const { default: handler } = await handlerPromise; + const { res, status } = makeRes(); + await handler(makeReq("http://github.com/example"), res); + expect(status).toHaveBeenCalledWith(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects IMDS URL (http://169.254.169.254/...) — TRIPWIRE", async () => { + // This test is the one we never let regress. AWS instance metadata URL. + // Even if someone allowlists `*` for OG_ALLOWED_HOSTS, the http:// scheme + // check rejects this immediately. No DNS lookup, no fetch. + const { default: handler } = await handlerPromise; + const { res, status } = makeRes(); + await handler(makeReq("http://169.254.169.254/latest/meta-data/"), res); + expect(status).toHaveBeenCalledWith(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects https IMDS-style URL when wildcard hosts but private IP", async () => { + // Even with OG_ALLOWED_HOSTS=*, the DNS / address-class check must reject + // direct private-IP literals, including the link-local 169.254.0.0/16. + envState.OG_ALLOWED_HOSTS = "*"; + const { default: handler } = await handlerPromise; + const { res, status, json } = makeRes(); + await handler(makeReq("https://169.254.169.254/"), res); + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringMatching(/private|loopback/i) })); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects host not on the allowlist with 400", async () => { + envState.OG_ALLOWED_HOSTS = "github.com,x.com"; + const { default: handler } = await handlerPromise; + const { res, status } = makeRes(); + await handler(makeReq("https://evil.example.com/"), res); + expect(status).toHaveBeenCalledWith(400); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects when DNS resolves to an RFC1918 address", async () => { + envState.OG_ALLOWED_HOSTS = "internal.example.com"; + dnsLookupMock.mockResolvedValueOnce([{ address: "10.0.0.5", family: 4 }]); + const { default: handler } = await handlerPromise; + const { res, status, json } = makeRes(); + await handler(makeReq("https://internal.example.com/"), res); + expect(status).toHaveBeenCalledWith(400); + expect(json).toHaveBeenCalledWith(expect.objectContaining({ error: expect.stringMatching(/private|loopback/i) })); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects when upstream returns a redirect (no auto-follow)", async () => { + envState.OG_ALLOWED_HOSTS = "github.com"; + dnsLookupMock.mockResolvedValueOnce([{ address: "140.82.114.4", family: 4 }]); + fetchMock.mockResolvedValueOnce( + new Response(null, { status: 302, headers: { location: "http://169.254.169.254/" } }), + ); + const { default: handler } = await handlerPromise; + const { res, status } = makeRes(); + await handler(makeReq("https://github.com/example"), res); + expect(status).toHaveBeenCalledWith(500); + }); + + it("returns 200 with extracted OG metadata for an allowlisted public host", async () => { + envState.OG_ALLOWED_HOSTS = "example.com"; + dnsLookupMock.mockResolvedValueOnce([{ address: "93.184.216.34", family: 4 }]); + const html = ` + + + + + `; + fetchMock.mockResolvedValueOnce(new Response(html, { status: 200 })); + const { default: handler } = await handlerPromise; + const { res, status, json } = makeRes(); + await handler(makeReq("https://example.com/page"), res); + expect(status).toHaveBeenCalledWith(200); + expect(json).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Hello", + description: "World", + image: "https://example.com/img.png", + siteName: "Example", + }), + ); + }); +}); diff --git a/src/__tests__/pendingTransactions.test.ts b/src/__tests__/pendingTransactions.test.ts index bfa54ab8..bef748cc 100644 --- a/src/__tests__/pendingTransactions.test.ts +++ b/src/__tests__/pendingTransactions.test.ts @@ -4,47 +4,73 @@ import type { NextApiRequest, NextApiResponse } from 'next'; const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>(); const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); -jest.mock( +jest.unstable_mockModule( '@/lib/cors', () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, }), - { virtual: true }, ); -const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>(); +type JwtPayloadLike = { address: string; botId?: string; type?: string }; +const verifyJwtMock = jest.fn<(token: string | undefined) => JwtPayloadLike | null>(); +const isBotJwtMock = jest.fn<(payload: JwtPayloadLike) => boolean>( + (payload) => Boolean(payload && (payload as JwtPayloadLike).type === "bot"), +); -jest.mock( +jest.unstable_mockModule( '@/lib/verifyJwt', () => ({ __esModule: true, verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, + }), +); + +const applyRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean +>(() => true); +const applyBotRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, botId: string, maxRequests?: number) => boolean +>(() => true); +const applyStrictRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean +>(() => true); +const enforceBodySizeMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean +>(() => true); + +jest.unstable_mockModule( + '@/lib/security/requestGuards', + () => ({ + __esModule: true, + applyRateLimit: applyRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, + applyStrictRateLimit: applyStrictRateLimitMock, + enforceBodySize: enforceBodySizeMock, + isBodyTooLarge: jest.fn(() => false), }), - { virtual: true }, ); const createCallerMock = jest.fn(); -jest.mock( +jest.unstable_mockModule( '@/server/api/root', () => ({ __esModule: true, createCaller: createCallerMock, }), - { virtual: true }, ); const dbMock = { __type: 'dbMock' }; -jest.mock( +jest.unstable_mockModule( '@/server/db', () => ({ __esModule: true, db: dbMock, }), - { virtual: true }, ); type ResponseMock = NextApiResponse & { statusCode?: number }; @@ -164,13 +190,16 @@ describe('pendingTransactions API route', () => { expect(addCorsCacheBustingHeadersMock).toHaveBeenCalledWith(res); expect(corsMock).toHaveBeenCalledWith(req, res); expect(verifyJwtMock).toHaveBeenCalledWith(token); - expect(createCallerMock).toHaveBeenCalledWith({ - db: dbMock, - session: expect.objectContaining({ - user: { id: address }, - expires: expect.any(String), + expect(createCallerMock).toHaveBeenCalledWith( + expect.objectContaining({ + db: dbMock, + session: expect.objectContaining({ + user: { id: address }, + expires: expect.any(String), + }), + sessionAddress: address, }), - }); + ); expect(walletGetWalletMock).toHaveBeenCalledWith({ walletId, address }); expect(transactionGetPendingTransactionsMock).toHaveBeenCalledWith({ walletId }); expect(res.status).toHaveBeenCalledWith(200); diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 8c3f3826..d9260246 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,5 +1,5 @@ // Test setup file for Jest -import '@jest/globals'; +import { jest, beforeEach, afterEach } from '@jest/globals'; // Mock console methods to reduce noise in tests global.console = { @@ -14,3 +14,34 @@ global.console = { // Global test timeout jest.setTimeout(10000); + +// Determinism: freeze the wall clock (`Date.now` / `new Date()`) so tests are +// byte-identical across runs. Timer APIs (setTimeout/setInterval/etc) stay +// real — many tests in this suite hit tRPC's timing middleware and other +// real-async paths that hang under faked timers. Tests that specifically +// exercise timer behavior can opt in via `jest.useFakeTimers()` in `beforeAll`. +beforeEach(() => { + jest.useFakeTimers({ + now: new Date('2026-01-01T00:00:00Z'), + doNotFake: [ + 'nextTick', + 'setImmediate', + 'clearImmediate', + 'queueMicrotask', + 'setTimeout', + 'clearTimeout', + 'setInterval', + 'clearInterval', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'requestIdleCallback', + 'cancelIdleCallback', + 'hrtime', + 'performance', + ], + }); +}); + +afterEach(() => { + jest.useRealTimers(); +}); diff --git a/src/__tests__/setupEnv.cjs b/src/__tests__/setupEnv.cjs new file mode 100644 index 00000000..1314d88b --- /dev/null +++ b/src/__tests__/setupEnv.cjs @@ -0,0 +1,21 @@ +// @ts-nocheck — env bootstrap; checkJs flags `NODE_ENV` as read-only +// because @types/node narrows it to a literal union, but writing it here +// is intentional and safe (runs before any test module is imported). +// +// Sets dummy env vars so that `src/env.js` (t3-oss validate) does not throw +// when test files import server modules transitively. +// Tests that need real values can override per-test with `process.env.X = ...` +// inside `beforeEach`. + +process.env['NODE_ENV'] = process.env['NODE_ENV'] || 'test'; +process.env.SKIP_ENV_VALIDATION = '1'; + +process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://test:test@localhost:5432/test'; +process.env.JWT_SECRET = process.env.JWT_SECRET || 'a'.repeat(48); +process.env.PINATA_JWT = process.env.PINATA_JWT || 'test-pinata-jwt'; + +process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET = + process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_MAINNET || 'test-blockfrost-mainnet'; +process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD = + process.env.NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD || 'test-blockfrost-preprod'; +process.env.NEXT_PUBLIC_NETWORK_ID = process.env.NEXT_PUBLIC_NETWORK_ID || '0'; diff --git a/src/__tests__/signTransaction.test.ts b/src/__tests__/signTransaction.test.ts index f4b2e873..41167d64 100644 --- a/src/__tests__/signTransaction.test.ts +++ b/src/__tests__/signTransaction.test.ts @@ -4,64 +4,73 @@ import type { NextApiRequest, NextApiResponse } from 'next'; const addCorsCacheBustingHeadersMock = jest.fn<(res: NextApiResponse) => void>(); const corsMock = jest.fn<(req: NextApiRequest, res: NextApiResponse) => Promise>(); -jest.mock( +jest.unstable_mockModule( '@/lib/cors', () => ({ __esModule: true, addCorsCacheBustingHeaders: addCorsCacheBustingHeadersMock, cors: corsMock, }), - { virtual: true }, ); -const verifyJwtMock = jest.fn<(token: string | undefined) => { address: string } | null>(); +type JwtPayloadLike = { address: string; botId?: string; type?: string }; +const verifyJwtMock = jest.fn<(token: string | undefined) => JwtPayloadLike | null>(); +const isBotJwtMock = jest.fn<(payload: JwtPayloadLike) => boolean>( + (payload) => Boolean(payload && (payload as JwtPayloadLike).type === "bot"), +); -jest.mock( +jest.unstable_mockModule( '@/lib/verifyJwt', () => ({ __esModule: true, verifyJwt: verifyJwtMock, + isBotJwt: isBotJwtMock, }), - { virtual: true }, ); const applyRateLimitMock = jest.fn< (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean >(); +const applyStrictRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, options?: unknown) => boolean +>(); +const applyBotRateLimitMock = jest.fn< + (req: NextApiRequest, res: NextApiResponse, botId: string, maxRequests?: number) => boolean +>(); const enforceBodySizeMock = jest.fn< (req: NextApiRequest, res: NextApiResponse, maxBytes: number) => boolean >(); -jest.mock( +jest.unstable_mockModule( '@/lib/security/requestGuards', () => ({ __esModule: true, applyRateLimit: applyRateLimitMock, + applyStrictRateLimit: applyStrictRateLimitMock, + applyBotRateLimit: applyBotRateLimitMock, enforceBodySize: enforceBodySizeMock, + isBodyTooLarge: jest.fn(() => false), }), - { virtual: true }, ); const getClientIPMock = jest.fn<(req: NextApiRequest) => string>(); -jest.mock( +jest.unstable_mockModule( '@/lib/security/rateLimit', () => ({ __esModule: true, getClientIP: getClientIPMock, }), - { virtual: true }, ); const createCallerMock = jest.fn(); -jest.mock( +jest.unstable_mockModule( '@/server/api/root', () => ({ __esModule: true, createCaller: createCallerMock, }), - { virtual: true }, ); const dbTransactionFindUniqueMock = jest.fn<(args: unknown) => Promise>(); @@ -79,35 +88,32 @@ const dbMock = { }, }; -jest.mock( +jest.unstable_mockModule( '@/server/db', () => ({ __esModule: true, db: dbMock, }), - { virtual: true }, ); const getProviderMock = jest.fn<(network: number) => unknown>(); -jest.mock( +jest.unstable_mockModule( '@/utils/get-provider', () => ({ __esModule: true, getProvider: getProviderMock, }), - { virtual: true }, ); const addressToNetworkMock = jest.fn<(address: string) => number>(); -jest.mock( +jest.unstable_mockModule( '@/utils/multisigSDK', () => ({ __esModule: true, addressToNetwork: addressToNetworkMock, }), - { virtual: true }, ); const shouldSubmitMultisigTxMock = jest.fn< @@ -132,7 +138,7 @@ const addUniqueVkeyWitnessToTxMock = jest.fn< } >(); -jest.mock( +jest.unstable_mockModule( '@/utils/txSignUtils', () => ({ __esModule: true, @@ -141,18 +147,16 @@ jest.mock( shouldSubmitMultisigTx: shouldSubmitMultisigTxMock, submitTxWithScriptRecovery: submitTxWithScriptRecoveryMock, }), - { virtual: true }, ); const resolvePaymentKeyHashMock = jest.fn<(address: string) => string>(); -jest.mock( +jest.unstable_mockModule( '@meshsdk/core', () => ({ __esModule: true, resolvePaymentKeyHash: resolvePaymentKeyHashMock, }), - { virtual: true }, ); const witnessKeyHashHex = '00112233'; @@ -355,14 +359,13 @@ const cslMock = { Vkeywitnesses: MockVkeywitnesses, }; -jest.mock( +jest.unstable_mockModule( '@meshsdk/core-csl', () => ({ __esModule: true, csl: cslMock, calculateTxHash: calculateTxHashMock, }), - { virtual: true }, ); const consoleErrorSpy = jest @@ -420,7 +423,15 @@ beforeEach(() => { addCorsCacheBustingHeadersMock.mockReset(); createCallerMock.mockReset(); verifyJwtMock.mockReset(); + isBotJwtMock.mockReset(); + isBotJwtMock.mockImplementation( + (payload) => Boolean(payload && (payload as JwtPayloadLike).type === "bot"), + ); applyRateLimitMock.mockReset(); + applyStrictRateLimitMock.mockReset(); + applyBotRateLimitMock.mockReset(); + applyBotRateLimitMock.mockReturnValue(true); + applyStrictRateLimitMock.mockReturnValue(true); enforceBodySizeMock.mockReset(); getClientIPMock.mockReset(); @@ -456,7 +467,7 @@ beforeEach(() => { for (let i = 0; i < existingWitnessCount; i++) { const existingWitness = mergedWitnesses.get(i); const existingKeyHash = Buffer.from( - existingWitness.vkey().public_key().hash().to_bytes(), + existingWitness!.vkey().public_key().hash().to_bytes(), ).toString('hex').toLowerCase(); if (existingKeyHash === incomingKeyHash) { diff --git a/src/__tests__/signing.test.ts b/src/__tests__/signing.test.ts new file mode 100644 index 00000000..81d46b3d --- /dev/null +++ b/src/__tests__/signing.test.ts @@ -0,0 +1,107 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import { describe, expect, it, jest } from "@jest/globals"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Tripwire: the "broken" pattern — `return true ? signature : undefined;` +// must never reappear in `src/utils/signing.ts`. It both throws away the +// `checkSignature` result and obscures the actual signing contract. The +// regression we fixed here was that a failed signature verification still +// returned a (forged-looking) signature to the caller because the ternary +// was always truthy. +// --------------------------------------------------------------------------- +describe("signing.ts source contract", () => { + it("never returns the always-true ternary on the verification result", () => { + const src = fs.readFileSync( + path.resolve(__dirname, "../utils/signing.ts"), + "utf8", + ); + expect(src).not.toMatch(/return\s+true\s*\?/); + // Positive: the verified result must drive an explicit `if (!verified)` + // throw. The exact identifier we use is `verified` — accept either name + // so a future rename doesn't trip the tripwire. + expect(src).toMatch(/if\s*\(\s*!\s*(verified|result)\b/); + }); +}); + +// --------------------------------------------------------------------------- +// Behavioural: import the real `sign` and exercise every role plus the +// failure path. We mock the @meshsdk/core helpers because they pull in +// CSL/serialization which is heavyweight for a unit test. +// --------------------------------------------------------------------------- +const checkSignatureMock = jest.fn< + (nonce: string, signature: { signature: string; key: string }, address?: string) => Promise +>(); +const generateNonceMock = jest.fn<(payload: string) => string>(); + +jest.unstable_mockModule("@meshsdk/core", () => ({ + __esModule: true, + checkSignature: checkSignatureMock, + generateNonce: generateNonceMock, +})); + +const { sign } = await import("../utils/signing"); + +type MockWallet = { + signData: jest.Mock<(payload: string, address?: string) => Promise<{ signature: string; key: string }>>; + getRewardAddresses: jest.Mock<() => Promise>; +}; + +function createWallet(overrides?: Partial): MockWallet { + return { + signData: jest.fn<(payload: string, address?: string) => Promise<{ signature: string; key: string }>>( + async () => ({ signature: "deadbeef", key: "cafe" }), + ), + getRewardAddresses: jest.fn<() => Promise>(async () => ["stake_addr"]), + ...overrides, + } as MockWallet; +} + +describe("sign", () => { + beforeEach(() => { + checkSignatureMock.mockReset(); + generateNonceMock.mockReset(); + generateNonceMock.mockReturnValue("nonce-payload"); + }); + + it("role=0 signs with the user payment address and returns the signature", async () => { + checkSignatureMock.mockResolvedValueOnce(true); + const wallet = createWallet(); + const sig = await sign("payload", wallet as never, 0, "addr_test_user"); + expect(sig).toEqual({ signature: "deadbeef", key: "cafe" }); + expect(wallet.signData).toHaveBeenCalledWith("payload", "addr_test_user"); + }); + + it("role=2 signs with the wallet's reward (stake) address", async () => { + checkSignatureMock.mockResolvedValueOnce(true); + const wallet = createWallet(); + await sign("payload", wallet as never, 2); + expect(wallet.getRewardAddresses).toHaveBeenCalled(); + expect(wallet.signData).toHaveBeenCalledWith("payload", "stake_addr"); + }); + + it("role=3 requires an explicit dRepAddress and uses it", async () => { + checkSignatureMock.mockResolvedValueOnce(true); + const wallet = createWallet(); + await sign("payload", wallet as never, 3, undefined, "drep_xxx"); + expect(wallet.signData).toHaveBeenCalledWith("payload", "drep_xxx"); + }); + + it("throws when the chosen role has no resolved address", async () => { + const wallet = createWallet(); + await expect(sign("payload", wallet as never, 0, undefined)).rejects.toThrow( + /missing address/i, + ); + }); + + it("throws when checkSignature returns false (no silent ternary fallback)", async () => { + checkSignatureMock.mockResolvedValueOnce(false); + const wallet = createWallet(); + await expect(sign("payload", wallet as never, 0, "addr_test_user")).rejects.toThrow( + /Signature failed verification/i, + ); + }); +}); diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx deleted file mode 100644 index f6afdaf2..00000000 --- a/src/components/ui/form.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { Slot } from "@radix-ui/react-slot" -import { - Controller, - ControllerProps, - FieldPath, - FieldValues, - FormProvider, - useFormContext, -} from "react-hook-form" - -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" - -const Form = FormProvider - -type FormFieldContextValue< - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath -> = { - name: TName -} - -const FormFieldContext = React.createContext( - {} as FormFieldContextValue -) - -const FormField = < - TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath ->({ - ...props -}: ControllerProps) => { - return ( - - - - ) -} - -const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState, formState } = useFormContext() - - const fieldState = getFieldState(fieldContext.name, formState) - - if (!fieldContext) { - throw new Error("useFormField should be used within ") - } - - const { id } = itemContext - - return { - id, - name: fieldContext.name, - formItemId: `${id}-form-item`, - formDescriptionId: `${id}-form-item-description`, - formMessageId: `${id}-form-item-message`, - ...fieldState, - } -} - -type FormItemContextValue = { - id: string -} - -const FormItemContext = React.createContext( - {} as FormItemContextValue -) - -const FormItem = React.forwardRef< - HTMLDivElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => { - const id = React.useId() - - return ( - -
- - ) -}) -FormItem.displayName = "FormItem" - -const FormLabel = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { error, formItemId } = useFormField() - - return ( -