fix(server): restrict partner timeline requests to timeline assets (#32565)

* fix(server): restrict partner timeline requests to timeline assets

* test(server): update medium timeline tests for consolidated check

(cherry picked from commit e9fb180f466a687cebda9b7e17cef403de4b81fa)
This commit is contained in:
bo0tzz
2026-10-22 05:21:00 +03:00
committed by Codex
parent 7df744b684
commit f80be1030b
3 changed files with 91 additions and 28 deletions
+67 -2
View File
@@ -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, {
+9 -19
View File
@@ -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',
);
}
}
}
}
@@ -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 () => {