From 23271299ae57878e5ca5ae2915f1fd7d5443875b Mon Sep 17 00:00:00 2001 From: priscaenoch Date: Mon, 29 Jun 2026 10:07:01 +0000 Subject: [PATCH 1/3] feat(auth): add fetchAuthChallenge and complete SEP-10 wallet auth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add fetchAuthChallenge(publicKey) to lib/api-client.ts calling GET /auth/challenge?publicKey=, returns { nonce, expiresAt } - Export AuthChallenge interface from api-client.ts - Update walletStore connectWallet() to run the full SEP-10 flow: fetch challenge → sign nonce via Freighter → verify → store JWT - isAuthenticated is set to true only after successful auth verification - Add tests for fetchAuthChallenge (success and 4xx error cases) - Add tests for walletStore SEP-10 flow (success, callback, error cases) Closes #698 Closes #700 --- nevo_frontend/__tests__/api-client.test.ts | 43 ++++++++++++ nevo_frontend/__tests__/walletStore.test.ts | 74 ++++++++++++++++++++- nevo_frontend/lib/api-client.ts | 13 ++++ nevo_frontend/src/store/walletStore.ts | 10 ++- 4 files changed, 136 insertions(+), 4 deletions(-) diff --git a/nevo_frontend/__tests__/api-client.test.ts b/nevo_frontend/__tests__/api-client.test.ts index e4517f4..590e7cb 100644 --- a/nevo_frontend/__tests__/api-client.test.ts +++ b/nevo_frontend/__tests__/api-client.test.ts @@ -2,6 +2,8 @@ import { ApiClient, RateLimitError, verifyAuthSignature, + fetchAuthChallenge, + ApiError, UnauthorizedError, } from '@/lib/api-client'; @@ -254,3 +256,44 @@ describe('verifyAuthSignature', () => { expect(global.fetch).toHaveBeenCalledTimes(1); }); }); + +describe('fetchAuthChallenge', () => { + beforeEach(() => { + Object.defineProperty(globalThis, 'Headers', { + value: TestHeaders, + configurable: true, + }); + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('calls GET /auth/challenge with publicKey query param and returns nonce and expiresAt', async () => { + const mockChallenge = { nonce: 'abc123', expiresAt: 1700000000 }; + (global.fetch as jest.Mock).mockResolvedValue(jsonResponse(mockChallenge)); + + const result = await fetchAuthChallenge('GABC123PUBLIC'); + + expect(result).toEqual(mockChallenge); + expect(global.fetch).toHaveBeenCalledTimes(1); + + const [url, fetchInit] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toContain('/auth/challenge'); + expect(url).toContain('publicKey=GABC123PUBLIC'); + expect(fetchInit.method).toBe('GET'); + }); + + it('throws ApiError when the response is not ok', async () => { + (global.fetch as jest.Mock).mockResolvedValue( + jsonResponse( + { error: 'Bad Request' }, + { status: 400, statusText: 'Bad Request' } + ) + ); + + await expect(fetchAuthChallenge('INVALID')).rejects.toBeInstanceOf(ApiError); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/nevo_frontend/__tests__/walletStore.test.ts b/nevo_frontend/__tests__/walletStore.test.ts index b1098c8..d534bbf 100644 --- a/nevo_frontend/__tests__/walletStore.test.ts +++ b/nevo_frontend/__tests__/walletStore.test.ts @@ -1,10 +1,22 @@ import { useWalletStore } from '@/src/store/walletStore'; -import { disconnect } from '@/app/stellar-wallets-kit'; +import { connect, disconnect, getPublicKey, signWithWallet } from '@/app/stellar-wallets-kit'; +import { fetchAuthChallenge, verifyAuthSignature } from '@/lib/api-client'; +import { getAccountBalances } from '@/lib/stellar'; jest.mock('@/app/stellar-wallets-kit', () => ({ getPublicKey: jest.fn(), connect: jest.fn(), disconnect: jest.fn(), + signWithWallet: jest.fn(), +})); + +jest.mock('@/lib/api-client', () => ({ + fetchAuthChallenge: jest.fn(), + verifyAuthSignature: jest.fn(), +})); + +jest.mock('@/lib/stellar', () => ({ + getAccountBalances: jest.fn(), })); describe('walletStore disconnectWallet', () => { @@ -34,3 +46,63 @@ describe('walletStore disconnectWallet', () => { expect(window.localStorage.getItem('nevo-wallet')).toBeNull(); }); }); + +describe('walletStore connectWallet SEP-10 auth', () => { + const PUBLIC_KEY = 'GABC123PUBLIC'; + const NONCE = 'challenge-nonce-xyz'; + const SIGNATURE = 'signed-nonce-hex'; + const ACCESS_TOKEN = 'jwt-access-token'; + + beforeEach(() => { + jest.clearAllMocks(); + window.localStorage.clear(); + useWalletStore.setState({ + publicKey: null, + accessToken: null, + balances: null, + loading: false, + isAuthenticated: false, + }); + + (connect as jest.Mock).mockImplementation(async (onConnect) => { + await onConnect(); + }); + (getPublicKey as jest.Mock).mockResolvedValue(PUBLIC_KEY); + (getAccountBalances as jest.Mock).mockResolvedValue(null); + (fetchAuthChallenge as jest.Mock).mockResolvedValue({ + nonce: NONCE, + expiresAt: 9999999999, + }); + (signWithWallet as jest.Mock).mockResolvedValue(SIGNATURE); + (verifyAuthSignature as jest.Mock).mockResolvedValue({ + accessToken: ACCESS_TOKEN, + }); + }); + + it('completes SEP-10 auth flow and sets isAuthenticated to true', async () => { + await useWalletStore.getState().connectWallet(); + + expect(fetchAuthChallenge).toHaveBeenCalledWith(PUBLIC_KEY); + expect(signWithWallet).toHaveBeenCalledWith(NONCE); + expect(verifyAuthSignature).toHaveBeenCalledWith(PUBLIC_KEY, NONCE, SIGNATURE); + + expect(useWalletStore.getState().publicKey).toBe(PUBLIC_KEY); + expect(useWalletStore.getState().accessToken).toBe(ACCESS_TOKEN); + expect(useWalletStore.getState().isAuthenticated).toBe(true); + }); + + it('calls the onSuccess callback after successful auth', async () => { + const onSuccess = jest.fn(); + await useWalletStore.getState().connectWallet(onSuccess); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it('does not set isAuthenticated if verifyAuthSignature throws', async () => { + (verifyAuthSignature as jest.Mock).mockRejectedValue(new Error('Verify failed')); + + await expect(useWalletStore.getState().connectWallet()).rejects.toThrow('Verify failed'); + + expect(useWalletStore.getState().isAuthenticated).toBe(false); + expect(useWalletStore.getState().accessToken).toBeNull(); + }); +}); diff --git a/nevo_frontend/lib/api-client.ts b/nevo_frontend/lib/api-client.ts index c2df148..4e59d54 100644 --- a/nevo_frontend/lib/api-client.ts +++ b/nevo_frontend/lib/api-client.ts @@ -603,3 +603,16 @@ export function verifyAuthSignature( message: nonce, }); } + +export interface AuthChallenge { + nonce: string; + expiresAt: number; +} + +export function fetchAuthChallenge(publicKey: string): Promise { + return apiClient.get('/auth/challenge', { + params: { publicKey }, + requireAuth: false, + cacheResponse: false, + }); +} diff --git a/nevo_frontend/src/store/walletStore.ts b/nevo_frontend/src/store/walletStore.ts index 3c8dcd4..889c484 100644 --- a/nevo_frontend/src/store/walletStore.ts +++ b/nevo_frontend/src/store/walletStore.ts @@ -1,8 +1,9 @@ import { create } from 'zustand'; import { persist } from 'zustand/middleware'; -import { getPublicKey, connect, disconnect } from '@/app/stellar-wallets-kit'; +import { getPublicKey, connect, disconnect, signWithWallet } from '@/app/stellar-wallets-kit'; import { clearJwt } from '@/lib/jwt-storage'; import { getAccountBalances, AccountBalances } from '@/lib/stellar'; +import { fetchAuthChallenge, verifyAuthSignature } from '@/lib/api-client'; interface WalletState { publicKey: string | null; @@ -47,11 +48,14 @@ export const useWalletStore = create()( const key = await getPublicKey(); if (key) { const balances = await getAccountBalances(key); - const accessToken = get().accessToken; + const { nonce } = await fetchAuthChallenge(key); + const signature = await signWithWallet(nonce); + const { accessToken } = await verifyAuthSignature(key, nonce, signature); set({ publicKey: key, balances, - isAuthenticated: !!accessToken, + accessToken, + isAuthenticated: true, }); onSuccess?.(); } From e156cba1a7a8e47ac5ad7db2fb9d7a67c3afa856 Mon Sep 17 00:00:00 2001 From: priscaenoch Date: Mon, 29 Jun 2026 10:12:27 +0000 Subject: [PATCH 2/3] fix(ci): resolve frontend and server build errors Frontend: - Add missing recentDonations useState declaration in profile/page.tsx; state was referenced in render and useEffect but never declared Server: - auth.module.ts: remove duplicate ConfigModule import and deduplicate PassportModule/TypeOrmModule.forFeature entries in imports array - donations.service.ts: add missing InjectRepository, Repository imports and Donation entity import; export DonationSortBy type consumed by donations.controller.ts - pools.controller.ts: add missing Query decorator import from @nestjs/common; inject DonationsService into constructor (already provided via DonationsModule import in PoolsModule) --- nevo_frontend/app/profile/page.tsx | 1 + nevo_server/src/auth/auth.module.ts | 5 +---- nevo_server/src/donations/donations.service.ts | 5 +++++ nevo_server/src/pools/pools.controller.ts | 2 ++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/nevo_frontend/app/profile/page.tsx b/nevo_frontend/app/profile/page.tsx index 1faa2a0..0ab5fad 100644 --- a/nevo_frontend/app/profile/page.tsx +++ b/nevo_frontend/app/profile/page.tsx @@ -41,6 +41,7 @@ export default function ProfilePage() { useState(DEFAULT_PREFERENCES); const [isEditingProfile, setIsEditingProfile] = useState(false); const [profile, setProfile] = useState(null); + const [recentDonations, setRecentDonations] = useState([]); const [isLoading, setIsLoading] = useState(true); useEffect(() => { diff --git a/nevo_server/src/auth/auth.module.ts b/nevo_server/src/auth/auth.module.ts index 809ed43..4b1e6e7 100644 --- a/nevo_server/src/auth/auth.module.ts +++ b/nevo_server/src/auth/auth.module.ts @@ -1,8 +1,7 @@ import { Module } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; +import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; -import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; import { UsersModule } from '../users/users.module'; import { AuthService } from './auth.service'; @@ -16,8 +15,6 @@ import { Nonce } from './nonce.entity'; ConfigModule, PassportModule, TypeOrmModule.forFeature([Nonce]), - PassportModule, - TypeOrmModule.forFeature([Nonce]), JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], diff --git a/nevo_server/src/donations/donations.service.ts b/nevo_server/src/donations/donations.service.ts index 874b379..6a5d053 100644 --- a/nevo_server/src/donations/donations.service.ts +++ b/nevo_server/src/donations/donations.service.ts @@ -1,4 +1,9 @@ import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Donation } from './donation.entity.js'; + +export type DonationSortBy = 'newest' | 'largest'; @Injectable() export class DonationsService { diff --git a/nevo_server/src/pools/pools.controller.ts b/nevo_server/src/pools/pools.controller.ts index f92d2ca..1574833 100644 --- a/nevo_server/src/pools/pools.controller.ts +++ b/nevo_server/src/pools/pools.controller.ts @@ -9,6 +9,7 @@ import { ParseIntPipe, Patch, Post, + Query, Req, UseGuards, } from '@nestjs/common'; @@ -59,6 +60,7 @@ export class PoolsController { constructor( private readonly poolsService: PoolsService, private readonly contractService: ContractService, + private readonly donationsService: DonationsService, ) {} @Get(':id') From fd03ba2ac20177db6047a001c4947d79667b00fb Mon Sep 17 00:00:00 2001 From: priscaenoch Date: Mon, 29 Jun 2026 10:16:49 +0000 Subject: [PATCH 3/3] ci: trigger rebuild after CI fix commits