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
43 changes: 43 additions & 0 deletions nevo_frontend/__tests__/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {
ApiClient,
RateLimitError,
verifyAuthSignature,
fetchAuthChallenge,
ApiError,
UnauthorizedError,
} from '@/lib/api-client';

Expand Down Expand Up @@ -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);
});
});
74 changes: 73 additions & 1 deletion nevo_frontend/__tests__/walletStore.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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();
});
});
1 change: 1 addition & 0 deletions nevo_frontend/app/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export default function ProfilePage() {
useState<UserPreferences>(DEFAULT_PREFERENCES);
const [isEditingProfile, setIsEditingProfile] = useState(false);
const [profile, setProfile] = useState<ApiProfile | null>(null);
const [recentDonations, setRecentDonations] = useState<ApiDonation[]>([]);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
Expand Down
13 changes: 13 additions & 0 deletions nevo_frontend/lib/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,3 +603,16 @@ export function verifyAuthSignature(
message: nonce,
});
}

export interface AuthChallenge {
nonce: string;
expiresAt: number;
}

export function fetchAuthChallenge(publicKey: string): Promise<AuthChallenge> {
return apiClient.get<AuthChallenge>('/auth/challenge', {
params: { publicKey },
requireAuth: false,
cacheResponse: false,
});
}
10 changes: 7 additions & 3 deletions nevo_frontend/src/store/walletStore.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -47,11 +48,14 @@ export const useWalletStore = create<WalletState>()(
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?.();
}
Expand Down
5 changes: 1 addition & 4 deletions nevo_server/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,8 +15,6 @@ import { Nonce } from './nonce.entity';
ConfigModule,
PassportModule,
TypeOrmModule.forFeature([Nonce]),
PassportModule,
TypeOrmModule.forFeature([Nonce]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand Down
5 changes: 5 additions & 0 deletions nevo_server/src/donations/donations.service.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions nevo_server/src/pools/pools.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
ParseIntPipe,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
Expand Down Expand Up @@ -59,6 +60,7 @@ export class PoolsController {
constructor(
private readonly poolsService: PoolsService,
private readonly contractService: ContractService,
private readonly donationsService: DonationsService,
) {}

@Get(':id')
Expand Down
Loading