refactor(server): split api and jobs into separate e2e suites (#6307)

* refactor: domain and infra modules

* refactor(server): e2e tests
This commit is contained in:
Jason Rasmussen 2024-01-09 23:04:16 -05:00 committed by GitHub
parent e5786b200a
commit bf1dd36fa9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
50 changed files with 852 additions and 439 deletions

View file

@ -0,0 +1,402 @@
import { AlbumResponseDto, LoginResponseDto, ReactionType } from '@app/domain';
import { ActivityController } from '@app/immich';
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
import { ActivityEntity } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${ActivityController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let asset: AssetFileUploadResponseDto;
let album: AlbumResponseDto;
let nonOwner: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
asset = await api.assetApi.upload(server, admin.accessToken, 'example');
await api.userApi.create(server, admin.accessToken, userDto.user1);
nonOwner = await api.authApi.login(server, userDto.user1);
album = await api.albumApi.create(server, admin.accessToken, {
albumName: 'Album 1',
assetIds: [asset.id],
sharedWithUserIds: [nonOwner.userId],
});
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset({ entities: [ActivityEntity] });
});
describe('GET /activity', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/activity');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require an albumId', async () => {
const { status, body } = await request(server)
.get('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should reject an invalid albumId', async () => {
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: uuidStub.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should reject an invalid assetId', async () => {
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: uuidStub.notFound, assetId: uuidStub.invalid })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['assetId must be a UUID'])));
});
it('should start off empty', async () => {
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(body).toEqual([]);
expect(status).toEqual(200);
});
it('should filter by album id', async () => {
const album2 = await api.albumApi.create(server, admin.accessToken, {
albumName: 'Album 2',
assetIds: [asset.id],
});
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.LIKE,
}),
api.activityApi.create(server, admin.accessToken, {
albumId: album2.id,
type: ReactionType.LIKE,
}),
]);
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
it('should filter by type=comment', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'comment',
}),
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
]);
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id, type: 'comment' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
it('should filter by type=like', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'comment',
}),
]);
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id, type: 'like' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
it('should filter by userId', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
]);
const response1 = await request(server)
.get('/activity')
.query({ albumId: album.id, userId: uuidStub.notFound })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response1.status).toEqual(200);
expect(response1.body.length).toBe(0);
const response2 = await request(server)
.get('/activity')
.query({ albumId: album.id, userId: admin.userId })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(response2.status).toEqual(200);
expect(response2.body.length).toBe(1);
expect(response2.body[0]).toEqual(reaction);
});
it('should filter by assetId', async () => {
const [reaction] = await Promise.all([
api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
assetId: asset.id,
type: ReactionType.LIKE,
}),
api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }),
]);
const { status, body } = await request(server)
.get('/activity')
.query({ albumId: album.id, assetId: asset.id })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(200);
expect(body.length).toBe(1);
expect(body[0]).toEqual(reaction);
});
});
describe('POST /activity', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post('/activity');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require an albumId', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidStub.invalid });
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(expect.arrayContaining(['albumId must be a UUID'])));
});
it('should require a comment when type is comment', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: uuidStub.notFound, type: 'comment', comment: null });
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['comment must be a string', 'comment should not be empty']));
});
it('should add a comment to an album', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'comment', comment: 'This is my first comment' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: null,
createdAt: expect.any(String),
type: 'comment',
comment: 'This is my first comment',
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should add a like to an album', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: null,
createdAt: expect.any(String),
type: 'like',
comment: null,
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should return a 200 for a duplicate like on the album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.LIKE,
});
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(200);
expect(body).toEqual(reaction);
});
it('should not confuse an album like with an asset like', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
assetId: asset.id,
type: ReactionType.LIKE,
});
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, type: 'like' });
expect(status).toEqual(201);
expect(body.id).not.toEqual(reaction.id);
});
it('should add a comment to an asset', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'comment', comment: 'This is my first comment' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: asset.id,
createdAt: expect.any(String),
type: 'comment',
comment: 'This is my first comment',
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should add a like to an asset', async () => {
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
expect(status).toEqual(201);
expect(body).toEqual({
id: expect.any(String),
assetId: asset.id,
createdAt: expect.any(String),
type: 'like',
comment: null,
user: expect.objectContaining({ email: admin.userEmail }),
});
});
it('should return a 200 for a duplicate like on an asset', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
assetId: asset.id,
type: ReactionType.LIKE,
});
const { status, body } = await request(server)
.post('/activity')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ albumId: album.id, assetId: asset.id, type: 'like' });
expect(status).toEqual(200);
expect(body).toEqual(reaction);
});
});
describe('DELETE /activity/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/activity/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a valid uuid', async () => {
const { status, body } = await request(server)
.delete(`/activity/${uuidStub.invalid}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['id must be a UUID']));
});
it('should remove a comment from an album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'This is a test comment',
});
const { status } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should remove a like from an album', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.LIKE,
});
const { status } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should let the owner remove a comment by another user', async () => {
const reaction = await api.activityApi.create(server, nonOwner.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'This is a test comment',
});
const { status } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toEqual(204);
});
it('should not let a user remove a comment by another user', async () => {
const reaction = await api.activityApi.create(server, admin.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'This is a test comment',
});
const { status, body } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no activity.delete access'));
});
it('should let a non-owner remove their own comment', async () => {
const reaction = await api.activityApi.create(server, nonOwner.accessToken, {
albumId: album.id,
type: ReactionType.COMMENT,
comment: 'This is a test comment',
});
const { status } = await request(server)
.delete(`/activity/${reaction.id}`)
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
expect(status).toBe(204);
});
});
});

View file

@ -0,0 +1,448 @@
import { AlbumResponseDto, LoginResponseDto } from '@app/domain';
import { AlbumController } from '@app/immich';
import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto';
import { SharedLinkType } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
const user1SharedUser = 'user1SharedUser';
const user1SharedLink = 'user1SharedLink';
const user1NotShared = 'user1NotShared';
const user2SharedUser = 'user2SharedUser';
const user2SharedLink = 'user2SharedLink';
const user2NotShared = 'user2NotShared';
describe(`${AlbumController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let user1: LoginResponseDto;
let user1Asset: AssetFileUploadResponseDto;
let user1Albums: AlbumResponseDto[];
let user2: LoginResponseDto;
let user2Albums: AlbumResponseDto[];
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]);
[user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
]);
user1Asset = await api.assetApi.upload(server, user1.accessToken, 'example');
const albums = await Promise.all([
// user 1
api.albumApi.create(server, user1.accessToken, {
albumName: user1SharedUser,
sharedWithUserIds: [user2.userId],
assetIds: [user1Asset.id],
}),
api.albumApi.create(server, user1.accessToken, { albumName: user1SharedLink, assetIds: [user1Asset.id] }),
api.albumApi.create(server, user1.accessToken, { albumName: user1NotShared, assetIds: [user1Asset.id] }),
// user 2
api.albumApi.create(server, user2.accessToken, {
albumName: user2SharedUser,
sharedWithUserIds: [user1.userId],
assetIds: [user1Asset.id],
}),
api.albumApi.create(server, user2.accessToken, { albumName: user2SharedLink }),
api.albumApi.create(server, user2.accessToken, { albumName: user2NotShared }),
]);
user1Albums = albums.slice(0, 3);
user2Albums = albums.slice(3);
await Promise.all([
// add shared link to user1SharedLink album
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: user1Albums[1].id,
}),
// add shared link to user2SharedLink album
api.sharedLinkApi.create(server, user2.accessToken, {
type: SharedLinkType.ALBUM,
albumId: user2Albums[1].id,
}),
]);
});
describe('GET /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/album');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should reject an invalid shared param', async () => {
const { status, body } = await request(server)
.get('/album?shared=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['shared must be a boolean value']));
});
it('should reject an invalid assetId param', async () => {
const { status, body } = await request(server)
.get('/album?assetId=invalid')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest(['assetId must be a UUID']));
});
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).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
]),
);
});
it('should return the album collection filtered by shared', async () => {
const { status, body } = await request(server)
.get('/album?shared=true')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(3);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedUser, shared: true }),
expect.objectContaining({ ownerId: user1.userId, albumName: user1SharedLink, shared: true }),
expect.objectContaining({ ownerId: user2.userId, albumName: user2SharedUser, shared: true }),
]),
);
});
it('should return the album collection filtered by NOT shared', async () => {
const { status, body } = await request(server)
.get('/album?shared=false')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ ownerId: user1.userId, albumName: user1NotShared, shared: false }),
]),
);
});
it('should return the album collection filtered by assetId', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example2');
await api.albumApi.addAssets(server, user1.accessToken, user1Albums[0].id, { ids: [asset.id] });
const { status, body } = await request(server)
.get(`/album?assetId=${asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
});
it('should return the album collection filtered by assetId and ignores shared=true', async () => {
const { status, body } = await request(server)
.get(`/album?shared=true&assetId=${user1Asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
it('should return the album collection filtered by assetId and ignores shared=false', async () => {
const { status, body } = await request(server)
.get(`/album?shared=false&assetId=${user1Asset.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(4);
});
});
describe('POST /album', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post('/album').send({ albumName: 'New album' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should create an album', async () => {
const body = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
expect(body).toEqual({
id: expect.any(String),
createdAt: expect.any(String),
updatedAt: expect.any(String),
ownerId: user1.userId,
albumName: 'New album',
description: '',
albumThumbnailAssetId: null,
shared: false,
sharedUsers: [],
hasSharedLink: false,
assets: [],
assetCount: 0,
owner: expect.objectContaining({ email: user1.userEmail }),
isActivityEnabled: true,
});
});
});
describe('GET /album/count', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/album/count');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return total count of albums the user has access to', async () => {
const { status, body } = await request(server)
.get('/album/count')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ owned: 3, shared: 3, notShared: 1 });
});
});
describe('GET /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/album/${user1Albums[0].id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return album info for own album', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(user1Albums[0]);
});
it('should return album info for shared album', async () => {
const { status, body } = await request(server)
.get(`/album/${user2Albums[0].id}?withoutAssets=false`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(user2Albums[0]);
});
it('should return album info with assets when withoutAssets is undefined', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(user1Albums[0]);
});
it('should return album info without assets when withoutAssets is true', async () => {
const { status, body } = await request(server)
.get(`/album/${user1Albums[0].id}?withoutAssets=true`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...user1Albums[0],
assets: [],
assetCount: 1,
});
});
});
describe('PUT /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/album/${user1Albums[0].id}/assets`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should be able to add own asset to own album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
const { status, body } = await request(server)
.put(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
});
it('should be able to add own asset to shared album', async () => {
const asset = await api.assetApi.upload(server, user1.accessToken, 'example1');
const { status, body } = await request(server)
.put(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: asset.id, success: true })]);
});
});
describe('PATCH /album/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.patch(`/album/${uuidStub.notFound}`)
.send({ albumName: 'New album name' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should update an album', async () => {
const album = await api.albumApi.create(server, user1.accessToken, { albumName: 'New album' });
const { status, body } = await request(server)
.patch(`/album/${album.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({
albumName: 'New album name',
description: 'An album description',
});
expect(status).toBe(200);
expect(body).toEqual({
...album,
updatedAt: expect.any(String),
albumName: 'New album name',
description: 'An album description',
});
});
});
describe('DELETE /album/:id/assets', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.delete(`/album/${user1Albums[0].id}/assets`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should be able to remove own asset from own album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
});
it('should be able to remove own asset from shared album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: true })]);
});
it('should not be able to remove foreign asset from own album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user2Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
});
it('should not be able to remove foreign asset from foreign album', async () => {
const { status, body } = await request(server)
.delete(`/album/${user1Albums[0].id}/assets`)
.set('Authorization', `Bearer ${user2.accessToken}`)
.send({ ids: [user1Asset.id] });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user1Asset.id, success: false, error: 'no_permission' })]);
});
});
describe('PUT :id/users', () => {
let album: AlbumResponseDto;
beforeEach(async () => {
album = await api.albumApi.create(server, user1.accessToken, { albumName: 'testAlbum' });
});
it('should require authentication', async () => {
const { status, body } = await request(server)
.put(`/album/${user1Albums[0].id}/users`)
.send({ sharedUserIds: [] });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should be able to add user to own album', async () => {
const { status, body } = await request(server)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
expect(status).toBe(200);
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: [user1.userId] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Cannot be shared with owner'));
});
it('should not be able to add existing user to shared album', async () => {
await request(server)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
const { status, body } = await request(server)
.put(`/album/${album.id}/users`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ sharedUserIds: [user2.userId] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('User already added'));
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,291 @@
import { AuthController } from '@app/immich';
import {
adminSignupStub,
changePasswordStub,
deviceStub,
errorStub,
loginResponseStub,
loginStub,
uuidStub,
} from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
const name = 'Immich Admin';
const password = 'Password123';
const email = 'admin@immich.app';
const adminSignupResponse = {
avatarColor: expect.any(String),
id: expect.any(String),
name: 'Immich Admin',
email: 'admin@immich.app',
storageLabel: 'admin',
externalPath: null,
profileImagePath: '',
// why? lol
shouldChangePassword: true,
isAdmin: true,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: null,
oauthId: '',
memoriesEnabled: true,
};
describe(`${AuthController.name} (e2e)`, () => {
let server: any;
let accessToken: string;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
const response = await api.authApi.adminLogin(server);
accessToken = response.accessToken;
});
describe('POST /auth/admin-sign-up', () => {
beforeEach(async () => {
await testApp.reset();
});
const invalid = [
{
should: 'require an email address',
data: { name, password },
},
{
should: 'require a password',
data: { name, email },
},
{
should: 'require a name',
data: { email, password },
},
{
should: 'require a valid email',
data: { name, email: 'immich', password },
},
];
for (const { should, data } of invalid) {
it(`should ${should}`, async () => {
const { status, body } = await request(server).post('/auth/admin-sign-up').send(data);
expect(status).toEqual(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it(`should sign up the admin`, async () => {
await api.authApi.adminSignUp(server);
});
it('should sign up the admin with a local domain', async () => {
const { status, body } = await request(server)
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, email: 'admin@local' });
expect(status).toEqual(201);
expect(body).toEqual({ ...adminSignupResponse, email: 'admin@local' });
});
it('should transform email to lower case', async () => {
const { status, body } = await request(server)
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, email: 'aDmIn@IMMICH.app' });
expect(status).toEqual(201);
expect(body).toEqual(adminSignupResponse);
});
it('should not allow a second admin to sign up', async () => {
await api.authApi.adminSignUp(server);
const { status, body } = await request(server).post('/auth/admin-sign-up').send(adminSignupStub);
expect(status).toBe(400);
expect(body).toEqual(errorStub.alreadyHasAdmin);
});
for (const key of Object.keys(adminSignupStub)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post('/auth/admin-sign-up')
.send({ ...adminSignupStub, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
});
describe(`POST /auth/login`, () => {
it('should reject an incorrect password', async () => {
const { status, body } = await request(server).post('/auth/login').send({ email, password: 'incorrect' });
expect(body).toEqual(errorStub.incorrectLogin);
expect(status).toBe(401);
});
for (const key of Object.keys(loginStub.admin)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post('/auth/login')
.send({ ...loginStub.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should accept a correct password', async () => {
const { status, body, headers } = await request(server).post('/auth/login').send({ email, password });
expect(status).toBe(201);
expect(body).toEqual(loginResponseStub.admin.response);
const token = body.accessToken;
expect(token).toBeDefined();
const cookies = headers['set-cookie'];
expect(cookies).toHaveLength(2);
expect(cookies[0]).toEqual(`immich_access_token=${token}; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;`);
expect(cookies[1]).toEqual('immich_auth_type=password; HttpOnly; Path=/; Max-Age=34560000; SameSite=Lax;');
});
});
describe('GET /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/auth/devices');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get a list of authorized devices', async () => {
const { status, body } = await request(server).get('/auth/devices').set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([deviceStub.current]);
});
});
describe('DELETE /auth/devices', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/auth/devices`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should logout all devices (except the current one)', async () => {
for (let i = 0; i < 5; i++) {
await api.authApi.adminLogin(server);
}
await expect(api.authApi.getAuthDevices(server, accessToken)).resolves.toHaveLength(6);
const { status } = await request(server).delete(`/auth/devices`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(204);
await api.authApi.validateToken(server, accessToken);
});
});
describe('DELETE /auth/devices/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/auth/devices/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should throw an error for a non-existent device id', async () => {
const { status, body } = await request(server)
.delete(`/auth/devices/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no authDevice.delete access'));
});
it('should logout a device', async () => {
const [device] = await api.authApi.getAuthDevices(server, accessToken);
const { status } = await request(server)
.delete(`/auth/devices/${device.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(204);
const response = await request(server).post('/auth/validateToken').set('Authorization', `Bearer ${accessToken}`);
expect(response.body).toEqual(errorStub.invalidToken);
expect(response.status).toBe(401);
});
});
describe('POST /auth/validateToken', () => {
it('should reject an invalid token', async () => {
const { status, body } = await request(server).post(`/auth/validateToken`).set('Authorization', 'Bearer 123');
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidToken);
});
it('should accept a valid token', async () => {
const { status, body } = await request(server)
.post(`/auth/validateToken`)
.send({})
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ authStatus: true });
});
});
describe('POST /auth/change-password', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/auth/change-password`).send(changePasswordStub);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of Object.keys(changePasswordStub)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post('/auth/change-password')
.send({ ...changePasswordStub, [key]: null })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should require the current password', async () => {
const { status, body } = await request(server)
.post(`/auth/change-password`)
.send({ ...changePasswordStub, password: 'wrong-password' })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.wrongPassword);
});
it('should change the password', async () => {
const { status } = await request(server)
.post(`/auth/change-password`)
.send(changePasswordStub)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
await api.authApi.login(server, { email: 'admin@immich.app', password: 'Password1234' });
});
});
describe('POST /auth/logout', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/auth/logout`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should logout the user', async () => {
const { status, body } = await request(server).post(`/auth/logout`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({ successful: true, redirectUri: '/auth/login?autoLaunch=0' });
});
});
});

