fix(server): Error when loading album with deleted owner (#4086)

* soft delete albums when user gets soft deleted

* fix wrong intl openapi version

* fix tests

* ability to restore albums, automatically restore when user restored

* (e2e) tests for shared albums via link and with user

* (e2e) test deletion of users and linked albums

* (e2e) fix share album with owner test

* fix: deletedAt

* chore: fix restore order

* fix: use timezone date column

* chore: cleanup e2e tests

* (e2e) fix user delete test

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Daniel Dietzler 2023-09-18 17:56:50 +02:00 committed by GitHub
parent 7d07aaeba3
commit f1c98ac9e6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 149 additions and 21 deletions

View File

@ -23,6 +23,8 @@ export interface IAlbumRepository {
getOwned(ownerId: string): Promise<AlbumEntity[]>;
getShared(ownerId: string): Promise<AlbumEntity[]>;
getNotShared(ownerId: string): Promise<AlbumEntity[]>;
restoreAll(userId: string): Promise<void>;
softDeleteAll(userId: string): Promise<void>;
deleteAll(userId: string): Promise<void>;
getAll(): Promise<AlbumEntity[]>;
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;

View File

@ -91,6 +91,7 @@ export class UserService {
if (!user) {
throw new BadRequestException('User not found');
}
await this.albumRepository.softDeleteAll(userId);
const deletedUser = await this.userCore.deleteUser(authUser, user);
return mapUser(deletedUser);
}
@ -101,6 +102,7 @@ export class UserService {
throw new BadRequestException('User not found');
}
const updatedUser = await this.userCore.restoreUser(authUser, user);
await this.albumRepository.restoreAll(userId);
return mapUser(updatedUser);
}

View File

@ -1,6 +1,7 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinTable,
ManyToMany,
@ -36,6 +37,9 @@ export class AlbumEntity {
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt!: Date | null;
@ManyToOne(() => AssetEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
albumThumbnailAsset!: AssetEntity | null;

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDeletedAtToAlbums1694638413248 implements MigrationInterface {
name = 'AddDeletedAtToAlbums1694638413248';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" ADD "deletedAt" TIMESTAMP WITH TIME ZONE`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "albums" DROP COLUMN "deletedAt"`);
}
}

View File

@ -142,6 +142,14 @@ export class AlbumRepository implements IAlbumRepository {
});
}
async restoreAll(userId: string): Promise<void> {
await this.repository.restore({ ownerId: userId });
}
async softDeleteAll(userId: string): Promise<void> {
await this.repository.softDelete({ ownerId: userId });
}
async deleteAll(userId: string): Promise<void> {
await this.repository.delete({ ownerId: userId });
}

View File

