mirror of
https://github.com/immich-app/immich.git
synced 2025-12-26 17:25:00 +03:00
Modify Access repository, to evaluate `asset` permissions in bulk.
This is the last set of permission changes, to migrate all of them to
run in bulk!
Queries have been validated to match what they currently generate for single ids.
Queries:
* `activity` owner access:
```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
SELECT 1
FROM "activity" "ActivityEntity"
WHERE
"ActivityEntity"."id" = $1
AND "ActivityEntity"."userId" = $2
)
LIMIT 1
-- After
SELECT "ActivityEntity"."id" AS "ActivityEntity_id"
FROM "activity" "ActivityEntity"
WHERE
"ActivityEntity"."id" IN ($1)
AND "ActivityEntity"."userId" = $2
```
* `activity` album owner access:
```sql
-- Before
SELECT 1 AS "row_exists" FROM (SELECT 1 AS dummy_column) "dummy_table" WHERE EXISTS (
SELECT 1
FROM "activity" "ActivityEntity"
LEFT JOIN "albums" "ActivityEntity__ActivityEntity_album"
ON "ActivityEntity__ActivityEntity_album"."id"="ActivityEntity"."albumId"
AND "ActivityEntity__ActivityEntity_album"."deletedAt" IS NULL
WHERE
"ActivityEntity"."id" = $1
AND "ActivityEntity__ActivityEntity_album"."ownerId" = $2
)
LIMIT 1
-- After
SELECT "ActivityEntity"."id" AS "ActivityEntity_id"
FROM "activity" "ActivityEntity"
LEFT JOIN "albums" "ActivityEntity__ActivityEntity_album"
ON "ActivityEntity__ActivityEntity_album"."id"="ActivityEntity"."albumId"
AND "ActivityEntity__ActivityEntity_album"."deletedAt" IS NULL
WHERE
"ActivityEntity"."id" IN ($1)
AND "ActivityEntity__ActivityEntity_album"."ownerId" = $2
```
* `activity` create 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"."isActivityEnabled" = $2
AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $3
)
OR (
"AlbumEntity"."id" = $4
AND "AlbumEntity"."isActivityEnabled" = $5
AND "AlbumEntity"."ownerId" = $6
)
)
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)
AND "AlbumEntity"."isActivityEnabled" = $2
AND "AlbumEntity__AlbumEntity_sharedUsers"."id" = $3
)
OR (
"AlbumEntity"."id" IN ($4)
AND "AlbumEntity"."isActivityEnabled" = $5
AND "AlbumEntity"."ownerId" = $6
)
)
AND "AlbumEntity"."deletedAt" IS NULL
```
182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import { authStub, IAccessRepositoryMock, newAccessRepositoryMock } from '@test';
|
|
import { activityStub } from '@test/fixtures/activity.stub';
|
|
import { newActivityRepositoryMock } from '@test/repositories/activity.repository.mock';
|
|
import { IActivityRepository } from '../repositories';
|
|
import { ReactionType } from './activity.dto';
|
|
import { ActivityService } from './activity.service';
|
|
|
|
describe(ActivityService.name, () => {
|
|
let sut: ActivityService;
|
|
let accessMock: IAccessRepositoryMock;
|
|
let activityMock: jest.Mocked<IActivityRepository>;
|
|
|
|
beforeEach(async () => {
|
|
accessMock = newAccessRepositoryMock();
|
|
activityMock = newActivityRepositoryMock();
|
|
|
|
sut = new ActivityService(accessMock, activityMock);
|
|
});
|
|
|
|
it('should work', () => {
|
|
expect(sut).toBeDefined();
|
|
});
|
|
|
|
describe('getAll', () => {
|
|
it('should get all', async () => {
|
|
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
|
activityMock.search.mockResolvedValue([]);
|
|
|
|
await expect(sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id' })).resolves.toEqual([]);
|
|
|
|
expect(activityMock.search).toHaveBeenCalledWith({
|
|
assetId: 'asset-id',
|
|
albumId: 'album-id',
|
|
isLiked: undefined,
|
|
});
|
|
});
|
|
|
|
it('should filter by type=like', async () => {
|
|
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
|
activityMock.search.mockResolvedValue([]);
|
|
|
|
await expect(
|
|
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.LIKE }),
|
|
).resolves.toEqual([]);
|
|
|
|
expect(activityMock.search).toHaveBeenCalledWith({
|
|
assetId: 'asset-id',
|
|
albumId: 'album-id',
|
|
isLiked: true,
|
|
});
|
|
});
|
|
|
|
it('should filter by type=comment', async () => {
|
|
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
|
activityMock.search.mockResolvedValue([]);
|
|
|
|
await expect(
|
|
sut.getAll(authStub.admin, { assetId: 'asset-id', albumId: 'album-id', type: ReactionType.COMMENT }),
|
|
).resolves.toEqual([]);
|
|
|
|
expect(activityMock.search).toHaveBeenCalledWith({
|
|
assetId: 'asset-id',
|
|
albumId: 'album-id',
|
|
isLiked: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('getStatistics', () => {
|
|
it('should get the comment count', async () => {
|
|
activityMock.getStatistics.mockResolvedValue(1);
|
|
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([activityStub.oneComment.albumId]));
|
|
await expect(
|
|
sut.getStatistics(authStub.admin, {
|
|
assetId: 'asset-id',
|
|
albumId: activityStub.oneComment.albumId,
|
|
}),
|
|
).resolves.toEqual({ comments: 1 });
|
|
});
|
|
});
|
|
|
|
describe('addComment', () => {
|
|
it('should require access to the album', async () => {
|
|
await expect(
|
|
sut.create(authStub.admin, {
|
|
albumId: 'album-id',
|
|
assetId: 'asset-id',
|
|
type: ReactionType.COMMENT,
|
|
comment: 'comment',
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('should create a comment', async () => {
|
|
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
|
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
|
|
|
await sut.create(authStub.admin, {
|
|
albumId: 'album-id',
|
|
assetId: 'asset-id',
|
|
type: ReactionType.COMMENT,
|
|
comment: 'comment',
|
|
});
|
|
|
|
expect(activityMock.create).toHaveBeenCalledWith({
|
|
userId: 'admin_id',
|
|
albumId: 'album-id',
|
|
assetId: 'asset-id',
|
|
comment: 'comment',
|
|
isLiked: false,
|
|
});
|
|
});
|
|
|
|
it('should fail because activity is disabled for the album', async () => {
|
|
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
|
activityMock.create.mockResolvedValue(activityStub.oneComment);
|
|
|
|
await expect(
|
|
sut.create(authStub.admin, {
|
|
albumId: 'album-id',
|
|
assetId: 'asset-id',
|
|
type: ReactionType.COMMENT,
|
|
comment: 'comment',
|
|
}),
|
|
).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('should create a like', async () => {
|
|
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
|
activityMock.create.mockResolvedValue(activityStub.liked);
|
|
activityMock.search.mockResolvedValue([]);
|
|
|
|
await sut.create(authStub.admin, {
|
|
albumId: 'album-id',
|
|
assetId: 'asset-id',
|
|
type: ReactionType.LIKE,
|
|
});
|
|
|
|
expect(activityMock.create).toHaveBeenCalledWith({
|
|
userId: 'admin_id',
|
|
albumId: 'album-id',
|
|
assetId: 'asset-id',
|
|
isLiked: true,
|
|
});
|
|
});
|
|
|
|
it('should skip if like exists', async () => {
|
|
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set(['album-id']));
|
|
accessMock.activity.checkCreateAccess.mockResolvedValue(new Set(['album-id']));
|
|
activityMock.search.mockResolvedValue([activityStub.liked]);
|
|
|
|
await sut.create(authStub.admin, {
|
|
albumId: 'album-id',
|
|
assetId: 'asset-id',
|
|
type: ReactionType.LIKE,
|
|
});
|
|
|
|
expect(activityMock.create).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('delete', () => {
|
|
it('should require access', async () => {
|
|
await expect(sut.delete(authStub.admin, activityStub.oneComment.id)).rejects.toBeInstanceOf(BadRequestException);
|
|
expect(activityMock.delete).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should let the activity owner delete a comment', async () => {
|
|
accessMock.activity.checkOwnerAccess.mockResolvedValue(new Set(['activity-id']));
|
|
await sut.delete(authStub.admin, 'activity-id');
|
|
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
|
|
});
|
|
|
|
it('should let the album owner delete a comment', async () => {
|
|
accessMock.activity.checkAlbumOwnerAccess.mockResolvedValue(new Set(['activity-id']));
|
|
await sut.delete(authStub.admin, 'activity-id');
|
|
expect(activityMock.delete).toHaveBeenCalledWith('activity-id');
|
|
});
|
|
});
|
|
});
|