refactor(server,web): add/remove album users (#2681)

* refactor(server,web): add/remove album users

* fix(web): bug fixes for multiple users

* fix: linting
This commit is contained in:
Jason Rasmussen
2023-06-07 10:37:25 -04:00
committed by GitHub
parent 284edd97d6
commit eb1225a0a5
15 changed files with 521 additions and 329 deletions

View File

@@ -1,7 +1,17 @@
import { BadRequestException, ForbiddenException } from '@nestjs/common';
import { albumStub, authStub, newAlbumRepositoryMock, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
import _ from 'lodash';
import {
albumStub,
authStub,
newAlbumRepositoryMock,
newAssetRepositoryMock,
newJobRepositoryMock,
newUserRepositoryMock,
userEntityStub,
} from '../../test';
import { IAssetRepository } from '../asset';
import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user';
import { IAlbumRepository } from './album.repository';
import { AlbumService } from './album.service';
@@ -10,13 +20,15 @@ describe(AlbumService.name, () => {
let albumMock: jest.Mocked<IAlbumRepository>;
let assetMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(async () => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
sut = new AlbumService(albumMock, assetMock, jobMock);
sut = new AlbumService(albumMock, assetMock, jobMock, userMock);
});
it('should work', () => {
@@ -152,6 +164,18 @@ describe(AlbumService.name, () => {
data: { ids: [albumStub.empty.id] },
});
});
it('should require valid userIds', async () => {
userMock.get.mockResolvedValue(null);
await expect(
sut.create(authStub.admin, {
albumName: 'Empty album',
sharedWithUserIds: ['user-3'],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.get).toHaveBeenCalledWith('user-3');
expect(albumMock.create).not.toHaveBeenCalled();
});
});
describe('update', () => {
@@ -240,4 +264,130 @@ describe(AlbumService.name, () => {
expect(albumMock.delete).toHaveBeenCalledWith(albumStub.empty);
});
});
describe('addUsers', () => {
it('should require a valid album id', async () => {
albumMock.getByIds.mockResolvedValue([]);
await expect(sut.addUsers(authStub.admin, 'album-1', { sharedUserIds: ['user-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should require the user to be the owner', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
await expect(
sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-1'] }),
).rejects.toBeInstanceOf(ForbiddenException);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should throw an error if the userId is already added', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should throw an error if the userId does not exist', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
userMock.get.mockResolvedValue(null);
await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-3'] }),
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should add valid shared users', async () => {
albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(userEntityStub.user2);
await sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.user2.id] });
expect(albumMock.update).toHaveBeenCalledWith({
id: albumStub.sharedWithAdmin.id,
updatedAt: expect.any(Date),
sharedUsers: [userEntityStub.admin, { id: authStub.user2.id }],
});
});
});
describe('removeUser', () => {
it('should require a valid album id', async () => {
albumMock.getByIds.mockResolvedValue([]);
await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should remove a shared user from an owned album', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
await expect(
sut.removeUser(authStub.admin, albumStub.sharedWithUser.id, userEntityStub.user1.id),
).resolves.toBeUndefined();
expect(albumMock.update).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledWith({
id: albumStub.sharedWithUser.id,
updatedAt: expect.any(Date),
sharedUsers: [],
});
});
it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]);
await expect(
sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
).rejects.toBeInstanceOf(ForbiddenException);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should allow a shared user to remove themselves', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, authStub.user1.id);
expect(albumMock.update).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledWith({
id: albumStub.sharedWithUser.id,
updatedAt: expect.any(Date),
sharedUsers: [],
});
});
it('should allow a shared user to remove themselves using "me"', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
await sut.removeUser(authStub.user1, albumStub.sharedWithUser.id, 'me');
expect(albumMock.update).toHaveBeenCalledTimes(1);
expect(albumMock.update).toHaveBeenCalledWith({
id: albumStub.sharedWithUser.id,
updatedAt: expect.any(Date),
sharedUsers: [],
});
});
it('should not allow the owner to be removed', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, authStub.admin.id)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(albumMock.update).not.toHaveBeenCalled();
});
it('should throw an error for a user not in the album', async () => {
albumMock.getByIds.mockResolvedValue([albumStub.empty]);
await expect(sut.removeUser(authStub.admin, albumStub.empty.id, 'user-3')).rejects.toBeInstanceOf(
BadRequestException,
);
expect(albumMock.update).not.toHaveBeenCalled();
});
});
});

View File