View file

@ -0,0 +1,387 @@
import { LibraryResponseDto, LoginResponseDto } from '@app/domain';
import { LibraryController } from '@app/immich';
import { LibraryType } from '@app/infra/entities';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${LibraryController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
});
describe('GET /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/library');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should start with a default upload library', async () => {
const { status, body } = await request(server)
.get('/library')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body).toEqual([
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.UPLOAD,
name: 'Default Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
]);
});
});
describe('POST /library', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post('/library').send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should create an external library with defaults', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an external library with options', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({
type: LibraryType.EXTERNAL,
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
exclusionPatterns: ['**/Raw/**'],
});
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
importPaths: ['/path/to/import'],
}),
);
});
it('should create an upload library with defaults', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.UPLOAD,
name: 'New Upload Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it('should create an upload library with options', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD, name: 'My Awesome Library' });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
name: 'My Awesome Library',
}),
);
});
it('should not allow upload libraries to have import paths', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD, importPaths: ['/path/to/import'] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have import paths'));
});
it('should not allow upload libraries to have exclusion patterns', async () => {
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ type: LibraryType.UPLOAD, exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Upload libraries cannot have exclusion patterns'));
});
it('should allow a non-admin to create a library', async () => {
await api.userApi.create(server, admin.accessToken, userDto.user1);
const user1 = await api.authApi.login(server, userDto.user1);
const { status, body } = await request(server)
.post('/library')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: LibraryType.EXTERNAL });
expect(status).toBe(201);
expect(body).toEqual(
expect.objectContaining({
ownerId: user1.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
});
describe('PUT /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/library/${uuidStub.notFound}`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
describe('external library', () => {
let library: LibraryResponseDto;
beforeEach(async () => {
// Create an external library with default settings
library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
});
it('should change the library name', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: 'New Library Name' });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
name: 'New Library Name',
}),
);
});
it('should not set an empty name', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ name: '' });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['name should not be empty']));
});
it('should change the import paths', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: ['/path/to/import'] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
importPaths: ['/path/to/import'],
}),
);
});
it('should not allow an empty import path', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ importPaths: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['each value in importPaths should not be empty']));
});
it('should change the exclusion pattern', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: ['**/Raw/**'] });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
exclusionPatterns: ['**/Raw/**'],
}),
);
});
it('should not allow an empty exclusion pattern', async () => {
const { status, body } = await request(server)
.put(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ exclusionPatterns: [''] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['each value in exclusionPatterns should not be empty']));
});
});
});
describe('GET /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/library/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get library by id', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
.get(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
ownerId: admin.userId,
type: LibraryType.EXTERNAL,
name: 'New External Library',
refreshedAt: null,
assetCount: 0,
importPaths: [],
exclusionPatterns: [],
}),
);
});
it("should not allow getting another user's library", async () => {
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]);
const [user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
]);
const library = await api.libraryApi.create(server, user1.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
.get(`/library/${library.id}`)
.set('Authorization', `Bearer ${user2.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no library.read access'));
});
});
describe('DELETE /library/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/library/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should not delete the last upload library', async () => {
const [defaultLibrary] = await api.libraryApi.getAll(server, admin.accessToken);
expect(defaultLibrary).toBeDefined();
const { status, body } = await request(server)
.delete(`/library/${defaultLibrary.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.noDeleteUploadLibrary);
});
it('should delete an empty library', async () => {
const library = await api.libraryApi.create(server, admin.accessToken, { type: LibraryType.EXTERNAL });
const { status, body } = await request(server)
.delete(`/library/${library.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({});
const libraries = await api.libraryApi.getAll(server, admin.accessToken);
expect(libraries).toHaveLength(1);
expect(libraries).not.toEqual(
expect.arrayContaining([
expect.objectContaining({
id: library.id,
}),
]),
);
});
});
describe('GET /library/:id/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/library/${uuidStub.notFound}/statistics`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
describe('POST /library/:id/scan', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/scan`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
describe('POST /library/:id/removeOffline', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/library/${uuidStub.notFound}/removeOffline`).send({});
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
});
});

