Files
immich/server/src/domain/shared-link/shared-link.service.spec.ts

261 lines
10 KiB
TypeScript
Raw Normal View History

2023-10-09 10:25:03 -04:00
import { SharedLinkType } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import {
IAccessRepositoryMock,
albumStub,
assetStub,
authStub,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newSharedLinkRepositoryMock,
sharedLinkResponseStub,
sharedLinkStub,
} from '@test';
import { when } from 'jest-when';
import _ from 'lodash';
2023-10-09 10:25:03 -04:00
import { AssetIdErrorReason } from '../asset';
import { ICryptoRepository, ISharedLinkRepository } from '../repositories';
import { SharedLinkService } from './shared-link.service';
describe(SharedLinkService.name, () => {
let sut: SharedLinkService;
let accessMock: IAccessRepositoryMock;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let shareMock: jest.Mocked<ISharedLinkRepository>;
beforeEach(async () => {
accessMock = newAccessRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
sut = new SharedLinkService(accessMock, cryptoMock, shareMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getAll', () => {
it('should return all shared links for a user', async () => {
shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
await expect(sut.getAll(authStub.user1)).resolves.toEqual([
sharedLinkResponseStub.expired,
sharedLinkResponseStub.valid,
]);
expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
});
});
describe('getMine', () => {
it('should only work for a public user', async () => {
await expect(sut.getMine(authStub.admin, {})).rejects.toBeInstanceOf(ForbiddenException);
expect(shareMock.get).not.toHaveBeenCalled();
});
it('should return the shared link for the public user', async () => {
const authDto = authStub.adminSharedLink;
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.valid);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
it('should not return metadata', async () => {
const authDto = authStub.adminSharedLinkNoExif;
shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
await expect(sut.getMine(authDto, {})).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
it('should throw an error for an password protected shared link', async () => {
const authDto = authStub.adminSharedLink;
shareMock.get.mockResolvedValue(sharedLinkStub.passwordRequired);
await expect(sut.getMine(authDto, {})).rejects.toBeInstanceOf(UnauthorizedException);
expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
});
});
describe('get', () => {
it('should throw an error for an invalid shared link', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, 'missing-id');
expect(shareMock.update).not.toHaveBeenCalled();
});
it('should get a shared link by id', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
});
});
describe('create', () => {
it('should not allow an album shared link without an albumId', async () => {
await expect(sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [] })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should not allow non-owners to create album shared links', async () => {
await expect(
sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [], albumId: 'album-1' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow individual shared links with no assets', async () => {
await expect(
sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: [] }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should require asset ownership to make an individual shared link', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
await expect(
sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('should create an album shared link', async () => {
chore(server): Check album permissions in bulk (#5290) * chore(server): Check album permissions in bulk Modify Access repository, to evaluate `album` permissions in bulk. Queries have been validated to match what they currently generate for single ids. Queries: * Owner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "albums" "AlbumEntity" WHERE "AlbumEntity"."id" = $1 AND "AlbumEntity"."ownerId" = $2 AND "AlbumEntity"."deletedAt" IS NULL ) LIMIT 1 -- After SELECT "AlbumEntity"."id" AS "AlbumEntity_id" FROM "albums" "AlbumEntity" WHERE "AlbumEntity"."id" IN ($1, $2) AND "AlbumEntity"."ownerId" = $3 AND "AlbumEntity"."deletedAt" IS NULL ``` * Shared link access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "shared_links" "SharedLinkEntity" WHERE "SharedLinkEntity"."id" = $1 AND "SharedLinkEntity"."albumId" = $2 ) LIMIT 1 -- After SELECT "SharedLinkEntity"."albumId" AS "SharedLinkEntity_albumId", "SharedLinkEntity"."id" AS "SharedLinkEntity_id" FROM "shared_links" "SharedLinkEntity" WHERE "SharedLinkEntity"."id" = $1 AND "SharedLinkEntity"."albumId" IN ($2, $3) ``` * Shared album access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL WHERE "AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $2 AND "AlbumEntity"."deletedAt" IS NULL ) LIMIT 1 -- After SELECT "AlbumEntity"."id" AS "AlbumEntity_id" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL WHERE "AlbumEntity"."id" IN ($1, $2) AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $3 AND "AlbumEntity"."deletedAt" IS NULL ``` * chore(server): Add set utils, avoid double queries for same ids * chore(server): Review feedback
2023-11-25 17:56:23 -05:00
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.oneAsset.id]));
shareMock.create.mockResolvedValue(sharedLinkStub.valid);
await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id });
chore(server): Check album permissions in bulk (#5290) * chore(server): Check album permissions in bulk Modify Access repository, to evaluate `album` permissions in bulk. Queries have been validated to match what they currently generate for single ids. Queries: * Owner access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "albums" "AlbumEntity" WHERE "AlbumEntity"."id" = $1 AND "AlbumEntity"."ownerId" = $2 AND "AlbumEntity"."deletedAt" IS NULL ) LIMIT 1 -- After SELECT "AlbumEntity"."id" AS "AlbumEntity_id" FROM "albums" "AlbumEntity" WHERE "AlbumEntity"."id" IN ($1, $2) AND "AlbumEntity"."ownerId" = $3 AND "AlbumEntity"."deletedAt" IS NULL ``` * Shared link access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "shared_links" "SharedLinkEntity" WHERE "SharedLinkEntity"."id" = $1 AND "SharedLinkEntity"."albumId" = $2 ) LIMIT 1 -- After SELECT "SharedLinkEntity"."albumId" AS "SharedLinkEntity_albumId", "SharedLinkEntity"."id" AS "SharedLinkEntity_id" FROM "shared_links" "SharedLinkEntity" WHERE "SharedLinkEntity"."id" = $1 AND "SharedLinkEntity"."albumId" IN ($2, $3) ``` * Shared album access: ```sql -- Before SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS ( SELECT 1 FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL WHERE "AlbumEntity"."id" = $1 AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $2 AND "AlbumEntity"."deletedAt" IS NULL ) LIMIT 1 -- After SELECT "AlbumEntity"."id" AS "AlbumEntity_id" FROM "albums" "AlbumEntity" LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId"="AlbumEntity"."id" LEFT JOIN "users" "AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity__AlbumEntity_sharedUsers"."id"="AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."usersId" AND "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" IS NULL WHERE "AlbumEntity"."id" IN ($1, $2) AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $3 AND "AlbumEntity"."deletedAt" IS NULL ``` * chore(server): Add set utils, avoid double queries for same ids * chore(server): Review feedback
2023-11-25 17:56:23 -05:00
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
authStub.admin.id,
new Set([albumStub.oneAsset.id]),
);
expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.ALBUM,
userId: authStub.admin.id,
albumId: albumStub.oneAsset.id,
allowDownload: true,
allowUpload: true,
assets: [],
description: null,
expiresAt: null,
showExif: true,
key: Buffer.from('random-bytes', 'utf8'),
});
});
it('should create an individual shared link', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await sut.create(authStub.admin, {
type: SharedLinkType.INDIVIDUAL,
assetIds: [assetStub.image.id],
showMetadata: true,
allowDownload: true,
allowUpload: true,
});
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id);
expect(shareMock.create).toHaveBeenCalledWith({
type: SharedLinkType.INDIVIDUAL,
userId: authStub.admin.id,
albumId: null,
allowDownload: true,
allowUpload: true,
assets: [{ id: assetStub.image.id }],
description: null,
expiresAt: null,
showExif: true,
key: Buffer.from('random-bytes', 'utf8'),
});
});
});
describe('update', () => {
it('should throw an error for an invalid shared link', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, 'missing-id');
expect(shareMock.update).not.toHaveBeenCalled();
});
it('should update a shared link', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
shareMock.update.mockResolvedValue(sharedLinkStub.valid);
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.update).toHaveBeenCalledWith({
id: sharedLinkStub.valid.id,
userId: authStub.user1.id,
allowDownload: false,
});
});
});
describe('remove', () => {
it('should throw an error for an invalid shared link', async () => {
shareMock.get.mockResolvedValue(null);
await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, 'missing-id');
expect(shareMock.update).not.toHaveBeenCalled();
});
it('should remove a key', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await sut.remove(authStub.user1, sharedLinkStub.valid.id);
expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
});
});
describe('addAssets', () => {
it('should not work on album shared links', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should add assets to a shared link', async () => {
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false);
when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
await expect(
sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2', 'asset-3'] }),
).resolves.toEqual([
{ assetId: assetStub.image.id, success: false, error: AssetIdErrorReason.DUPLICATE },
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NO_PERMISSION },
{ assetId: 'asset-3', success: true },
]);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledTimes(2);
expect(shareMock.update).toHaveBeenCalledWith({
...sharedLinkStub.individual,
assets: [assetStub.image, { id: 'asset-3' }],
});
});
});
describe('removeAssets', () => {
it('should not work on album shared links', async () => {
shareMock.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should remove assets from a shared link', async () => {
shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
await expect(
sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetStub.image.id, 'asset-2'] }),
).resolves.toEqual([
{ assetId: assetStub.image.id, success: true },
{ assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
]);
expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] });
});
});
});