1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

feat(server): user and server license endpoints (#10682)

* feat: user license endpoints

* feat: server license endpoints

* chore: pr feedback

* chore: add more test cases

* chore: add prod license public keys

* chore: open-api generation
This commit is contained in:
Zack Pollard 2024-07-01 18:43:16 +01:00 committed by GitHub
parent 4193b0dede
commit 3b37b70626
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 839 additions and 16 deletions

View File

@ -22,6 +22,7 @@ services:
- DB_DATABASE_NAME=immich
- IMMICH_MACHINE_LEARNING_ENABLED=false
- IMMICH_METRICS=true
- IMMICH_ENV=testing
volumes:
- upload:/usr/src/app/upload
- ./test-assets:/test-assets

View File

@ -44,6 +44,7 @@ describe('/server-info', () => {
imagemagick: expect.any(String),
libvips: expect.any(String),
exiftool: expect.any(String),
licensed: false,
});
});
});

View File

@ -5,6 +5,12 @@ import { app, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
const serverLicense = {
licenseKey: 'IMSV-6ECZ-91TE-WZRM-Q7AQ-MBN4-UW48-2CPT-71X9',
activationKey:
'4kJUNUWMq13J14zqPFm1NodRcI6MV6DeOGvQNIgrM8Sc9nv669wyEVvFw1Nz4Kb1W7zLWblOtXEQzpRRqC4r4fKjewJxfbpeo9sEsqAVIfl4Ero-Vp1Dg21-sVdDGZEAy2oeTCXAyCT5d1JqrqR6N1qTAm4xOx9ujXQRFYhjRG8uwudw7_Q49pF18Tj5OEv9qCqElxztoNck4i6O_azsmsoOQrLIENIWPh3EynBN3ESpYERdCgXO8MlWeuG14_V1HbNjnJPZDuvYg__YfMzoOEtfm1sCqEaJ2Ww-BaX7yGfuCL4XsuZlCQQNHjfscy_WywVfIZPKCiW8QR74i0cSzQ',
};
describe('/server', () => {
let admin: LoginResponseDto;
let nonAdmin: LoginResponseDto;
@ -44,6 +50,7 @@ describe('/server', () => {
imagemagick: expect.any(String),
libvips: expect.any(String),
exiftool: expect.any(String),
licensed: false,
});
});
});
@ -197,4 +204,104 @@ describe('/server', () => {
});
});
});
describe('GET /server/license', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/server/license');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.get('/server/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should return the server license', async () => {
await request(app).put('/server/license').set('Authorization', `Bearer ${admin.accessToken}`).send(serverLicense);
const { status, body } = await request(app)
.get('/server/license')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...serverLicense,
activatedAt: expect.any(String),
});
});
});
describe('DELETE /server/license', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).delete('/server/license');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.delete('/server/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should delete the server license', async () => {
await request(app)
.delete('/server/license')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send(serverLicense);
const { status } = await request(app).get('/server/license').set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);
});
});
describe('PUT /server/license', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).put('/server/license');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should only work for admins', async () => {
const { status, body } = await request(app)
.put('/server/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(403);
expect(body).toEqual(errorDto.forbidden);
});
it('should set the server license', async () => {
const { status, body } = await request(app)
.put('/server/license')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send(serverLicense);
expect(status).toBe(200);
expect(body).toEqual({ ...serverLicense, activatedAt: expect.any(String) });
const { body: licenseBody } = await request(app)
.get('/server/license')
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(licenseBody).toEqual({ ...serverLicense, activatedAt: expect.any(String) });
});
it('should reject license not starting with IMSV-', async () => {
const { status, body } = await request(app)
.put('/server/license')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ licenseKey: 'IMCL-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', activationKey: 'activationKey' });
expect(status).toBe(400);
expect(body.message).toBe('Invalid license key');
});
it('should reject license with invalid activation key', async () => {
const { status, body } = await request(app)
.put('/server/license')
.set('Authorization', `Bearer ${admin.accessToken}`)
.send({ licenseKey: serverLicense.licenseKey, activationKey: `invalid${serverLicense.activationKey}` });
expect(status).toBe(400);
expect(body.message).toBe('Invalid license key');
});
});
});

View File