@@ -3,8 +3,9 @@ import { BadRequestException, ForbiddenException, Inject, Injectable } from '@ne
import { IAssetRepository, mapAsset } from '../asset';
import { AuthUserDto } from '../auth';
import { IJobRepository, JobName } from '../job';
import { IUserRepository } from '../user';
import { IAlbumRepository } from './album.repository';
import { CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto';
import { AlbumResponseDto, mapAlbum } from './response-dto';
@Injectable()
@@ -13,6 +14,7 @@ export class AlbumService {
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {}
async getAll({ id: ownerId }: AuthUserDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
@@ -48,7 +50,7 @@ export class AlbumService {
});
}
async updateInvalidThumbnails(): Promise<number> {
private async updateInvalidThumbnails(): Promise<number> {
const invalidAlbumIds = await this.albumRepository.getInvalidThumbnail();
for (const albumId of invalidAlbumIds) {
@@ -60,7 +62,13 @@ export class AlbumService {
}
async create(authUser: AuthUserDto, dto: CreateAlbumDto): Promise<AlbumResponseDto> {
// TODO: Handle nonexistent sharedWithUserIds and assetIds.
for (const userId of dto.sharedWithUserIds || []) {
const exists = await this.userRepository.get(userId);
if (!exists) {
throw new BadRequestException('User not found');
}
}
const album = await this.albumRepository.create({
ownerId: authUser.id,
albumName: dto.albumName,
@@ -68,19 +76,14 @@ export class AlbumService {
assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
albumThumbnailAssetId: dto.assetIds?.[0] || null,
});
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ALBUM, data: { ids: [album.id] } });
return mapAlbum(album);
}
async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
const [album] = await this.albumRepository.getByIds([id]);
if (!album) {
throw new BadRequestException('Album not found');
}
if (album.ownerId !== authUser.id) {
throw new ForbiddenException('Album not owned by user');
}
const album = await this.get(id);
this.assertOwner(authUser, album);
if (dto.albumThumbnailAssetId) {
const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
@@ -113,4 +116,73 @@ export class AlbumService {
await this.albumRepository.delete(album);
await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
}
async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
const album = await this.get(id);
this.assertOwner(authUser, album);
for (const userId of dto.sharedUserIds) {
const exists = album.sharedUsers.find((user) => user.id === userId);
if (exists) {
throw new BadRequestException('User already added');
}
const user = await this.userRepository.get(userId);
if (!user) {
throw new BadRequestException('User not found');
}
album.sharedUsers.push({ id: userId } as UserEntity);
}
return this.albumRepository
.update({
id: album.id,
updatedAt: new Date(),
sharedUsers: album.sharedUsers,
})
.then(mapAlbum);
}
async removeUser(authUser: AuthUserDto, id: string, userId: string | 'me'): Promise<void> {
if (userId === 'me') {
userId = authUser.id;
}
const album = await this.get(id);
if (album.ownerId === userId) {
throw new BadRequestException('Cannot remove album owner');
}
const exists = album.sharedUsers.find((user) => user.id === userId);
if (!exists) {
throw new BadRequestException('Album not shared with user');
}
// non-admin can remove themselves
if (authUser.id !== userId) {
this.assertOwner(authUser, album);
}
await this.albumRepository.update({
id: album.id,
updatedAt: new Date(),
sharedUsers: album.sharedUsers.filter((user) => user.id !== userId),
});
}
private async get(id: string) {
const [album] = await this.albumRepository.getByIds([id]);
if (!album) {
throw new BadRequestException('Album not found');
}
return album;
}
private assertOwner(authUser: AuthUserDto, album: AlbumEntity) {
if (album.ownerId !== authUser.id) {
throw new ForbiddenException('Album not owned by user');
}
}
}

View File

@@ -0,0 +1,8 @@
import { ArrayNotEmpty } from 'class-validator';
import { ValidateUUID } from '../../../../../apps/immich/src/decorators/validate-uuid.decorator';
export class AddUsersDto {
@ValidateUUID({ each: true })
@ArrayNotEmpty()
sharedUserIds!: string[];
}

View File

@@ -1,3 +1,4 @@
export * from './album-add-users.dto';
export * from './album-create.dto';
export * from './album-update.dto';
export * from './get-albums.dto';

View File

@@ -61,6 +61,16 @@ export const authStub = {
isShowExif: true,
accessTokenId: 'token-id',
}),
user2: Object.freeze<AuthUserDto>({
id: 'user-2',
email: 'user2@immich.app',
isAdmin: false,
isPublicUser: false,
isAllowUpload: true,
isAllowDownload: true,
isShowExif: true,
accessTokenId: 'token-id',
}),
adminSharedLink: Object.freeze<AuthUserDto>({
id: 'admin_id',
email: 'admin@test.com',
@@ -125,6 +135,21 @@ export const userEntityStub = {
tags: [],
assets: [],
}),
user2: Object.freeze<UserEntity>({
...authStub.user2,
password: 'immich_password',
firstName: 'immich_first_name',
lastName: 'immich_last_name',
storageLabel: null,
oauthId: '',
shouldChangePassword: false,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
}),
storageLabel: Object.freeze<UserEntity>({
...authStub.user1,
password: 'immich_password',
@@ -357,6 +382,19 @@ export const albumStub = {
sharedLinks: [],
sharedUsers: [userEntityStub.user1],
}),
sharedWithMultiple: Object.freeze<AlbumEntity>({
id: 'album-3',
albumName: 'Empty album shared with users',
ownerId: authStub.admin.id,
owner: userEntityStub.admin,
assets: [],
albumThumbnailAsset: null,
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
sharedLinks: [],
sharedUsers: [userEntityStub.user1, userEntityStub.user2],
}),
sharedWithAdmin: Object.freeze<AlbumEntity>({
id: 'album-3',
albumName: 'Empty album shared with admin',

View File

@@ -16,6 +16,7 @@ export class AlbumRepository implements IAlbumRepository {
},
relations: {
owner: true,
sharedUsers: true,
},
});
}
@@ -153,6 +154,12 @@ export class AlbumRepository implements IAlbumRepository {
private async save(album: Partial<AlbumEntity>) {
const { id } = await this.repository.save(album);
return this.repository.findOneOrFail({ where: { id }, relations: { owner: true } });
return this.repository.findOneOrFail({
where: { id },
relations: {
owner: true,
sharedUsers: true,
},
});
}
}