mirror of
https://github.com/immich-app/immich.git
synced 2024-12-25 10:43:13 +02:00
chore(server): Use access core for person permissions (#4138)
* use access core for all person methods * minor fixes, feedback * reorder assignments * remove unnecessary permission requirement * unify naming of tests * reorder variables
This commit is contained in:
parent
dda735ec51
commit
1a633f3fca
@ -23,6 +23,10 @@ export enum Permission {
|
|||||||
|
|
||||||
LIBRARY_READ = 'library.read',
|
LIBRARY_READ = 'library.read',
|
||||||
LIBRARY_DOWNLOAD = 'library.download',
|
LIBRARY_DOWNLOAD = 'library.download',
|
||||||
|
|
||||||
|
PERSON_READ = 'person.read',
|
||||||
|
PERSON_WRITE = 'person.write',
|
||||||
|
PERSON_MERGE = 'person.merge',
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AccessCore {
|
export class AccessCore {
|
||||||
@ -167,6 +171,15 @@ export class AccessCore {
|
|||||||
case Permission.LIBRARY_DOWNLOAD:
|
case Permission.LIBRARY_DOWNLOAD:
|
||||||
return authUser.id === id;
|
return authUser.id === id;
|
||||||
|
|
||||||
|
case Permission.PERSON_READ:
|
||||||
|
return this.repository.person.hasOwnerAccess(authUser.id, id);
|
||||||
|
|
||||||
|
case Permission.PERSON_WRITE:
|
||||||
|
return this.repository.person.hasOwnerAccess(authUser.id, id);
|
||||||
|
|
||||||
|
case Permission.PERSON_MERGE:
|
||||||
|
return this.repository.person.hasOwnerAccess(authUser.id, id);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -17,4 +17,8 @@ export interface IAccessRepository {
|
|||||||
library: {
|
library: {
|
||||||
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
|
hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
person: {
|
||||||
|
hasOwnerAccess(userId: string, personId: string): Promise<boolean>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -17,9 +17,9 @@ export interface IPersonRepository {
|
|||||||
getAllWithoutThumbnail(): Promise<PersonEntity[]>;
|
getAllWithoutThumbnail(): Promise<PersonEntity[]>;
|
||||||
getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
|
getAllForUser(userId: string, options: PersonSearchOptions): Promise<PersonEntity[]>;
|
||||||
getAllWithoutFaces(): Promise<PersonEntity[]>;
|
getAllWithoutFaces(): Promise<PersonEntity[]>;
|
||||||
getById(userId: string, personId: string): Promise<PersonEntity | null>;
|
getById(personId: string): Promise<PersonEntity | null>;
|
||||||
|
|
||||||
getAssets(userId: string, personId: string): Promise<AssetEntity[]>;
|
getAssets(personId: string): Promise<AssetEntity[]>;
|
||||||
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
|
prepareReassignFaces(data: UpdateFacesData): Promise<string[]>;
|
||||||
reassignFaces(data: UpdateFacesData): Promise<number>;
|
reassignFaces(data: UpdateFacesData): Promise<number>;
|
||||||
|
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
|
IAccessRepositoryMock,
|
||||||
assetStub,
|
assetStub,
|
||||||
authStub,
|
authStub,
|
||||||
faceStub,
|
faceStub,
|
||||||
|
newAccessRepositoryMock,
|
||||||
newJobRepositoryMock,
|
newJobRepositoryMock,
|
||||||
newPersonRepositoryMock,
|
newPersonRepositoryMock,
|
||||||
newStorageRepositoryMock,
|
newStorageRepositoryMock,
|
||||||
@ -26,18 +28,20 @@ const responseDto: PersonResponseDto = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe(PersonService.name, () => {
|
describe(PersonService.name, () => {
|
||||||
let sut: PersonService;
|
let accessMock: IAccessRepositoryMock;
|
||||||
let personMock: jest.Mocked<IPersonRepository>;
|
|
||||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||||
let storageMock: jest.Mocked<IStorageRepository>;
|
|
||||||
let jobMock: jest.Mocked<IJobRepository>;
|
let jobMock: jest.Mocked<IJobRepository>;
|
||||||
|
let personMock: jest.Mocked<IPersonRepository>;
|
||||||
|
let storageMock: jest.Mocked<IStorageRepository>;
|
||||||
|
let sut: PersonService;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
accessMock = newAccessRepositoryMock();
|
||||||
personMock = newPersonRepositoryMock();
|
personMock = newPersonRepositoryMock();
|
||||||
storageMock = newStorageRepositoryMock();
|
storageMock = newStorageRepositoryMock();
|
||||||
configMock = newSystemConfigRepositoryMock();
|
configMock = newSystemConfigRepositoryMock();
|
||||||
jobMock = newJobRepositoryMock();
|
jobMock = newJobRepositoryMock();
|
||||||
sut = new PersonService(personMock, configMock, storageMock, jobMock);
|
sut = new PersonService(accessMock, personMock, configMock, storageMock, jobMock);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@ -93,74 +97,124 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getById', () => {
|
describe('getById', () => {
|
||||||
|
it('should require person.read permission', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(personStub.withName);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||||
|
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw a bad request when person is not found', async () => {
|
it('should throw a bad request when person is not found', async () => {
|
||||||
personMock.getById.mockResolvedValue(null);
|
personMock.getById.mockResolvedValue(null);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should get a person by id', async () => {
|
it('should get a person by id', async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.withName);
|
personMock.getById.mockResolvedValue(personStub.withName);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
|
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
|
||||||
expect(personMock.getById).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getThumbnail', () => {
|
describe('getThumbnail', () => {
|
||||||
|
it('should require person.read permission', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(personStub.noName);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||||
|
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
|
});
|
||||||
|
|
||||||
it('should throw an error when personId is invalid', async () => {
|
it('should throw an error when personId is invalid', async () => {
|
||||||
personMock.getById.mockResolvedValue(null);
|
personMock.getById.mockResolvedValue(null);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when person has no thumbnail', async () => {
|
it('should throw an error when person has no thumbnail', async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.noThumbnail);
|
personMock.getById.mockResolvedValue(personStub.noThumbnail);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
expect(storageMock.createReadStream).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should serve the thumbnail', async () => {
|
it('should serve the thumbnail', async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.noName);
|
personMock.getById.mockResolvedValue(personStub.noName);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
await sut.getThumbnail(authStub.admin, 'person-1');
|
await sut.getThumbnail(authStub.admin, 'person-1');
|
||||||
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg');
|
expect(storageMock.createReadStream).toHaveBeenCalledWith('/path/to/thumbnail.jpg', 'image/jpeg');
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAssets', () => {
|
describe('getAssets', () => {
|
||||||
|
it('should require person.read permission', async () => {
|
||||||
|
personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||||
|
await expect(sut.getAssets(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
expect(personMock.getAssets).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
|
});
|
||||||
|
|
||||||
it("should return a person's assets", async () => {
|
it("should return a person's assets", async () => {
|
||||||
personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]);
|
personMock.getAssets.mockResolvedValue([assetStub.image, assetStub.video]);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
await sut.getAssets(authStub.admin, 'person-1');
|
await sut.getAssets(authStub.admin, 'person-1');
|
||||||
expect(personMock.getAssets).toHaveBeenCalledWith('admin_id', 'person-1');
|
expect(personMock.getAssets).toHaveBeenCalledWith('person-1');
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('update', () => {
|
describe('update', () => {
|
||||||
it('should throw an error when personId is invalid', async () => {
|
it('should require person.write permission', async () => {
|
||||||
personMock.getById.mockResolvedValue(null);
|
personMock.getById.mockResolvedValue(personStub.noName);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
expect(personMock.update).not.toHaveBeenCalled();
|
expect(personMock.update).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw an error when personId is invalid', async () => {
|
||||||
|
personMock.getById.mockResolvedValue(null);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
expect(personMock.update).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's name", async () => {
|
it("should update a person's name", async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.noName);
|
personMock.getById.mockResolvedValue(personStub.noName);
|
||||||
personMock.update.mockResolvedValue(personStub.withName);
|
personMock.update.mockResolvedValue(personStub.withName);
|
||||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
|
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
|
||||||
|
|
||||||
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
|
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
|
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.SEARCH_INDEX_ASSET,
|
name: JobName.SEARCH_INDEX_ASSET,
|
||||||
data: { ids: [assetStub.image.id] },
|
data: { ids: [assetStub.image.id] },
|
||||||
});
|
});
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's date of birth", async () => {
|
it("should update a person's date of birth", async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.noBirthDate);
|
personMock.getById.mockResolvedValue(personStub.noBirthDate);
|
||||||
personMock.update.mockResolvedValue(personStub.withBirthDate);
|
personMock.update.mockResolvedValue(personStub.withBirthDate);
|
||||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
|
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
|
||||||
id: 'person-1',
|
id: 'person-1',
|
||||||
@ -170,35 +224,39 @@ describe(PersonService.name, () => {
|
|||||||
isHidden: false,
|
isHidden: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
|
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
||||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should update a person visibility', async () => {
|
it('should update a person visibility', async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.hidden);
|
personMock.getById.mockResolvedValue(personStub.hidden);
|
||||||
personMock.update.mockResolvedValue(personStub.withName);
|
personMock.update.mockResolvedValue(personStub.withName);
|
||||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
|
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
|
||||||
|
|
||||||
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
|
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
|
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
|
||||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.SEARCH_INDEX_ASSET,
|
name: JobName.SEARCH_INDEX_ASSET,
|
||||||
data: { ids: [assetStub.image.id] },
|
data: { ids: [assetStub.image.id] },
|
||||||
});
|
});
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should update a person's thumbnailPath", async () => {
|
it("should update a person's thumbnailPath", async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.withName);
|
personMock.getById.mockResolvedValue(personStub.withName);
|
||||||
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
personMock.getFaceById.mockResolvedValue(faceStub.face1);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
|
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
|
||||||
).resolves.toEqual(responseDto);
|
).resolves.toEqual(responseDto);
|
||||||
|
|
||||||
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
|
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||||
expect(personMock.getFaceById).toHaveBeenCalledWith({
|
expect(personMock.getFaceById).toHaveBeenCalledWith({
|
||||||
assetId: faceStub.face1.assetId,
|
assetId: faceStub.face1.assetId,
|
||||||
personId: 'person-1',
|
personId: 'person-1',
|
||||||
@ -218,25 +276,31 @@ describe(PersonService.name, () => {
|
|||||||
imageWidth: faceStub.face1.imageWidth,
|
imageWidth: faceStub.face1.imageWidth,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when the face feature assetId is invalid', async () => {
|
it('should throw an error when the face feature assetId is invalid', async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.withName);
|
personMock.getById.mockResolvedValue(personStub.withName);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow(
|
await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
expect(personMock.update).not.toHaveBeenCalled();
|
expect(personMock.update).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('updateAll', () => {
|
describe('updateAll', () => {
|
||||||
it('should throw an error when personId is invalid', async () => {
|
it('should throw an error when personId is invalid', async () => {
|
||||||
personMock.getById.mockResolvedValue(null);
|
personMock.getById.mockResolvedValue(null);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }),
|
sut.updatePeople(authStub.admin, { people: [{ id: 'person-1', name: 'Person 1' }] }),
|
||||||
).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]);
|
).resolves.toEqual([{ error: BulkIdErrorReason.UNKNOWN, id: 'person-1', success: false }]);
|
||||||
expect(personMock.update).not.toHaveBeenCalled();
|
expect(personMock.update).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -255,11 +319,31 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('mergePerson', () => {
|
describe('mergePerson', () => {
|
||||||
|
it('should require person.write and person.merge permission', async () => {
|
||||||
|
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||||
|
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
|
||||||
|
personMock.prepareReassignFaces.mockResolvedValue([]);
|
||||||
|
personMock.delete.mockResolvedValue(personStub.mergePerson);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
||||||
|
BadRequestException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
expect(personMock.delete).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
|
});
|
||||||
|
|
||||||
it('should merge two people', async () => {
|
it('should merge two people', async () => {
|
||||||
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||||
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
|
personMock.getById.mockResolvedValueOnce(personStub.mergePerson);
|
||||||
personMock.prepareReassignFaces.mockResolvedValue([]);
|
personMock.prepareReassignFaces.mockResolvedValue([]);
|
||||||
personMock.delete.mockResolvedValue(personStub.mergePerson);
|
personMock.delete.mockResolvedValue(personStub.mergePerson);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||||
{ id: 'person-2', success: true },
|
{ id: 'person-2', success: true },
|
||||||
@ -276,12 +360,14 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(personMock.delete).toHaveBeenCalledWith(personStub.mergePerson);
|
expect(personMock.delete).toHaveBeenCalledWith(personStub.mergePerson);
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete conflicting faces before merging', async () => {
|
it('should delete conflicting faces before merging', async () => {
|
||||||
personMock.getById.mockResolvedValue(personStub.primaryPerson);
|
personMock.getById.mockResolvedValue(personStub.primaryPerson);
|
||||||
personMock.getById.mockResolvedValue(personStub.mergePerson);
|
personMock.getById.mockResolvedValue(personStub.mergePerson);
|
||||||
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
|
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||||
{ id: 'person-2', success: true },
|
{ id: 'person-2', success: true },
|
||||||
@ -296,21 +382,25 @@ describe(PersonService.name, () => {
|
|||||||
name: JobName.SEARCH_REMOVE_FACE,
|
name: JobName.SEARCH_REMOVE_FACE,
|
||||||
data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id },
|
data: { assetId: assetStub.image.id, personId: personStub.mergePerson.id },
|
||||||
});
|
});
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error when the primary person is not found', async () => {
|
it('should throw an error when the primary person is not found', async () => {
|
||||||
personMock.getById.mockResolvedValue(null);
|
personMock.getById.mockResolvedValue(null);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(personMock.delete).not.toHaveBeenCalled();
|
expect(personMock.delete).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle invalid merge ids', async () => {
|
it('should handle invalid merge ids', async () => {
|
||||||
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
personMock.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||||
personMock.getById.mockResolvedValueOnce(null);
|
personMock.getById.mockResolvedValueOnce(null);
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||||
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
|
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
|
||||||
@ -319,6 +409,7 @@ describe(PersonService.name, () => {
|
|||||||
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
|
expect(personMock.prepareReassignFaces).not.toHaveBeenCalled();
|
||||||
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
expect(personMock.reassignFaces).not.toHaveBeenCalled();
|
||||||
expect(personMock.delete).not.toHaveBeenCalled();
|
expect(personMock.delete).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle an error reassigning faces', async () => {
|
it('should handle an error reassigning faces', async () => {
|
||||||
@ -326,12 +417,14 @@ describe(PersonService.name, () => {
|
|||||||
personMock.getById.mockResolvedValue(personStub.mergePerson);
|
personMock.getById.mockResolvedValue(personStub.mergePerson);
|
||||||
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
|
personMock.prepareReassignFaces.mockResolvedValue([assetStub.image.id]);
|
||||||
personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
|
personMock.reassignFaces.mockRejectedValue(new Error('update failed'));
|
||||||
|
accessMock.person.hasOwnerAccess.mockResolvedValue(true);
|
||||||
|
|
||||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||||
{ id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
|
{ id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(personMock.delete).not.toHaveBeenCalled();
|
expect(personMock.delete).not.toHaveBeenCalled();
|
||||||
|
expect(accessMock.person.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'person-1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { AccessCore, IAccessRepository, Permission } from '../access';
|
||||||
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
|
import { AssetResponseDto, BulkIdErrorReason, BulkIdResponseDto, mapAsset } from '../asset';
|
||||||
import { AuthUserDto } from '../auth';
|
import { AuthUserDto } from '../auth';
|
||||||
import { mimeTypes } from '../domain.constant';
|
import { mimeTypes } from '../domain.constant';
|
||||||
@ -18,15 +19,18 @@ import { IPersonRepository, UpdateFacesData } from './person.repository';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersonService {
|
export class PersonService {
|
||||||
|
private access: AccessCore;
|
||||||
private configCore: SystemConfigCore;
|
private configCore: SystemConfigCore;
|
||||||
readonly logger = new Logger(PersonService.name);
|
readonly logger = new Logger(PersonService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@Inject(IAccessRepository) private accessRepository: IAccessRepository,
|
||||||
@Inject(IPersonRepository) private repository: IPersonRepository,
|
@Inject(IPersonRepository) private repository: IPersonRepository,
|
||||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
) {
|
) {
|
||||||
|
this.access = new AccessCore(accessRepository);
|
||||||
this.configCore = new SystemConfigCore(configRepository);
|
this.configCore = new SystemConfigCore(configRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,12 +52,14 @@ export class PersonService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
|
async getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
|
||||||
return this.findOrFail(authUser, id).then(mapPerson);
|
await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
|
||||||
|
return this.findOrFail(id).then(mapPerson);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
|
async getThumbnail(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
|
||||||
const person = await this.repository.getById(authUser.id, id);
|
await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
|
||||||
|
const person = await this.repository.getById(id);
|
||||||
if (!person || !person.thumbnailPath) {
|
if (!person || !person.thumbnailPath) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
@ -62,17 +68,19 @@ export class PersonService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
|
async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
|
||||||
const assets = await this.repository.getAssets(authUser.id, id);
|
await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
|
||||||
|
const assets = await this.repository.getAssets(id);
|
||||||
return assets.map(mapAsset);
|
return assets.map(mapAsset);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
|
||||||
let person = await this.findOrFail(authUser, id);
|
await this.access.requirePermission(authUser, Permission.PERSON_WRITE, id);
|
||||||
|
let person = await this.findOrFail(id);
|
||||||
|
|
||||||
if (dto.name !== undefined || dto.birthDate !== undefined || dto.isHidden !== undefined) {
|
if (dto.name !== undefined || dto.birthDate !== undefined || dto.isHidden !== undefined) {
|
||||||
person = await this.repository.update({ id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden });
|
person = await this.repository.update({ id, name: dto.name, birthDate: dto.birthDate, isHidden: dto.isHidden });
|
||||||
if (this.needsSearchIndexUpdate(dto)) {
|
if (this.needsSearchIndexUpdate(dto)) {
|
||||||
const assets = await this.repository.getAssets(authUser.id, id);
|
const assets = await this.repository.getAssets(id);
|
||||||
const ids = assets.map((asset) => asset.id);
|
const ids = assets.map((asset) => asset.id);
|
||||||
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
|
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });
|
||||||
}
|
}
|
||||||
@ -141,14 +149,22 @@ export class PersonService {
|
|||||||
|
|
||||||
async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
|
async mergePerson(authUser: AuthUserDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
|
||||||
const mergeIds = dto.ids;
|
const mergeIds = dto.ids;
|
||||||
const primaryPerson = await this.findOrFail(authUser, id);
|
await this.access.requirePermission(authUser, Permission.PERSON_WRITE, id);
|
||||||
|
const primaryPerson = await this.findOrFail(id);
|
||||||
const primaryName = primaryPerson.name || primaryPerson.id;
|
const primaryName = primaryPerson.name || primaryPerson.id;
|
||||||
|
|
||||||
const results: BulkIdResponseDto[] = [];
|
const results: BulkIdResponseDto[] = [];
|
||||||
|
|
||||||
for (const mergeId of mergeIds) {
|
for (const mergeId of mergeIds) {
|
||||||
|
const hasPermission = await this.access.hasPermission(authUser, Permission.PERSON_MERGE, mergeId);
|
||||||
|
|
||||||
|
if (!hasPermission) {
|
||||||
|
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NO_PERMISSION });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const mergePerson = await this.repository.getById(authUser.id, mergeId);
|
const mergePerson = await this.repository.getById(mergeId);
|
||||||
if (!mergePerson) {
|
if (!mergePerson) {
|
||||||
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
|
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.NOT_FOUND });
|
||||||
continue;
|
continue;
|
||||||
@ -188,8 +204,8 @@ export class PersonService {
|
|||||||
return dto.name !== undefined || dto.isHidden !== undefined;
|
return dto.name !== undefined || dto.isHidden !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findOrFail(authUser: AuthUserDto, id: string) {
|
private async findOrFail(id: string) {
|
||||||
const person = await this.repository.getById(authUser.id, id);
|
const person = await this.repository.getById(id);
|
||||||
if (!person) {
|
if (!person) {
|
||||||
throw new BadRequestException('Person not found');
|
throw new BadRequestException('Person not found');
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import { IAccessRepository } from '@app/domain';
|
import { IAccessRepository } from '@app/domain';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { AlbumEntity, AssetEntity, PartnerEntity, SharedLinkEntity } from '../entities';
|
import { AlbumEntity, AssetEntity, PartnerEntity, PersonEntity, SharedLinkEntity } from '../entities';
|
||||||
|
|
||||||
export class AccessRepository implements IAccessRepository {
|
export class AccessRepository implements IAccessRepository {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
|
||||||
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
|
@InjectRepository(AlbumEntity) private albumRepository: Repository<AlbumEntity>,
|
||||||
@InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
|
@InjectRepository(PartnerEntity) private partnerRepository: Repository<PartnerEntity>,
|
||||||
|
@InjectRepository(PersonEntity) private personRepository: Repository<PersonEntity>,
|
||||||
@InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
|
@InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -156,4 +157,15 @@ export class AccessRepository implements IAccessRepository {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
person = {
|
||||||
|
hasOwnerAccess: (userId: string, personId: string): Promise<boolean> => {
|
||||||
|
return this.personRepository.exist({
|
||||||
|
where: {
|
||||||
|
id: personId,
|
||||||
|
ownerId: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
@ -86,14 +86,13 @@ export class PersonRepository implements IPersonRepository {
|
|||||||
.getMany();
|
.getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
getById(ownerId: string, personId: string): Promise<PersonEntity | null> {
|
getById(personId: string): Promise<PersonEntity | null> {
|
||||||
return this.personRepository.findOne({ where: { id: personId, ownerId } });
|
return this.personRepository.findOne({ where: { id: personId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
getAssets(ownerId: string, personId: string): Promise<AssetEntity[]> {
|
getAssets(personId: string): Promise<AssetEntity[]> {
|
||||||
return this.assetRepository.find({
|
return this.assetRepository.find({
|
||||||
where: {
|
where: {
|
||||||
ownerId,
|
|
||||||
faces: {
|
faces: {
|
||||||
personId,
|
personId,
|
||||||
},
|
},
|
||||||
|
@ -4,6 +4,7 @@ export interface IAccessRepositoryMock {
|
|||||||
asset: jest.Mocked<IAccessRepository['asset']>;
|
asset: jest.Mocked<IAccessRepository['asset']>;
|
||||||
album: jest.Mocked<IAccessRepository['album']>;
|
album: jest.Mocked<IAccessRepository['album']>;
|
||||||
library: jest.Mocked<IAccessRepository['library']>;
|
library: jest.Mocked<IAccessRepository['library']>;
|
||||||
|
person: jest.Mocked<IAccessRepository['person']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
|
export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
|
||||||
@ -24,5 +25,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
|
|||||||
library: {
|
library: {
|
||||||
hasPartnerAccess: jest.fn(),
|
hasPartnerAccess: jest.fn(),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
person: {
|
||||||
|
hasOwnerAccess: jest.fn(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user