mirror of
https://github.com/immich-app/immich.git
synced 2026-04-29 12:38:49 -07:00
Compare commits
3 Commits
refactor/a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf32864644 | ||
|
|
7ef7ecec5b | ||
|
|
bc4abd18e4 |
@@ -10,9 +10,9 @@ export const errorDto = {
|
||||
forbidden: {
|
||||
message: expect.any(String),
|
||||
},
|
||||
missingPermission: {
|
||||
message: 'Access denied',
|
||||
},
|
||||
missingPermission: (permission: string) => ({
|
||||
message: `Missing required permission: ${permission}`,
|
||||
}),
|
||||
wrongPassword: {
|
||||
message: 'Wrong password',
|
||||
},
|
||||
@@ -29,7 +29,7 @@ export const errorDto = {
|
||||
message: message ?? expect.anything(),
|
||||
}),
|
||||
noPermission: {
|
||||
message: 'Access denied',
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
},
|
||||
incorrectLogin: {
|
||||
message: 'Incorrect email or password',
|
||||
|
||||
@@ -323,8 +323,8 @@ describe('/activities', () => {
|
||||
.delete(`/activities/${reaction.id}`)
|
||||
.set('Authorization', `Bearer ${nonOwner.accessToken}`);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no activity.delete access'));
|
||||
});
|
||||
|
||||
it('should let a non-owner remove their own comment', async () => {
|
||||
|
||||
@@ -480,8 +480,8 @@ describe('/albums', () => {
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ ids: [asset.id] });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no albumAsset.create access'));
|
||||
});
|
||||
|
||||
it('should add duplicate assets only once', async () => {
|
||||
@@ -526,8 +526,8 @@ describe('/albums', () => {
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ albumName: 'New album name' });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
});
|
||||
|
||||
it('should be able to update as an editor', async () => {
|
||||
@@ -553,7 +553,7 @@ describe('/albums', () => {
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -614,8 +614,8 @@ describe('/albums', () => {
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ ids: [user1Asset1.id] });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no albumAsset.delete access'));
|
||||
});
|
||||
|
||||
it('should remove duplicate assets only once', async () => {
|
||||
@@ -728,8 +728,8 @@ describe('/albums', () => {
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ role: AlbumUserRole.Editor });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.share access'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('/api-keys', () => {
|
||||
const { secret } = await create(user.accessToken, [Permission.ApiKeyRead]);
|
||||
const { status, body } = await request(app).post('/api-keys').set('x-api-key', secret).send({ name: 'API Key' });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('apiKey.create'));
|
||||
});
|
||||
|
||||
it('should work with apiKey.create', async () => {
|
||||
|
||||
@@ -157,7 +157,7 @@ describe('/asset', () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/assets/${user2Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -385,7 +385,7 @@ describe('/asset', () => {
|
||||
.put(`/assets/${user2Assets[0].id}`)
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({});
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -609,8 +609,8 @@ describe('/asset', () => {
|
||||
.send({ ids: [uuidDto.notFound] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no asset.delete access'));
|
||||
});
|
||||
|
||||
it('should move an asset to trash', async () => {
|
||||
|
||||
@@ -46,7 +46,7 @@ describe('/memories', () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/memories/${userMemory.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('/memories', () => {
|
||||
.put(`/memories/${userMemory.id}`)
|
||||
.send({ isSaved: true })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -91,7 +91,7 @@ describe('/memories', () => {
|
||||
.put(`/memories/${userMemory.id}/assets`)
|
||||
.send({ ids: [userAsset1.id] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -126,7 +126,7 @@ describe('/memories', () => {
|
||||
.delete(`/memories/${userMemory.id}/assets`)
|
||||
.send({ ids: [userAsset1.id] })
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -160,7 +160,7 @@ describe('/memories', () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/memories/${userMemory.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@ describe('/sessions', () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/sessions/${uuidDto.notFound}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no authDevice.delete access'));
|
||||
});
|
||||
|
||||
it('should logout a device', async () => {
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('/stacks', () => {
|
||||
.set('Authorization', `Bearer ${user1.accessToken}`)
|
||||
.send({ assetIds: [asset.id, user2Asset.id] });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('/tags', () => {
|
||||
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
|
||||
const { status, body } = await request(app).post('/tags').set('x-api-key', secret).send({ name: 'TagA' });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('tag.create'));
|
||||
});
|
||||
|
||||
it('should work with tag.create', async () => {
|
||||
@@ -127,7 +127,7 @@ describe('/tags', () => {
|
||||
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
|
||||
const { status, body } = await request(app).get('/tags').set('x-api-key', secret);
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('tag.read'));
|
||||
});
|
||||
|
||||
it('should start off empty', async () => {
|
||||
@@ -179,7 +179,7 @@ describe('/tags', () => {
|
||||
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
|
||||
const { status, body } = await request(app).put('/tags').set('x-api-key', secret).send({ name: 'TagA' });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('tag.create'));
|
||||
});
|
||||
|
||||
it('should upsert tags', async () => {
|
||||
@@ -226,7 +226,7 @@ describe('/tags', () => {
|
||||
.set('x-api-key', secret)
|
||||
.send({ assetIds: [], tagIds: [] });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('tag.asset'));
|
||||
});
|
||||
|
||||
it('should skip assets that are not owned by the user', async () => {
|
||||
@@ -290,7 +290,7 @@ describe('/tags', () => {
|
||||
const { status, body } = await request(app)
|
||||
.get(`/tags/${tag.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('/tags', () => {
|
||||
.set('x-api-key', secret)
|
||||
.send({ assetIds: [], tagIds: [] });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('tag.read'));
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
@@ -362,7 +362,7 @@ describe('/tags', () => {
|
||||
.put(`/tags/${tag.id}`)
|
||||
.send({ color: '#000000' })
|
||||
.set('Authorization', `Bearer ${user.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -374,7 +374,7 @@ describe('/tags', () => {
|
||||
.set('x-api-key', secret)
|
||||
.send({ color: '#000000' });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('tag.update'));
|
||||
});
|
||||
|
||||
it('should update a tag', async () => {
|
||||
@@ -410,7 +410,7 @@ describe('/tags', () => {
|
||||
const { status, body } = await request(app)
|
||||
.delete(`/tags/${tag.id}`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`);
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -419,7 +419,7 @@ describe('/tags', () => {
|
||||
const { secret } = await utils.createApiKey(user.accessToken, [Permission.AssetRead]);
|
||||
const { status, body } = await request(app).delete(`/tags/${tag.id}`).set('x-api-key', secret);
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('tag.delete'));
|
||||
});
|
||||
|
||||
it('should require a valid uuid', async () => {
|
||||
@@ -478,7 +478,7 @@ describe('/tags', () => {
|
||||
.put(`/tags/${tag.id}/assets`)
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ids: [userAsset.id] });
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -490,7 +490,7 @@ describe('/tags', () => {
|
||||
.set('x-api-key', secret)
|
||||
.send({ ids: [userAsset.id] });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('tag.asset'));
|
||||
});
|
||||
|
||||
it('should be able to tag own asset', async () => {
|
||||
@@ -511,8 +511,8 @@ describe('/tags', () => {
|
||||
.set('Authorization', `Bearer ${user.accessToken}`)
|
||||
.send({ ids: [userAsset.id] });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no tag.asset access'));
|
||||
});
|
||||
|
||||
it('should add duplicate assets only once', async () => {
|
||||
@@ -552,7 +552,7 @@ describe('/tags', () => {
|
||||
.set('Authorization', `Bearer ${admin.accessToken}`)
|
||||
.send({ ids: [userAsset.id] });
|
||||
|
||||
expect(status).toBe(403);
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.noPermission);
|
||||
});
|
||||
|
||||
@@ -564,7 +564,7 @@ describe('/tags', () => {
|
||||
.set('x-api-key', secret)
|
||||
.send({ ids: [userAsset.id] });
|
||||
expect(status).toBe(403);
|
||||
expect(body).toEqual(errorDto.missingPermission);
|
||||
expect(body).toEqual(errorDto.missingPermission('tag.asset'));
|
||||
});
|
||||
|
||||
it('should be able to remove own asset from own tag', async () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ config_roots = [
|
||||
|
||||
[tools]
|
||||
node = "24.15.0"
|
||||
flutter = "3.41.6"
|
||||
flutter = "3.41.7"
|
||||
pnpm = "10.33.1"
|
||||
terragrunt = "1.0.2"
|
||||
opentofu = "1.11.6"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
app_identifier "app.alextran.immich" # The bundle identifier of your app
|
||||
apple_id "alex.tran1502@gmail.com" # Your Apple email address
|
||||
apple_id "altran@futo.org" # Your Apple email address
|
||||
|
||||
|
||||
# For more information about the Appfile, see:
|
||||
|
||||
@@ -17,10 +17,11 @@ default_platform(:ios)
|
||||
|
||||
platform :ios do
|
||||
# Constants
|
||||
TEAM_ID = "2F67MQ8R79"
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: Hau Tran (#{TEAM_ID})"
|
||||
TEAM_ID = "2W7AC6T8T5"
|
||||
CODE_SIGN_IDENTITY = "Apple Distribution: FUTO Holdings, Inc. (#{TEAM_ID})"
|
||||
BASE_BUNDLE_ID = "app.alextran.immich"
|
||||
|
||||
DEV_BUNDLE_ID = "tech.futo.immich.testflight"
|
||||
|
||||
# Helper method to get App Store Connect API key
|
||||
def get_api_key
|
||||
app_store_connect_api_key(
|
||||
@@ -44,47 +45,45 @@ def get_version_from_pubspec
|
||||
end
|
||||
|
||||
# Helper method to configure code signing for all targets
|
||||
def configure_code_signing(bundle_id_suffix: "", profile_name_main:, profile_name_share:, profile_name_widget:)
|
||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||
|
||||
def configure_code_signing(base_bundle_id:, profile_name_main:, profile_name_share:, profile_name_widget:)
|
||||
# Runner (main app)
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}",
|
||||
bundle_identifier: base_bundle_id,
|
||||
profile_name: profile_name_main,
|
||||
targets: ["Runner"]
|
||||
)
|
||||
|
||||
|
||||
# ShareExtension
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.ShareExtension",
|
||||
bundle_identifier: "#{base_bundle_id}.ShareExtension",
|
||||
profile_name: profile_name_share,
|
||||
targets: ["ShareExtension"]
|
||||
)
|
||||
|
||||
|
||||
# WidgetExtension
|
||||
update_code_signing_settings(
|
||||
use_automatic_signing: false,
|
||||
path: "./Runner.xcodeproj",
|
||||
team_id: ENV["FASTLANE_TEAM_ID"] || TEAM_ID,
|
||||
code_sign_identity: CODE_SIGN_IDENTITY,
|
||||
bundle_identifier: "#{BASE_BUNDLE_ID}#{bundle_suffix}.Widget",
|
||||
bundle_identifier: "#{base_bundle_id}.Widget",
|
||||
profile_name: profile_name_widget,
|
||||
targets: ["WidgetExtension"]
|
||||
)
|
||||
end
|
||||
|
||||
|
||||
# Helper method to build and upload to TestFlight
|
||||
def build_and_upload(
|
||||
api_key:,
|
||||
bundle_id_suffix: "",
|
||||
base_bundle_id:,
|
||||
configuration: "Release",
|
||||
distribute_external: true,
|
||||
version_number: nil,
|
||||
@@ -92,9 +91,8 @@ end
|
||||
profile_name_share:,
|
||||
profile_name_widget:
|
||||
)
|
||||
bundle_suffix = bundle_id_suffix.empty? ? "" : ".#{bundle_id_suffix}"
|
||||
app_identifier = "#{BASE_BUNDLE_ID}#{bundle_suffix}"
|
||||
|
||||
app_identifier = base_bundle_id
|
||||
|
||||
# Set version number if provided
|
||||
if version_number
|
||||
increment_version_number(version_number: version_number)
|
||||
@@ -138,31 +136,31 @@ end
|
||||
desc "iOS Development Build to TestFlight (requires separate bundle ID)"
|
||||
lane :gha_testflight_dev do
|
||||
api_key = get_api_key
|
||||
|
||||
|
||||
# Download and install provisioning profiles from App Store Connect
|
||||
# Certificate is imported by GHA workflow into build.keychain
|
||||
# Capture profile names after each sigh call
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
|
||||
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
|
||||
main_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
|
||||
share_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
|
||||
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
|
||||
# Configure code signing for dev bundle IDs using the downloaded profile names
|
||||
configure_code_signing(
|
||||
bundle_id_suffix: "development",
|
||||
base_bundle_id: DEV_BUNDLE_ID,
|
||||
profile_name_main: main_profile_name,
|
||||
profile_name_share: share_profile_name,
|
||||
profile_name_widget: widget_profile_name
|
||||
)
|
||||
|
||||
|
||||
# Build and upload
|
||||
build_and_upload(
|
||||
api_key: api_key,
|
||||
bundle_id_suffix: "development",
|
||||
base_bundle_id: DEV_BUNDLE_ID,
|
||||
configuration: "Profile",
|
||||
distribute_external: false,
|
||||
profile_name_main: main_profile_name,
|
||||
@@ -189,6 +187,7 @@ end
|
||||
|
||||
# Configure code signing for production bundle IDs
|
||||
configure_code_signing(
|
||||
base_bundle_id: BASE_BUNDLE_ID,
|
||||
profile_name_main: main_profile_name,
|
||||
profile_name_share: share_profile_name,
|
||||
profile_name_widget: widget_profile_name
|
||||
@@ -197,6 +196,7 @@ end
|
||||
# Build and upload with version number
|
||||
build_and_upload(
|
||||
api_key: api_key,
|
||||
base_bundle_id: BASE_BUNDLE_ID,
|
||||
version_number: get_version_from_pubspec,
|
||||
distribute_external: false,
|
||||
profile_name_main: main_profile_name,
|
||||
@@ -243,30 +243,30 @@ end
|
||||
|
||||
desc "iOS Build Only (no TestFlight upload)"
|
||||
lane :gha_build_only do
|
||||
# Use the same build process as production, just skip the upload
|
||||
# This ensures PR builds validate the same way as production builds
|
||||
|
||||
# Use the same build process as the dev TestFlight lane, just skip the upload
|
||||
# This ensures PR builds validate the same way as dev TestFlight builds
|
||||
|
||||
api_key = get_api_key
|
||||
|
||||
|
||||
# Download and install provisioning profiles from App Store Connect
|
||||
# Certificate is imported by GHA workflow into build.keychain
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development", force: true)
|
||||
sigh(api_key: api_key, app_identifier: DEV_BUNDLE_ID, force: true)
|
||||
main_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.ShareExtension", force: true)
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.ShareExtension", force: true)
|
||||
share_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{BASE_BUNDLE_ID}.development.Widget", force: true)
|
||||
|
||||
sigh(api_key: api_key, app_identifier: "#{DEV_BUNDLE_ID}.Widget", force: true)
|
||||
widget_profile_name = lane_context[SharedValues::SIGH_NAME]
|
||||
|
||||
|
||||
# Configure code signing for dev bundle IDs
|
||||
configure_code_signing(
|
||||
bundle_id_suffix: "development",
|
||||
base_bundle_id: DEV_BUNDLE_ID,
|
||||
profile_name_main: main_profile_name,
|
||||
profile_name_share: share_profile_name,
|
||||
profile_name_widget: widget_profile_name
|
||||
)
|
||||
|
||||
|
||||
# Build the app (same as gha_testflight_dev but without upload)
|
||||
build_app(
|
||||
scheme: "Runner",
|
||||
@@ -277,9 +277,9 @@ end
|
||||
xcargs: "-skipMacroValidation CODE_SIGN_IDENTITY='#{CODE_SIGN_IDENTITY}' CODE_SIGN_STYLE=Manual",
|
||||
export_options: {
|
||||
provisioningProfiles: {
|
||||
"#{BASE_BUNDLE_ID}.development" => main_profile_name,
|
||||
"#{BASE_BUNDLE_ID}.development.ShareExtension" => share_profile_name,
|
||||
"#{BASE_BUNDLE_ID}.development.Widget" => widget_profile_name
|
||||
DEV_BUNDLE_ID => main_profile_name,
|
||||
"#{DEV_BUNDLE_ID}.ShareExtension" => share_profile_name,
|
||||
"#{DEV_BUNDLE_ID}.Widget" => widget_profile_name
|
||||
},
|
||||
signingStyle: "manual",
|
||||
signingCertificate: CODE_SIGN_IDENTITY
|
||||
|
||||
@@ -445,6 +445,12 @@ export enum VideoCodec {
|
||||
|
||||
export const VideoCodecSchema = z.enum(VideoCodec).describe('Target video codec').meta({ id: 'VideoCodec' });
|
||||
|
||||
export enum VideoSegmentCodec {
|
||||
Av1 = 'av1',
|
||||
Hevc = 'hevc',
|
||||
H264 = 'h264',
|
||||
}
|
||||
|
||||
export enum AudioCodec {
|
||||
Mp3 = 'mp3',
|
||||
Aac = 'aac',
|
||||
|
||||
46
server/src/queries/video.stream.repository.sql
Normal file
46
server/src/queries/video.stream.repository.sql
Normal file
@@ -0,0 +1,46 @@
|
||||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- VideoStreamRepository.getSession
|
||||
select
|
||||
*
|
||||
from
|
||||
"video_stream_session"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- VideoStreamRepository.getVariant
|
||||
select
|
||||
*
|
||||
from
|
||||
"video_stream_variant"
|
||||
where
|
||||
"id" = $1
|
||||
|
||||
-- VideoStreamRepository.getSegment
|
||||
select
|
||||
*
|
||||
from
|
||||
"video_stream_segment"
|
||||
where
|
||||
"variantId" = $1
|
||||
and "index" = $2
|
||||
|
||||
-- VideoStreamRepository.getExpiredSessions
|
||||
select
|
||||
"id"
|
||||
from
|
||||
"video_stream_session"
|
||||
where
|
||||
"expiresAt" <= $1
|
||||
|
||||
-- VideoStreamRepository.extendSession
|
||||
update "video_stream_session"
|
||||
set
|
||||
"expiresAt" = $1
|
||||
where
|
||||
"id" = $2
|
||||
|
||||
-- VideoStreamRepository.deleteSession
|
||||
delete from "video_stream_session"
|
||||
where
|
||||
"id" = $1
|
||||
@@ -46,6 +46,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
@@ -100,6 +101,7 @@ export const repositories = [
|
||||
UserRepository,
|
||||
ViewRepository,
|
||||
VersionHistoryRepository,
|
||||
VideoStreamRepository,
|
||||
WebsocketRepository,
|
||||
WorkflowRepository,
|
||||
];
|
||||
|
||||
62
server/src/repositories/video-stream.repository.ts
Normal file
62
server/src/repositories/video-stream.repository.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { DB } from 'src/schema';
|
||||
import {
|
||||
VideoStreamSegmentTable,
|
||||
VideoStreamSessionTable,
|
||||
VideoStreamVariantTable,
|
||||
} from 'src/schema/tables/video-stream.table';
|
||||
|
||||
@Injectable()
|
||||
export class VideoStreamRepository {
|
||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
createSession(session: Insertable<VideoStreamSessionTable>) {
|
||||
return this.db.insertInto('video_stream_session').values(session).returning(['id']).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
createVariant(variant: Insertable<VideoStreamVariantTable>) {
|
||||
return this.db.insertInto('video_stream_variant').values(variant).returning(['id']).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async createSegment(segment: Insertable<VideoStreamSegmentTable>) {
|
||||
await this.db.insertInto('video_stream_segment').values(segment).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getSession(id: string) {
|
||||
return this.db.selectFrom('video_stream_session').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
getVariant(id: string) {
|
||||
return this.db.selectFrom('video_stream_variant').selectAll().where('id', '=', id).executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
|
||||
getSegment(variantId: string, index: number) {
|
||||
return this.db
|
||||
.selectFrom('video_stream_segment')
|
||||
.selectAll()
|
||||
.where('variantId', '=', variantId)
|
||||
.where('index', '=', index)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
getExpiredSessions() {
|
||||
return this.db.selectFrom('video_stream_session').select(['id']).where('expiresAt', '<=', new Date()).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.DATE] })
|
||||
async extendSession(id: string, expiresAt: Date) {
|
||||
await this.db.updateTable('video_stream_session').set({ expiresAt }).where('id', '=', id).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
async deleteSession(id: string) {
|
||||
await this.db.deleteFrom('video_stream_session').where('id', '=', id).execute();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { registerEnum } from '@immich/sql-tools';
|
||||
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetStatus,
|
||||
AssetVisibility,
|
||||
ChecksumAlgorithm,
|
||||
SourceType,
|
||||
VideoSegmentCodec,
|
||||
} from 'src/enum';
|
||||
|
||||
export const album_user_role_enum = registerEnum({
|
||||
name: 'album_user_role_enum',
|
||||
@@ -25,3 +32,8 @@ export const asset_checksum_algorithm_enum = registerEnum({
|
||||
name: 'asset_checksum_algorithm_enum',
|
||||
values: Object.values(ChecksumAlgorithm),
|
||||
});
|
||||
|
||||
export const video_stream_variant_codec_enum = registerEnum({
|
||||
name: 'video_stream_variant_codec_enum',
|
||||
values: Object.values(VideoSegmentCodec),
|
||||
});
|
||||
|
||||
@@ -76,6 +76,11 @@ import { UserMetadataAuditTable } from 'src/schema/tables/user-metadata-audit.ta
|
||||
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
|
||||
import {
|
||||
VideoStreamSegmentTable,
|
||||
VideoStreamSessionTable,
|
||||
VideoStreamVariantTable,
|
||||
} from 'src/schema/tables/video-stream.table';
|
||||
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
|
||||
|
||||
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
|
||||
@@ -133,6 +138,9 @@ export class ImmichDatabase {
|
||||
UserMetadataAuditTable,
|
||||
UserTable,
|
||||
VersionHistoryTable,
|
||||
VideoStreamSessionTable,
|
||||
VideoStreamVariantTable,
|
||||
VideoStreamSegmentTable,
|
||||
PluginTable,
|
||||
PluginFilterTable,
|
||||
PluginActionTable,
|
||||
@@ -247,6 +255,10 @@ export interface DB {
|
||||
|
||||
version_history: VersionHistoryTable;
|
||||
|
||||
video_stream_session: VideoStreamSessionTable;
|
||||
video_stream_variant: VideoStreamVariantTable;
|
||||
video_stream_segment: VideoStreamSegmentTable;
|
||||
|
||||
plugin: PluginTable;
|
||||
plugin_filter: PluginFilterTable;
|
||||
plugin_action: PluginActionTable;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await sql`CREATE TYPE "video_stream_variant_codec_enum" AS ENUM ('av1','hevc','h264');`.execute(db);
|
||||
await sql`CREATE TABLE "video_stream_session" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"assetId" uuid NOT NULL,
|
||||
"expiresAt" timestamp with time zone NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
CONSTRAINT "video_stream_session_assetId_fkey" FOREIGN KEY ("assetId") REFERENCES "asset" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
|
||||
CONSTRAINT "video_stream_session_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE INDEX "video_stream_session_assetId_idx" ON "video_stream_session" ("assetId");`.execute(db);
|
||||
await sql`CREATE INDEX "video_stream_session_expiresAt_idx" ON "video_stream_session" ("expiresAt");`.execute(db);
|
||||
await sql`CREATE TABLE "video_stream_variant" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"sessionId" uuid NOT NULL,
|
||||
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
|
||||
"bitrate" integer NOT NULL,
|
||||
"codec" video_stream_variant_codec_enum NOT NULL,
|
||||
"resolution" smallint NOT NULL,
|
||||
CONSTRAINT "video_stream_variant_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "video_stream_session" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
|
||||
CONSTRAINT "video_stream_variant_pkey" PRIMARY KEY ("id")
|
||||
);`.execute(db);
|
||||
await sql`CREATE UNIQUE INDEX "video_stream_variant_sessionId_bitrate_resolution_codec_idx" ON "video_stream_variant" ("sessionId", "bitrate", "resolution", "codec");`.execute(db);
|
||||
await sql`CREATE TABLE "video_stream_segment" (
|
||||
"variantId" uuid NOT NULL,
|
||||
"index" integer NOT NULL,
|
||||
"durationUs" integer NOT NULL,
|
||||
CONSTRAINT "video_stream_segment_variantId_fkey" FOREIGN KEY ("variantId") REFERENCES "video_stream_variant" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
|
||||
CONSTRAINT "video_stream_segment_pkey" PRIMARY KEY ("variantId", "index")
|
||||
);`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TABLE "video_stream_segment";`.execute(db);
|
||||
await sql`DROP TABLE "video_stream_variant";`.execute(db);
|
||||
await sql`DROP TABLE "video_stream_session";`.execute(db);
|
||||
await sql`DROP TYPE "asset_checksum_algorithm_enum";`.execute(db);
|
||||
}
|
||||
63
server/src/schema/tables/video-stream.table.ts
Normal file
63
server/src/schema/tables/video-stream.table.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ForeignKeyColumn,
|
||||
Generated,
|
||||
Index,
|
||||
PrimaryColumn,
|
||||
PrimaryGeneratedColumn,
|
||||
Table,
|
||||
Timestamp,
|
||||
} from '@immich/sql-tools';
|
||||
import { VideoSegmentCodec } from 'src/enum';
|
||||
import { video_stream_variant_codec_enum } from 'src/schema/enums';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
|
||||
@Table('video_stream_session')
|
||||
export class VideoStreamSessionTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE' })
|
||||
assetId!: string;
|
||||
|
||||
@Column({ type: 'timestamp with time zone', index: true })
|
||||
expiresAt!: Timestamp;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
@Index({ columns: ['sessionId', 'bitrate', 'resolution', 'codec'], unique: true })
|
||||
@Table('video_stream_variant')
|
||||
export class VideoStreamVariantTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: Generated<string>;
|
||||
|
||||
@ForeignKeyColumn(() => VideoStreamSessionTable, { onDelete: 'CASCADE', index: false })
|
||||
sessionId!: string;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt!: Generated<Timestamp>;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
bitrate!: number;
|
||||
|
||||
@Column({ enum: video_stream_variant_codec_enum })
|
||||
codec!: VideoSegmentCodec;
|
||||
|
||||
@Column({ type: 'smallint' })
|
||||
resolution!: number;
|
||||
}
|
||||
|
||||
@Table('video_stream_segment')
|
||||
export class VideoStreamSegmentTable {
|
||||
@ForeignKeyColumn(() => VideoStreamVariantTable, { onDelete: 'CASCADE', primary: true, index: false })
|
||||
variantId!: string;
|
||||
|
||||
@PrimaryColumn({ type: 'integer' })
|
||||
index!: number;
|
||||
|
||||
@Column({ type: 'integer' })
|
||||
durationUs!: number;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ReactionType } from 'src/dtos/activity.dto';
|
||||
import { ActivityService } from 'src/services/activity.service';
|
||||
import { ActivityFactory } from 'test/factories/activity.factory';
|
||||
@@ -78,7 +78,7 @@ describe(ActivityService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create a comment', async () => {
|
||||
@@ -113,7 +113,7 @@ describe(ActivityService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.create(AuthFactory.create(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create a like', async () => {
|
||||
@@ -145,7 +145,7 @@ describe(ActivityService.name, () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.delete(AuthFactory.create(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.activity.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum';
|
||||
import { AlbumService } from 'src/services/album.service';
|
||||
@@ -323,7 +323,7 @@ describe(AlbumService.name, () => {
|
||||
sut.update(AuthFactory.create(), 'invalid-id', {
|
||||
albumName: 'Album',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -334,7 +334,7 @@ describe(AlbumService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(
|
||||
sut.update(AuthFactory.create(owner), album.id, { albumName: 'new album name' }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should require a valid thumbnail asset id', async () => {
|
||||
@@ -376,7 +376,7 @@ describe(AlbumService.name, () => {
|
||||
const { user: owner } = album.albumUsers.find(({ role }) => role === AlbumUserRole.Owner)!;
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
|
||||
await expect(sut.delete(AuthFactory.create(owner), album.id)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.delete(AuthFactory.create(owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.album.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -387,7 +387,7 @@ describe(AlbumService.name, () => {
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
|
||||
await expect(sut.delete(AuthFactory.create(owner), album.id)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.delete(AuthFactory.create(owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.album.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -412,7 +412,7 @@ describe(AlbumService.name, () => {
|
||||
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(
|
||||
sut.addUsers(AuthFactory.create(user), album.id, { albumUsers: [{ userId: newUuid() }] }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -512,7 +512,7 @@ describe(AlbumService.name, () => {
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await expect(sut.removeUser(AuthFactory.create(user1), album.id, user2.id)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.albumUser.delete).not.toHaveBeenCalled();
|
||||
@@ -655,7 +655,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
await expect(sut.get(auth, 'album-123')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.get(auth, 'album-123')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['album-123']));
|
||||
expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith(
|
||||
@@ -764,7 +764,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.album.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -833,7 +833,7 @@ describe(AlbumService.name, () => {
|
||||
mocks.album.getById.mockResolvedValue(getForAlbum(album));
|
||||
|
||||
await expect(sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalled();
|
||||
@@ -847,7 +847,7 @@ describe(AlbumService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.addAssets(AuthFactory.from().sharedLink({ allowUpload: true }).build(), album.id, { ids: [asset.id] }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
@@ -460,7 +459,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('downloadOriginal', () => {
|
||||
it('should require the asset.download permission', async () => {
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(
|
||||
authStub.admin.user.id,
|
||||
@@ -568,7 +567,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('viewThumbnail', () => {
|
||||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
@@ -711,7 +710,7 @@ describe(AssetMediaService.name, () => {
|
||||
|
||||
describe('playbackVideo', () => {
|
||||
it('should require asset.view permissions', async () => {
|
||||
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']), undefined);
|
||||
expect(mocks.access.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
|
||||
|
||||
@@ -216,7 +216,7 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new NotFoundException('Not found');
|
||||
throw new NotFoundException('Asset media not found');
|
||||
}
|
||||
|
||||
const fileNameBase =
|
||||
@@ -237,7 +237,7 @@ export class AssetMediaService extends BaseService {
|
||||
const asset = await this.assetRepository.getForVideo(id);
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Not found');
|
||||
throw new NotFoundException('Asset not found or asset is not a video');
|
||||
}
|
||||
|
||||
const filepath = asset.encodedVideoPath || asset.originalPath;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||
@@ -136,14 +136,14 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
|
||||
it('should throw an error for no access', async () => {
|
||||
await expect(sut.get(authStub.admin, AssetFactory.create().id)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.get(authStub.admin, AssetFactory.create().id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error for an invalid shared link', async () => {
|
||||
await expect(sut.get(authStub.adminSharedLink, AssetFactory.create().id)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).not.toHaveBeenCalled();
|
||||
@@ -162,7 +162,7 @@ describe(AssetService.name, () => {
|
||||
it('should require asset write access for the id', async () => {
|
||||
await expect(
|
||||
sut.update(authStub.admin, 'asset-1', { visibility: AssetVisibility.Timeline }),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -357,7 +357,7 @@ describe(AssetService.name, () => {
|
||||
describe('updateAll', () => {
|
||||
it('should require asset write access for all ids', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
await expect(sut.updateAll(auth, { ids: ['asset-1'] })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.updateAll(auth, { ids: ['asset-1'] })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should update all assets', async () => {
|
||||
@@ -461,7 +461,7 @@ describe(AssetService.name, () => {
|
||||
sut.deleteAll(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should force delete a batch of assets', async () => {
|
||||
@@ -612,7 +612,7 @@ describe(AssetService.name, () => {
|
||||
it('should require asset read permission', async () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
|
||||
await expect(sut.getOcr(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.getOcr(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.ocr.getByAssetId).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -736,7 +736,7 @@ describe(AssetService.name, () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.assetEdit.replaceAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -598,7 +598,7 @@ describe(AuthService.name, () => {
|
||||
});
|
||||
|
||||
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(result).rejects.toThrow('Access denied');
|
||||
await expect(result).rejects.toThrow('Missing required permission: asset.read');
|
||||
});
|
||||
|
||||
it('should default to requiring the all permission when omitted', async () => {
|
||||
@@ -613,7 +613,7 @@ describe(AuthService.name, () => {
|
||||
metadata: { adminRoute: false, sharedLinkRoute: false, uri: 'test' },
|
||||
});
|
||||
await expect(result).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(result).rejects.toThrow('Access denied');
|
||||
await expect(result).rejects.toThrow('Missing required permission: all');
|
||||
});
|
||||
|
||||
it('should not require any permission when metadata is set to `false`', async () => {
|
||||
|
||||
@@ -238,7 +238,7 @@ export class AuthService extends BaseService {
|
||||
requestedPermission !== false &&
|
||||
!isGranted({ requested: [requestedPermission], current: authDto.apiKey.permissions })
|
||||
) {
|
||||
throw new ForbiddenException('Access denied');
|
||||
throw new ForbiddenException(`Missing required permission: ${requestedPermission}`);
|
||||
}
|
||||
|
||||
return authDto;
|
||||
|
||||
@@ -53,6 +53,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
@@ -109,6 +110,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
TrashRepository,
|
||||
UserRepository,
|
||||
VersionHistoryRepository,
|
||||
VideoStreamRepository,
|
||||
ViewRepository,
|
||||
WebsocketRepository,
|
||||
WorkflowRepository,
|
||||
@@ -167,6 +169,7 @@ export class BaseService {
|
||||
protected trashRepository: TrashRepository,
|
||||
protected userRepository: UserRepository,
|
||||
protected versionRepository: VersionHistoryRepository,
|
||||
protected videoStreamRepository: VideoStreamRepository,
|
||||
protected viewRepository: ViewRepository,
|
||||
protected websocketRepository: WebsocketRepository,
|
||||
protected workflowRepository: WorkflowRepository,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { MemoryService } from 'src/services/memory.service';
|
||||
import { OnThisDayData } from 'src/types';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
@@ -53,7 +53,7 @@ describe(MemoryService.name, () => {
|
||||
|
||||
describe('get', () => {
|
||||
it('should throw an error when no access', async () => {
|
||||
await expect(sut.get(factory.auth(), 'not-found')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.get(factory.auth(), 'not-found')).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw an error when the memory is not found', async () => {
|
||||
@@ -151,7 +151,7 @@ describe(MemoryService.name, () => {
|
||||
describe('update', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.update(factory.auth(), 'not-found', { isSaved: true })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.memory.update).not.toHaveBeenCalled();
|
||||
@@ -171,7 +171,7 @@ describe(MemoryService.name, () => {
|
||||
|
||||
describe('remove', () => {
|
||||
it('should require access', async () => {
|
||||
await expect(sut.remove(factory.auth(), newUuid())).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.remove(factory.auth(), newUuid())).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.memory.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -193,7 +193,7 @@ describe(MemoryService.name, () => {
|
||||
const [memoryId, assetId] = newUuids();
|
||||
|
||||
await expect(sut.addAssets(factory.auth(), memoryId, { ids: [assetId] })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.memory.addAssetIds).not.toHaveBeenCalled();
|
||||
@@ -251,7 +251,7 @@ describe(MemoryService.name, () => {
|
||||
describe('removeAssets', () => {
|
||||
it('should require memory access', async () => {
|
||||
await expect(sut.removeAssets(factory.auth(), 'not-found', { ids: ['asset1'] })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.memory.removeAssetIds).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { PartnerDirection } from 'src/repositories/partner.repository';
|
||||
import { PartnerService } from 'src/services/partner.service';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
@@ -109,7 +109,7 @@ describe(PartnerService.name, () => {
|
||||
const user2 = UserFactory.create();
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.update(auth, user2.id, { inTimeline: false })).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should update partner', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { mapFaces, mapPerson } from 'src/dtos/person.dto';
|
||||
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
||||
@@ -95,7 +95,7 @@ describe(PersonService.name, () => {
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
@@ -124,7 +124,7 @@ describe(PersonService.name, () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
@@ -172,7 +172,7 @@ describe(PersonService.name, () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
@@ -180,7 +180,7 @@ describe(PersonService.name, () => {
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
@@ -286,7 +286,7 @@ describe(PersonService.name, () => {
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(ForbiddenException);
|
||||
await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(BadRequestException);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
@@ -312,7 +312,7 @@ describe(PersonService.name, () => {
|
||||
sut.reassignFaces(AuthFactory.create(), 'person-id', {
|
||||
data: [{ personId: 'asset-face-1', assetId: '' }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
||||
});
|
||||
@@ -371,7 +371,7 @@ describe(PersonService.name, () => {
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
mocks.person.getFaces.mockResolvedValue([getForAssetFace(face)]);
|
||||
await expect(sut.getFacesById(AuthFactory.create(), { id: face.assetId })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -507,7 +507,7 @@ describe(PersonService.name, () => {
|
||||
sut.reassignFacesById(AuthFactory.create(), person.id, {
|
||||
id: face.id,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
||||
@@ -1189,7 +1189,7 @@ describe(PersonService.name, () => {
|
||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
|
||||
@@ -1313,7 +1313,7 @@ describe(PersonService.name, () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ export class PersonService extends BaseService {
|
||||
if (closestPersonId) {
|
||||
const person = await this.personRepository.getById(closestPersonId);
|
||||
if (!person?.faceAssetId) {
|
||||
throw new NotFoundException('Not found');
|
||||
throw new NotFoundException('Person not found');
|
||||
}
|
||||
closestFaceAssetId = person.faceAssetId;
|
||||
}
|
||||
@@ -637,7 +637,7 @@ export class PersonService extends BaseService {
|
||||
]);
|
||||
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Not found');
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
const edits = asset.edits || [];
|
||||
|
||||
@@ -123,7 +123,7 @@ describe(SharedLinkService.name, () => {
|
||||
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(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should not allow individual shared links with no assets', async () => {
|
||||
@@ -135,7 +135,7 @@ describe(SharedLinkService.name, () => {
|
||||
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(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create an album shared link', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { StackService } from 'src/services/stack.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { AuthFactory } from 'test/factories/auth.factory';
|
||||
@@ -43,7 +43,7 @@ describe(StackService.name, () => {
|
||||
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
|
||||
|
||||
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled();
|
||||
@@ -77,7 +77,7 @@ describe(StackService.name, () => {
|
||||
|
||||
describe('get', () => {
|
||||
it('should require stack.read permissions', async () => {
|
||||
await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.get(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled();
|
||||
expect(mocks.stack.getById).not.toHaveBeenCalled();
|
||||
@@ -115,7 +115,7 @@ describe(StackService.name, () => {
|
||||
|
||||
describe('update', () => {
|
||||
it('should require stack.update permissions', async () => {
|
||||
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.stack.getById).not.toHaveBeenCalled();
|
||||
expect(mocks.stack.update).not.toHaveBeenCalled();
|
||||
@@ -179,7 +179,7 @@ describe(StackService.name, () => {
|
||||
|
||||
describe('delete', () => {
|
||||
it('should require stack.delete permissions', async () => {
|
||||
await expect(sut.delete(AuthFactory.create(), 'stack-id')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.delete(AuthFactory.create(), 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.stack.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.event.emit).not.toHaveBeenCalled();
|
||||
@@ -203,7 +203,7 @@ describe(StackService.name, () => {
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should require stack.delete permissions', async () => {
|
||||
await expect(sut.deleteAll(authStub.admin, { ids: ['stack-id'] })).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.deleteAll(authStub.admin, { ids: ['stack-id'] })).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(mocks.stack.deleteAll).not.toHaveBeenCalled();
|
||||
expect(mocks.event.emit).not.toHaveBeenCalled();
|
||||
@@ -226,7 +226,7 @@ describe(StackService.name, () => {
|
||||
describe('removeAsset', () => {
|
||||
it('should require stack.update permissions', async () => {
|
||||
await expect(sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: 'asset-id' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.stack.getForAssetRemoval).not.toHaveBeenCalled();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { JobStatus } from 'src/enum';
|
||||
import { TagService } from 'src/services/tag.service';
|
||||
@@ -45,7 +45,7 @@ describe(TagService.name, () => {
|
||||
it('should throw an error for no parent tag access', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(sut.create(authStub.admin, { name: 'tag', parentId: 'tag-parent' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.tag.create).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -105,7 +105,7 @@ describe(TagService.name, () => {
|
||||
it('should throw an error for no update permission', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(sut.update(authStub.admin, 'tag-1', { color: '#000000' })).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
);
|
||||
expect(mocks.tag.update).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -165,7 +165,7 @@ describe(TagService.name, () => {
|
||||
describe('remove', () => {
|
||||
it('should throw an error for an invalid id', async () => {
|
||||
mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(sut.remove(authStub.admin, 'tag-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.tag.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { JobName, JobStatus } from 'src/enum';
|
||||
import { TrashService } from 'src/services/trash.service';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
@@ -29,7 +29,7 @@ describe(TrashService.name, () => {
|
||||
sut.restoreAssets(authStub.user1, {
|
||||
ids: ['asset-1'],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('should handle an empty list', async () => {
|
||||
|
||||
@@ -136,7 +136,7 @@ export class UserService extends BaseService {
|
||||
async getProfileImage(id: string): Promise<ImmichFileResponse> {
|
||||
const user = await this.findOrFail(id, {});
|
||||
if (!user.profileImagePath) {
|
||||
throw new NotFoundException('Not found');
|
||||
throw new NotFoundException('User does not have a profile image');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ForbiddenException, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { AuthSharedLink } from 'src/database';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AlbumUserRole, Permission } from 'src/enum';
|
||||
@@ -37,7 +37,7 @@ export const requireUploadAccess = (auth: AuthDto | null): AuthDto => {
|
||||
export const requireAccess = async (access: AccessRepository, request: AccessRequest) => {
|
||||
const allowedIds = await checkAccess(access, request);
|
||||
if (!setIsEqual(new Set(request.ids), allowedIds)) {
|
||||
throw new ForbiddenException('Access denied');
|
||||
throw new BadRequestException(`Not found or no ${request.permission} access`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ export const errorDto = {
|
||||
forbidden: {
|
||||
message: expect.any(String),
|
||||
},
|
||||
missingPermission: {
|
||||
message: 'Access denied',
|
||||
},
|
||||
missingPermission: (permission: string) => ({
|
||||
message: `Missing required permission: ${permission}`,
|
||||
}),
|
||||
wrongPassword: {
|
||||
message: 'Wrong password',
|
||||
},
|
||||
@@ -26,7 +26,7 @@ export const errorDto = {
|
||||
message: message ?? expect.anything(),
|
||||
}),
|
||||
noPermission: {
|
||||
message: 'Access denied',
|
||||
message: expect.stringContaining('Not found or no'),
|
||||
},
|
||||
incorrectLogin: {
|
||||
message: 'Incorrect email or password',
|
||||
|
||||
@@ -598,7 +598,7 @@ describe(AssetService.name, () => {
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id });
|
||||
|
||||
await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Access denied');
|
||||
await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access');
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
@@ -649,7 +649,7 @@ describe(AssetService.name, () => {
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user2.id });
|
||||
|
||||
await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Access denied');
|
||||
await expect(sut.getOcr(auth, asset.id)).rejects.toThrow('Not found or no asset.read access');
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
@@ -875,7 +875,7 @@ describe(AssetService.name, () => {
|
||||
|
||||
await expect(
|
||||
sut.editAsset(auth, asset.id, { edits: [{ action: AssetEditAction.Rotate, parameters: { angle: 90 } }] }),
|
||||
).rejects.toThrow('Access denied');
|
||||
).rejects.toThrow('Not found or no asset.edit.create access');
|
||||
});
|
||||
|
||||
it('should work', async () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ describe(PersonService.name, () => {
|
||||
const { sut } = setup();
|
||||
const auth = factory.auth();
|
||||
const personId = factory.uuid();
|
||||
await expect(sut.delete(auth, personId)).rejects.toThrow('Access denied');
|
||||
await expect(sut.delete(auth, personId)).rejects.toThrow('Not found or no person.delete access');
|
||||
});
|
||||
|
||||
it('should delete the person', async () => {
|
||||
@@ -60,7 +60,7 @@ describe(PersonService.name, () => {
|
||||
const { sut } = setup();
|
||||
const auth = factory.auth();
|
||||
const personId = factory.uuid();
|
||||
await expect(sut.deleteAll(auth, { ids: [personId] })).rejects.toThrow('Access denied');
|
||||
await expect(sut.deleteAll(auth, { ids: [personId] })).rejects.toThrow('Not found or no person.delete access');
|
||||
});
|
||||
|
||||
it('should delete the person', async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { Kysely } from 'kysely';
|
||||
import { AssetVisibility } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
@@ -90,8 +90,8 @@ describe(TimelineService.name, () => {
|
||||
const { sut } = setup();
|
||||
const auth = factory.auth({ sharedLink: {} });
|
||||
const response = sut.getTimeBuckets(auth, {});
|
||||
await expect(response).rejects.toBeInstanceOf(ForbiddenException);
|
||||
await expect(response).rejects.toThrow('Access denied');
|
||||
await expect(response).rejects.toBeInstanceOf(BadRequestException);
|
||||
await expect(response).rejects.toThrow('Not found or no timeline.read access');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -724,7 +724,7 @@ describe(WorkflowService.name, () => {
|
||||
|
||||
await sut.delete(auth, workflow.id);
|
||||
|
||||
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Access denied');
|
||||
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access');
|
||||
});
|
||||
|
||||
it('should delete workflow with filters and actions', async () => {
|
||||
@@ -743,7 +743,7 @@ describe(WorkflowService.name, () => {
|
||||
|
||||
await sut.delete(auth, workflow.id);
|
||||
|
||||
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Access denied');
|
||||
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access');
|
||||
});
|
||||
|
||||
it('should throw error when deleting non-existent workflow', async () => {
|
||||
|
||||
@@ -64,6 +64,7 @@ import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { TrashRepository } from 'src/repositories/trash.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { VideoStreamRepository } from 'src/repositories/video-stream.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
@@ -260,6 +261,7 @@ export type ServiceOverrides = {
|
||||
trash: TrashRepository;
|
||||
user: UserRepository;
|
||||
versionHistory: VersionHistoryRepository;
|
||||
videoStream: VideoStreamRepository;
|
||||
view: ViewRepository;
|
||||
websocket: WebsocketRepository;
|
||||
workflow: WorkflowRepository;
|
||||
@@ -344,6 +346,7 @@ export const getMocks = () => {
|
||||
trash: automock(TrashRepository),
|
||||
user: automock(UserRepository, { strict: false }),
|
||||
versionHistory: automock(VersionHistoryRepository),
|
||||
videoStream: automock(VideoStreamRepository),
|
||||
view: automock(ViewRepository),
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
|
||||
@@ -408,6 +411,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.trash || (mocks.trash as As<TrashRepository>),
|
||||
overrides.user || (mocks.user as As<UserRepository>),
|
||||
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
|
||||
overrides.videoStream || (mocks.videoStream as As<VideoStreamRepository>),
|
||||
overrides.view || (mocks.view as As<ViewRepository>),
|
||||
overrides.websocket || (mocks.websocket as As<WebsocketRepository>),
|
||||
overrides.workflow || (mocks.workflow as As<WorkflowRepository>),
|
||||
|
||||
Reference in New Issue
Block a user