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
25 changes: 9 additions & 16 deletions apps/backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DynamicModule, Logger, Module } from '@nestjs/common';
import { DynamicModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import ms from 'ms';

import { MailingModule } from '@server/mailing/mailing.module';
import { UserModule } from '@server/user/user.module';
Expand All @@ -26,21 +27,13 @@ export class AuthModule {
inject: [ConfigService],
imports: [ConfigModule],
useFactory: async (config: ConfigService) => {
const JWT_SECRET = config.get('JWT_SECRET');
const JWT_EXPIRES_IN = config.get('JWT_EXPIRES_IN');

if (!JWT_SECRET) {
Logger.error('JWT_SECRET is not set');
throw new Error('JWT_SECRET is not set');
}

if (!JWT_EXPIRES_IN) {
Logger.warn('JWT_EXPIRES_IN is not set, using default of 60s');
}
const JWT_SECRET = config.getOrThrow<ms.StringValue>('JWT_SECRET');
const JWT_EXPIRES_IN =
config.getOrThrow<ms.StringValue>('JWT_EXPIRES_IN');

return {
secret: JWT_SECRET,
signOptions: { expiresIn: JWT_EXPIRES_IN || '60s' },
signOptions: { expiresIn: JWT_EXPIRES_IN },
};
},
}),
Expand All @@ -58,7 +51,7 @@ export class AuthModule {
inject: [ConfigService],
provide: 'COOKIE_EXPIRES_IN',
useFactory: (configService: ConfigService) =>
configService.getOrThrow<string>('COOKIE_EXPIRES_IN'),
configService.getOrThrow<ms.StringValue>('COOKIE_EXPIRES_IN'),
},
{
inject: [ConfigService],
Expand All @@ -82,7 +75,7 @@ export class AuthModule {
inject: [ConfigService],
provide: 'JWT_EXPIRES_IN',
useFactory: (configService: ConfigService) =>
configService.getOrThrow<string>('JWT_EXPIRES_IN'),
configService.getOrThrow<ms.StringValue>('JWT_EXPIRES_IN'),
},
{
inject: [ConfigService],
Expand All @@ -94,7 +87,7 @@ export class AuthModule {
inject: [ConfigService],
provide: 'JWT_REFRESH_EXPIRES_IN',
useFactory: (configService: ConfigService) =>
configService.getOrThrow<string>('JWT_REFRESH_EXPIRES_IN'),
configService.getOrThrow<ms.StringValue>('JWT_REFRESH_EXPIRES_IN'),
},
{
inject: [ConfigService],
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ describe('AuthService', () => {
const refreshToken = 'refresh-token';

spyOn(jwtService, 'signAsync').mockImplementation(
(payload, options: any) => {
(payload: any, options: any) => {
if (options.secret === 'test-jwt-secret') {
return Promise.resolve(accessToken);
} else if (options.secret === 'test-jwt-refresh-secret') {
Expand Down
13 changes: 7 additions & 6 deletions apps/backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import axios from 'axios';
import type { Request, Response } from 'express';
import ms from 'ms';

import { CreateUser } from '@nbw/database';
import type { UserDocument } from '@nbw/database';
Expand All @@ -22,18 +23,18 @@ export class AuthService {
@Inject(JwtService)
private readonly jwtService: JwtService,
@Inject('COOKIE_EXPIRES_IN')
private readonly COOKIE_EXPIRES_IN: string,
private readonly COOKIE_EXPIRES_IN: ms.StringValue,
@Inject('FRONTEND_URL')
private readonly FRONTEND_URL: string,

@Inject('JWT_SECRET')
private readonly JWT_SECRET: string,
@Inject('JWT_EXPIRES_IN')
private readonly JWT_EXPIRES_IN: string,
private readonly JWT_EXPIRES_IN: ms.StringValue,
@Inject('JWT_REFRESH_SECRET')
private readonly JWT_REFRESH_SECRET: string,
@Inject('JWT_REFRESH_EXPIRES_IN')
private readonly JWT_REFRESH_EXPIRES_IN: string,
private readonly JWT_REFRESH_EXPIRES_IN: ms.StringValue,
@Inject('APP_DOMAIN')
private readonly APP_DOMAIN?: string,
) {}
Expand Down Expand Up @@ -171,11 +172,11 @@ export class AuthService {

public async createJwtPayload(payload: TokenPayload): Promise<Tokens> {
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(payload, {
this.jwtService.signAsync<TokenPayload>(payload, {
secret: this.JWT_SECRET,
expiresIn: this.JWT_EXPIRES_IN,
}),
this.jwtService.signAsync(payload, {
this.jwtService.signAsync<TokenPayload>(payload, {
secret: this.JWT_REFRESH_SECRET,
expiresIn: this.JWT_REFRESH_EXPIRES_IN,
}),
Expand All @@ -199,7 +200,7 @@ export class AuthService {

const frontEndURL = this.FRONTEND_URL;
const domain = this.APP_DOMAIN;
const maxAge = parseInt(this.COOKIE_EXPIRES_IN) * 1000;
const maxAge = ms(this.COOKIE_EXPIRES_IN) * 1000;

res.cookie('token', token.access_token, {
domain: domain,
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/auth/strategies/JWT.strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe('JwtStrategy', () => {
it('should throw an error if JWT_SECRET is not set', () => {
jest.spyOn(configService, 'getOrThrow').mockReturnValue(null);

expect(() => new JwtStrategy(configService)).toThrowError(
expect(() => new JwtStrategy(configService)).toThrow(
'JwtStrategy requires a secret or key',
);
});
Expand Down Expand Up @@ -84,7 +84,7 @@ describe('JwtStrategy', () => {

const payload = { userId: 'test-user-id' };

expect(() => jwtStrategy.validate(req, payload)).toThrowError(
expect(() => jwtStrategy.validate(req, payload)).toThrow(
'No refresh token',
);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('DiscordStrategy', () => {
it('should throw an error if Discord config is missing', () => {
jest.spyOn(configService, 'getOrThrow').mockReturnValueOnce(null);

expect(() => new DiscordStrategy(configService)).toThrowError(
expect(() => new DiscordStrategy(configService)).toThrow(
'OAuth2Strategy requires a clientID option',
);
});
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/auth/strategies/discord.strategy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export class DiscordStrategy extends PassportStrategy(strategy, 'discord') {
callbackUrl: `${SERVER_URL}/v1/auth/discord/callback`,
scope: [DiscordPermissionScope.Email, DiscordPermissionScope.Identify],
fetchScope: true,
prompt: 'none',
prompt: 'none' as const,
};

super(config);
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/auth/strategies/github.strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ describe('GithubStrategy', () => {
it('should throw an error if GitHub config is missing', () => {
jest.spyOn(configService, 'getOrThrow').mockReturnValueOnce(null);

expect(() => new GithubStrategy(configService)).toThrowError(
expect(() => new GithubStrategy(configService)).toThrow(
'OAuth2Strategy requires a clientID option',
);
});
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/auth/strategies/github.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,10 @@ export class GithubStrategy extends PassportStrategy(strategy, 'github') {
super({
clientID: GITHUB_CLIENT_ID,
clientSecret: GITHUB_CLIENT_SECRET,
redirect_uri: `${SERVER_URL}/v1/auth/github/callback`,
callbackURL: `${SERVER_URL}/v1/auth/github/callback`,
scope: 'user:read,user:email',
state: false,
});
} as any); // TODO: Fix types
}

async validate(accessToken: string, refreshToken: string, profile: any) {
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/auth/strategies/google.strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ describe('GoogleStrategy', () => {
it('should throw an error if Google config is missing', () => {
jest.spyOn(configService, 'getOrThrow').mockReturnValueOnce(null);

expect(() => new GoogleStrategy(configService)).toThrowError(
expect(() => new GoogleStrategy(configService)).toThrow(
'OAuth2Strategy requires a clientID option',
);
});
Expand Down
52 changes: 44 additions & 8 deletions apps/backend/src/config/EnvironmentVariables.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,35 @@
import { plainToInstance } from 'class-transformer';
import { IsEnum, IsOptional, IsString, validateSync } from 'class-validator';
import {
IsEnum,
IsOptional,
IsString,
registerDecorator,
validateSync,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
import ms from 'ms';

// Validate if the value is a valid duration string from the 'ms' library
function IsDuration(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'isDuration',
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return false;
return typeof ms(value as ms.StringValue) === 'number';
},
defaultMessage(args: ValidationArguments) {
return `${args.property} must be a valid duration string (e.g., "1h", "30m", "7d")`;
},
},
});
};
}

enum Environment {
Development = 'development',
Expand Down Expand Up @@ -38,14 +68,14 @@ export class EnvironmentVariables {
@IsString()
JWT_SECRET: string;

@IsString()
JWT_EXPIRES_IN: string;
@IsDuration()
JWT_EXPIRES_IN: ms.StringValue;

@IsString()
JWT_REFRESH_SECRET: string;

@IsString()
JWT_REFRESH_EXPIRES_IN: string;
@IsDuration()
JWT_REFRESH_EXPIRES_IN: ms.StringValue;

// database
@IsString()
Expand Down Expand Up @@ -91,8 +121,8 @@ export class EnvironmentVariables {
@IsString()
DISCORD_WEBHOOK_URL: string;

@IsString()
COOKIE_EXPIRES_IN: string;
@IsDuration()
COOKIE_EXPIRES_IN: ms.StringValue;
}

export function validate(config: Record<string, unknown>) {
Expand All @@ -105,7 +135,13 @@ export function validate(config: Record<string, unknown>) {
});

if (errors.length > 0) {
throw new Error(errors.toString());
const messages = errors
.map((error) => {
const constraints = Object.values(error.constraints || {});
return ` - ${error.property}: ${constraints.join(', ')}`;
})
.join('\n');
throw new Error(`Environment validation failed:\n${messages}`);
}

return validatedConfig;
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/lib/GetRequestUser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('validateUser', () => {
});

it('should throw an error if the user does not exist', () => {
expect(() => validateUser(null)).toThrowError(
expect(() => validateUser(null)).toThrow(
new HttpException(
{
error: {
Expand Down
24 changes: 12 additions & 12 deletions apps/backend/src/song/song.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ describe('SongController', () => {
const query: SongListQueryDTO = { page: 1, limit: 10 };
const songList: SongPreviewDto[] = Array(10)
.fill(null)
.map((_, i) => ({ id: `song-${i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand All @@ -280,7 +280,7 @@ describe('SongController', () => {
const query: SongListQueryDTO = { page: 1, limit: 10 };
const songList: SongPreviewDto[] = Array(5)
.fill(null)
.map((_, i) => ({ id: `song-${i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand All @@ -300,7 +300,7 @@ describe('SongController', () => {
const query: SongListQueryDTO = { page: 3, limit: 10 };
const songList: SongPreviewDto[] = Array(10)
.fill(null)
.map((_, i) => ({ id: `song-${20 + i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${20 + i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand All @@ -321,7 +321,7 @@ describe('SongController', () => {
const query: SongListQueryDTO = { page: 1, limit: 10, q: 'test search' };
const songList: SongPreviewDto[] = Array(8)
.fill(null)
.map((_, i) => ({ id: `song-${i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand All @@ -346,7 +346,7 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = Array(3)
.fill(null)
.map((_, i) => ({ id: `rock-song-${i}` } as SongPreviewDto));
.map((_, i) => ({ id: `rock-song-${i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand Down Expand Up @@ -430,7 +430,7 @@ describe('SongController', () => {
const q = 'test query';
const songList: SongPreviewDto[] = Array(5)
.fill(null)
.map((_, i) => ({ id: `song-${i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand Down Expand Up @@ -493,7 +493,7 @@ describe('SongController', () => {
const q = 'test search';
const songList: SongPreviewDto[] = Array(10)
.fill(null)
.map((_, i) => ({ id: `song-${10 + i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${10 + i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand All @@ -516,7 +516,7 @@ describe('SongController', () => {
const q = 'popular song';
const songList: SongPreviewDto[] = Array(50)
.fill(null)
.map((_, i) => ({ id: `song-${i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand All @@ -538,7 +538,7 @@ describe('SongController', () => {
const q = 'search term';
const songList: SongPreviewDto[] = Array(3)
.fill(null)
.map((_, i) => ({ id: `song-${40 + i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${40 + i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand Down Expand Up @@ -596,7 +596,7 @@ describe('SongController', () => {
const q = 'test';
const songList: SongPreviewDto[] = Array(25)
.fill(null)
.map((_, i) => ({ id: `song-${i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand All @@ -623,7 +623,7 @@ describe('SongController', () => {
const q = 'trending';
const songList: SongPreviewDto[] = Array(10)
.fill(null)
.map((_, i) => ({ id: `song-${i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand All @@ -644,7 +644,7 @@ describe('SongController', () => {
const q = 'search';
const songList: SongPreviewDto[] = Array(20)
.fill(null)
.map((_, i) => ({ id: `song-${40 + i}` } as SongPreviewDto));
.map((_, i) => ({ id: `song-${40 + i}` } as unknown as SongPreviewDto));

mockSongService.querySongs.mockResolvedValueOnce({
content: songList,
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/song/song.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export class SongController {
[SongSortType.NOTE_COUNT, 'stats.noteCount'],
]);

const sortField = sortFieldMap.get(query.sort) ?? 'createdAt';
const sortField = sortFieldMap.get(query.sort ?? SongSortType.RECENT);
const isDescending = query.order ? query.order === 'desc' : true;

// Build PageQueryDTO with the sort field
Expand Down
Loading