View file

@ -0,0 +1,30 @@
import { OAuthController } from '@app/immich';
import { errorStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${OAuthController.name} (e2e)`, () => {
let server: any;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
});
describe('POST /oauth/authorize', () => {
it(`should throw an error if a redirect uri is not provided`, async () => {
const { status, body } = await request(server).post('/oauth/authorize').send({});
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['redirectUri must be a string', 'redirectUri should not be empty']));
});
});
});

View file

@ -0,0 +1,143 @@
import { LoginResponseDto, PartnerDirection } from '@app/domain';
import { PartnerController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${PartnerController.name} (e2e)`, () => {
let server: any;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let user3: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
const admin = await api.authApi.adminLogin(server);
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
api.userApi.create(server, admin.accessToken, userDto.user3),
]);
[user1, user2, user3] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
api.authApi.login(server, userDto.user3),
]);
await Promise.all([
api.partnerApi.create(server, user1.accessToken, user2.userId),
api.partnerApi.create(server, user2.accessToken, user1.userId),
]);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /partner', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/partner');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get all partners shared by user', async () => {
const { status, body } = await request(server)
.get('/partner')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: PartnerDirection.SharedBy });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
});
it('should get all partners that share with user', async () => {
const { status, body } = await request(server)
.get('/partner')
.set('Authorization', `Bearer ${user1.accessToken}`)
.query({ direction: PartnerDirection.SharedWith });
expect(status).toBe(200);
expect(body).toEqual([expect.objectContaining({ id: user2.userId })]);
});
});
describe('POST /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should share with new partner', async () => {
const { status, body } = await request(server)
.post(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(201);
expect(body).toEqual(expect.objectContaining({ id: user3.userId }));
});
it('should not share with new partner if already sharing with this partner', async () => {
const { status, body } = await request(server)
.post(`/partner/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Partner already exists' }));
});
});
describe('PUT /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/partner/${user2.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should update partner', async () => {
const { status, body } = await request(server)
.put(`/partner/${user2.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ inTimeline: false });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: user2.userId, inTimeline: false }));
});
});
describe('DELETE /partner/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/partner/${user3.userId}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should delete partner', async () => {
const { status } = await request(server)
.delete(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
});
it('should throw a bad request if partner not found', async () => {
const { status, body } = await request(server)
.delete(`/partner/${user3.userId}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Partner not found' }));
});
});
});

View file

@ -0,0 +1,189 @@
import { IPersonRepository, LoginResponseDto } from '@app/domain';
import { PersonController } from '@app/immich';
import { PersonEntity } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { errorStub, uuidStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${PersonController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
let personRepository: IPersonRepository;
let visiblePerson: PersonEntity;
let hiddenPerson: PersonEntity;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
personRepository = app.get<IPersonRepository>(IPersonRepository);
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
const faceAsset = await api.assetApi.upload(server, accessToken, 'face_asset');
visiblePerson = await personRepository.create({
ownerId: loginResponse.userId,
name: 'visible_person',
thumbnailPath: '/thumbnail/face_asset',
});
await personRepository.createFace({
assetId: faceAsset.id,
personId: visiblePerson.id,
embedding: Array.from({ length: 512 }, Math.random),
});
hiddenPerson = await personRepository.create({
ownerId: loginResponse.userId,
name: 'hidden_person',
isHidden: true,
thumbnailPath: '/thumbnail/face_asset',
});
await personRepository.createFace({
assetId: faceAsset.id,
personId: hiddenPerson.id,
embedding: Array.from({ length: 512 }, Math.random),
});
});
describe('GET /person', () => {
beforeEach(async () => {});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/person');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return all people (including hidden)', async () => {
const { status, body } = await request(server)
.get('/person')
.set('Authorization', `Bearer ${accessToken}`)
.query({ withHidden: true });
expect(status).toBe(200);
expect(body).toEqual({
total: 2,
visible: 1,
people: [
expect.objectContaining({ name: 'visible_person' }),
expect.objectContaining({ name: 'hidden_person' }),
],
});
});
it('should return only visible people', async () => {
const { status, body } = await request(server).get('/person').set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
total: 1,
visible: 1,
people: [expect.objectContaining({ name: 'visible_person' })],
});
});
});
describe('GET /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/person/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should throw error if person with id does not exist', async () => {
const { status, body } = await request(server)
.get(`/person/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should return person information', async () => {
const { status, body } = await request(server)
.get(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: visiblePerson.id }));
});
});
describe('PUT /person/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).put(`/person/${uuidStub.notFound}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const { key, type } of [
{ key: 'name', type: 'string' },
{ key: 'featureFaceAssetId', type: 'string' },
{ key: 'isHidden', type: 'boolean value' },
]) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest([`${key} must be a ${type}`]));
});
}
it('should not accept invalid birth dates', async () => {
for (const { birthDate, response } of [
{ birthDate: false, response: 'Not found or no person.write access' },
{ birthDate: 'false', response: ['birthDate must be a Date instance'] },
{ birthDate: '123567', response: 'Not found or no person.write access' },
{ birthDate: 123567, response: 'Not found or no person.write access' },
]) {
const { status, body } = await request(server)
.put(`/person/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(response));
}
});
it('should update a date of birth', async () => {
const { status, body } = await request(server)
.put(`/person/${visiblePerson.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate: '1990-01-01T05:00:00.000Z' });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: '1990-01-01' });
});
it('should clear a date of birth', async () => {
const person = await personRepository.create({
birthDate: new Date('1990-01-01'),
ownerId: loginResponse.userId,
});
expect(person.birthDate).toBeDefined();
const { status, body } = await request(server)
.put(`/person/${person.id}`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ birthDate: null });
expect(status).toBe(200);
expect(body).toMatchObject({ birthDate: null });
});
});
});