@ -19,6 +19,7 @@ const user2NotShared = 'user2NotShared';
describe(`${AlbumController.name} (e2e)`, () => {
let app: INestApplication;
let server: any;
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset: AssetFileUploadResponseDto;
let user1Albums: AlbumResponseDto[];
@ -37,7 +38,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
beforeEach(async () => {
await db.reset();
await api.authApi.adminSignUp(server);
const admin = await api.authApi.adminLogin(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, {
email: 'user1@immich.app',
@ -105,7 +106,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
const { status, body } = await request(server)
.get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
});
@ -113,13 +114,28 @@ describe(`${AlbumController.name} (e2e)`, () => {
const { status, body } = await request(server)
.get('/album?assetId=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest);
});
it('should not return shared albums with a deleted owner', async () => {
await api.userApi.delete(server, admin.accessToken, user1.userId);
const { status, body } = await request(server)
.get('/album?shared=true')
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedLink, shared: true }),
]),
);
});
it('should return the album collection including owned and shared', async () => {
const { status, body } = await request(server).get('/album').set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
@ -134,7 +150,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
const { status, body } = await request(server)
.get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
@ -149,7 +165,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
const { status, body } = await request(server)
.get('/album?shared=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual(
expect.arrayContaining([
@ -164,7 +180,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
const { status, body } = await request(server)
.get(`/album?assetId=${asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200);
expect(status).toBe(200);
expect(body).toHaveLength(1);
});
@ -172,7 +188,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
const { status, body } = await request(server)
.get(`/album?shared=true&assetId=${user1Asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
@ -180,7 +196,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
const { status, body } = await request(server)
.get(`/album?shared=false&assetId=${user1Asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(200);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
});
@ -390,15 +406,15 @@ describe(`${AlbumController.name} (e2e)`, () => {
expect(body).toEqual(expect.objectContaining({ sharedUsers: [expect.objectContaining({ id: user2.userId })] }));
});
// it('should not be able to share album with owner', async () => {
// const { status, body } = await request(server)
// .put(`/album/${album.id}/users`)
// .set('Authorization', `Bearer ${user1.accessToken}`)
// .send({ sharedUserIds: [user2.userId] });
it('should not be able to share album with owner', async () => {
const { status, body } = await request(server)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user1.userId] });
// expect(status).toBe(400);
// expect(body).toEqual(errorStub.badRequest);
// });
expect(status).toBe(400);
expect(body).toEqual({ ...errorStub.badRequest, message: 'Cannot be shared with owner' });
});
it('should not be able to add existing user to shared album', async () => {
await request(server)

View File

@ -99,7 +99,21 @@ describe(`${PartnerController.name} (e2e)`, () => {
.query({ key: sharedLink.key + 'foo' });
expect(status).toBe(401);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid share key' }));
expect(body).toEqual(errorStub.invalidShareKey);
});
it('should return unauthorized if target has been soft deleted', async () => {
const softDeletedAlbum = await api.albumApi.create(server, user1.accessToken, { albumName: 'shared with link' });
const softDeletedAlbumLink = await api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: softDeletedAlbum.id,
});
await api.userApi.delete(server, accessToken, user1.userId);
const { status, body } = await request(server).get('/shared-link/me').query({ key: softDeletedAlbumLink.key });
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidShareKey);
});
});

View File

@ -1,17 +1,21 @@
import { LoginResponseDto } from '@app/domain';
import { LoginResponseDto, UserResponseDto, UserService } from '@app/domain';
import { AppModule, UserController } from '@app/immich';
import { UserEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { api } from '@test/api';
import { db } from '@test/db';
import { errorStub, userSignupStub, userStub } from '@test/fixtures';
import request from 'supertest';
import { Repository } from 'typeorm';
describe(`${UserController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
let userService: UserService;
let userRepository: Repository<UserEntity>;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
@ -19,6 +23,7 @@ describe(`${UserController.name}`, () => {
}).compile();
app = await moduleFixture.createNestApplication().init();
userRepository = moduleFixture.get('UserEntityRepository');
server = app.getHttpServer();
});
@ -27,6 +32,8 @@ describe(`${UserController.name}`, () => {
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
userService = app.get<UserService>(UserService);
});
afterAll(async () => {
@ -173,6 +180,50 @@ describe(`${UserController.name}`, () => {
});
});
describe('DELETE /user/:id', () => {
let userToDelete: UserResponseDto;
beforeEach(async () => {
userToDelete = await api.userApi.create(server, accessToken, {
email: userStub.user1.email,
firstName: userStub.user1.firstName,
lastName: userStub.user1.lastName,
password: 'superSecurePassword',
});
});
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/user/${userToDelete.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should delete user', async () => {
const deleteRequest = await request(server)
.delete(`/user/${userToDelete.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(deleteRequest.status).toBe(200);
expect(deleteRequest.body).toEqual({
...userToDelete,
updatedAt: expect.any(String),
deletedAt: expect.any(String),
});
await userRepository.save({ id: deleteRequest.body.id, deletedAt: new Date('1970-01-01').toISOString() });
await userService.handleUserDelete({ id: userToDelete.id });
const { status, body } = await request(server)
.get('/user')
.query({ isAll: false })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
});
});
describe('PUT /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/user`);
@ -237,9 +288,9 @@ describe(`${UserController.name}`, () => {
lastName: 'Last Name',
});
expect(after).toMatchObject({
expect(after).toEqual({
...before,
updatedAt: expect.anything(),
updatedAt: expect.any(String),
firstName: 'First Name',
lastName: 'Last Name',
});

View File

@ -15,6 +15,7 @@ export const albumStub = {
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
}),
@ -29,6 +30,7 @@ export const albumStub = {
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [userStub.user1],
}),
@ -43,6 +45,7 @@ export const albumStub = {
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [userStub.user1, userStub.user2],
}),
@ -57,6 +60,7 @@ export const albumStub = {
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [userStub.admin],
}),
@ -71,6 +75,7 @@ export const albumStub = {
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
}),
@ -85,6 +90,7 @@ export const albumStub = {
albumThumbnailAssetId: assetStub.image.id,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
}),
@ -99,6 +105,7 @@ export const albumStub = {
albumThumbnailAssetId: assetStub.image.id,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
}),
@ -113,6 +120,7 @@ export const albumStub = {
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
}),
@ -127,6 +135,7 @@ export const albumStub = {
albumThumbnailAssetId: assetStub.livePhotoMotionAsset.id,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
}),
@ -141,6 +150,7 @@ export const albumStub = {
albumThumbnailAssetId: assetStub.image.id,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
sharedUsers: [],
}),

View File

@ -19,6 +19,11 @@ export const errorStub = {
statusCode: 401,
message: 'Invalid user token',
},
invalidShareKey: {
error: 'Unauthorized',
statusCode: 401,
message: 'Invalid share key',
},
badRequest: {
error: 'Bad Request',
statusCode: 400,

View File

@ -151,6 +151,7 @@ export const sharedLinkStub = {
description: '',
createdAt: today,
updatedAt: today,
deletedAt: null,
albumThumbnailAsset: null,
albumThumbnailAssetId: null,
sharedUsers: [],

View File

@ -10,6 +10,8 @@ export const newAlbumRepositoryMock = (): jest.Mocked<IAlbumRepository> => {
getOwned: jest.fn(),
getShared: jest.fn(),
getNotShared: jest.fn(),
restoreAll: jest.fn(),
softDeleteAll: jest.fn(),
deleteAll: jest.fn(),
getAll: jest.fn(),
removeAsset: jest.fn(),