feat: api validation

This commit is contained in:
Jason Rasmussen 2023-08-14 16:38:28 -04:00
parent 89021ce995
commit b7b3735c40
No known key found for this signature in database
GPG key ID: 75AD31BF84C94773
7 changed files with 326 additions and 44 deletions

View file

@ -5578,9 +5578,9 @@
}
},
"required": [
"albumId",
"key",
"value"
"value",
"albumId"
],
"type": "object"
},
@ -6325,8 +6325,8 @@
"city",
"state",
"country",
"make",
"model",
"camera-make",
"camera-model",
"location"
],
"type": "string"
@ -6343,13 +6343,13 @@
"type": "string"
},
"value": {
"type": "object"
"type": "string"
}
},
"required": [
"key",
"id",
"value",
"id",
"ownerId"
],
"type": "object"
@ -7243,9 +7243,13 @@
"$ref": "#/components/schemas/RuleKey"
},
"value": {
"type": "object"
"type": "string"
}
},
"required": [
"key",
"value"
],
"type": "object"
},
"UpdateTagDto": {

View file

@ -1,35 +1,92 @@
import { RuleEntity, RuleKey } from '@app/infra/entities';
import { GeoRuleValue, RuleEntity, RuleKey, RuleValue, RuleValueType, RULE_TO_TYPE } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
import { ClassConstructor, plainToInstance, Transform, Type } from 'class-transformer';
import {
IsDate,
IsEnum,
IsLatitude,
IsLongitude,
IsNotEmpty,
IsNumber,
IsPositive,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { ValidateUUID } from '../domain.util';
export class CreateRuleDto {
@ValidateUUID()
albumId!: string;
@ApiProperty({ enumName: 'RuleKey', enum: RuleKey })
@IsEnum(RuleKey)
key!: RuleKey;
class UUIDRuleDto {
@IsUUID(4)
value!: string;
}
class StringRuleDto {
@IsString()
@IsNotEmpty()
value!: string;
}
class DateRuleDto {
@IsDate()
@Type(() => Date)
value!: Date;
}
class GeoRuleValueDto implements GeoRuleValue {
@IsLatitude()
lat!: number;
@IsLongitude()
lng!: number;
@IsPositive()
@IsNumber()
radius!: number;
}
class GeoRuleDto {
@ValidateNested()
@Type(() => GeoRuleValueDto)
value!: GeoRuleValueDto;
}
const toRuleValueDto = (key: RuleKey, value: RuleValue) => {
const type = RULE_TO_TYPE[key];
const map: Record<RuleValueType, ClassConstructor<{ value: RuleValue }>> = {
[RuleValueType.UUID]: UUIDRuleDto,
[RuleValueType.STRING]: StringRuleDto,
[RuleValueType.DATE]: DateRuleDto,
[RuleValueType.GEO]: GeoRuleDto,
};
if (type && map[type]) {
return plainToInstance(map[type], { value });
}
return value;
};
export class UpdateRuleDto {
@ApiProperty({ enumName: 'RuleKey', enum: RuleKey })
@IsOptional()
@IsEnum(RuleKey)
key?: RuleKey;
key!: RuleKey;
@IsOptional()
@IsNotEmpty()
value?: any;
@ApiProperty({ type: String })
@ValidateNested()
@Transform(({ obj, value }) => toRuleValueDto(obj.key, value))
value!: { value: RuleValue };
}
export class CreateRuleDto extends UpdateRuleDto {
@ValidateUUID()
albumId!: string;
}
export class RuleResponseDto {
id!: string;
@ApiProperty({ enumName: 'RuleKey', enum: RuleKey })
key!: RuleKey;
@ApiProperty({ type: String })
value!: any;
ownerId!: string;
}

View file

@ -35,7 +35,7 @@ describe(RuleService.name, () => {
sut.create(authStub.admin, {
albumId: 'not-found-album',
key: RuleKey.CITY,
value: 'abc',
value: { value: 'abc' },
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'not-found-album');
@ -49,7 +49,7 @@ describe(RuleService.name, () => {
sut.create(authStub.admin, {
albumId: 'album-123',
key: RuleKey.CITY,
value: 'abc',
value: { value: 'abc' },
}),
).resolves.toEqual(responseDto);
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-123');
@ -73,9 +73,12 @@ describe(RuleService.name, () => {
describe('update', () => {
it('should throw a bad request when the rule is not found', async () => {
accessMock.rule.hasOwnerAccess.mockResolvedValue(false);
await expect(sut.update(authStub.admin, 'rule-1', { value: 'Atlanta' })).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
sut.update(authStub.admin, 'rule-1', {
key: RuleKey.CITY,
value: { value: 'Atlanta' },
}),
).rejects.toBeInstanceOf(BadRequestException);
});
});
});

View file

@ -1,4 +1,5 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { RuleKey } from '../../infra/entities/rule.entity';
import { AccessCore, IAccessRepository, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { CreateRuleDto, mapRule, UpdateRuleDto } from './rule.dto';
@ -24,11 +25,13 @@ export class RuleService {
async create(authUser: AuthUserDto, dto: CreateRuleDto) {
await this.access.requirePermission(authUser, Permission.RULE_CREATE, dto.albumId);
// TODO additional validation on key and value
if (dto.key === RuleKey.PERSON) {
// TODO: validate personId
}
const rule = await this.repository.create({
key: dto.key,
value: dto.value,
value: dto.value.value,
albumId: dto.albumId,
ownerId: authUser.id,
});
@ -37,14 +40,7 @@ export class RuleService {
async update(authUser: AuthUserDto, id: string, dto: UpdateRuleDto) {
await this.access.requirePermission(authUser, Permission.RULE_UPDATE, id);
// TODO additional validation on key and value
const rule = await this.repository.update({
id,
key: dto.key,
value: dto.value,
});
const rule = await this.repository.update({ id, key: dto.key, value: dto.value.value });
return mapRule(rule);
}

View file

@ -33,12 +33,12 @@ export enum RuleKey {
CITY = 'city',
STATE = 'state',
COUNTRY = 'country',
MAKE = 'make',
MODEL = 'model',
CAMERA_MAKE = 'camera-make',
CAMERA_MODEL = 'camera-model',
LOCATION = 'location',
}
export type RuleValue = string | Date | RuleGeoValue;
export type RuleValue = string | Date | GeoRuleValue;
export enum RuleValueType {
UUID = 'uuid',
@ -47,9 +47,9 @@ export enum RuleValueType {
GEO = 'geo',
}
export interface RuleGeoValue {
export interface GeoRuleValue {
lat: number;
long: number;
lng: number;
radius: number;
}
@ -59,7 +59,7 @@ export const RULE_TO_TYPE: Record<RuleKey, RuleValueType> = {
[RuleKey.CITY]: RuleValueType.STRING,
[RuleKey.STATE]: RuleValueType.STRING,
[RuleKey.COUNTRY]: RuleValueType.STRING,
[RuleKey.MAKE]: RuleValueType.STRING,
[RuleKey.MODEL]: RuleValueType.STRING,
[RuleKey.CAMERA_MAKE]: RuleValueType.STRING,
[RuleKey.CAMERA_MODEL]: RuleValueType.STRING,
[RuleKey.LOCATION]: RuleValueType.GEO,
};

View file

@ -0,0 +1,222 @@
import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
import { AppModule, RuleController } from '@app/immich';
import { RuleKey } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import request from 'supertest';
import { errorStub, uuidStub } from '../fixtures';
import { api, db } from '../test-utils';
describe(`${RuleController.name} (e2e)`, () => {
let app: INestApplication;
let server: any;
let accessToken: string;
let album: AlbumResponseDto;
let loginResponse: LoginResponseDto;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = await moduleFixture.createNestApplication().init();
server = app.getHttpServer();
});
beforeEach(async () => {
await db.reset();
await api.adminSignUp(server);
loginResponse = await api.adminLogin(server);
accessToken = loginResponse.accessToken;
album = await api.albumApi.create(server, accessToken, { albumName: 'New album' });
});
afterAll(async () => {
await db.disconnect();
await app.close();
});
describe('POST /rule', () => {
const tests = {
invalid: [
{
should: 'reject an invalid uuid',
dto: () => ({ albumId: uuidStub.invalid, key: RuleKey.CITY, value: 'Chandler' }),
},
{
should: 'reject an album that does not exist',
dto: () => ({ albumId: uuidStub.notFound, key: RuleKey.CITY, value: 'Chandler' }),
},
{
should: 'reject invalid keys',
dto: (albumId: string) => ({ albumId, key: 'invalid', value: 'Chandler' }),
},
{
should: 'validate string field values',
dto: (albumId: string) => ({ albumId, key: RuleKey.CAMERA_MAKE, value: true }),
},
{
should: 'validate date field values',
dto: (albumId: string) => ({ albumId, key: RuleKey.TAKEN_AFTER, value: 'Chandler' }),
},
{
should: 'reject an empty geo field value',
dto: (albumId: string) => ({ albumId, key: RuleKey.LOCATION, value: {} }),
},
{
should: 'validate geo.lat field values',
dto: (albumId: string) => ({ albumId, key: RuleKey.LOCATION, value: { lat: 200, lng: 50, radius: 5 } }),
},
{
should: 'validate geo.lng field values',
dto: (albumId: string) => ({ albumId, key: RuleKey.LOCATION, value: { lat: 50, lng: 200, radius: 5 } }),
},
{
should: 'validate geo.radius field values',
dto: (albumId: string) => ({ albumId, key: RuleKey.LOCATION, value: { lat: 50, lng: 50, radius: false } }),
},
],
};
it('should require authentication', async () => {
const { status, body } = await request(server).post('/rule').send({ albumId: uuidStub.notFound });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const { should, dto } of tests.invalid) {
it(should, async () => {
const { status, body } = await request(server)
.post('/rule')
.set('Authorization', `Bearer ${accessToken}`)
.send(dto(album.id));
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
});
}
it('should create a rule for camera make', async () => {
const { status, body } = await request(server)
.post('/rule')
.set('Authorization', `Bearer ${accessToken}`)
.send({ albumId: album.id, key: RuleKey.CAMERA_MAKE, value: 'Cannon' });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
key: RuleKey.CAMERA_MAKE,
value: 'Cannon',
});
});
it('should create a rule for camera model', async () => {
const { status, body } = await request(server)
.post('/rule')
.set('Authorization', `Bearer ${accessToken}`)
.send({ albumId: album.id, key: RuleKey.CAMERA_MODEL, value: 'E0S 5D Mark III' });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
key: RuleKey.CAMERA_MODEL,
value: 'E0S 5D Mark III',
});
});
it('should create a rule for city', async () => {
const { status, body } = await request(server)
.post('/rule')
.set('Authorization', `Bearer ${accessToken}`)
.send({ albumId: album.id, key: RuleKey.CITY, value: 'Chandler' });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
key: RuleKey.CITY,
value: 'Chandler',
});
});
it('should create a rule for state', async () => {
const { status, body } = await request(server)
.post('/rule')
.set('Authorization', `Bearer ${accessToken}`)
.send({ albumId: album.id, key: RuleKey.STATE, value: 'Arizona' });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
key: RuleKey.STATE,
value: 'Arizona',
});
});
it('should create a rule for country', async () => {
const { status, body } = await request(server)
.post('/rule')
.set('Authorization', `Bearer ${accessToken}`)
.send({ albumId: album.id, key: RuleKey.COUNTRY, value: 'United States' });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
key: RuleKey.COUNTRY,
value: 'United States',
});
});
it('should create a rule with a person', async () => {
const { status, body } = await request(server)
.post('/rule')
.set('Authorization', `Bearer ${accessToken}`)
.send({ albumId: album.id, key: RuleKey.PERSON, value: '4b5d0632-1bc1-48d1-8c89-174fd26bf29d' });
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
key: RuleKey.PERSON,
value: '4b5d0632-1bc1-48d1-8c89-174fd26bf29d',
});
expect(status).toBe(201);
});
it('should create a rule with taken after', async () => {
const { status, body } = await request(server)
.post('/rule')
.set('Authorization', `Bearer ${accessToken}`)
.send({ albumId: album.id, key: RuleKey.TAKEN_AFTER, value: '2023-08-14T20:12:34.908Z' });
expect(status).toBe(201);
expect(body).toEqual({
id: expect.any(String),
ownerId: loginResponse.userId,
key: RuleKey.TAKEN_AFTER,
value: '2023-08-14T20:12:34.908Z',
});
});
});
describe('GET /rule/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/rule/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
describe('PUT /rule/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.put(`/rule/${uuidStub.notFound}`)
.send({ albumId: uuidStub.notFound });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
describe('DELETE /rule/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/rule/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
});

View file

@ -17,7 +17,7 @@ export const errorStub = {
badRequest: {
error: 'Bad Request',
statusCode: 400,
message: expect.any(Array),
message: expect.anything(),
},
incorrectLogin: {
error: 'Unauthorized',