import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { SharedLinkType } from 'src/enum'; import { SharedLinkService } from 'src/services/shared-link.service'; import { AlbumFactory } from 'test/factories/album.factory'; import { AssetFactory } from 'test/factories/asset.factory'; import { SharedLinkFactory } from 'test/factories/shared-link.factory'; import { authStub } from 'test/fixtures/auth.stub'; import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub'; import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(SharedLinkService.name, () => { let sut: SharedLinkService; let mocks: ServiceMocks; beforeEach(() => { ({ sut, mocks } = newTestService(SharedLinkService)); }); it('should work', () => { expect(sut).toBeDefined(); }); describe('getAll', () => { it('should return all shared links for a user', async () => { mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]); await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([ sharedLinkResponseStub.expired, sharedLinkResponseStub.valid, ]); expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id }); }); }); describe('getMine', () => { it('should only work for a public user', async () => { await expect(sut.getMine(authStub.admin, [])).rejects.toBeInstanceOf(ForbiddenException); expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return the shared link for the public user', async () => { const authDto = authStub.adminSharedLink; mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.getMine(authDto, [])).resolves.toEqual(sharedLinkResponseStub.valid); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should not return metadata', async () => { const authDto = factory.auth({ sharedLink: { showExif: false, allowDownload: true, allowUpload: true, }, }); mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif); const response = await sut.getMine(authDto, []); expect(response.assets[0]).toMatchObject({ hasMetadata: false }); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should throw an error for a request without a shared link auth token', async () => { const authDto = authStub.adminSharedLink; mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.passwordRequired); await expect(sut.getMine(authDto, [])).rejects.toBeInstanceOf(UnauthorizedException); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id); }); it('should accept a valid shared link auth token', async () => { mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' }); mocks.crypto.hashSha256.mockReturnValue('hashed-auth-token'); await expect(sut.getMine(authStub.adminSharedLink, ['hashed-auth-token'])).resolves.toBeDefined(); expect(mocks.sharedLink.get).toHaveBeenCalledWith( authStub.adminSharedLink.user.id, authStub.adminSharedLink.sharedLink?.id, ); }); }); describe('get', () => { it('should throw an error for an invalid shared link', async () => { mocks.sharedLink.get.mockResolvedValue(void 0); await expect(sut.get(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should get a shared link by id', async () => { mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.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 () => { await expect( sut.create(authStub.admin, { type: SharedLinkType.Individual, assetIds: ['asset-1'] }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should create an album shared link', async () => { const album = AlbumFactory.from().asset().build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid); await sut.create(authStub.admin, { type: SharedLinkType.Album, albumId: album.id }); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set([album.id])); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.Album, userId: authStub.admin.user.id, albumId: album.id, allowDownload: true, allowUpload: true, description: null, expiresAt: null, slug: null, showExif: true, key: Buffer.from('random-bytes', 'utf8'), }); }); it('should create an individual shared link', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.Individual, assetIds: [asset.id], showMetadata: true, allowDownload: true, allowUpload: true, }); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([asset.id]), false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.Individual, userId: authStub.admin.user.id, albumId: null, allowDownload: true, slug: null, allowUpload: true, assetIds: [asset.id], description: null, expiresAt: null, showExif: true, key: Buffer.from('random-bytes', 'utf8'), }); }); it('should create a shared link with allowDownload set to false when showMetadata is false', async () => { const asset = AssetFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual); await sut.create(authStub.admin, { type: SharedLinkType.Individual, assetIds: [asset.id], showMetadata: false, allowDownload: true, allowUpload: true, }); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( authStub.admin.user.id, new Set([asset.id]), false, ); expect(mocks.sharedLink.create).toHaveBeenCalledWith({ type: SharedLinkType.Individual, userId: authStub.admin.user.id, albumId: null, allowDownload: false, allowUpload: true, assetIds: [asset.id], description: null, expiresAt: null, showExif: false, slug: null, key: Buffer.from('random-bytes', 'utf8'), }); }); }); describe('update', () => { it('should throw an error for an invalid shared link', async () => { mocks.sharedLink.get.mockResolvedValue(void 0); await expect(sut.update(authStub.user1, 'missing-id', {})).rejects.toBeInstanceOf(BadRequestException); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should update a shared link', async () => { mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid); await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false }); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ id: sharedLinkStub.valid.id, slug: null, userId: authStub.user1.user.id, allowDownload: false, }); }); }); describe('remove', () => { it('should throw an error for an invalid shared link', async () => { mocks.sharedLink.get.mockResolvedValue(void 0); await expect(sut.remove(authStub.user1, 'missing-id')).rejects.toBeInstanceOf(BadRequestException); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, 'missing-id'); expect(mocks.sharedLink.update).not.toHaveBeenCalled(); }); it('should remove a key', async () => { mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid); mocks.sharedLink.remove.mockResolvedValue(); await sut.remove(authStub.user1, sharedLinkStub.valid.id); expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id); expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid.id); }); }); describe('addAssets', () => { it('should not work on album shared links', async () => { mocks.sharedLink.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 () => { const asset = AssetFactory.create(); const sharedLink = SharedLinkFactory.from().asset(asset).build(); const newAsset = AssetFactory.create(); mocks.sharedLink.get.mockResolvedValue(sharedLink); mocks.sharedLink.create.mockResolvedValue(sharedLink); mocks.sharedLink.update.mockResolvedValue(sharedLink); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([newAsset.id])); await expect( sut.addAssets(authStub.admin, sharedLink.id, { assetIds: [asset.id, 'asset-2', newAsset.id] }), ).resolves.toEqual([ { assetId: asset.id, success: false, error: AssetIdErrorReason.DUPLICATE }, { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NO_PERMISSION }, { assetId: newAsset.id, success: true }, ]); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1); expect(mocks.sharedLink.update).toHaveBeenCalled(); expect(mocks.sharedLink.update).toHaveBeenCalledWith({ ...sharedLink, slug: null, assetIds: [newAsset.id], }); }); }); describe('removeAssets', () => { it('should not work on album shared links', async () => { mocks.sharedLink.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 () => { const asset = AssetFactory.create(); const sharedLink = SharedLinkFactory.from().asset(asset).build(); mocks.sharedLink.get.mockResolvedValue(sharedLink); mocks.sharedLink.create.mockResolvedValue(sharedLink); mocks.sharedLink.update.mockResolvedValue(sharedLink); mocks.sharedLinkAsset.remove.mockResolvedValue([asset.id]); await expect( sut.removeAssets(authStub.admin, sharedLink.id, { assetIds: [asset.id, 'asset-2'] }), ).resolves.toEqual([ { assetId: asset.id, success: true }, { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND }, ]); expect(mocks.sharedLinkAsset.remove).toHaveBeenCalledWith(sharedLink.id, [asset.id, 'asset-2']); expect(mocks.sharedLink.update).toHaveBeenCalledWith(expect.objectContaining({ assets: [] })); }); }); describe('getMetadataTags', () => { it('should return null when auth is not a shared link', async () => { await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null); expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return null when shared link has a password', async () => { const auth = factory.auth({ user: {}, sharedLink: { password: 'password' } }); await expect(sut.getMetadataTags(auth)).resolves.toBe(null); expect(mocks.sharedLink.get).not.toHaveBeenCalled(); }); it('should return metadata tags', async () => { mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '1 shared photos & videos', imageUrl: `https://my.immich.app/api/assets/${sharedLinkStub.individual.assets[0].id}/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`, title: 'Public Share', }); expect(mocks.sharedLink.get).toHaveBeenCalled(); }); it('should return metadata tags with a default image path if the asset id is not set', async () => { mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: null, assets: [] }); await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({ description: '0 shared photos & videos', imageUrl: `https://my.immich.app/feature-panel.png`, title: 'Public Share', }); expect(mocks.sharedLink.get).toHaveBeenCalled(); }); }); });