feat: api validation
This commit is contained in:
parent
89021ce995
commit
b7b3735c40
7 changed files with 326 additions and 44 deletions
|
@ -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": {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
222
server/test/e2e/rule.e2e-spec.ts
Normal file
222
server/test/e2e/rule.e2e-spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
2
server/test/fixtures/error.stub.ts
vendored
2
server/test/fixtures/error.stub.ts
vendored
|
@ -17,7 +17,7 @@ export const errorStub = {
|
|||
badRequest: {
|
||||
error: 'Bad Request',
|
||||
statusCode: 400,
|
||||
message: expect.any(Array),
|
||||
message: expect.anything(),
|
||||
},
|
||||
incorrectLogin: {
|
||||
error: 'Unauthorized',
|
||||
|
|
Loading…
Reference in a new issue