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
21 changes: 3 additions & 18 deletions backend/src/assets/assets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from '@nestjs/swagger';
import { Throttle, ThrottlerGuard } from '@nestjs/throttler';
import { AssetsService } from './assets.service';
import { ListAssetsQueryDto } from './dto/list-assets-query.dto';
import { AssetLabelService } from './asset-label.service';
import { AssetQrQueryDto, AssetLabelQueryDto } from './dto/asset-qr-query.dto';
import { PublicAssetDto } from './dto/public-asset.dto';
Expand Down Expand Up @@ -51,24 +52,8 @@ export class AssetsController {
@Get()
@ApiOperation({ summary: 'List all assets (paginated, filterable)' })
@ApiResponse({ status: 200, description: 'Paginated list of assets' })
findAll(
@Query('search') search?: string,
@Query('categoryId') categoryId?: string,
@Query('departmentId') departmentId?: string,
@Query('locationId') locationId?: string,
@Query('status') status?: string,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.assetsService.findAll({
search,
categoryId,
departmentId,
locationId,
status,
page,
limit,
});
findAll(@Query() query: ListAssetsQueryDto) {
return this.assetsService.findAll(query);
}

@Post()
Expand Down
41 changes: 41 additions & 0 deletions backend/src/assets/assets.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,47 @@ describe('AssetsService', () => {
expect(repository.softRemove).toHaveBeenCalledWith(mockAsset);
});

describe('findAll', () => {
function mockQb() {
const qb: any = {
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
jest.spyOn(repository, 'createQueryBuilder').mockReturnValue(qb);
return qb;
}

it('escapes LIKE wildcards so "100%" matches literally', async () => {
const qb = mockQb();
await service.findAll({ search: '100%' });
const [sql, params] = qb.andWhere.mock.calls[0];
expect(params).toEqual({ s: '%100\\%%' });
expect(sql).toContain("ESCAPE '\\'");
});

it('clamps limit to 100 and uses an id tiebreaker for stable pages', async () => {
const qb = mockQb();
const result = await service.findAll({ page: 3, limit: 1_000_000 });
expect(qb.take).toHaveBeenCalledWith(100);
expect(qb.skip).toHaveBeenCalledWith(200);
expect(qb.orderBy).toHaveBeenCalledWith('asset.createdAt', 'DESC');
expect(qb.addOrderBy).toHaveBeenCalledWith('asset.id', 'ASC');
expect(result.limit).toBe(100);
});

it('sorts by a whitelisted column and ignores unknown ones', async () => {
const qb = mockQb();
await service.findAll({ sortBy: 'name', sortOrder: 'ASC' });
expect(qb.orderBy).toHaveBeenCalledWith('asset.name', 'ASC');
await service.findAll({ sortBy: 'passwordHash; --' });
expect(qb.orderBy).toHaveBeenLastCalledWith('asset.createdAt', 'DESC');
});
});

describe('findPublicByScan', () => {
it('looks up a UUID by id and returns the public shape', async () => {
jest.spyOn(repository, 'findOne').mockResolvedValue({
Expand Down
27 changes: 23 additions & 4 deletions backend/src/assets/assets.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import {
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { EventEmitter2 } from '@nestjs/event-emitter';
import {
LIKE_ESCAPE,
containsPattern,
resolveSort,
} from '../common/utils/list-query';
import { ASSET_SORT_COLUMNS } from './dto/list-assets-query.dto';
import { PublicAssetDto, toPublicAsset } from './dto/public-asset.dto';
import { Asset, ASSET_DETAIL_RELATIONS } from './entities/asset.entity';
import { AssetLifecycleService, AssetStatus } from './asset-lifecycle.service';
Expand All @@ -26,6 +32,8 @@ export interface BulkResult {
skipped: string[];
}

/** Hard ceiling on list page size, enforced again here as defence in depth. */
const MAX_PAGE_SIZE = 100;
const PROTECTED_ASSET_FIELDS = [
'id',
'createdAt',
Expand Down Expand Up @@ -54,18 +62,29 @@ export class AssetsService {
status?: string;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: string;
}) {
const page = query?.page || 1;
const limit = query?.limit || 20;
const limit = Math.min(query?.limit || 20, MAX_PAGE_SIZE);
const sort = resolveSort(
query?.sortBy,
query?.sortOrder,
ASSET_SORT_COLUMNS,
'createdAt',
);
const qb = this.assetRepo
.createQueryBuilder('asset')
.skip((page - 1) * limit)
.take(limit);
.take(limit)
// `id` breaks ties so rows never move between pages.
.orderBy(`asset.${sort.column}`, sort.order)
.addOrderBy('asset.id', 'ASC');

if (query?.search) {
qb.andWhere(
'(asset.name ILIKE :s OR asset.assetTag ILIKE :s OR asset.serialNumber ILIKE :s)',
{ s: `%${query.search}%` },
`(asset.name ILIKE :s ${LIKE_ESCAPE} OR asset.assetTag ILIKE :s ${LIKE_ESCAPE} OR asset.serialNumber ILIKE :s ${LIKE_ESCAPE})`,
{ s: containsPattern(query.search) },
);
}
if (query?.categoryId)
Expand Down
24 changes: 24 additions & 0 deletions backend/src/assets/dto/list-assets-query.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { ListAssetsQueryDto } from './list-assets-query.dto';

const check = async (input: Record<string, unknown>) =>
validate(plainToInstance(ListAssetsQueryDto, input));

describe('ListAssetsQueryDto', () => {
it('accepts a normal query', async () => {
expect(
await check({ page: '2', limit: '50', sortBy: 'name', sortOrder: 'ASC' }),
).toEqual([]);
});

it('rejects limit above 100 and page below 1', async () => {
expect((await check({ limit: '101' })).length).toBeGreaterThan(0);
expect((await check({ page: '0' })).length).toBeGreaterThan(0);
});

it('rejects non-whitelisted sort columns and directions', async () => {
expect((await check({ sortBy: 'serialNumber; DROP' })).length).toBeGreaterThan(0);
expect((await check({ sortOrder: 'sideways' })).length).toBeGreaterThan(0);
});
});
29 changes: 29 additions & 0 deletions backend/src/assets/dto/list-assets-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator';
import { PaginationQueryDto } from '../../common/dto/pagination.dto';

export const ASSET_SORT_COLUMNS = [
'createdAt',
'updatedAt',
'name',
'assetTag',
'status',
'purchaseDate',
] as const;

export class ListAssetsQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: ASSET_SORT_COLUMNS, default: 'createdAt' })
@IsOptional()
@IsIn(ASSET_SORT_COLUMNS)
sortBy?: string;

@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';

@ApiPropertyOptional() @IsOptional() @IsString() categoryId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() departmentId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() locationId?: string;
@ApiPropertyOptional() @IsOptional() @IsString() status?: string;
}
31 changes: 31 additions & 0 deletions backend/src/common/utils/list-query.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { containsPattern, escapeLike, resolveSort } from './list-query';

describe('list-query helpers', () => {
it('escapes LIKE wildcards and backslashes', () => {
expect(escapeLike('100%')).toBe('100\\%');
expect(escapeLike('a_b')).toBe('a\\_b');
expect(escapeLike('c:\\x')).toBe('c:\\\\x');
expect(containsPattern('100%')).toBe('%100\\%%');
});

it('leaves ordinary text untouched', () => {
expect(escapeLike('laptop')).toBe('laptop');
});

it('only sorts by whitelisted columns', () => {
const allowed = ['name', 'createdAt'];
expect(resolveSort('name', 'ASC', allowed, 'createdAt')).toEqual({
column: 'name',
order: 'ASC',
});
expect(resolveSort('id; DROP TABLE x', 'ASC', allowed, 'createdAt')).toEqual({
column: 'createdAt',
order: 'ASC',
});
});

it('defaults to DESC for anything but ASC', () => {
expect(resolveSort(undefined, 'sideways', ['a'], 'a').order).toBe('DESC');
expect(resolveSort(undefined, 'asc', ['a'], 'a').order).toBe('ASC');
});
});
26 changes: 26 additions & 0 deletions backend/src/common/utils/list-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/** Escapes `%`, `_` and `\` so user input is matched literally by LIKE/ILIKE. */
export function escapeLike(term: string): string {
return term.replace(/[\\%_]/g, '\\$&');
}

/** `%term%` pattern with the term escaped; pair with `ESCAPE '\'` in SQL. */
export function containsPattern(term: string): string {
return `%${escapeLike(term)}%`;
}

export const LIKE_ESCAPE = "ESCAPE '\\'";

/**
* Picks a sortable column from a whitelist and a direction. Unknown input
* falls back to the default, so a client can never inject a column name.
*/
export function resolveSort(
sortBy: string | undefined,
sortOrder: string | undefined,
allowed: readonly string[],
defaultColumn: string,
): { column: string; order: 'ASC' | 'DESC' } {
const column = sortBy && allowed.includes(sortBy) ? sortBy : defaultColumn;
const order = String(sortOrder).toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
return { column, order };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

/**
* Trigram GIN indexes so `ILIKE '%term%'` searches on assets (#1585) don't
* scan the whole table. Requires the `pg_trgm` extension.
*/
export class AddAssetSearchTrigramIndexes1760100000000
implements MigrationInterface
{
name = 'AddAssetSearchTrigramIndexes1760100000000';

public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasTable('assets'))) return;
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`);
for (const [index, column] of [
['IDX_assets_name_trgm', 'name'],
['IDX_assets_asset_tag_trgm', '"assetTag"'],
['IDX_assets_serial_number_trgm', '"serialNumber"'],
]) {
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "${index}" ON assets USING gin (${column} gin_trgm_ops)`,
);
}
}

public async down(queryRunner: QueryRunner): Promise<void> {
for (const index of [
'IDX_assets_name_trgm',
'IDX_assets_asset_tag_trgm',
'IDX_assets_serial_number_trgm',
]) {
await queryRunner.query(`DROP INDEX IF EXISTS "${index}"`);
}
}
}
29 changes: 29 additions & 0 deletions backend/src/users/dto/list-users-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsIn, IsOptional } from 'class-validator';
import { PaginationQueryDto } from '../../common/dto/pagination.dto';
import { UserRole } from '../entities/user.entity';

export const USER_SORT_COLUMNS = [
'createdAt',
'email',
'firstName',
'lastName',
'role',
] as const;

export class ListUsersQueryDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: USER_SORT_COLUMNS, default: 'createdAt' })
@IsOptional()
@IsIn(USER_SORT_COLUMNS)
sortBy?: string;

@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })
@IsOptional()
@IsIn(['ASC', 'DESC'])
sortOrder?: 'ASC' | 'DESC';