View file

@ -0,0 +1,215 @@
import {
AssetResponseDto,
IAssetRepository,
ISmartInfoRepository,
LibraryResponseDto,
LoginResponseDto,
mapAsset,
} from '@app/domain';
import { SearchController } from '@app/immich';
import { INestApplication } from '@nestjs/common';
import { errorStub, searchStub } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { generateAsset, testApp } from '../utils';
describe(`${SearchController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
let libraries: LibraryResponseDto[];
let assetRepository: IAssetRepository;
let smartInfoRepository: ISmartInfoRepository;
let asset1: AssetResponseDto;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
assetRepository = app.get<IAssetRepository>(IAssetRepository);
smartInfoRepository = app.get<ISmartInfoRepository>(ISmartInfoRepository);
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
libraries = await api.libraryApi.getAll(server, accessToken);
});
describe('GET /search (exif)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true });
if (!assetWithMetadata) {
throw new Error('Asset not found');
}
asset1 = mapAsset(assetWithMetadata);
});
it('should require authentication', async () => {
const { status, body } = await request(server).get('/search');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return assets when searching by exif', async () => {
if (!asset1?.exifInfo?.make) {
throw new Error('Asset 1 does not have exif info');
}
const { status, body } = await request(server)
.get('/search')
.set('Authorization', `Bearer ${accessToken}`)
.query({ q: asset1.exifInfo.make });
expect(status).toBe(200);
expect(body).toMatchObject({
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [
{
id: asset1.id,
exifInfo: {
make: asset1.exifInfo.make,
},
},
],
facets: [],
},
});
});
it('should be case-insensitive for metadata search', async () => {
if (!asset1?.exifInfo?.make) {
throw new Error('Asset 1 does not have exif info');
}
const { status, body } = await request(server)
.get('/search')
.set('Authorization', `Bearer ${accessToken}`)
.query({ q: asset1.exifInfo.make.toLowerCase() });
expect(status).toBe(200);
expect(body).toMatchObject({
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [
{
id: asset1.id,
exifInfo: {
make: asset1.exifInfo.make,
},
},
],
facets: [],
},
});
});
it('should be whitespace-insensitive for metadata search', async () => {
if (!asset1?.exifInfo?.make) {
throw new Error('Asset 1 does not have exif info');
}
const { status, body } = await request(server)
.get('/search')
.set('Authorization', `Bearer ${accessToken}`)
.query({ q: ` ${asset1.exifInfo.make} ` });
expect(status).toBe(200);
expect(body).toMatchObject({
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [
{
id: asset1.id,
exifInfo: {
make: asset1.exifInfo.make,
},
},
],
facets: [],
},
});
});
});
describe('GET /search (smart info)', () => {
beforeEach(async () => {
const assetId = (await assetRepository.create(generateAsset(loginResponse.userId, libraries))).id;
await assetRepository.upsertExif({ assetId, ...searchStub.exif });
await smartInfoRepository.upsert({ assetId, ...searchStub.smartInfo }, Array.from({ length: 512 }, Math.random));
const assetWithMetadata = await assetRepository.getById(assetId, { exifInfo: true, smartInfo: true });
if (!assetWithMetadata) {
throw new Error('Asset not found');
}
asset1 = mapAsset(assetWithMetadata);
});
it('should return assets when searching by object', async () => {
if (!asset1?.smartInfo?.objects) {
throw new Error('Asset 1 does not have smart info');
}
const { status, body } = await request(server)
.get('/search')
.set('Authorization', `Bearer ${accessToken}`)
.query({ q: asset1.smartInfo.objects[0] });
expect(status).toBe(200);
expect(body).toMatchObject({
albums: {
total: 0,
count: 0,
items: [],
facets: [],
},
assets: {
total: 1,
count: 1,
items: [
{
id: asset1.id,
smartInfo: {
objects: asset1.smartInfo.objects,
tags: asset1.smartInfo.tags,
},
},
],
facets: [],
},
});
});
});
});

View file

@ -0,0 +1,186 @@
import { LoginResponseDto } from '@app/domain';
import { ServerInfoController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${ServerInfoController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, userDto.user1);
nonAdmin = await api.authApi.login(server, userDto.user1);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /server-info', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/server-info');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should return the disk information', async () => {
const { status, body } = await request(server)
.get('/server-info')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
diskAvailable: expect.any(String),
diskAvailableRaw: expect.any(Number),
diskSize: expect.any(String),
diskSizeRaw: expect.any(Number),
diskUsagePercentage: expect.any(Number),
diskUse: expect.any(String),
diskUseRaw: expect.any(Number),
});
});
});
describe('GET /server-info/ping', () => {
it('should respond with pong', async () => {
const { status, body } = await request(server).get('/server-info/ping');
expect(status).toBe(200);
expect(body).toEqual({ res: 'pong' });
});
});
describe('GET /server-info/version', () => {
it('should respond with the server version', async () => {
const { status, body } = await request(server).get('/server-info/version');
expect(status).toBe(200);
expect(body).toEqual({
major: expect.any(Number),
minor: expect.any(Number),
patch: expect.any(Number),
});
});
});
describe('GET /server-info/features', () => {
it('should respond with the server features', async () => {
const { status, body } = await request(server).get('/server-info/features');
expect(status).toBe(200);
expect(body).toEqual({
clipEncode: true,
configFile: false,
facialRecognition: true,
map: true,
reverseGeocoding: true,
oauth: false,
oauthAutoLaunch: false,
passwordLogin: true,
search: true,
sidecar: true,
trash: true,
});
});
});
describe('GET /server-info/config', () => {
it('should respond with the server configuration', async () => {
const { status, body } = await request(server).get('/server-info/config');
expect(status).toBe(200);
expect(body).toEqual({
loginPageMessage: '',
oauthButtonText: 'Login with OAuth',
trashDays: 30,
isInitialized: true,
externalDomain: '',
isOnboarded: false,
});
});
});
describe('GET /server-info/statistics', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/server-info/statistics');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(server)
.get('/server-info/statistics')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorStub.forbidden);
});
it('should return the server stats', async () => {
const { status, body } = await request(server)
.get('/server-info/statistics')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
photos: 0,
usage: 0,
usageByUser: [
{
photos: 0,
usage: 0,
userName: 'Immich Admin',
userId: admin.userId,
videos: 0,
},
{
photos: 0,
usage: 0,
userName: 'User 1',
userId: nonAdmin.userId,
videos: 0,
},
],
videos: 0,
});
});
});
describe('GET /server-info/media-types', () => {
it('should return accepted media types', async () => {
const { status, body } = await request(server).get('/server-info/media-types');
expect(status).toBe(200);
expect(body).toEqual({
sidecar: ['.xmp'],
image: expect.any(Array),
video: expect.any(Array),
});
});
});
describe('GET /server-info/theme', () => {
it('should respond with the server theme', async () => {
const { status, body } = await request(server).get('/server-info/theme');
expect(status).toBe(200);
expect(body).toEqual({
customCss: '',
});
});
});
describe('POST /server-info/admin-onboarding', () => {
it('should set admin onboarding', async () => {
const config = await api.serverInfoApi.getConfig(server);
expect(config.isOnboarded).toBe(false);
const { status } = await request(server)
.post('/server-info/admin-onboarding')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(204);
const newConfig = await api.serverInfoApi.getConfig(server);
expect(newConfig.isOnboarded).toBe(true);
});
});
});

View file

@ -0,0 +1,422 @@
import {
AlbumResponseDto,
AssetResponseDto,
IAssetRepository,
LoginResponseDto,
SharedLinkResponseDto,
} from '@app/domain';
import { SharedLinkController } from '@app/immich';
import { SharedLinkType } from '@app/infra/entities';
import { INestApplication } from '@nestjs/common';
import { errorStub, userDto, uuidStub } from '@test/fixtures';
import { DateTime } from 'luxon';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${SharedLinkController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let asset1: AssetResponseDto;
let asset2: AssetResponseDto;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let album: AlbumResponseDto;
let metadataAlbum: AlbumResponseDto;
let deletedAlbum: AlbumResponseDto;
let linkWithDeletedAlbum: SharedLinkResponseDto;
let linkWithPassword: SharedLinkResponseDto;
let linkWithAlbum: SharedLinkResponseDto;
let linkWithAssets: SharedLinkResponseDto;
let linkWithMetadata: SharedLinkResponseDto;
let linkWithoutMetadata: SharedLinkResponseDto;
let app: INestApplication<any>;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
const assetRepository = app.get<IAssetRepository>(IAssetRepository);
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
]);
[user1, user2] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
]);
[asset1, asset2] = await Promise.all([
api.assetApi.create(server, user1.accessToken),
api.assetApi.create(server, user1.accessToken),
]);
await assetRepository.upsertExif({
assetId: asset1.id,
longitude: -108.400968333333,
latitude: 39.115,
orientation: '1',
dateTimeOriginal: DateTime.fromISO('2022-01-10T19:15:44.310Z').toJSDate(),
timeZone: 'UTC-4',
state: 'Mesa County, Colorado',
country: 'United States of America',
});
[album, deletedAlbum, metadataAlbum] = await Promise.all([
api.albumApi.create(server, user1.accessToken, { albumName: 'album' }),
api.albumApi.create(server, user2.accessToken, { albumName: 'deleted album' }),
api.albumApi.create(server, user1.accessToken, { albumName: 'metadata album', assetIds: [asset1.id] }),
]);
[linkWithDeletedAlbum, linkWithAlbum, linkWithAssets, linkWithPassword, linkWithMetadata, linkWithoutMetadata] =
await Promise.all([
api.sharedLinkApi.create(server, user2.accessToken, {
type: SharedLinkType.ALBUM,
albumId: deletedAlbum.id,
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: album.id,
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [asset1.id],
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: album.id,
password: 'foo',
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: metadataAlbum.id,
showMetadata: true,
}),
api.sharedLinkApi.create(server, user1.accessToken, {
type: SharedLinkType.ALBUM,
albumId: metadataAlbum.id,
showMetadata: false,
}),
]);
await api.userApi.delete(server, admin.accessToken, user2.userId);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /shared-link', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/shared-link');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get all shared links created by user', async () => {
const { status, body } = await request(server)
.get('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(5);
expect(body).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: linkWithAlbum.id }),
expect.objectContaining({ id: linkWithAssets.id }),
expect.objectContaining({ id: linkWithPassword.id }),
expect.objectContaining({ id: linkWithMetadata.id }),
expect.objectContaining({ id: linkWithoutMetadata.id }),
]),
);
});
it('should not get shared links created by other users', async () => {
const { status, body } = await request(server)
.get('/shared-link')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual([]);
});
});
describe('GET /shared-link/me', () => {
it('should not require admin authentication', async () => {
const { status } = await request(server)
.get('/shared-link/me')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(403);
});
it('should get data for correct shared link', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithAlbum.key });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({
album,
userId: user1.userId,
type: SharedLinkType.ALBUM,
}),
);
});
it('should return unauthorized for incorrect shared link', async () => {
const { status, body } = await request(server)
.get('/shared-link/me')
.query({ key: linkWithAlbum.key + 'foo' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidShareKey);
});
it('should return unauthorized if target has been soft deleted', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithDeletedAlbum.key });
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidShareKey);
});
it('should return unauthorized for password protected link', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithPassword.key });
expect(status).toBe(401);
expect(body).toEqual(errorStub.invalidSharePassword);
});
it('should get data for correct password protected link', async () => {
const { status, body } = await request(server)
.get('/shared-link/me')
.query({ key: linkWithPassword.key, password: 'foo' });
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
});
it('should return metadata for album shared link', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
expect(body.assets[0]).toEqual(
expect.objectContaining({
originalFileName: 'example',
localDateTime: expect.any(String),
fileCreatedAt: expect.any(String),
exifInfo: expect.objectContaining({
longitude: -108.400968333333,
latitude: 39.115,
orientation: '1',
dateTimeOriginal: expect.any(String),
timeZone: 'UTC-4',
state: 'Mesa County, Colorado',
country: 'United States of America',
}),
}),
);
expect(body.album).toBeDefined();
});
it('should not return metadata for album shared link without metadata', async () => {
const { status, body } = await request(server).get('/shared-link/me').query({ key: linkWithoutMetadata.key });
expect(status).toBe(200);
expect(body.assets).toHaveLength(1);
expect(body.album).toBeDefined();
const asset = body.assets[0];
expect(asset).not.toHaveProperty('exifInfo');
expect(asset).not.toHaveProperty('fileCreatedAt');
expect(asset).not.toHaveProperty('originalFilename');
expect(asset).not.toHaveProperty('originalPath');
});
});
describe('GET /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get shared link by id', async () => {
const { status, body } = await request(server)
.get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ album, userId: user1.userId, type: SharedLinkType.ALBUM }));
});
it('should not get shared link by id if user has not created the link or it does not exist', async () => {
const { status, body } = await request(server)
.get(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
});
});
describe('POST /shared-link', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.send({ type: SharedLinkType.ALBUM, albumId: uuidStub.notFound });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should require a type and the correspondent asset/album id', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should require an asset/album id', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.ALBUM });
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid albumId' }));
});
it('should require a valid asset id', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.INDIVIDUAL, assetId: uuidStub.notFound });
expect(status).toBe(400);
expect(body).toEqual(expect.objectContaining({ message: 'Invalid assetIds' }));
});
it('should create a shared link', async () => {
const { status, body } = await request(server)
.post('/shared-link')
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ type: SharedLinkType.ALBUM, albumId: album.id });
expect(status).toBe(201);
expect(body).toEqual(expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId }));
});
});
describe('PATCH /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server)
.patch(`/shared-link/${linkWithAlbum.id}`)
.send({ description: 'foo' });
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should fail if invalid link', async () => {
const { status, body } = await request(server)
.patch(`/shared-link/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should update shared link', async () => {
const { status, body } = await request(server)
.patch(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ description: 'foo' });
expect(status).toBe(200);
expect(body).toEqual(
expect.objectContaining({ type: SharedLinkType.ALBUM, userId: user1.userId, description: 'foo' }),
);
});
});
describe('PUT /shared-link/:id/assets', () => {
it('should not add assets to shared link (album)', async () => {
const { status, body } = await request(server)
.put(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Invalid shared link type'));
});
it('should add an assets to a shared link (individual)', async () => {
const { status, body } = await request(server)
.put(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(body).toEqual([{ assetId: asset2.id, success: true }]);
expect(status).toBe(200);
});
});
describe('DELETE /shared-link/:id/assets', () => {
it('should not remove assets from a shared link (album)', async () => {
const { status, body } = await request(server)
.delete(`/shared-link/${linkWithAlbum.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Invalid shared link type'));
});
it('should remove assets from a shared link (individual)', async () => {
const { status, body } = await request(server)
.delete(`/shared-link/${linkWithAssets.id}/assets`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ assetIds: [asset2.id] });
expect(body).toEqual([{ assetId: asset2.id, success: true }]);
expect(status).toBe(200);
});
});
describe('DELETE /shared-link/:id', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).delete(`/shared-link/${linkWithAlbum.id}`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should fail if invalid link', async () => {
const { status, body } = await request(server)
.delete(`/shared-link/${uuidStub.notFound}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
it('should delete a shared link', async () => {
const { status } = await request(server)
.delete(`/shared-link/${linkWithAlbum.id}`)
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
});
});
});

View file

@ -0,0 +1,72 @@
import { LoginResponseDto } from '@app/domain';
import { SystemConfigController } from '@app/immich';
import { errorStub, userDto } from '@test/fixtures';
import request from 'supertest';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${SystemConfigController.name} (e2e)`, () => {
let server: any;
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
beforeAll(async () => {
server = (await testApp.create()).getHttpServer();
await testApp.reset();
await api.authApi.adminSignUp(server);
admin = await api.authApi.adminLogin(server);
await api.userApi.create(server, admin.accessToken, userDto.user1);
nonAdmin = await api.authApi.login(server, userDto.user1);
});
afterAll(async () => {
await testApp.teardown();
});
describe('GET /system-config/map/style.json', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/system-config/map/style.json');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should throw an error if a theme is not light or dark', async () => {
for (const theme of ['dark1', true, 123, '', null, undefined]) {
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest(['theme must be one of the following values: light, dark']));
}
});
it('should return the light style.json', async () => {
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme: 'light' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' }));
});
it('should return the dark style.json', async () => {
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
it('should not require admin authentication', async () => {
const { status, body } = await request(server)
.get('/system-config/map/style.json')
.query({ theme: 'dark' })
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' }));
});
});
});

View file

@ -0,0 +1,299 @@
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 { getRepositoryToken } from '@nestjs/typeorm';
import { errorStub, userDto, userSignupStub, userStub } from '@test/fixtures';
import request from 'supertest';
import { Repository } from 'typeorm';
import { api } from '../client';
import { testApp } from '../utils';
describe(`${UserController.name}`, () => {
let app: INestApplication;
let server: any;
let loginResponse: LoginResponseDto;
let accessToken: string;
let userService: UserService;
let userRepository: Repository<UserEntity>;
beforeAll(async () => {
app = await testApp.create();
server = app.getHttpServer();
userRepository = app.select(AppModule).get(getRepositoryToken(UserEntity));
});
afterAll(async () => {
await testApp.teardown();
});
beforeEach(async () => {
await testApp.reset();
await api.authApi.adminSignUp(server);
loginResponse = await api.authApi.adminLogin(server);
accessToken = loginResponse.accessToken;
userService = app.get<UserService>(UserService);
});
describe('GET /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get('/user');
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should start with the admin', async () => {
const { status, body } = await request(server).get('/user').set('Authorization', `Bearer ${accessToken}`);
expect(status).toEqual(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.app' });
});
it('should hide deleted users', async () => {
const user1 = await api.userApi.create(server, accessToken, {
email: `user1@immich.app`,
password: 'Password123',
name: `User 1`,
});
await api.userApi.delete(server, accessToken, user1.id);
const { status, body } = await request(server)
.get(`/user`)
.query({ isAll: true })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(1);
expect(body[0]).toMatchObject({ email: 'admin@immich.app' });
});
it('should include deleted users', async () => {
const user1 = await api.userApi.create(server, accessToken, userDto.user1);
await api.userApi.delete(server, accessToken, user1.id);
const { status, body } = await request(server)
.get(`/user`)
.query({ isAll: false })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toHaveLength(2);
expect(body[0]).toMatchObject({ id: user1.id, email: 'user1@immich.app', deletedAt: expect.any(String) });
expect(body[1]).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
});
});
describe('GET /user/info/:id', () => {
it('should require authentication', async () => {
const { status } = await request(server).get(`/user/info/${loginResponse.userId}`);
expect(status).toEqual(401);
});
it('should get the user info', async () => {
const { status, body } = await request(server)
.get(`/user/info/${loginResponse.userId}`)
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
});
});
describe('GET /user/me', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).get(`/user/me`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
it('should get my info', async () => {
const { status, body } = await request(server).get(`/user/me`).set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ id: loginResponse.userId, email: 'admin@immich.app' });
});
});
describe('POST /user', () => {
it('should require authentication', async () => {
const { status, body } = await request(server).post(`/user`).send(userSignupStub);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of Object.keys(userSignupStub)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.post(`/user`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ ...userSignupStub, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should ignore `isAdmin`', async () => {
const { status, body } = await request(server)
.post(`/user`)
.send({
isAdmin: true,
email: 'user1@immich.app',
password: 'Password123',
name: 'Immich',
})
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toMatchObject({
email: 'user1@immich.app',
isAdmin: false,
shouldChangePassword: true,
});
expect(status).toBe(201);
});
it('should create a user without memories enabled', async () => {
const { status, body } = await request(server)
.post(`/user`)
.send({
email: 'no-memories@immich.app',
password: 'Password123',
name: 'No Memories',
memoriesEnabled: false,
})
.set('Authorization', `Bearer ${accessToken}`);
expect(body).toMatchObject({
email: 'no-memories@immich.app',
memoriesEnabled: false,
});
expect(status).toBe(201);
});
});
describe('DELETE /user/:id', () => {
let userToDelete: UserResponseDto;
beforeEach(async () => {
userToDelete = await api.userApi.create(server, accessToken, {
email: userStub.user1.email,
name: userStub.user1.name,
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`);
expect(status).toBe(401);
expect(body).toEqual(errorStub.unauthorized);
});
for (const key of Object.keys(userStub.admin)) {
it(`should not allow null ${key}`, async () => {
const { status, body } = await request(server)
.put(`/user`)
.set('Authorization', `Bearer ${accessToken}`)
.send({ ...userStub.admin, [key]: null });
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest());
});
}
it('should not allow a non-admin to become an admin', async () => {
const user = await api.userApi.create(server, accessToken, {
email: 'user1@immich.app',
password: 'Password123',
name: 'Immich User',
});
const { status, body } = await request(server)
.put(`/user`)
.send({ isAdmin: true, id: user.id })
.set('Authorization', `Bearer ${accessToken}`);
expect(status).toBe(400);
expect(body).toEqual(errorStub.alreadyHasAdmin);
});
it('ignores updates to profileImagePath', async () => {
const user = await api.userApi.update(server, accessToken, {
id: loginResponse.userId,
profileImagePath: 'invalid.jpg',
} as any);
expect(user).toMatchObject({ id: loginResponse.userId, profileImagePath: '' });
});
it('should ignore updates to createdAt, updatedAt and deletedAt', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: loginResponse.userId,
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
deletedAt: '2023-01-01T00:00:00.000Z',
} as any);
expect(after).toStrictEqual(before);
});
it('should update first and last name', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: before.id,
name: 'Name',
});
expect(after).toEqual({
...before,
updatedAt: expect.any(String),
name: 'Name',
});
expect(before.updatedAt).not.toEqual(after.updatedAt);
});
it('should update memories enabled', async () => {
const before = await api.userApi.get(server, accessToken, loginResponse.userId);
const after = await api.userApi.update(server, accessToken, {
id: before.id,
memoriesEnabled: false,
});
expect(after).toMatchObject({
...before,
updatedAt: expect.anything(),
memoriesEnabled: false,
});
expect(before.updatedAt).not.toEqual(after.updatedAt);
});
});
});