mirror of
https://github.com/immich-app/immich
synced 2025-06-09 06:18:04 +00:00

* attempt to use fqdn for og:image opengraph image specifies that the url contains http or https, thus implying a fqdn. this change uses the external domain from the server config to attempt to make the og:image have both the existing path to the thumbnail along with the desired domain if the server setting is empty, the old behavior will persist please note, some og implementations do work with relative paths, so not all og image checkers may still pass, but not all implementations have this fallback and thus will not find the image otherwise * tests and ssr for og:image value as fqdn * formatting * fix test * formatting * formatting * fix tests getConfig was requiring authentication. using already initiated global stores instead * load config in shared link service itself * join host and pathname/params safely * use origin instead of host for full domain string also fixes lint and address the imageURL type which is optional * chore: clean up --------- Co-authored-by: eleith <eleith@lemon.localdomain> Co-authored-by: eleith <online-github@eleith.com> Co-authored-by: Jason Rasmussen <jason@rasm.me>
319 lines
13 KiB
TypeScript
319 lines
13 KiB
TypeScript
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
|
import _ from 'lodash';
|
|
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
|
|
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
|
import { SharedLinkType } from 'src/entities/shared-link.entity';
|
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
|
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
|
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
|
|
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
|
import { SharedLinkService } from 'src/services/shared-link.service';
|
|
import { albumStub } from 'test/fixtures/album.stub';
|
|
import { assetStub } from 'test/fixtures/asset.stub';
|
|
import { authStub } from 'test/fixtures/auth.stub';
|
|
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
|
|
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
|
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
|
|
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
|
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
|
|
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
|
import { Mocked } from 'vitest';
|
|
|
|
describe(SharedLinkService.name, () => {
|
|
let sut: SharedLinkService;
|
|
let accessMock: IAccessRepositoryMock;
|
|
let cryptoMock: Mocked<ICryptoRepository>;
|
|
let shareMock: Mocked<ISharedLinkRepository>;
|
|
let systemMock: Mocked<ISystemMetadataRepository>;
|
|
let logMock: Mocked<ILoggerRepository>;
|
|
|
|
beforeEach(() => {
|
|
accessMock = newAccessRepositoryMock();
|
|
cryptoMock = newCryptoRepositoryMock();
|
|
shareMock = newSharedLinkRepositoryMock();
|
|
systemMock = newSystemMetadataRepositoryMock();
|
|
logMock = newLoggerRepositoryMock();
|
|
|
|
sut = new SharedLinkService(accessMock, cryptoMock, logMock, shareMock, systemMock);
|
|
});
|
|
|
|
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.user.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.user.id, authDto.sharedLink?.id);
|
|
});
|
|
|
|
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.user.id, authDto.sharedLink?.id);
|
|
});
|
|
|
|
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.user.id, authDto.sharedLink?.id);
|
|
});
|
|
});
|
|
|
|
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.user.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.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 () => {
|
|
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 });
|
|
|
|
expect(accessMock.album.checkOwnerAccess).toHaveBeenCalledWith(
|
|
authStub.admin.user.id,
|
|
new Set([albumStub.oneAsset.id]),
|
|
);
|
|
expect(shareMock.create).toHaveBeenCalledWith({
|
|
type: SharedLinkType.ALBUM,
|
|
userId: authStub.admin.user.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.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
|
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.checkOwnerAccess).toHaveBeenCalledWith(
|
|
authStub.admin.user.id,
|
|
new Set([assetStub.image.id]),
|
|
);
|
|
expect(shareMock.create).toHaveBeenCalledWith({
|
|
type: SharedLinkType.INDIVIDUAL,
|
|
userId: authStub.admin.user.id,
|
|
albumId: null,
|
|
allowDownload: true,
|
|
allowUpload: true,
|
|
assets: [{ id: assetStub.image.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 () => {
|
|
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
|
|
shareMock.create.mockResolvedValue(sharedLinkStub.individual);
|
|
|
|
await sut.create(authStub.admin, {
|
|
type: SharedLinkType.INDIVIDUAL,
|
|
assetIds: [assetStub.image.id],
|
|
showMetadata: false,
|
|
allowDownload: true,
|
|
allowUpload: true,
|
|
});
|
|
|
|
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
|
authStub.admin.user.id,
|
|
new Set([assetStub.image.id]),
|
|
);
|
|
expect(shareMock.create).toHaveBeenCalledWith({
|
|
type: SharedLinkType.INDIVIDUAL,
|
|
userId: authStub.admin.user.id,
|
|
albumId: null,
|
|
allowDownload: false,
|
|
allowUpload: true,
|
|
assets: [{ id: assetStub.image.id }],
|
|
description: null,
|
|
expiresAt: null,
|
|
showExif: false,
|
|
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.user.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.user.id, sharedLinkStub.valid.id);
|
|
expect(shareMock.update).toHaveBeenCalledWith({
|
|
id: sharedLinkStub.valid.id,
|
|
userId: authStub.user1.user.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.user.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.user.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);
|
|
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-3']));
|
|
|
|
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.checkOwnerAccess).toHaveBeenCalledTimes(1);
|
|
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: [] });
|
|
});
|
|
});
|
|
|
|
describe('getMetadataTags', () => {
|
|
it('should return null when auth is not a shared link', async () => {
|
|
await expect(sut.getMetadataTags(authStub.admin)).resolves.toBe(null);
|
|
expect(shareMock.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return null when shared link has a password', async () => {
|
|
await expect(sut.getMetadataTags(authStub.passwordSharedLink)).resolves.toBe(null);
|
|
expect(shareMock.get).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should return metadata tags', async () => {
|
|
shareMock.get.mockResolvedValue(sharedLinkStub.individual);
|
|
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
|
description: '1 shared photos & videos',
|
|
imageUrl: `${DEFAULT_EXTERNAL_DOMAIN}/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
|
|
title: 'Public Share',
|
|
});
|
|
expect(shareMock.get).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|