Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@
"dependencies": {
"@nestjs/common": "^11.0.1",
"@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@stellar/stellar-sdk": "^14.6.1",
"bcryptjs": "^3.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"reflect-metadata": "^0.2.2",
Expand Down
64 changes: 64 additions & 0 deletions apps/api/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { MerchantsService } from '../merchants/merchants.service';

describe('AuthController', () => {
let controller: AuthController;
let authService: jest.Mocked<Pick<AuthService, 'register' | 'login'>>;
let merchantsService: jest.Mocked<Pick<MerchantsService, 'findById' | 'toPublicProfile'>>;

beforeEach(async () => {
authService = {
register: jest.fn(),
login: jest.fn(),
};
merchantsService = {
findById: jest.fn(),
toPublicProfile: jest.fn(),
};

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

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

it('register delegates to AuthService', async () => {
const dto = { email: '[email protected]', password: 'password12', name: 'Shop' };
const expected = {
access_token: 't',
expires_in: 100,
merchant: { id: '1', email: '[email protected]', name: 'Shop' },
};
authService.register.mockResolvedValue(expected);
await expect(controller.register(dto)).resolves.toEqual(expected);
expect(authService.register).toHaveBeenCalledWith(dto);
});

it('me returns public profile when merchant exists', async () => {
const record = {
id: 'mid',
email: '[email protected]',
passwordHash: 'h',
name: 'M',
createdAt: new Date(),
};
merchantsService.findById.mockResolvedValue(record);
merchantsService.toPublicProfile.mockReturnValue({
id: 'mid',
email: '[email protected]',
name: 'M',
});
await expect(controller.me({ merchant_id: 'mid' })).resolves.toEqual({
id: 'mid',
email: '[email protected]',
name: 'M',
});
});
});
38 changes: 38 additions & 0 deletions apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Body, Controller, Get, NotFoundException, Post } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthResponse } from './interfaces/auth-response.interface';
import { Public } from './decorators/public.decorator';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { CurrentMerchant } from './decorators/current-merchant.decorator';
import type { MerchantUser } from './interfaces/merchant-user.interface';
import { MerchantsService } from '../merchants/merchants.service';

@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly merchantsService: MerchantsService,
) {}

@Public()
@Post('register')
register(@Body() dto: RegisterDto): Promise<AuthResponse> {
return this.authService.register(dto);
}

@Public()
@Post('login')
login(@Body() dto: LoginDto): Promise<AuthResponse> {
return this.authService.login(dto);
}

@Get('me')
async me(@CurrentMerchant() user: MerchantUser) {
const merchant = await this.merchantsService.findById(user.merchant_id);
if (!merchant) {
throw new NotFoundException('Merchant not found');
}
return this.merchantsService.toPublicProfile(merchant);
}
}
24 changes: 21 additions & 3 deletions apps/api/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,29 @@
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './strategies/jwt.strategy';
import { MerchantsModule } from '../merchants/merchants.module';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { JwtStrategy } from './strategies/jwt.strategy';

function jwtExpiresSec(): number {
const raw = process.env.JWT_EXPIRES_IN_SEC;
const n = raw ? Number.parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : 60 * 60 * 24 * 7;
}

@Module({
imports: [PassportModule.register({ defaultStrategy: 'jwt' })],
providers: [JwtStrategy, JwtAuthGuard],
imports: [
MerchantsModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.register({
secret: process.env.JWT_SECRET ?? 'default-secret-change-me',
signOptions: { expiresIn: jwtExpiresSec() },
}),
],
controllers: [AuthController],
providers: [JwtStrategy, JwtAuthGuard, AuthService],
exports: [JwtAuthGuard],
})
export class AuthModule {}
76 changes: 76 additions & 0 deletions apps/api/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { MerchantsModule } from '../merchants/merchants.module';
import { AuthService } from './auth.service';

describe('AuthService', () => {
let service: AuthService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
MerchantsModule,
JwtModule.register({
secret: 'unit-test-secret',
signOptions: { expiresIn: 3600 },
}),
],
providers: [AuthService],
}).compile();

service = module.get<AuthService>(AuthService);
});

it('registers a merchant and returns a JWT-shaped response', async () => {
const res = await service.register({
email: '[email protected]',
password: 'password12',
name: 'Test Shop',
});
expect(res.access_token).toBeDefined();
expect(res.expires_in).toBeGreaterThan(0);
expect(res.merchant.email).toBe('[email protected]');
expect(res.merchant.name).toBe('Test Shop');
});

