Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/app/api/auth/passkey/__tests__/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ vi.mock("@simplewebauthn/server", () => ({
}),
}))

vi.mock("@/lib/crypto/key-derivation", () => ({
deriveStellarKeypair: vi.fn().mockResolvedValue({
publicKey: new Uint8Array(32).fill(7),
secretKey: new Uint8Array(32).fill(9),
}),
hexEncode: vi.fn((bytes: Uint8Array) => Buffer.from(bytes).toString("hex")),
secureZeroMemory: vi.fn(),
}))

const routeRedisStore = new Map<string, { value: string; expiresAt: number }>()
vi.mock("@/lib/redis/client", () => ({
getRedisClient: vi.fn().mockResolvedValue({
Expand Down Expand Up @@ -70,7 +79,9 @@ function writeSessionFixture(userId = "user-123") {
fs.writeFileSync(path.join(contentDir, "users.json"), JSON.stringify([{ id: userId, username: "demo", role: "user" }], null, 2))
}

function makeRequest(body: unknown, ip = "127.0.0.1") {
let ipSequence = 0

function makeRequest(body: unknown, ip = `10.9.${++ipSequence}.1`) {
return new NextRequest("http://localhost:1110/api/auth/passkey/test", {
method: "POST",
headers: {
Expand Down Expand Up @@ -105,6 +116,7 @@ function makeRequest(body: unknown, ip = "127.0.0.1") {
publicKey: new Uint8Array(32).fill(42),
counter: 0,
transports: ["internal"],
userId: "user-123",
})
const res = await generateOptions(makeRequest({ credentialId: "cred-id-123", mode: "authenticate" }))
expect(res.status).toBe(200)
Expand Down Expand Up @@ -277,6 +289,7 @@ describe("auth-verify API", () => {
await storeCredential("cred-id-123", {
publicKey: new Uint8Array(32).fill(1),
counter: 0,
userId: "user-123",
})

const genRes = await generateOptions(makeRequest({ credentialId: "cred-id-123", mode: "authenticate" }))
Expand All @@ -301,6 +314,7 @@ describe("auth-verify API", () => {
await storeCredential("cred-counter-test", {
publicKey: new Uint8Array(32).fill(1),
counter: 0,
userId: "user-123",
})

const genRes = await generateOptions(makeRequest({ credentialId: "cred-counter-test", mode: "authenticate" }))
Expand Down
7 changes: 5 additions & 2 deletions src/app/api/auth/passkey/auth-verify/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { AuthenticatorTransportFuture } from "@simplewebauthn/server"
import {
getAndVerifyTempChallenge,
getCredential,
updateCredentialCounter,
getPepper,
getRpId,
getExpectedOrigin,
Expand Down Expand Up @@ -58,7 +59,7 @@ export async function POST(req: NextRequest) {
}

// Verify challenge
if (!tempKey || !getAndVerifyTempChallenge(tempKey, parsed.challenge)) {
if (!tempKey || !(await getAndVerifyTempChallenge(tempKey, parsed.challenge))) {
return NextResponse.json({ error: "challenge_mismatch" }, { status: 400 })
}

Expand All @@ -83,7 +84,9 @@ export async function POST(req: NextRequest) {
}

if (verification.authenticationInfo) {
storedCredential.counter = verification.authenticationInfo.newCounter ?? storedCredential.counter
const newCounter = verification.authenticationInfo.newCounter ?? storedCredential.counter
await updateCredentialCounter(resolvedCredentialId, newCounter)
storedCredential.counter = newCounter
}

// The Stellar secret key is derived here only to compute the public key.
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/auth/passkey/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "invalid_client_data" }, { status: 400 })
}

if (!tempKey || !getAndVerifyTempChallenge(tempKey, parsed.challenge)) {
if (!tempKey || !(await getAndVerifyTempChallenge(tempKey, parsed.challenge))) {
return NextResponse.json({ error: "challenge_mismatch" }, { status: 400 })
}

Expand Down
9 changes: 2 additions & 7 deletions src/hooks/__tests__/use-contributions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { describe, expect, it, vi } from "vitest"
import { get } from "@/lib/api-client"
import { useContributions } from "@/hooks/use-contributions"
import { createQueryWrapper } from "./test-utils"
Expand All @@ -9,7 +9,6 @@ vi.mock("@/lib/api-client", () => ({ get: vi.fn() }))
const mockedGet = vi.mocked(get)

describe("useContributions", () => {
beforeEach(() => mockedGet.mockReset())

it("loads contributions with the existing filters and pagination", async () => {
const contribution = {
Expand Down Expand Up @@ -71,11 +70,7 @@ describe("useContributions", () => {

it("exposes request errors through TanStack Query", async () => {
const error = new Error("contributions unavailable")
mockedGet.mockImplementation(() => {
return new Promise((_, reject) => {
setTimeout(() => reject(error), 0)
})
})
mockedGet.mockRejectedValue(error)
const { QueryWrapper } = createQueryWrapper()
const { result } = renderHook(() => useContributions(), {
wrapper: QueryWrapper,
Expand Down
9 changes: 2 additions & 7 deletions src/hooks/__tests__/use-payouts.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { describe, expect, it, vi } from "vitest"
import { get } from "@/lib/api-client"
import { useCirclePayouts, usePayouts } from "@/hooks/use-payouts"
import { createQueryWrapper } from "./test-utils"
Expand All @@ -9,7 +9,6 @@ vi.mock("@/lib/api-client", () => ({ get: vi.fn() }))
const mockedGet = vi.mocked(get)

describe("usePayouts", () => {
beforeEach(() => mockedGet.mockReset())

it("loads payouts with the existing pagination", async () => {
const payout = {
Expand Down Expand Up @@ -53,11 +52,7 @@ describe("usePayouts", () => {

it("exposes request errors through TanStack Query", async () => {
const error = new Error("payouts unavailable")
mockedGet.mockImplementation(() => {
return new Promise((_, reject) => {
setTimeout(() => reject(error), 0)
})
})
mockedGet.mockRejectedValue(error)
const { QueryWrapper } = createQueryWrapper()
const { result } = renderHook(() => usePayouts(), {
wrapper: QueryWrapper,
Expand Down
9 changes: 2 additions & 7 deletions src/hooks/__tests__/use-reputation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { renderHook, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { describe, expect, it, vi } from "vitest"
import { get } from "@/lib/api-client"
import { useReputation } from "@/hooks/use-reputation"
import { createQueryWrapper } from "./test-utils"
Expand All @@ -9,7 +9,6 @@ vi.mock("@/lib/api-client", () => ({ get: vi.fn() }))
const mockedGet = vi.mocked(get)

describe("useReputation", () => {
beforeEach(() => mockedGet.mockReset())

it("loads reputation from the build-plan endpoint", async () => {
const reputation = {
Expand Down Expand Up @@ -45,11 +44,7 @@ describe("useReputation", () => {

it("exposes request errors through TanStack Query", async () => {
const error = new Error("reputation unavailable")
mockedGet.mockImplementation(() => {
return new Promise((_, reject) => {
setTimeout(() => reject(error), 0)
})
})
mockedGet.mockRejectedValue(error)
const { QueryWrapper } = createQueryWrapper()
const { result } = renderHook(() => useReputation("user-1"), {
wrapper: QueryWrapper,
Expand Down
13 changes: 7 additions & 6 deletions src/hooks/use-multi-wallet.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
"use client"

import { useShallow } from "zustand/react/shallow"
import { useMultiWalletStore } from "@/stores/multi-wallet-store"

/** Connection state slice — subscribe only when connection/address/error changes. */
export function useMultiWalletConnection() {
return useMultiWalletStore((s) => ({
return useMultiWalletStore(useShallow((s) => ({
isConnected: s.isConnected,
isConnecting: s.isConnecting,
address: s.address,
error: s.error,
activeAdapter: s.activeAdapter,
}))
})))
}

/** Active-wallet identity slice — subscribe only when the active wallet/id changes. */
Expand All @@ -23,22 +24,22 @@ export function useMultiWalletActive() {

/** Wallet list slice — subscribe only when the detected/available wallet list changes. */
export function useMultiWalletList() {
return useMultiWalletStore((s) => ({
return useMultiWalletStore(useShallow((s) => ({
detectedWallets: s.detectedWallets,
isSelectorOpen: s.isSelectorOpen,
}))
})))
}

/** Action slice — actions are stable references and never cause re-renders. */
export function useMultiWalletActions() {
return useMultiWalletStore((s) => ({
return useMultiWalletStore(useShallow((s) => ({
connect: s.connect,
disconnect: s.disconnect,
signMessage: s.signMessage,
switchWallet: s.switchWallet,
refreshBalance: s.refreshBalance,
setSelectorOpen: s.setSelectorOpen,
}))
})))
}

/**
Expand Down
17 changes: 17 additions & 0 deletions src/lib/passkey/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,23 @@ export async function getCredential(credentialId: string): Promise<CredentialRec
}
}

export async function updateCredentialCounter(credentialId: string, counter: number): Promise<void> {
const local = credentialStore.get(credentialId)
if (local) {
local.counter = counter
}

try {
await fetch(`${API_BASE}/passkey/credentials/${encodeURIComponent(credentialId)}/counter`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ counter }),
})
} catch {
// Fall back to local in-memory storage in environments without the backend.
}
}

// Placeholder pepper used only for local development and tests. It was once
// committed to the repository, so it must be treated as public: production
// refuses to fall back to it and requires a real, rotated PASSKEY_SERVER_PEPPER.
Expand Down
32 changes: 19 additions & 13 deletions src/lib/wallet/hmac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,25 @@ import { bytesToHex } from "@noble/hashes/utils.js"
*/
let hmacKey: Uint8Array | null = null

fetch("/api/wallet/hmac/key")
.then((res) => {
if (!res.ok) throw new Error(`Failed to get HMAC key: ${res.status}`)
return res.json() as Promise<{ keyHex: string }>
})
.then(({ keyHex }) => {
const raw = new Uint8Array(keyHex.length / 2)
for (let i = 0; i < keyHex.length; i += 2) {
raw[i / 2] = parseInt(keyHex.substring(i, i + 2), 16)
}
hmacKey = raw
})
.catch((err) => console.warn("[hmac] Failed to load key — HMAC will fail until retry:", err))
// Only the browser can resolve a relative URL against a page origin. When
// this module is imported server-side (SSR / route handlers), Node's fetch
// would throw "Invalid URL" — skip the fetch there entirely; the cached-key
// behaviour below already degrades gracefully to "" until a client loads.
if (typeof window !== "undefined") {
fetch("/api/wallet/hmac/key")
.then((res) => {
if (!res.ok) throw new Error(`Failed to get HMAC key: ${res.status}`)
return res.json() as Promise<{ keyHex: string }>
})
.then(({ keyHex }) => {
const raw = new Uint8Array(keyHex.length / 2)
for (let i = 0; i < keyHex.length; i += 2) {
raw[i / 2] = parseInt(keyHex.substring(i, i + 2), 16)
}
hmacKey = raw
})
.catch((err) => console.warn("[hmac] Failed to load key — HMAC will fail until retry:", err))
}

export function computeHmacSha256(data: string): string {
if (!hmacKey) {
Expand Down
20 changes: 10 additions & 10 deletions tests/e2e/circle-creation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ test.describe('Circle Creation Flow', () => {
await expect(page.getByRole('heading', { name: /create circle/i })).toBeVisible()

// Step 1: Details
await page.fill('input[name="name"]', 'Test Savings Circle')
await page.fill('textarea[name="description"]', 'A test circle for E2E testing')
await page.getByLabel(/circle name/i).fill('Test Savings Circle')
await page.getByLabel(/description/i).fill('A test circle for E2E testing')

// Set max members
const maxMembersInput = page.getByLabel(/max members/i)
Expand All @@ -45,7 +45,7 @@ test.describe('Circle Creation Flow', () => {
await nextButton3.click()

// Step 4: Review
await expect(page.getByText(/review/i)).toBeVisible()
await expect(page.getByRole('heading', { name: /review your circle/i })).toBeVisible()
await expect(page.getByText('Test Savings Circle')).toBeVisible()
await expect(page.getByText('100 USDC')).toBeVisible()

Expand All @@ -60,7 +60,7 @@ test.describe('Circle Creation Flow', () => {
test('should show validation error for short circle name', async ({ page }: { page: any }) => {
await page.goto('/circles/create')

await page.fill('input[name="name"]', 'AB')
await page.getByLabel(/circle name/i).fill('AB')

const nextButton = page.getByRole('button', { name: /next/i })
await nextButton.click()
Expand All @@ -72,7 +72,7 @@ test.describe('Circle Creation Flow', () => {
test('should show validation error for insufficient max members', async ({ page }: { page: any }) => {
await page.goto('/circles/create')

await page.fill('input[name="name"]', 'Test Circle')
await page.getByLabel(/circle name/i).fill('Test Circle')

const maxMembersInput = page.getByLabel(/max members/i)
await maxMembersInput.fill('1')
Expand All @@ -88,7 +88,7 @@ test.describe('Circle Creation Flow', () => {
await page.goto('/circles/create')

// Complete step 1
await page.fill('input[name="name"]', 'Test Circle')
await page.getByLabel(/circle name/i).fill('Test Circle')
await page.getByLabel(/max members/i).fill('10')

const nextButton = page.getByRole('button', { name: /next/i })
Expand All @@ -109,7 +109,7 @@ test.describe('Circle Creation Flow', () => {
await page.goto('/circles/create')

// Step 1
await page.fill('input[name="name"]', 'Test Circle')
await page.getByLabel(/circle name/i).fill('Test Circle')
await page.getByLabel(/max members/i).fill('10')

const nextButton = page.getByRole('button', { name: /next/i })
Expand All @@ -132,8 +132,8 @@ test.describe('Circle Creation Flow', () => {
const backButton2 = page.getByRole('button', { name: /previous/i })
await backButton2.click()

await expect(page.locator('input[name="name"]')).toBeVisible()
await expect(page.locator('input[name="name"]')).toHaveValue('Test Circle')
await expect(page.getByLabel(/circle name/i)).toBeVisible()
await expect(page.getByLabel(/circle name/i)).toHaveValue('Test Circle')
})

test('should show error on API failure during creation', async ({ page }: { page: any }) => {
Expand All @@ -151,7 +151,7 @@ test.describe('Circle Creation Flow', () => {
await page.goto('/circles/create')

// Complete all steps
await page.fill('input[name="name"]', 'Test Circle')
await page.getByLabel(/circle name/i).fill('Test Circle')
await page.getByLabel(/max members/i).fill('10')
await page.getByRole('button', { name: /next/i }).click()

Expand Down
Loading
Loading