diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index 47080e8a2..baddb32f1 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -160,7 +160,7 @@ describe(TimelineService.name, () => { ); }); - it('should throw an error if withParners is true and visibility true or undefined', async () => { + it('should throw an error if withParners is true and visibility is archive or hidden', async () => { await expect( sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', @@ -173,13 +173,78 @@ describe(TimelineService.name, () => { await expect( sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', - visibility: undefined, + visibility: AssetVisibility.Hidden, withPartners: true, userId: authStub.admin.user.id, }), ).rejects.toThrow(BadRequestException); }); + it('should default to timeline visibility if withPartners is true and visibility is undefined', async () => { + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); + mocks.partner.getAll.mockResolvedValue([]); + + await expect( + sut.getTimeBucket(authStub.admin, { + timeBucket: 'bucket', + withPartners: true, + userId: authStub.admin.user.id, + }), + ).resolves.toEqual(json); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + expect.objectContaining({ visibility: AssetVisibility.Timeline, userIds: [authStub.admin.user.id] }), + authStub.admin, + ); + }); + + it("should default to timeline visibility for another user's timeline", async () => { + const json = `[{ id: ['asset-id'] }]`; + mocks.asset.getTimeBucket.mockResolvedValue({ assets: json }); + mocks.access.timeline.checkPartnerAccess.mockResolvedValue(new Set(['partner-id'])); + + await expect(sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', userId: 'partner-id' })).resolves.toEqual( + json, + ); + expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith( + 'bucket', + { timeBucket: 'bucket', visibility: AssetVisibility.Timeline, userIds: ['partner-id'] }, + authStub.admin, + ); + }); + + it("should throw an error for another user's archived, hidden, trashed or favorite assets", async () => { + mocks.access.timeline.checkPartnerAccess.mockResolvedValue(new Set(['partner-id'])); + + for (const dto of [ + { visibility: AssetVisibility.Archive }, + { visibility: AssetVisibility.Hidden }, + { isTrashed: true }, + { isFavorite: true }, + { isFavorite: false }, + ]) { + await expect( + sut.getTimeBucket(authStub.admin, { timeBucket: 'bucket', userId: 'partner-id', ...dto }), + ).rejects.toThrow(BadRequestException); + } + + expect(mocks.asset.getTimeBucket).not.toHaveBeenCalled(); + }); + + it("should throw an error for another user's locked assets even with an elevated session", async () => { + mocks.access.timeline.checkPartnerAccess.mockResolvedValue(new Set(['partner-id'])); + + await expect( + sut.getTimeBucket(authStub.adminWithElevatedPermission, { + timeBucket: 'bucket', + userId: 'partner-id', + visibility: AssetVisibility.Locked, + }), + ).rejects.toThrow(BadRequestException); + expect(mocks.asset.getTimeBucket).not.toHaveBeenCalled(); + }); + it('should throw an error if withParners is true and isFavorite is either true or false', async () => { await expect( sut.getTimeBucket(authStub.admin, { diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 44aa6366b..03063cd3a 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -57,11 +57,15 @@ export class TimelineService extends BaseService { if (dto.userId) { await this.requireAccess({ auth, permission: Permission.TimelineRead, ids: [dto.userId] }); - if (dto.visibility === AssetVisibility.Archive) { - await this.requireAccess({ auth, permission: Permission.ArchiveRead, ids: [dto.userId] }); - } - if (dto.visibility === AssetVisibility.Locked && dto.userId !== auth.user.id) { - throw new BadRequestException("You may not access another user's locked timeline"); + } + + const includesOtherUsers = dto.withPartners || (dto.userId && dto.userId !== auth.user.id); + if (includesOtherUsers) { + dto.visibility ??= AssetVisibility.Timeline; + if (dto.visibility !== AssetVisibility.Timeline || dto.isTrashed || dto.isFavorite !== undefined) { + throw new BadRequestException( + 'Only non-archived, non-trashed, non-favorited, non-locked assets can be requested for other users', + ); } } @@ -72,19 +76,5 @@ export class TimelineService extends BaseService { if (isMetadataHidden(auth)) { dto.withCoordinates = false; } - - // eslint-disable-next-line unicorn/prefer-early-return - if (dto.withPartners) { - const isRequestedLocked = dto.visibility === AssetVisibility.Locked; - const isRequestedArchived = dto.visibility === AssetVisibility.Archive || dto.visibility === undefined; - const isRequestedFavorite = dto.isFavorite === true || dto.isFavorite === false; - const isRequestedTrash = dto.isTrashed === true; - - if (isRequestedLocked || isRequestedArchived || isRequestedFavorite || isRequestedTrash) { - throw new BadRequestException( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', - ); - } - } } } diff --git a/server/test/medium/specs/services/timeline.service.spec.ts b/server/test/medium/specs/services/timeline.service.spec.ts index b6884a2a5..75fb6e20c 100644 --- a/server/test/medium/specs/services/timeline.service.spec.ts +++ b/server/test/medium/specs/services/timeline.service.spec.ts @@ -51,29 +51,35 @@ describe(TimelineService.name, () => { const response1 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Archive }); await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', + 'Only non-archived, non-trashed, non-favorited, non-locked assets can be requested for other users', ); - const response2 = sut.getTimeBuckets(auth, { withPartners: true }); + const response2 = sut.getTimeBuckets(auth, { withPartners: true, visibility: AssetVisibility.Hidden }); await expect(response2).rejects.toBeInstanceOf(BadRequestException); await expect(response2).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', + 'Only non-archived, non-trashed, non-favorited, non-locked assets can be requested for other users', ); }); + it('should default to timeline visibility if time bucket is requested with partners assets', async () => { + const { sut } = setup(); + const auth = factory.auth(); + await expect(sut.getTimeBuckets(auth, { withPartners: true })).resolves.toEqual([]); + }); + it('should return error if time bucket is requested with partners asset and favorite', async () => { const { sut } = setup(); const auth = factory.auth(); const response1 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: false }); await expect(response1).rejects.toBeInstanceOf(BadRequestException); await expect(response1).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', + 'Only non-archived, non-trashed, non-favorited, non-locked assets can be requested for other users', ); const response2 = sut.getTimeBuckets(auth, { withPartners: true, isFavorite: true }); await expect(response2).rejects.toBeInstanceOf(BadRequestException); await expect(response2).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', + 'Only non-archived, non-trashed, non-favorited, non-locked assets can be requested for other users', ); }); @@ -83,7 +89,7 @@ describe(TimelineService.name, () => { const response = sut.getTimeBuckets(auth, { withPartners: true, isTrashed: true }); await expect(response).rejects.toBeInstanceOf(BadRequestException); await expect(response).rejects.toThrow( - 'withPartners is only supported for non-archived, non-trashed, non-favorited, non-locked assets', + 'Only non-archived, non-trashed, non-favorited, non-locked assets can be requested for other users', ); }); @@ -96,7 +102,9 @@ describe(TimelineService.name, () => { const auth = factory.auth({ user, session: { hasElevatedPermission: true } }); const response = sut.getTimeBuckets(auth, { userId: partner.id, visibility: AssetVisibility.Locked }); - await expect(response).rejects.toThrow("You may not access another user's locked timeline"); + await expect(response).rejects.toThrow( + 'Only non-archived, non-trashed, non-favorited, non-locked assets can be requested for other users', + ); }); it('should not allow access for unrelated shared links', async () => {