소스 검색

feat: add server error strings for apps.service

Nicolas Meienberger 2 년 전
부모
커밋
e9b7f9a73f
4개의 변경된 파일73개의 추가작업 그리고 47개의 파일을 삭제
  1. 13 1
      src/client/messages/en.json
  2. 23 21
      src/server/services/apps/apps.service.test.ts
  3. 29 23
      src/server/services/apps/apps.service.ts
  4. 8 2
      src/server/utils/errors.ts

+ 13 - 1
src/client/messages/en.json

@@ -17,7 +17,19 @@
       "totp-session-not-found": "2FA session not found",
       "totp-not-enabled": "2FA is not enabled for this user",
       "totp-invalid-code": "Invalid 2FA code",
-      "totp-already-enabled": "2FA is already enabled for this user"
+      "totp-already-enabled": "2FA is already enabled for this user",
+      "app-not-found": "App {id} not found",
+      "app-failed-to-start": "Failed to start app {id}, see logs for more details",
+      "app-failed-to-install": "Failed to install app {id}, see logs for more details",
+      "app-failed-to-stop": "Failed to stop app {id}, see logs for more details",
+      "app-failed-to-uninstall": "Failed to uninstall app {id}, see logs for more details",
+      "app-failed-to-update": "Failed to update app {id}, see logs for more details",
+      "domain-required-if-expose-app": "Domain is required if app is exposed",
+      "domain-not-valid": "Domain {domain} is not a valid domain",
+      "invalid-config": "App {id} has an invalid config.json file",
+      "app-not-exposable": "App {id} is not exposable",
+      "app-force-exposed": "App {id} works only with exposed domain",
+      "domain-already-in-use": "Domain {domain} is already in use by app {id}"
     },
     "success": {}
   },

+ 23 - 21
src/server/services/apps/apps.service.test.ts

@@ -71,7 +71,7 @@ describe('Install app', () => {
     // Arrange
     EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
 
-    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow(`App ${app1.id} failed to install\nstdout: error`);
+    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow('server-messages.errors.app-failed-to-install');
 
     const app = await getAppById(app1.id, db);
 
@@ -121,11 +121,11 @@ describe('Install app', () => {
   });
 
   it('Should throw if app is exposed and domain is not provided', async () => {
-    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required if app is exposed');
+    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app');
   });
 
   it('Should throw if app is exposed and config does not allow it', async () => {
-    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`);
+    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
   });
 
   it('Should throw if app is exposed and domain is not valid', async () => {
@@ -133,7 +133,7 @@ describe('Install app', () => {
     // @ts-expect-error - Mocking fs
     fs.__createMockFiles(MockFiles);
 
-    await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid');
+    await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('server-messages.errors.domain-not-valid');
   });
 
   it('Should throw if app is exposed and domain is already used', async () => {
@@ -144,7 +144,7 @@ describe('Install app', () => {
 
     await AppsService.installApp(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
 
-    await expect(AppsService.installApp(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
+    await expect(AppsService.installApp(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError('server-messages.errors.domain-already-in-use');
   });
 
   it('Should throw if architecure is not supported', async () => {
@@ -211,7 +211,7 @@ describe('Install app', () => {
     fs.__createMockFiles(MockFiles);
 
     // act & assert
-    await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} works only with exposed domain`);
+    await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError();
   });
 });
 
