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
220 changes: 220 additions & 0 deletions backend/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, HttpException, HttpStatus } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { WalletAuthService } from './wallet-auth.service';

describe('AuthController', () => {
let controller: AuthController;
let authService: jest.Mocked<Pick<AuthService, 'validateStellarAddress' | 'createSession'>>;
let walletAuthService: jest.Mocked<Pick<WalletAuthService, 'createChallenge' | 'verifyAndIssueToken'>>;

const validAddress = `G${'A'.repeat(55)}`;

beforeEach(async () => {
authService = {
validateStellarAddress: jest.fn().mockReturnValue(true),
createSession: jest.fn().mockResolvedValue({
userId: validAddress,
token: Buffer.from(validAddress).toString('base64'),
}),
};

walletAuthService = {
createChallenge: jest.fn().mockResolvedValue({
nonce: 'abc123',
expiresAt: new Date('2026-12-31'),
}),
verifyAndIssueToken: jest.fn().mockResolvedValue({
access_token: 'jwt-token',
token_type: 'Bearer',
}),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: authService },
{ provide: WalletAuthService, useValue: walletAuthService },
],
}).compile();

controller = module.get(AuthController);
});

describe('login', () => {
it('creates a session for a valid address', async () => {
const result = await controller.login({ address: validAddress });

expect(authService.validateStellarAddress).toHaveBeenCalledWith(validAddress);
expect(authService.createSession).toHaveBeenCalledWith(validAddress);
expect(result).toEqual({
userId: validAddress,
token: expect.any(String),
});
});

it('throws BadRequestException for invalid address', async () => {
authService.validateStellarAddress.mockReturnValue(false);

await expect(controller.login({ address: 'invalid' })).rejects.toThrow(
BadRequestException,
);
expect(authService.createSession).not.toHaveBeenCalled();
});

it('throws BadRequestException when address is omitted', async () => {
authService.validateStellarAddress.mockReturnValue(false);

await expect(controller.login({})).rejects.toThrow(BadRequestException);
});

it('throws on network mismatch', async () => {
await expect(
controller.login({ address: validAddress }, 'mainnet'),
).rejects.toThrow(HttpException);
});

it('passes when x-network matches server network', async () => {
const result = await controller.login({ address: validAddress }, 'testnet');

expect(result).toEqual({
userId: validAddress,
token: expect.any(String),
});
});

it('ignores network header when not provided', async () => {
const result = await controller.login({ address: validAddress }, undefined);

expect(result).toEqual({
userId: validAddress,
token: expect.any(String),
});
});
});

describe('register (deprecated)', () => {
it('creates a session for a valid address', async () => {
const result = await controller.register({ address: validAddress });

expect(authService.createSession).toHaveBeenCalledWith(validAddress);
expect(result).toEqual({
userId: validAddress,
token: expect.any(String),
});
});

it('throws BadRequestException for invalid address', async () => {
authService.validateStellarAddress.mockReturnValue(false);

await expect(controller.register({ address: 'bad' })).rejects.toThrow(
BadRequestException,
);
});

it('throws on network mismatch', async () => {
await expect(
controller.register({ address: validAddress }, 'mainnet'),
).rejects.toThrow(HttpException);
});
});

describe('requestChallenge', () => {
it('returns nonce and expiry for a valid address', async () => {
const result = await controller.requestChallenge({ address: validAddress });

expect(authService.validateStellarAddress).toHaveBeenCalledWith(validAddress);
expect(walletAuthService.createChallenge).toHaveBeenCalledWith(validAddress);
expect(result).toEqual({
nonce: 'abc123',
expiresAt: expect.any(Date),
});
});

it('throws BadRequestException for invalid address', async () => {
authService.validateStellarAddress.mockReturnValue(false);

await expect(
controller.requestChallenge({ address: 'invalid' }),
).rejects.toThrow(BadRequestException);
expect(walletAuthService.createChallenge).not.toHaveBeenCalled();
});

it('throws on network mismatch', async () => {
await expect(
controller.requestChallenge({ address: validAddress }, 'mainnet'),
).rejects.toThrow(HttpException);
});
});

describe('verifyChallenge', () => {
const dto = { address: validAddress, nonce: 'abc123', signature: 'deadbeef' };

it('returns JWT for valid verification', async () => {
const result = await controller.verifyChallenge(dto);

expect(walletAuthService.verifyAndIssueToken).toHaveBeenCalledWith(
validAddress,
'abc123',
'deadbeef',
);
expect(result).toEqual({
access_token: 'jwt-token',
token_type: 'Bearer',
});
});

it('throws BadRequestException for invalid address', async () => {
authService.validateStellarAddress.mockReturnValue(false);

await expect(controller.verifyChallenge(dto)).rejects.toThrow(
BadRequestException,
);
expect(walletAuthService.verifyAndIssueToken).not.toHaveBeenCalled();
});

it('throws on network mismatch', async () => {
await expect(
controller.verifyChallenge(dto, 'mainnet'),
).rejects.toThrow(HttpException);
});

it('propagates service errors', async () => {
walletAuthService.verifyAndIssueToken.mockRejectedValue(
new BadRequestException('Invalid signature'),
);

await expect(controller.verifyChallenge(dto)).rejects.toThrow(
BadRequestException,
);
});
});

