1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-03-01 15:11:21 +01:00

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[]>; getOwned(ownerId: string): Promise<AlbumEntity[]>;
getShared(ownerId: string): Promise<AlbumEntity[]>; getShared(ownerId: string): Promise<AlbumEntity[]>;
getNotShared(ownerId: string): Promise<AlbumEntity[]>; getNotShared(ownerId: string): Promise<AlbumEntity[]>;
restoreAll(userId: string): Promise<void>;
softDeleteAll(userId: string): Promise<void>;
deleteAll(userId: string): Promise<void>; deleteAll(userId: string): Promise<void>;
getAll(): Promise<AlbumEntity[]>; getAll(): Promise<AlbumEntity[]>;
create(album: Partial<AlbumEntity>): Promise<AlbumEntity>; create(album: Partial<AlbumEntity>): Promise<AlbumEntity>;

View file

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

View file

@ -1,6 +1,7 @@
import { import {
Column, Column,
CreateDateColumn, CreateDateColumn,
DeleteDateColumn,
Entity, Entity,
JoinTable, JoinTable,
ManyToMany, ManyToMany,
@ -36,6 +37,9 @@ export class AlbumEntity {
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date; updatedAt!: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt!: Date | null;
@ManyToOne(() => AssetEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' }) @ManyToOne(() => AssetEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
albumThumbnailAsset!: AssetEntity | null; 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> { async deleteAll(userId: string): Promise<void> {
await this.repository.delete({ ownerId: userId }); await this.repository.delete({ ownerId: userId });
} }

View file

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

View file

@ -99,7 +99,21 @@ describe(`${PartnerController.name} (e2e)`, () => {
.query({ key: sharedLink.key + 'foo' }); .query({ key: sharedLink.key + 'foo' });
expect(status).toBe(401); 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 { AppModule, UserController } from '@app/immich';
import { UserEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { api } from '@test/api'; import { api } from '@test/api';
import { db } from '@test/db'; import { db } from '@test/db';
import { errorStub, userSignupStub, userStub } from '@test/fixtures'; import { errorStub, userSignupStub, userStub } from '@test/fixtures';
import request from 'supertest'; import request from 'supertest';
import { Repository } from 'typeorm';
describe(`${UserController.name}`, () => { describe(`${UserController.name}`, () => {
let app: INestApplication; let app: INestApplication;
let server: any; let server: any;
let loginResponse: LoginResponseDto; let loginResponse: LoginResponseDto;
let accessToken: string; let accessToken: string;
let userService: UserService;
let userRepository: Repository<UserEntity>;
beforeAll(async () => { beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({ const moduleFixture: TestingModule = await Test.createTestingModule({
@ -19,6 +23,7 @@ describe(`${UserController.name}`, () => {
}).compile(); }).compile();
app = await moduleFixture.createNestApplication().init(); app = await moduleFixture.createNestApplication().init();
userRepository = moduleFixture.get('UserEntityRepository');
server = app.getHttpServer(); server = app.getHttpServer();
}); });
@ -27,6 +32,8 @@ describe(`${UserController.name}`, () => {
await api.authApi.adminSignUp(server); await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server); loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken; accessToken = loginResponse.accessToken;
userService = app.get<UserService>(UserService);
}); });
afterAll(async () => { 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', () => { describe('PUT /user', () => {
it('should require authentication', async () => { it('should require authentication', async () => {
const { status, body } = await request(server).put(`/user`); const { status, body } = await request(server).put(`/user`);
@ -237,9 +288,9 @@ describe(`${UserController.name}`, () => {
lastName: 'Last Name', lastName: 'Last Name',
}); });
expect(after).toMatchObject({ expect(after).toEqual({
...before, ...before,
updatedAt: expect.anything(), updatedAt: expect.any(String),
firstName: 'First Name', firstName: 'First Name',
lastName: 'Last Name', lastName: 'Last Name',
}); });

View file

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

View file

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

View file

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

View file

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