@ -5,6 +5,12 @@ import { app, asBearerAuth, utils } from 'src/utils';
import request from 'supertest';
import { beforeAll, describe, expect, it } from 'vitest';
const userLicense = {
licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4',
activationKey:
'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw',
};
describe('/users', () => {
let admin: LoginResponseDto;
let deletedUser: LoginResponseDto;
@ -72,6 +78,24 @@ describe('/users', () => {
quotaUsageInBytes: 0,
});
});
it('should get my user with license info', async () => {
const { status: licenseStatus } = await request(app)
.put(`/users/me/license`)
.send(userLicense)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(licenseStatus).toBe(200);
const { status, body } = await request(app)
.get(`/users/me`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({
id: nonAdmin.userId,
email: nonAdmin.userEmail,
quotaUsageInBytes: 0,
license: userLicense,
});
});
});
describe('PUT /users/me', () => {
@ -236,4 +260,81 @@ describe('/users', () => {
});
});
});
describe('GET /server/license', () => {
it('should require authentication', async () => {
const { status, body } = await request(app).get('/users/me/license');
expect(status).toBe(401);
expect(body).toEqual(errorDto.unauthorized);
});
it('should return the user license', async () => {
await request(app)
.put('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send(userLicense);
const { status, body } = await request(app)
.get('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual({
...userLicense,
activatedAt: expect.any(String),
});
});
});
describe('PUT /users/me/license', () => {
it('should require authentication', async () => {
const { status } = await request(app).put(`/users/me/license`);
expect(status).toEqual(401);
});
it('should set the user license', async () => {
const { status, body } = await request(app)
.put(`/users/me/license`)
.send(userLicense)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
expect(body).toMatchObject({ ...userLicense, activatedAt: expect.any(String) });
expect(status).toBe(200);
expect(body).toEqual({ ...userLicense, activatedAt: expect.any(String) });
const { body: licenseBody } = await request(app)
.get('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(licenseBody).toEqual({ ...userLicense, activatedAt: expect.any(String) });
});
it('should reject license not starting with IMCL-', async () => {
const { status, body } = await request(app)
.put('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ licenseKey: 'IMSV-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', activationKey: 'activationKey' });
expect(status).toBe(400);
expect(body.message).toBe('Invalid license key');
});
it('should reject license with invalid activation key', async () => {
const { status, body } = await request(app)
.put('/users/me/license')
.set('Authorization', `Bearer ${nonAdmin.accessToken}`)
.send({ licenseKey: userLicense.licenseKey, activationKey: `invalid${userLicense.activationKey}` });
expect(status).toBe(400);
expect(body.message).toBe('Invalid license key');
});
});
describe('DELETE /users/me/license', () => {
it('should require authentication', async () => {
const { status } = await request(app).put(`/users/me/license`);
expect(status).toEqual(401);
});
it('should delete the user license', async () => {
const { status } = await request(app)
.delete(`/users/me/license`)
.set('Authorization', `Bearer ${nonAdmin.accessToken}`);
expect(status).toBe(200);
});
});
});

View File

@ -81,6 +81,7 @@ export const signupResponseDto = {
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
status: 'active',
license: null,
},
};

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/api/server_api.dart generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/lib/model/user_license.dart generated Normal file

Binary file not shown.

View File