describe('assertNetworkMatch (via endpoints)', () => {
it('accepts case-insensitive network match', async () => {
const result = await controller.login({ address: validAddress }, 'Testnet');
expect(result).toBeDefined();
});

it('trims whitespace from network header', async () => {
const result = await controller.login({ address: validAddress }, ' testnet ');
expect(result).toBeDefined();
});

it('includes expected and current network in mismatch error', async () => {
try {
await controller.login({ address: validAddress }, 'mainnet');
fail('should have thrown');
} catch (e) {
expect(e).toBeInstanceOf(HttpException);
const response = (e as HttpException).getResponse();
expect(response).toMatchObject({
error: 'NETWORK_MISMATCH',
expectedNetwork: 'testnet',
currentNetwork: 'mainnet',
});
}
});
});
});
149 changes: 149 additions & 0 deletions backend/src/subscriptions/dto/checkout.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateCheckoutDto {
@ApiProperty({ description: 'Fan Stellar G-address' })
@IsString()
@IsNotEmpty()
fanAddress: string;

@ApiProperty({ description: 'Creator Stellar G-address' })
@IsString()
@IsNotEmpty()
creatorAddress: string;

@ApiProperty({ description: 'Subscription plan ID', minimum: 1 })
@IsInt()
@Min(1)
planId: number;

@ApiPropertyOptional({ description: 'Asset code (default: XLM)', default: 'XLM' })
@IsOptional()
@IsString()
assetCode?: string;

@ApiPropertyOptional({ description: 'Asset issuer address (for non-native assets)' })
@IsOptional()
@IsString()
assetIssuer?: string;
}

export class CheckoutResponseDto {
@ApiProperty({ description: 'Checkout session ID' }) id: string;
@ApiProperty({ description: 'Fan Stellar G-address' }) fanAddress: string;
@ApiProperty({ description: 'Creator Stellar G-address' }) creatorAddress: string;
@ApiProperty({ description: 'Subscription plan ID' }) planId: number;
@ApiProperty({ description: 'Asset code' }) assetCode: string;
@ApiPropertyOptional({ description: 'Asset issuer address' }) assetIssuer?: string;
@ApiProperty({ description: 'Subscription amount' }) amount: string;
@ApiProperty({ description: 'Platform fee' }) fee: string;
@ApiProperty({ description: 'Total including fees' }) total: string;
@ApiProperty({ description: 'Checkout status', enum: ['pending', 'completed', 'failed', 'rejected', 'expired'] }) status: string;
@ApiProperty({ description: 'Session expiry timestamp' }) expiresAt: Date;
@ApiPropertyOptional({ description: 'Transaction hash (after confirmation)' }) txHash?: string;
@ApiPropertyOptional({ description: 'Error message (on failure)' }) error?: string;
@ApiProperty({ description: 'Creation timestamp' }) createdAt: Date;
@ApiProperty({ description: 'Last update timestamp' }) updatedAt: Date;
}

export class ValidateBalanceDto {
@ApiProperty({ description: 'Asset code to check balance for' })
@IsString()
@IsNotEmpty()
assetCode: string;

@ApiProperty({ description: 'Required amount' })
@IsString()
@IsNotEmpty()
amount: string;
}

export class ValidateBalanceResponseDto {
@ApiProperty({ description: 'Whether balance is sufficient' }) valid: boolean;
@ApiProperty({ description: 'Current balance' }) balance: string;
@ApiPropertyOptional({ description: 'Amount short (if insufficient)' }) shortfall?: string;
}

export class ConfirmSubscriptionDto {
@ApiPropertyOptional({ description: 'Transaction hash from Stellar network' })
@IsOptional()
@IsString()
txHash?: string;
}

export class ConfirmSubscriptionResponseDto {
@ApiProperty() success: boolean;
@ApiProperty() checkoutId: string;
@ApiProperty() status: string;
@ApiProperty() txHash: string;
@ApiProperty() explorerUrl: string;
@ApiProperty() subscriptionId: string;
@ApiProperty({ enum: ['created', 'renewed'] }) lifecycleEvent: string;
@ApiProperty() message: string;
}

export class FailCheckoutDto {
@ApiProperty({ description: 'Error message describing the failure' })
@IsString()
@IsNotEmpty()
error: string;

@ApiPropertyOptional({ description: 'Whether the transaction was rejected by the user', default: false })
@IsOptional()
rejected?: boolean;
}

export class CancelSubscriptionDto {
@ApiProperty({ description: 'Fan Stellar G-address' })
@IsString()
@IsNotEmpty()
fanAddress: string;

@ApiProperty({ description: 'Creator Stellar G-address' })
@IsString()
@IsNotEmpty()
creatorAddress: string;
}

export class PlanSummaryResponseDto {
@ApiProperty() id: number;
@ApiProperty() creatorName: string;
@ApiProperty() creatorAddress: string;
@ApiProperty() name: string;
@ApiPropertyOptional() description?: string;
@ApiProperty() assetCode: string;
@ApiPropertyOptional() assetIssuer?: string;
@ApiProperty() amount: string;
@ApiProperty() interval: string;
@ApiProperty() intervalDays: number;
}

export class PriceBreakdownResponseDto {
@ApiProperty() subtotal: string;
@ApiProperty() platformFee: string;
@ApiProperty() networkFee: string;
@ApiProperty() total: string;
@ApiProperty() currency: string;
}

export class WalletStatusResponseDto {
@ApiProperty() address: string;
@ApiProperty({ isArray: true }) balances: {
code: string;
issuer?: string;
balance: string;
isNative: boolean;
}[];
@ApiProperty() isConnected: boolean;
}

export class TransactionPreviewResponseDto {
@ApiProperty() checkoutId: string;
@ApiProperty() from: string;
@ApiProperty() to: string;
@ApiProperty() asset: { code: string; issuer?: string };
@ApiProperty() amount: string;
@ApiProperty() fee: string;
@ApiProperty() total: string;
@ApiProperty() memo: string;
}
Loading
Loading