@@ -261,7 +261,7 @@ describe('Uninstall app', () => {
 
   it('Should throw if app is not installed', async () => {
     // Act & Assert
-    await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found');
+    await expect(AppsService.uninstallApp('any')).rejects.toThrowError('server-messages.errors.app-not-found');
   });
 
   it('Should throw if uninstall script fails', async () => {
@@ -270,7 +270,7 @@ describe('Uninstall app', () => {
     await updateApp(app1.id, { status: 'updating' }, db);
 
     // Act & Assert
-    await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
+    await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow('server-messages.errors.app-failed-to-uninstall');
     const app = await getAppById(app1.id, db);
     expect(app?.status).toBe('stopped');
   });
@@ -297,7 +297,7 @@ describe('Start app', () => {
   });
 
   it('Should throw if app is not installed', async () => {
-    await expect(AppsService.startApp('any')).rejects.toThrowError('App any not found');
+    await expect(AppsService.startApp('any')).rejects.toThrowError('server-messages.errors.app-not-found');
   });
 
   it('Should restart if app is already running', async () => {
@@ -326,7 +326,7 @@ describe('Start app', () => {
     EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
 
     // Act & Assert
-    await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`);
+    await expect(AppsService.startApp(app1.id)).rejects.toThrow('server-messages.errors.app-failed-to-start');
     const app = await getAppById(app1.id, db);
     expect(app?.status).toBe('stopped');
   });
@@ -351,7 +351,7 @@ describe('Stop app', () => {
   });
 
   it('Should throw if app is not installed', async () => {
-    await expect(AppsService.stopApp('any')).rejects.toThrowError('App any not found');
+    await expect(AppsService.stopApp('any')).rejects.toThrowError('server-messages.errors.app-not-found');
   });
 
   it('Should throw if stop script fails', async () => {
@@ -359,7 +359,7 @@ describe('Stop app', () => {
     EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
 
     // Act & Assert
-    await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`);
+    await expect(AppsService.stopApp(app1.id)).rejects.toThrow('server-messages.errors.app-failed-to-stop');
     const app = await getAppById(app1.id, db);
     expect(app?.status).toBe('running');
   });
@@ -388,7 +388,7 @@ describe('Update app config', () => {
   });
 
   it('Should throw if app is not installed', async () => {
-    await expect(AppsService.updateAppConfig('test-app-2', { test: 'test' })).rejects.toThrowError('App test-app-2 not found');
+    await expect(AppsService.updateAppConfig('test-app-2', { test: 'test' })).rejects.toThrowError('server-messages.errors.app-not-found');
   });
 
   it('Should not recreate random field if already present in .env', async () => {
@@ -406,13 +406,15 @@ describe('Update app config', () => {
     expect(envMap.get('RANDOM_FIELD')).toBe('test');
   });
 
-  it('Should throw if app is exposed and domain is not provided', () => expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required'));
+  it('Should throw if app is exposed and domain is not provided', () =>
+    expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('server-messages.errors.domain-required-if-expose-app'));
 
   it('Should throw if app is exposed and domain is not valid', () =>
-    expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid'));
+    expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('server-messages.errors.domain-not-valid'));
 
-  it('Should throw if app is exposed and config does not allow it', () =>
-    expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`));
+  it('Should throw if app is exposed and config does not allow it', () => {
+    expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
+  });
 
   it('Should throw if app is exposed and domain is already used', async () => {
     const app2 = await createApp({ exposable: true, installed: true }, db);
@@ -421,7 +423,7 @@ describe('Update app config', () => {
     fs.__createMockFiles(Object.assign(app2.MockFiles, app3.MockFiles));
 
     await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
-    await expect(AppsService.updateAppConfig(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
+    await expect(AppsService.updateAppConfig(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError('server-messages.errors.domain-already-in-use');
   });
 
   it('Should not throw if updating with same domain', async () => {
@@ -451,7 +453,7 @@ describe('Update app config', () => {
     fs.__createMockFiles(MockFiles);
 
     // act & assert
-    await expect(AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} works only with exposed domain`);
+    await expect(AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError('server-messages.errors.app-force-exposed');
   });
 });
 
@@ -593,7 +595,7 @@ describe('Update app', () => {
   });
 
   it("Should throw if app doesn't exist", async () => {
-    await expect(AppsService.updateApp('test-app2')).rejects.toThrow('App test-app2 not found');
+    await expect(AppsService.updateApp('test-app2')).rejects.toThrow('server-messages.errors.app-not-found');
   });
 
   it('Should throw if update script fails', async () => {
@@ -604,7 +606,7 @@ describe('Update app', () => {
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
     EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
 
-    await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`);
+    await expect(AppsService.updateApp(app1.id)).rejects.toThrow('server-messages.errors.app-failed-to-update');
     const app = await getAppById(app1.id, db);
     expect(app?.status).toBe('stopped');
   });

+ 29 - 23
src/server/services/apps/apps.service.ts

@@ -2,6 +2,7 @@ import validator from 'validator';
 import { NodePgDatabase } from 'drizzle-orm/node-postgres';
 import { App } from '@/server/db/schema';
 import { AppQueries } from '@/server/queries/apps/apps.queries';
+import { TranslatedError } from '@/server/utils/errors';
 import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, AppInfo, getAppInfo, getUpdateInfo } from './apps.helpers';
 import { getConfig } from '../../core/TipiConfig';
 import { EventDispatcher } from '../../core/EventDispatcher';
@@ -75,7 +76,7 @@ export class AppServiceClass {
   public startApp = async (appName: string) => {
     const app = await this.queries.getApp(appName);
     if (!app) {
-      throw new Error(`App ${appName} not found`);
+      throw new TranslatedError('server-messages.errors.app-not-found', { id: appName });
     }
 
     ensureAppFolder(appName);
@@ -90,7 +91,8 @@ export class AppServiceClass {
       await this.queries.updateApp(appName, { status: 'running' });
     } else {
       await this.queries.updateApp(appName, { status: 'stopped' });
-      throw new Error(`App ${appName} failed to start\nstdout: ${stdout}`);
+      Logger.error(`Failed to start app ${appName}: ${stdout}`);
+      throw new TranslatedError('server-messages.errors.app-failed-to-start', { id: appName });
     }
 
     const updatedApp = await this.queries.getApp(appName);
@@ -112,11 +114,11 @@ export class AppServiceClass {
       await this.startApp(id);
     } else {
       if (exposed && !domain) {
-        throw new Error('Domain is required if app is exposed');
+        throw new TranslatedError('server-messages.errors.domain-required-if-expose-app');
       }
 
       if (domain && !validator.isFQDN(domain)) {
-        throw new Error(`Domain ${domain} is not valid`);
+        throw new TranslatedError('server-messages.errors.domain-not-valid', { domain });
       }
 
       ensureAppFolder(id, true);
@@ -128,22 +130,22 @@ export class AppServiceClass {
       const appInfo = getAppInfo(id);
 
       if (!appInfo) {
-        throw new Error(`App ${id} has invalid config.json file`);
+        throw new TranslatedError('server-messages.errors.invalid-config', { id });
       }
 
       if (!appInfo.exposable && exposed) {
-        throw new Error(`App ${id} is not exposable`);
+        throw new TranslatedError('server-messages.errors.app-not-exposable', { id });
       }
 
       if ((appInfo.force_expose && !exposed) || (appInfo.force_expose && !domain)) {
-        throw new Error(`App ${id} works only with exposed domain`);
+        throw new TranslatedError('server-messages.errors.app-force-exposed', { id });
       }
 
       if (exposed && domain) {
         const appsWithSameDomain = await this.queries.getAppsByDomain(domain, id);
 
         if (appsWithSameDomain.length > 0) {
-          throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
+          throw new TranslatedError('server-messages.errors.domain-already-in-use', { domain, id: appsWithSameDomain[0]?.id });
         }
       }
 
@@ -159,7 +161,8 @@ export class AppServiceClass {
 
       if (!success) {
         await this.queries.deleteApp(id);
-        throw new Error(`App ${id} failed to install\nstdout: ${stdout}`);
+        Logger.error(`Failed to install app ${id}: ${stdout}`);
+        throw new TranslatedError('server-messages.errors.app-failed-to-install', { id });
       }
     }
 
@@ -187,38 +190,38 @@ export class AppServiceClass {
    */
   public updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
     if (exposed && !domain) {
-      throw new Error('Domain is required if app is exposed');
+      throw new TranslatedError('server-messages.errors.domain-required-if-expose-app');
     }
 
     if (domain && !validator.isFQDN(domain)) {
-      throw new Error(`Domain ${domain} is not valid`);
+      throw new TranslatedError('server-messages.errors.domain-not-valid');
     }
 
     const app = await this.queries.getApp(id);
 
     if (!app) {
-      throw new Error(`App ${id} not found`);
+      throw new TranslatedError('server-messages.errors.app-not-found', { id });
     }
 
     const appInfo = getAppInfo(app.id, app.status);
 
     if (!appInfo) {
-      throw new Error(`App ${id} has invalid config.json`);
+      throw new TranslatedError('server-messages.errors.invalid-config', { id });
     }
 
     if (!appInfo.exposable && exposed) {
-      throw new Error(`App ${id} is not exposable`);
+      throw new TranslatedError('server-messages.errors.app-not-exposable', { id });
     }
 
     if ((appInfo.force_expose && !exposed) || (appInfo.force_expose && !domain)) {
-      throw new Error(`App ${id} works only with exposed domain`);
+      throw new TranslatedError('server-messages.errors.app-force-exposed', { id });
     }
 
     if (exposed && domain) {
       const appsWithSameDomain = await this.queries.getAppsByDomain(domain, id);
 
       if (appsWithSameDomain.length > 0) {
-        throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
+        throw new TranslatedError('server-messages.errors.domain-already-in-use', { domain, id: appsWithSameDomain[0]?.id });
       }
     }
 
@@ -241,7 +244,7 @@ export class AppServiceClass {
     const app = await this.queries.getApp(id);
 
     if (!app) {
-      throw new Error(`App ${id} not found`);
+      throw new TranslatedError('server-messages.errors.app-not-found', { id });
     }
 
     ensureAppFolder(id);
@@ -256,7 +259,8 @@ export class AppServiceClass {
       await this.queries.updateApp(id, { status: 'stopped' });
     } else {
       await this.queries.updateApp(id, { status: 'running' });
-      throw new Error(`App ${id} failed to stop\nstdout: ${stdout}`);
+      Logger.error(`Failed to stop app ${id}: ${stdout}`);
+      throw new TranslatedError('server-messages.errors.app-failed-to-stop', { id });
     }
 
     const updatedApp = await this.queries.getApp(id);
@@ -273,7 +277,7 @@ export class AppServiceClass {
     const app = await this.queries.getApp(id);
 
     if (!app) {
-      throw new Error(`App ${id} not found`);
+      throw new TranslatedError('server-messages.errors.app-not-found', { id });
     }
     if (app.status === 'running') {
       await this.stopApp(id);
@@ -288,7 +292,8 @@ export class AppServiceClass {
 
     if (!success) {
       await this.queries.updateApp(id, { status: 'stopped' });
-      throw new Error(`App ${id} failed to uninstall\nstdout: ${stdout}`);
+      Logger.error(`Failed to uninstall app ${id}: ${stdout}`);
+      throw new TranslatedError('server-messages.errors.app-failed-to-uninstall', { id });
     }
 
     await this.queries.deleteApp(id);
@@ -314,7 +319,7 @@ export class AppServiceClass {
       return { ...app, ...updateInfo, info };
     }
 
-    throw new Error(`App ${id} has invalid config.json`);
+    throw new TranslatedError('server-messages.errors.invalid-config', { id });
   };
 
   /**
@@ -327,7 +332,7 @@ export class AppServiceClass {
     const app = await this.queries.getApp(id);
 
     if (!app) {
-      throw new Error(`App ${id} not found`);
+      throw new TranslatedError('server-messages.errors.app-not-found', { id });
     }
 
     ensureAppFolder(id);
@@ -343,7 +348,8 @@ export class AppServiceClass {
       await this.queries.updateApp(id, { status: 'running', version: appInfo?.tipi_version });
     } else {
       await this.queries.updateApp(id, { status: 'stopped' });
-      throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
+      Logger.error(`Failed to update app ${id}: ${stdout}`);
+      throw new TranslatedError('server-messages.errors.app-failed-to-update', { id });
     }
 
     const updatedApp = await this.queries.updateApp(id, { status: 'stopped' });

+ 8 - 2
src/server/utils/errors.ts

@@ -1,13 +1,19 @@
-import { createTranslator } from 'next-intl';
+import { TranslationValues, createTranslator } from 'next-intl';
+import { Logger } from '@/server/core/Logger';
 import messages from '../../client/messages/en.json';
 
 const t = createTranslator({ locale: 'en', messages });
 export type MessageKey = Parameters<typeof t>[0];
 
 export class TranslatedError extends Error {
-  constructor(message: MessageKey) {
+  public readonly variableValues: TranslationValues;
+
+  constructor(message: MessageKey, variableValues: TranslationValues = {}) {
     super(message);
 
+    Logger.error(`server error: ${t(message, variableValues)}`);
+
     this.name = 'TranslatedError';
+    this.variableValues = variableValues;
   }
 }