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
40 changes: 37 additions & 3 deletions apps/backend/src/anthology/anthology.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@ import {
Post,
Body,
Delete,
Patch,
Param,
ParseIntPipe,
UseGuards,
NotFoundException,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { AnthologyService } from './anthology.service';
import { Anthology } from './anthology.entity';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { FilterSortAnthologyDto } from './dtos/filter-anthology.dto';
import { OmchaiRoles, UserStatus } from '../auth/roles.decorator';
import { OmchaiRole } from 'src/omchai/omchai.entity';
import { CreateAnthologyDto } from './dtos/create-anthology.dto';
import { UpdateAnthologyDto } from './dtos/update-anthology.dto';
import { Status } from 'src/users/types';

@ApiTags('Anthologies')
@Controller('anthologies')
Expand Down Expand Up @@ -44,12 +50,40 @@ export class AnthologyController {
}

@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Delete('/:anthologyId')
async removeAnthology(
@Param('anthologyId', ParseIntPipe) anthologyId: number,
): Promise<{ message: string }> {
await this.anthologyService.remove(anthologyId);
return { message: 'Anthology deleted successfully' };
}

@ApiBearerAuth()
@UserStatus(Status.ADMIN)
@Post()
@HttpCode(HttpStatus.CREATED)
async createAnthology(
@Body() createAnthologyDto: CreateAnthologyDto,
): Promise<Anthology> {
return this.anthologyService.create(
createAnthologyDto.title,
createAnthologyDto.description,
createAnthologyDto.status,
createAnthologyDto.pub_level,
createAnthologyDto.programs,
createAnthologyDto.photo_url,
createAnthologyDto.isbn,
createAnthologyDto.shopify_url,
);
}

@ApiBearerAuth()
@OmchaiRoles(OmchaiRole.OWNER, OmchaiRole.MANAGER)
@Patch(':id')
async updateAnthology(
@Param('id', ParseIntPipe) id: number,
@Body() updateAnthologyDto: UpdateAnthologyDto,
): Promise<Anthology> {
return this.anthologyService.update(id, updateAnthologyDto);
}
}
4 changes: 2 additions & 2 deletions apps/backend/src/anthology/anthology.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export class Anthology {
@Column({ type: 'simple-array', default: [] })
triggers: string[];

@Column({ name: 'published_date', type: 'date' })
publishedDate: Date;
@Column({ type: 'date', nullable: true })
publishedDate?: Date;

@Column({ type: 'simple-array', nullable: true })
programs?: string[];
Expand Down
3 changes: 1 addition & 2 deletions apps/backend/src/anthology/anthology.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AnthologyController } from './anthology.controller';
import { AnthologyService } from './anthology.service';
import { Anthology } from './anthology.entity';
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';
import { AuthModule } from '../auth/auth.module';
import { UsersModule } from '../users/users.module';