@ApiPropertyOptional({ enum: UserRole })
@IsOptional()
@IsEnum(UserRole)
role?: UserRole;
}
10 changes: 3 additions & 7 deletions backend/src/users/users.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ApiEnvelope, ApiErrorEnvelope } from '../common/swagger/api-envelope.de
import { UsersService } from './users.service';
import { User, UserRole } from './entities/user.entity';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { GetUser } from '../auth/decorators/get-user.decorator';
import { RolesGuard } from '../auth/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
Expand All @@ -34,13 +35,8 @@ export class UsersController {
@Get()
@ApiOperation({ summary: 'List users (paginated, searchable)' })
@ApiResponse({ status: 200, description: 'List of users' })
findAll(
@Query('search') search?: string,
@Query('role') role?: UserRole,
@Query('page') page?: number,
@Query('limit') limit?: number,
) {
return this.usersService.findAll({ search, role, page, limit });
findAll(@Query() query: ListUsersQueryDto) {
return this.usersService.findAll(query);
}

@Get('me')
Expand Down
24 changes: 20 additions & 4 deletions backend/src/users/users.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
LIKE_ESCAPE,
containsPattern,
resolveSort,
} from '../common/utils/list-query';
import { USER_SORT_COLUMNS } from './dto/list-users-query.dto';
import { PasswordService } from '../common/security/password.service';
import { User, UserRole } from './entities/user.entity';
import { UpdateProfileDto } from './dto/update-profile.dto';
Expand All @@ -32,18 +38,28 @@ export class UsersService {
role?: UserRole;
page?: number;
limit?: number;
sortBy?: string;
sortOrder?: string;
}) {
const page = query?.page || 1;
const limit = query?.limit || 20;
const limit = Math.min(query?.limit || 20, 100);
const sort = resolveSort(
query?.sortBy,
query?.sortOrder,
USER_SORT_COLUMNS,
'createdAt',
);
const qb = this.userRepository
.createQueryBuilder('user')
.skip((page - 1) * limit)
.take(limit);
.take(limit)
.orderBy(`user.${sort.column}`, sort.order)
.addOrderBy('user.id', 'ASC');

if (query?.search) {
qb.andWhere(
'(user.email ILIKE :s OR user.firstName ILIKE :s OR user.lastName ILIKE :s)',
{ s: `%${query.search}%` },
`(user.email ILIKE :s ${LIKE_ESCAPE} OR user.firstName ILIKE :s ${LIKE_ESCAPE} OR user.lastName ILIKE :s ${LIKE_ESCAPE})`,
{ s: containsPattern(query.search) },
);
}
if (query?.role) {
Expand Down
Loading