it('rejects duplicate registration email', async () => {
await service.register({
email: '[email protected]',
password: 'password12',
name: 'A',
});
await expect(
service.register({
email: '[email protected]',
password: 'otherpass1',
name: 'B',
}),
).rejects.toBeInstanceOf(ConflictException);
});

it('logs in with correct credentials', async () => {
await service.register({
email: '[email protected]',
password: 'password12',
name: 'Login Co',
});
const res = await service.login({
email: '[email protected]',
password: 'password12',
});
expect(res.access_token).toBeDefined();
expect(res.merchant.name).toBe('Login Co');
});

it('rejects login with wrong password', async () => {
await service.register({
email: '[email protected]',
password: 'password12',
name: 'X',
});
await expect(
service.login({ email: '[email protected]', password: 'wrongpass' }),
).rejects.toBeInstanceOf(UnauthorizedException);
});
});
51 changes: 51 additions & 0 deletions apps/api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs';
import { MerchantsService } from '../merchants/merchants.service';
import { MerchantRecord } from '../merchants/interfaces/merchant-record.interface';
import { RegisterDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
import { AuthResponse } from './interfaces/auth-response.interface';

const BCRYPT_ROUNDS = 10;

function jwtExpiresSec(): number {
const raw = process.env.JWT_EXPIRES_IN_SEC;
const n = raw ? Number.parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : 60 * 60 * 24 * 7;
}

@Injectable()
export class AuthService {
private readonly expiresInSec = jwtExpiresSec();

constructor(
private readonly merchantsService: MerchantsService,
private readonly jwtService: JwtService,
) {}

async register(dto: RegisterDto): Promise<AuthResponse> {
const passwordHash = await bcrypt.hash(dto.password, BCRYPT_ROUNDS);
const merchant = await this.merchantsService.create(dto.email, passwordHash, dto.name);
return this.buildAuthResponse(merchant);
}

async login(dto: LoginDto): Promise<AuthResponse> {
const merchant = await this.merchantsService.findByEmail(dto.email);
if (!merchant || !(await bcrypt.compare(dto.password, merchant.passwordHash))) {
throw new UnauthorizedException('Invalid credentials');
}
return this.buildAuthResponse(merchant);
}

private async buildAuthResponse(merchant: MerchantRecord): Promise<AuthResponse> {
const access_token = await this.jwtService.signAsync({
merchant_id: merchant.id,
});
return {
access_token,
expires_in: this.expiresInSec,
merchant: this.merchantsService.toPublicProfile(merchant),
};
}
}
12 changes: 12 additions & 0 deletions apps/api/src/auth/dto/login.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';

export class LoginDto {
@IsEmail()
@MaxLength(255)
email!: string;

@IsString()
@MinLength(1)
@MaxLength(128)
password!: string;
}
17 changes: 17 additions & 0 deletions apps/api/src/auth/dto/register.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';

export class RegisterDto {
@IsEmail()
@MaxLength(255)
email!: string;

@IsString()
@MinLength(8)
@MaxLength(128)
password!: string;

@IsString()
@MinLength(1)
@MaxLength(200)
name!: string;
}
9 changes: 9 additions & 0 deletions apps/api/src/auth/interfaces/auth-response.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export interface AuthResponse {
access_token: string;
expires_in: number;
merchant: {
id: string;
email: string;
name: string;
};
}
13 changes: 7 additions & 6 deletions apps/api/src/auth/strategies/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { MerchantsService } from '../../merchants/merchants.service';
import { JwtPayload } from '../interfaces/jwt-payload.interface';
import { MerchantUser } from '../interfaces/merchant-user.interface';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
constructor(private readonly merchantsService: MerchantsService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
Expand All @@ -19,11 +20,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Invalid token: merchant_id missing');
}

// TODO: Validate merchant exists in database
// Example:
// const merchant = await this.merchantService.findById(payload.merchant_id);
// if (!merchant) throw new UnauthorizedException('Merchant not found');
const merchant = await this.merchantsService.findById(payload.merchant_id);
if (!merchant) {
throw new UnauthorizedException('Merchant not found');
}

return { merchant_id: payload.merchant_id };
return { merchant_id: merchant.id };
}
}
8 changes: 8 additions & 0 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface MerchantRecord {
id: string;
email: string;
passwordHash: string;
name: string;
createdAt: Date;
}
8 changes: 8 additions & 0 deletions apps/api/src/merchants/merchants.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MerchantsService } from './merchants.service';

@Module({
providers: [MerchantsService],
exports: [MerchantsService],
})
export class MerchantsModule {}
Loading
Loading