@Module({
imports: [TypeOrmModule.forFeature([Anthology]), AuthModule, UsersModule],
controllers: [AnthologyController],
providers: [AnthologyService, CurrentUserInterceptor],
providers: [AnthologyService],
exports: [AnthologyService],
})
export class AnthologyModule {}
4 changes: 0 additions & 4 deletions apps/backend/src/anthology/anthology.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,16 @@ export class AnthologyService {
async create(
title: string,
description: string,
publishedDate: string,
status: AnthologyStatus,
pubLevel: AnthologyPubLevel,
programs?: string[],
photoUrl?: string,
isbn?: string,
shopifyUrl?: string,
) {
const anthologyId = (await this.repo.count()) + 1;
const anthology = this.repo.create({
id: anthologyId,
title,
description,
publishedDate,
status,
pubLevel,
programs,
Expand Down
5 changes: 0 additions & 5 deletions apps/backend/src/anthology/dtos/create-anthology.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AnthologyStatus, AnthologyPubLevel } from '../types';

//TODO: outdated DTO needs to match the schema
export class CreateAnthologyDto {
@ApiProperty({ description: 'Title of the anthology' })
@IsString()
Expand All @@ -18,10 +17,6 @@ export class CreateAnthologyDto {
@IsString()
description: string;

@ApiProperty({ description: 'Year the anthology was published' })
@IsNumber()
published_year: number;

@ApiProperty({
description: 'Status of the anthology',
enum: AnthologyStatus,
Expand Down
22 changes: 21 additions & 1 deletion apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { AuthorModule } from './author/author.module';
import { AnthologyModule } from './anthology/anthology.module';
import { InventoryModule } from './inventory/inventory.module';
import { InventoryHoldingModule } from './inventory-holding/inventory-holding.module';
import AppDataSource from './data-source';
Expand All @@ -13,6 +14,10 @@ import { OmchaiModule } from './omchai/omchai.module';
import { UsersModule } from './users/users.module';
import { StoryDraftModule } from './story-draft/story-draft.module';
import { AuthModule } from './auth/auth.module';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { OmchaiGuard } from './auth/guards/omchai.guard';
import { UserStatusGuard } from './auth/guards/user-status.guard';

@Module({
imports: [
Expand All @@ -21,6 +26,7 @@ import { AuthModule } from './auth/auth.module';
migrations: [], // ensures migrations not run on app startup
}),
AuthorModule,
AnthologyModule,
InventoryModule,
InventoryHoldingModule,
ProductionInfoModule,
Expand All @@ -31,6 +37,20 @@ import { AuthModule } from './auth/auth.module';
AuthModule,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: JwtAuthGuard,
},
{
provide: APP_GUARD,
useClass: UserStatusGuard,
},
{
provide: APP_GUARD,
useClass: OmchaiGuard,
},
],
})
export class AppModule {}
22 changes: 19 additions & 3 deletions apps/backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,31 @@ import { AuthService } from './auth.service';
import { UsersService } from '../users/users.service';
import { User } from '../users/user.entity';
import { JwtStrategy } from './jwt.strategy';
import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor';
import { JwtAuthGuard } from './guards/jwt-auth.guard';
import { OmchaiGuard } from './guards/omchai.guard';
import { UserStatusGuard } from './guards/user-status.guard';

@Module({
imports: [
TypeOrmModule.forFeature([User]),
PassportModule.register({ defaultStrategy: 'jwt' }),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy, UsersService, CurrentUserInterceptor],
exports: [AuthService, JwtStrategy, UsersService, CurrentUserInterceptor],
providers: [
AuthService,
JwtStrategy,
UsersService,
JwtAuthGuard,
OmchaiGuard,
UserStatusGuard,
],
exports: [
AuthService,
JwtStrategy,
UsersService,
JwtAuthGuard,
OmchaiGuard,
UserStatusGuard,
],
})
export class AuthModule {}
85 changes: 85 additions & 0 deletions apps/backend/src/auth/guards/jwt-auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ExecutionContext } from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';
import { UsersService } from '../../users/users.service';
import { User } from '../../users/user.entity';
import { Status } from '../../users/types';
import { Omchai, OmchaiRole } from '../../omchai/omchai.entity';

describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let usersService: jest.Mocked<UsersService>;

const mockOmchai: Omchai = {
id: 1,
anthologyId: 1,
userId: 1,
role: OmchaiRole.OWNER,
datetimeAssigned: new Date(),
user: null,
anthology: null,
};

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
JwtAuthGuard,
{
provide: UsersService,
useValue: {
findWithOmchai: jest.fn(),
},
},
],
}).compile();

guard = module.get<JwtAuthGuard>(JwtAuthGuard);
usersService = module.get(UsersService) as jest.Mocked<UsersService>;

jest
.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'canActivate')
.mockResolvedValue(true);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('canActivate', () => {
it('should return false when parent guard returns false', async () => {
const mockRequest = {
user: { email: 'test@example.com' },
};

const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as unknown as ExecutionContext;

jest.spyOn(guard, 'canActivate').mockResolvedValueOnce(false);

const result = await guard.canActivate(mockContext);

expect(result).toBe(false);
});

it('should handle requests with no user email gracefully', async () => {
const mockRequest = {
user: {},
};

const mockContext = {
switchToHttp: () => ({
getRequest: () => mockRequest,
}),
} as unknown as ExecutionContext;

const result = await guard.canActivate(mockContext);
// we can still let this pass thru since JWT was valid
// but will fail any other guards if they have roles associated
expect(result).toBe(true);
expect(usersService.findWithOmchai).not.toHaveBeenCalled();
});
});
});
45 changes: 45 additions & 0 deletions apps/backend/src/auth/guards/jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Injectable, ExecutionContext, Logger } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { UsersService } from '../../users/users.service';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
private readonly logger = new Logger(JwtAuthGuard.name);

constructor(private usersService: UsersService) {
super();
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const { method, url } = request;

let result: boolean;
try {
result = await (super.canActivate(context) as Promise<boolean>);
} catch (err) {
this.logger.warn(
`JWT validation failed for ${method} ${url}: ${err.message}`,
);
throw err;
}

if (!result) {
this.logger.warn(`JWT guard denied ${method} ${url}`);
return false;
}

const email = request.user?.email;
if (email) {
const users = await this.usersService.findWithOmchai(email);
if (users.length > 0) {
request.user = users[0];
this.logger.debug(`Authenticated user ${email} for ${method} ${url}`);
} else {
this.logger.warn(`JWT valid but no user found for email ${email}`);
}
}

return result;
}
}
Loading