@ -4994,6 +4994,101 @@
}
}
},
"/server/license": {
"delete": {
"operationId": "deleteServerLicense",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Server"
]
},
"get": {
"operationId": "getServerLicense",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"type": "object"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Server"
]
},
"put": {
"operationId": "setServerLicense",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LicenseKeyDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LicenseResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Server"
]
}
},
"/sessions": {
"delete": {
"operationId": "deleteAllSessions",
@ -6594,6 +6689,101 @@
]
}
},
"/users/me/license": {
"delete": {
"operationId": "deleteUserLicense",
"parameters": [],
"responses": {
"200": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Users"
]
},
"get": {
"operationId": "getUserLicense",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LicenseResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Users"
]
},
"put": {
"operationId": "setUserLicense",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LicenseKeyDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/LicenseResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Users"
]
}
},
"/users/me/preferences": {
"get": {
"operationId": "getMyPreferences",
@ -8765,6 +8955,43 @@
],
"type": "object"
},
"LicenseKeyDto": {
"properties": {
"activationKey": {
"type": "string"
},
"licenseKey": {
"pattern": "/IM(SV|CL)(-[\\dA-Za-z]{4}){8}/",
"type": "string"
}
},
"required": [
"activationKey",
"licenseKey"
],
"type": "object"
},
"LicenseResponseDto": {
"properties": {
"activatedAt": {
"format": "date-time",
"type": "string"
},
"activationKey": {
"type": "string"
},
"licenseKey": {
"pattern": "/IM(SV|CL)(-[\\dA-Za-z]{4}){8}/",
"type": "string"
}
},
"required": [
"activatedAt",
"activationKey",
"licenseKey"
],
"type": "object"
},
"LogLevel": {
"enum": [
"verbose",
@ -9752,6 +9979,9 @@
"libvips": {
"type": "string"
},
"licensed": {
"type": "boolean"
},
"nodejs": {
"type": "string"
},
@ -9778,6 +10008,7 @@
}
},
"required": [
"licensed",
"version",
"versionUrl"
],
@ -11330,6 +11561,14 @@
"isAdmin": {
"type": "boolean"
},
"license": {
"allOf": [
{
"$ref": "#/components/schemas/UserLicense"
}
],
"nullable": true
},
"name": {
"type": "string"
},
@ -11371,6 +11610,7 @@
"email",
"id",
"isAdmin",
"license",
"name",
"oauthId",
"profileImagePath",
@ -11425,6 +11665,26 @@
],
"type": "string"
},
"UserLicense": {
"properties": {
"activatedAt": {
"format": "date-time",
"type": "string"
},
"activationKey": {
"type": "string"
},
"licenseKey": {
"type": "string"
}
},
"required": [
"activatedAt",
"activationKey",
"licenseKey"
],
"type": "object"
},
"UserPreferencesResponseDto": {
"properties": {
"avatar": {

View File

@ -38,6 +38,11 @@ export type ActivityCreateDto = {
export type ActivityStatisticsResponseDto = {
comments: number;
};
export type UserLicense = {
activatedAt: string;
activationKey: string;
licenseKey: string;
};
export type UserAdminResponseDto = {
avatarColor: UserAvatarColor;
createdAt: string;
@ -45,6 +50,7 @@ export type UserAdminResponseDto = {
email: string;
id: string;
isAdmin: boolean;
license: (UserLicense) | null;
name: string;
oauthId: string;
profileImagePath: string;
@ -800,6 +806,7 @@ export type ServerAboutResponseDto = {
ffmpeg?: string;
imagemagick?: string;
libvips?: string;
licensed: boolean;
nodejs?: string;
repository?: string;
repositoryUrl?: string;
@ -873,6 +880,15 @@ export type ServerVersionResponseDto = {
minor: number;
patch: number;
};
export type LicenseKeyDto = {
activationKey: string;
licenseKey: string;
};
export type LicenseResponseDto = {
activatedAt: string;
activationKey: string;
licenseKey: string;
};
export type SessionResponseDto = {
createdAt: string;
current: boolean;
@ -2484,6 +2500,32 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function deleteServerLicense(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/server/license", {
...opts,
method: "DELETE"
}));
}
export function getServerLicense(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: object;
}>("/server/license", {
...opts
}));
}
export function setServerLicense({ licenseKeyDto }: {
licenseKeyDto: LicenseKeyDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: LicenseResponseDto;
}>("/server/license", oazapfts.json({
...opts,
method: "PUT",
body: licenseKeyDto
})));
}
export function deleteAllSessions(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/sessions", {
...opts,
@ -2892,6 +2934,32 @@ export function updateMyUser({ userUpdateMeDto }: {
body: userUpdateMeDto
})));
}
export function deleteUserLicense(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/users/me/license", {
...opts,
method: "DELETE"
}));
}
export function getUserLicense(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: LicenseResponseDto;
}>("/users/me/license", {
...opts
}));
}
export function setUserLicense({ licenseKeyDto }: {
licenseKeyDto: LicenseKeyDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: LicenseResponseDto;
}>("/users/me/license", oazapfts.json({
...opts,
method: "PUT",
body: licenseKeyDto
})));
}
export function getMyPreferences(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;

View File

@ -361,7 +361,7 @@ export const immichAppConfig: ConfigModuleOptions = {
envFilePath: '.env',
isGlobal: true,
validationSchema: Joi.object({
IMMICH_ENV: Joi.string().optional().valid('development', 'production').default('production'),
IMMICH_ENV: Joi.string().optional().valid('development', 'testing', 'production').default('production'),
IMMICH_LOG_LEVEL: Joi.string()
.optional()
.valid(...Object.values(LogLevel)),
@ -441,3 +441,29 @@ export const getBuildMetadata = () => ({
sourceCommit: process.env.IMMICH_SOURCE_COMMIT,
sourceUrl: process.env.IMMICH_SOURCE_URL,
});
const clientLicensePublicKeyProd =
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF2LzdTMzJjUkE1KysxTm5WRHNDTQpzcFAvakpISU1xT0pYRm5oNE53QTJPcHorUk1mZGNvOTJQc09naCt3d1FlRXYxVTJjMnBqelRpUS8ybHJLcS9rCnpKUmxYd2M0Y1Vlc1FETUpPRitQMnFPTlBiQUprWHZDWFlCVUxpdENJa29Md2ZoU0dOanlJS2FSRGhkL3ROeU4KOCtoTlJabllUMWhTSWo5U0NrS3hVQ096YXRQVjRtQ0RlclMrYkUrZ0VVZVdwOTlWOWF6dkYwRkltblRXcFFTdwpjOHdFWmdPTWg0c3ZoNmFpY3dkemtQQ3dFTGFrMFZhQkgzMUJFVUNRTGI5K0FJdEhBVXRKQ0t4aGI1V2pzMXM5CmJyWGZpMHZycGdjWi82RGFuWTJxZlNQem5PbXZEMkZycmxTMXE0SkpOM1ZvN1d3LzBZeS95TWNtelRXWmhHdWgKVVFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=';
const clientLicensePublicKeyStaging =
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuSUNyTm5jbGpPSC9JdTNtWVVaRQp0dGJLV1c3OGRuajl5M0U2ekk3dU1NUndEckdYWFhkTGhkUDFxSWtlZHh0clVVeUpCMWR4R04yQW91S082MlNGCldrbU9PTmNGQlRBWFZTdjhUNVY0S0VwWnFQYWEwaXpNaGxMaE5sRXEvY1ZKdllrWlh1Z2x6b1o3cG1nbzFSdHgKam1iRm5NNzhrYTFRUUJqOVdLaEw2eWpWRUl2MDdVS0lKWHBNTnNuS2g1V083MjZhYmMzSE9udTlETjY5VnFFRQo3dGZrUnRWNmx2U1NzMkFVMngzT255cHA4ek53b0lPTWRibGsyb09aWWROZzY0Y3l2SzJoU0FlU3NVMFRyOVc5Ckgra0Y5QlNCNlk0QXl0QlVkSmkrK2pMSW5HM2Q5cU9ieFVzTlYrN05mRkF5NjJkL0xNR0xSOC9OUFc0U0s3c0MKRlFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=';
export const getClientLicensePublicKey = (): string => {
if (process.env.IMMICH_ENV === 'production') {
return clientLicensePublicKeyProd;
}
return clientLicensePublicKeyStaging;
};
const serverLicensePublicKeyProd =
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFvcG5ZRGEwYS9kVTVJZUc3NGlFRQpNd2RBS2pzTmN6TGRDcVJkMVo5eTVUMndqTzdlWUlPZUpUc2wzNTBzUjBwNEtmU1VEU1h2QzlOcERwYzF0T0tsCjVzaEMvQXhwdlFBTENva0Y0anQ4dnJyZDlmQ2FYYzFUcVJiT21uaGl1Z0Q2dmtyME8vRmIzVURpM1UwVHZoUFAKbFBkdlNhd3pMcldaUExmbUhWVnJiclNLbW45SWVTZ3kwN3VrV1RJeUxzY2lOcnZuQnl3c0phUmVEdW9OV1BCSApVL21vMm1YYThtNHdNV2hpWGVoaUlPUXFNdVNVZ1BlQ3NXajhVVngxQ0dsUnpQREEwYlZOUXZlS1hXVnhjRUk2ClVMRWdKeTJGNDlsSDArYVlDbUJmN05FcjZWUTJXQjk1ZXZUS1hLdm4wcUlNN25nRmxjVUF3NmZ1VjFjTkNUSlMKNndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=';
const serverLicensePublicKeyStaging =
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE3Sy8yd3ZLUS9NdU8ydi9MUm5saAoyUy9zTHhDOGJiTEw1UUlKOGowQ3BVZW40YURlY2dYMUpKUmtGNlpUVUtpNTdTbEhtS3RSM2JOTzJmdTBUUVg5Ck5WMEJzVzllZVB0MmlTMWl4VVFmTzRObjdvTjZzbEtac01qd29RNGtGRGFmM3VHTlZJc0dMb3UxVWRLUVhpeDEKUlRHcXVTb3NZVjNWRlk3Q1hGYTVWaENBL3poVXNsNGFuVXp3eEF6M01jUFVlTXBaenYvbVZiQlRKVzBPSytWZgpWQUJvMXdYMkVBanpBekVHVzQ3Vko4czhnMnQrNHNPaHFBNStMQjBKVzlORUg5QUpweGZzWE4zSzVtM00yNUJVClZXcTlRYStIdHRENnJ0bnAvcUFweXVkWUdwZk9HYTRCUlZTR1MxMURZM0xrb2FlRzYwUEU5NHpoYjduOHpMWkgKelFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo=';
export const getServerLicensePublicKey = (): string => {
if (process.env.IMMICH_ENV === 'production') {
return serverLicensePublicKeyProd;
}
return serverLicensePublicKeyStaging;
};

View File

@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { Body, Controller, Delete, Get, Put } from '@nestjs/common';
import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import {
ServerAboutResponseDto,
ServerConfigDto,
@ -79,4 +80,22 @@ export class ServerController {
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return this.service.getSupportedMediaTypes();
}
@Put('license')
@Authenticated({ admin: true })
setServerLicense(@Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(license);
}
@Delete('license')
@Authenticated({ admin: true })
deleteServerLicense(): Promise<void> {
return this.service.deleteLicense();
}
@Get('license')
@Authenticated({ admin: true })
getServerLicense(): Promise<LicenseKeyDto | null> {
return this.service.getLicense();
}
}

View File

@ -17,6 +17,7 @@ import {
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AuthDto } from 'src/dtos/auth.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
@ -68,6 +69,24 @@ export class UserController {
return this.service.updateMyPreferences(auth, dto);
}
@Get('me/license')
@Authenticated()
getUserLicense(@Auth() auth: AuthDto): LicenseResponseDto {
return this.service.getLicense(auth);
}
@Put('me/license')
@Authenticated()
async setUserLicense(@Auth() auth: AuthDto, @Body() license: LicenseKeyDto): Promise<LicenseResponseDto> {
return this.service.setLicense(auth, license);
}
@Delete('me/license')
@Authenticated()
async deleteUserLicense(@Auth() auth: AuthDto): Promise<void> {
await this.service.deleteLicense(auth);
}
@Get(':id')
@Authenticated()
getUser(@Param() { id }: UUIDParamDto): Promise<UserResponseDto> {

View File

@ -0,0 +1,16 @@
import { IsNotEmpty, IsString, Matches } from 'class-validator';
export class LicenseKeyDto {
@IsString()
@IsNotEmpty()
@Matches(/IM(SV|CL)(-[\dA-Za-z]{4}){8}/)
licenseKey!: string;
@IsString()
@IsNotEmpty()
activationKey!: string;
}
export class LicenseResponseDto extends LicenseKeyDto {
activatedAt!: Date;
}

View File

@ -28,6 +28,8 @@ export class ServerAboutResponseDto {
imagemagick?: string;
libvips?: string;
exiftool?: string;
licensed!: boolean;
}
export class ServerStorageResponseDto {

View File

@ -1,10 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator';
import { UserAvatarColor } from 'src/entities/user-metadata.entity';
import { UserAvatarColor, UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity';
import { UserEntity, UserStatus } from 'src/entities/user.entity';
import { getPreferences } from 'src/utils/preferences';
import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation';
import { Optional, toEmail, toSanitized, ValidateBoolean } from 'src/validation';
export class UserUpdateMeDto {
@Optional()
@ -33,6 +33,12 @@ export class UserResponseDto {
avatarColor!: UserAvatarColor;
}
export class UserLicense {
licenseKey!: string;
activationKey!: string;
activatedAt!: Date;
}
export const mapUser = (entity: UserEntity): UserResponseDto => {
return {
id: entity.id,
@ -130,9 +136,13 @@ export class UserAdminResponseDto extends UserResponseDto {
quotaUsageInBytes!: number | null;
@ApiProperty({ enumName: 'UserStatus', enum: UserStatus })
status!: string;
license!: UserLicense | null;
}
export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto {
const license = entity.metadata.find(
(item): item is UserMetadataEntity<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
)?.value;
return {
...mapUser(entity),
storageLabel: entity.storageLabel,
@ -145,5 +155,6 @@ export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto {
quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes,
status: entity.status,
license: license ?? null,
};
}

View File

@ -15,6 +15,7 @@ export enum SystemMetadataKey {
ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config',
VERSION_CHECK_STATE = 'version-check-state',
LICENSE = 'license',
}
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
@ -24,4 +25,5 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
[SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date };
}

View File

@ -73,8 +73,10 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences
export enum UserMetadataKey {
PREFERENCES = 'preferences',
LICENSE = 'license',
}
export interface UserMetadata extends Record<UserMetadataKey, Record<string, any>> {
[UserMetadataKey.PREFERENCES]: DeepPartial<UserPreferences>;
[UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date };
}

View File

@ -5,6 +5,7 @@ export interface ICryptoRepository {
randomUUID(): string;
hashFile(filePath: string | Buffer): Promise<Buffer>;
hashSha256(data: string): string;
verifySha256(data: string, encrypted: string, publicKey: string): boolean;
hashSha1(data: string | Buffer): Buffer;
hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
compareBcrypt(data: string | Buffer, encrypted: string): boolean;

View File

@ -5,5 +5,6 @@ export const ISystemMetadataRepository = 'ISystemMetadataRepository';
export interface ISystemMetadataRepository {
get<T extends keyof SystemMetadata>(key: T): Promise<SystemMetadata[T] | null>;
set<T extends keyof SystemMetadata>(key: T, value: SystemMetadata[T]): Promise<void>;
delete<T extends keyof SystemMetadata>(key: T): Promise<void>;
readFile(filename: string): Promise<string>;
}

View File

@ -33,6 +33,7 @@ export interface IUserRepository {
create(user: Partial<UserEntity>): Promise<UserEntity>;
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
upsertMetadata<T extends keyof UserMetadata>(id: string, item: { key: T; value: UserMetadata[T] }): Promise<void>;
deleteMetadata<T extends keyof UserMetadata>(id: string, key: T): Promise<void>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;
updateUsage(id: string, delta: number): Promise<void>;
syncUsage(id?: string): Promise<void>;

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { compareSync, hash } from 'bcrypt';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto';
import { createReadStream } from 'node:fs';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { Instrumentation } from 'src/utils/instrumentation';
@ -28,6 +28,21 @@ export class CryptoRepository implements ICryptoRepository {
return createHash('sha256').update(value).digest('base64');
}
verifySha256(value: string, encryptedValue: string, publicKey: string) {
const publicKeyBuffer = Buffer.from(publicKey, 'base64');
const cryptoPublicKey = createPublicKey({
key: publicKeyBuffer,
type: 'spki',
format: 'pem',
});
const verifier = createVerify('SHA256');
verifier.update(value);
verifier.end();
const encryptedValueBuffer = Buffer.from(encryptedValue, 'base64');
return verifier.verify(cryptoPublicKey, encryptedValueBuffer);
}
hashSha1(value: string | Buffer): Buffer {
return createHash('sha1').update(value).digest();
}

View File

@ -26,6 +26,10 @@ export class SystemMetadataRepository implements ISystemMetadataRepository {
await this.repository.upsert({ key, value }, { conflictPaths: { key: true } });
}
async delete<T extends keyof SystemMetadata>(key: T): Promise<void> {
await this.repository.delete({ key });
}
readFile(filename: string): Promise<string> {
return readFile(filename, { encoding: 'utf8' });
}

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity';
import { UserMetadata, UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity';
import { UserMetadata, UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import {
IUserRepository,
@ -89,13 +89,14 @@ export class UserRepository implements IUserRepository {
return this.save({ ...user, id });
}
async upsertMetadata<T extends UserMetadataKey.PREFERENCES>(
id: string,
{ key, value }: { key: T; value: UserMetadata[T] },
) {
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
await this.metadataRepository.upsert({ userId: id, key, value }, { conflictPaths: { userId: true, key: true } });
}
async deleteMetadata<T extends keyof UserMetadata>(id: string, key: T) {
await this.metadataRepository.delete({ userId: id, key });
}
async delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user);
}

View File

@ -1,9 +1,12 @@
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerService } from 'src/services/server.service';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
@ -18,6 +21,7 @@ describe(ServerService.name, () => {
let serverInfoMock: Mocked<IServerInfoRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
beforeEach(() => {
storageMock = newStorageRepositoryMock();
@ -25,8 +29,9 @@ describe(ServerService.name, () => {
serverInfoMock = newServerInfoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock);
sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock);
});
it('should work', () => {
@ -249,4 +254,33 @@ describe(ServerService.name, () => {
expect(userMock.getUserStats).toHaveBeenCalled();
});
});
describe('setLicense', () => {
it('should save license if valid', async () => {
systemMock.set.mockResolvedValue();
const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' };
await sut.setLicense(license);
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object));
});
it('should not save license if invalid', async () => {
userMock.upsertMetadata.mockResolvedValue();
const license = { licenseKey: 'license-key', activationKey: 'activation-key' };
const call = sut.setLicense(license);
await expect(call).rejects.toThrowError('Invalid license key');
expect(userMock.upsertMetadata).not.toHaveBeenCalled();
});
});
describe('deleteLicense', () => {
it('should delete license', async () => {
userMock.upsertMetadata.mockResolvedValue();
await sut.deleteLicense();
expect(userMock.upsertMetadata).not.toHaveBeenCalled();
});
});
});

View File

@ -1,8 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { getBuildMetadata } from 'src/config';
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { getBuildMetadata, getServerLicensePublicKey } from 'src/config';
import { serverVersion } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import {
ServerAboutResponseDto,
ServerConfigDto,
@ -14,6 +15,7 @@ import {
UsageByUserDto,
} from 'src/dtos/server.dto';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { OnEvents } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
@ -34,6 +36,7 @@ export class ServerService implements OnEvents {
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
@Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
) {
this.logger.setContext(ServerService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
@ -53,10 +56,12 @@ export class ServerService implements OnEvents {
const version = `v${serverVersion.toString()}`;
const buildMetadata = getBuildMetadata();
const buildVersions = await this.serverInfoRepository.getBuildVersions();
const licensed = await this.systemMetadataRepository.get(SystemMetadataKey.LICENSE);
return {
version,
versionUrl: `https://github.com/immich-app/immich/releases/tag/${version}`,
licensed: !!licensed,
...buildMetadata,
...buildVersions,
};
@ -154,4 +159,36 @@ export class ServerService implements OnEvents {
sidecar: Object.keys(mimeTypes.sidecar),
};
}
async deleteLicense(): Promise<void> {
await this.systemMetadataRepository.delete(SystemMetadataKey.LICENSE);
}
async getLicense(): Promise<LicenseKeyDto | null> {
return this.systemMetadataRepository.get(SystemMetadataKey.LICENSE);
}
async setLicense(dto: LicenseKeyDto): Promise<LicenseResponseDto> {
if (!dto.licenseKey.startsWith('IMSV-')) {
throw new BadRequestException('Invalid license key');
}
const licenseValid = this.cryptoRepository.verifySha256(
dto.licenseKey,
dto.activationKey,
getServerLicensePublicKey(),
);
if (!licenseValid) {
throw new BadRequestException('Invalid license key');
}
const licenseData = {
...dto,
activatedAt: new Date(),
};
await this.systemMetadataRepository.set(SystemMetadataKey.LICENSE, licenseData);
return licenseData;
}
}

View File

@ -1,4 +1,5 @@
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
@ -285,6 +286,38 @@ describe(UserService.name, () => {
});
});
describe('setLicense', () => {
it('should save license if valid', async () => {
userMock.upsertMetadata.mockResolvedValue();
const license = { licenseKey: 'IMCL-license-key', activationKey: 'activation-key' };
await sut.setLicense(authStub.user1, license);
expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, {
key: UserMetadataKey.LICENSE,
value: expect.any(Object),
});
});
it('should not save license if invalid', async () => {
userMock.upsertMetadata.mockResolvedValue();
const license = { licenseKey: 'license-key', activationKey: 'activation-key' };
const call = sut.setLicense(authStub.admin, license);
await expect(call).rejects.toThrowError('Invalid license key');
expect(userMock.upsertMetadata).not.toHaveBeenCalled();
});
});
describe('deleteLicense', () => {
it('should delete license', async () => {
userMock.upsertMetadata.mockResolvedValue();
await sut.deleteLicense(authStub.admin);
expect(userMock.upsertMetadata).not.toHaveBeenCalled();
});
});
describe('handleUserSyncUsage', () => {
it('should sync usage', async () => {
await sut.handleUserSyncUsage();

View File

@ -1,13 +1,15 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { getClientLicensePublicKey } from 'src/config';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto';
import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto';
import { mapPreferences, UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { UserMetadataKey } from 'src/entities/user-metadata.entity';
import { mapUser, mapUserAdmin, UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
@ -123,6 +125,47 @@ export class UserService {
});
}
getLicense({ user }: AuthDto): LicenseResponseDto {
const license = user.metadata.find(
(item): item is UserMetadataEntity<UserMetadataKey.LICENSE> => item.key === UserMetadataKey.LICENSE,
);
if (!license) {
throw new NotFoundException();
}
return license.value;
}
async deleteLicense({ user }: AuthDto): Promise<void> {
await this.userRepository.deleteMetadata(user.id, UserMetadataKey.LICENSE);
}
async setLicense(auth: AuthDto, license: LicenseKeyDto): Promise<LicenseResponseDto> {
if (!license.licenseKey.startsWith('IMCL-')) {
throw new BadRequestException('Invalid license key');
}
const licenseValid = this.cryptoRepository.verifySha256(
license.licenseKey,
license.activationKey,
getClientLicensePublicKey(),
);
if (!licenseValid) {
throw new BadRequestException('Invalid license key');
}
const licenseData = {
...license,
activatedAt: new Date(),
};
await this.userRepository.upsertMetadata(auth.user.id, {
key: UserMetadataKey.LICENSE,
value: licenseData,
});
return licenseData;
}
async handleUserSyncUsage(): Promise<JobStatus> {
await this.userRepository.syncUsage();
return JobStatus.SUCCESS;

View File

@ -1,6 +1,7 @@
import { AuthDto } from 'src/dtos/auth.dto';
import { SessionEntity } from 'src/entities/session.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
export const authStub = {
@ -9,6 +10,7 @@ export const authStub = {
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
}),
user1: Object.freeze<AuthDto>({
@ -16,6 +18,7 @@ export const authStub = {
id: 'user-id',
email: 'immich@test.com',
isAdmin: false,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
session: {
id: 'token-id',
@ -26,6 +29,7 @@ export const authStub = {
id: 'user-2',
email: 'user2@immich.app',
isAdmin: false,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
session: {
id: 'token-id',
@ -36,6 +40,7 @@ export const authStub = {
id: 'user-id',
email: 'immich@test.com',
isAdmin: false,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
session: {
id: 'token-id',
@ -46,6 +51,7 @@ export const authStub = {
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
sharedLink: {
id: '123',
@ -60,6 +66,7 @@ export const authStub = {
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
sharedLink: {
id: '123',
@ -74,6 +81,7 @@ export const authStub = {
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
sharedLink: {
id: '123',
@ -87,6 +95,7 @@ export const authStub = {
id: 'admin_id',
email: 'admin@test.com',
isAdmin: true,
metadata: [] as UserMetadataEntity[],
} as UserEntity,
sharedLink: {
id: '123',

View File

@ -8,6 +8,7 @@ export const newCryptoRepositoryMock = (): Mocked<ICryptoRepository> => {
compareBcrypt: vitest.fn().mockReturnValue(true),
hashBcrypt: vitest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)),
hashSha256: vitest.fn().mockImplementation((input) => `${input} (hashed)`),
verifySha256: vitest.fn().mockImplementation(() => true),
hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)),
hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`),
newPassword: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')),

View File

@ -10,6 +10,7 @@ export const newSystemMetadataRepositoryMock = (reset = true): Mocked<ISystemMet
return {
get: vitest.fn() as any,
set: vitest.fn(),
delete: vitest.fn(),
readFile: vitest.fn(),
};
};

View File

@ -23,5 +23,6 @@ export const newUserRepositoryMock = (reset = true): Mocked<IUserRepository> =>
updateUsage: vitest.fn(),
syncUsage: vitest.fn(),
upsertMetadata: vitest.fn(),
deleteMetadata: vitest.fn(),
};
};

View File

@ -26,4 +26,9 @@ export const userAdminFactory = Sync.makeFactory<UserAdminResponseDto>({
shouldChangePassword: false,
status: UserStatus.Active,
storageLabel: null,
license: {
licenseKey: 'IMCL-license-key',
activationKey: 'activation-key',
activatedAt: new Date().toISOString(),
},
});