Ver código fonte

chore: fix eslint rules

Nicolas Meienberger 2 anos atrás
pai
commit
35ebb1069a
30 arquivos alterados com 241 adições e 251 exclusões
  1. 8 7
      packages/system-api/.eslintrc.cjs
  2. 1 1
      packages/system-api/__mocks__/child_process.ts
  3. 8 10
      packages/system-api/__mocks__/fs-extra.ts
  4. 3 9
      packages/system-api/__mocks__/redis.ts
  5. 1 1
      packages/system-api/src/config/datasource.ts
  6. 2 4
      packages/system-api/src/config/logger/apollo.logger.ts
  7. 2 2
      packages/system-api/src/core/config/TipiConfig.ts
  8. 9 8
      packages/system-api/src/core/config/__tests__/EventDispatcher.test.ts
  9. 1 1
      packages/system-api/src/core/jobs/__tests__/jobs.test.ts
  10. 1 1
      packages/system-api/src/core/jobs/jobs.ts
  11. 1 3
      packages/system-api/src/core/updates/__tests__/v040.test.ts
  12. 17 11
      packages/system-api/src/core/updates/recover-migrations.ts
  13. 37 33
      packages/system-api/src/core/updates/v040.ts
  14. 2 1
      packages/system-api/src/modules/apps/__tests__/apps.factory.ts
  15. 77 3
      packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts
  16. 2 2
      packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts
  17. 16 20
      packages/system-api/src/modules/apps/__tests__/apps.service.test.ts
  18. 1 1
      packages/system-api/src/modules/apps/app.entity.ts
  19. 6 0
      packages/system-api/src/modules/apps/app.types.ts
  20. 24 13
      packages/system-api/src/modules/apps/apps.helpers.ts
  21. 6 12
      packages/system-api/src/modules/apps/apps.service.ts
  22. 2 2
      packages/system-api/src/modules/auth/__tests__/auth.resolver.test.ts
  23. 2 2
      packages/system-api/src/modules/auth/__tests__/auth.service.test.ts
  24. 1 1
      packages/system-api/src/modules/auth/__tests__/user.factory.ts
  25. 0 1
      packages/system-api/src/modules/auth/auth.types.ts
  26. 6 82
      packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts
  27. 0 15
      packages/system-api/src/modules/fs/fs.helpers.ts
  28. 1 1
      packages/system-api/src/modules/system/__tests__/system.service.test.ts
  29. 3 3
      packages/system-api/src/server.ts
  30. 1 1
      packages/system-api/src/test/connection.ts

+ 8 - 7
packages/system-api/.eslintrc.cjs

@@ -1,6 +1,6 @@
 module.exports = {
-  env: { node: true, jest: true },
-  extends: ['airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
+  plugins: ['@typescript-eslint', 'import', 'react'],
+  extends: ['airbnb-base', 'airbnb-typescript/base', 'eslint:recommended', 'plugin:import/typescript', 'plugin:@typescript-eslint/recommended', 'prettier'],
   parser: '@typescript-eslint/parser',
   parserOptions: {
     project: './tsconfig.json',
@@ -8,18 +8,19 @@ module.exports = {
     ecmaVersion: 'latest',
     sourceType: 'module',
   },
-  plugins: ['@typescript-eslint', 'import', 'react'],
   rules: {
-    'arrow-body-style': 0,
-    'no-restricted-exports': 0,
     'max-len': [1, { code: 200 }],
     'import/extensions': ['error', 'ignorePackages', { js: 'never', jsx: 'never', ts: 'never', tsx: 'never' }],
-    indent: 'off',
-    '@typescript-eslint/indent': 0,
     'no-unused-vars': [1, { argsIgnorePattern: '^_' }],
     '@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }],
+    'max-classes-per-file': 0,
+    'class-methods-use-this': 0,
+    'import/prefer-default-export': 0,
+    'no-underscore-dangle': 0,
+    '@typescript-eslint/ban-ts-comment': 0,
   },
   globals: {
     NodeJS: true,
   },
+  env: { node: true, jest: true },
 };

+ 1 - 1
packages/system-api/__mocks__/child_process.ts

@@ -1,6 +1,6 @@
 const childProcess: { execFile: typeof execFile } = jest.genMockFromModule('child_process');
 
-const execFile = (_path: string, _args: string[], _thing: any, callback: Function) => {
+const execFile = (_path: string, _args: string[], _thing: any, callback: any) => {
   callback();
 };
 

+ 8 - 10
packages/system-api/__mocks__/fs-extra.ts

@@ -1,4 +1,5 @@
 import path from 'path';
+
 const fs: {
   __createMockFiles: typeof createMockFiles;
   __resetAllMocks: typeof resetAllMocks;
@@ -20,7 +21,7 @@ const createMockFiles = (newMockFiles: Record<string, string>) => {
   mockFiles = Object.create(null);
 
   // Create folder tree
-  for (const file in newMockFiles) {
+  Object.keys(newMockFiles).forEach((file) => {
     const dir = path.dirname(file);
 
     if (!mockFiles[dir]) {
@@ -29,16 +30,12 @@ const createMockFiles = (newMockFiles: Record<string, string>) => {
 
     mockFiles[dir].push(path.basename(file));
     mockFiles[file] = newMockFiles[file];
-  }
+  });
 };
 
-const readFileSync = (p: string) => {
-  return mockFiles[p];
-};
+const readFileSync = (p: string) => mockFiles[p];
 
-const existsSync = (p: string) => {
-  return mockFiles[p] !== undefined;
-};
+const existsSync = (p: string) => mockFiles[p] !== undefined;
 
 const writeFileSync = (p: string, data: any) => {
   mockFiles[p] = data;
@@ -85,7 +82,7 @@ const copySync = (source: string, destination: string) => {
 
   if (mockFiles[source] instanceof Array) {
     mockFiles[source].forEach((file: string) => {
-      mockFiles[destination + '/' + file] = mockFiles[source + '/' + file];
+      mockFiles[`${destination}/${file}`] = mockFiles[`${source}/${file}`];
     });
   }
 };
@@ -120,4 +117,5 @@ fs.createFileSync = createFileSync;
 fs.__createMockFiles = createMockFiles;
 fs.__resetAllMocks = resetAllMocks;
 
-module.exports = fs;
+export default fs;
+// module.exports = fs;

+ 3 - 9
packages/system-api/__mocks__/redis.ts

@@ -9,16 +9,10 @@ module.exports = {
         values.set(key, value);
         expirations.set(key, exp);
       },
-      get: (key: string) => {
-        return values.get(key);
-      },
+      get: (key: string) => values.get(key),
       quit: jest.fn(),
-      del: (key: string) => {
-        return values.delete(key);
-      },
-      ttl: (key: string) => {
-        return expirations.get(key);
-      },
+      del: (key: string) => values.delete(key),
+      ttl: (key: string) => expirations.get(key),
     };
   }),
 };

+ 1 - 1
packages/system-api/src/config/datasource.ts

@@ -22,5 +22,5 @@ export default new DataSource({
   logging: !__prod__,
   synchronize: false,
   entities: [App, User, Update],
-  migrations: [process.cwd() + '/dist/config/migrations/*.js'],
+  migrations: [`${process.cwd()}/dist/config/migrations/*.js`],
 });

+ 2 - 4
packages/system-api/src/config/logger/apollo.logger.ts

@@ -4,15 +4,13 @@ import { __prod__ } from '../constants/constants';
 import logger from './logger';
 
 const ApolloLogs: PluginDefinition = {
-  requestDidStart: async () => {
-    return {
+  requestDidStart: async () => ({
       async didEncounterErrors(errors) {
         if (!__prod__) {
           logger.error(JSON.stringify(errors.errors));
         }
       },
-    };
-  },
+    }),
 };
 
 export { ApolloLogs };

+ 2 - 2
packages/system-api/src/core/config/TipiConfig.ts

@@ -105,7 +105,7 @@ class Config {
     this.config = parsed;
   }
 
-  public setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile: boolean = false) {
+  public setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) {
     const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
     newConf[key] = value;
 
@@ -122,7 +122,7 @@ class Config {
   }
 }
 
-export const setConfig = <T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile: boolean = false) => {
+export const setConfig = <T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) => {
   Config.getInstance().setConfig(key, value, writeFile);
 };
 

+ 9 - 8
packages/system-api/src/core/config/__tests__/EventDispatcher.test.ts

@@ -5,6 +5,7 @@ const WATCH_FILE = '/runtipi/state/events';
 
 jest.mock('fs-extra');
 
+// eslint-disable-next-line no-promise-executor-return
 const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
 
 beforeEach(() => {
@@ -29,7 +30,7 @@ describe('EventDispatcher - dispatchEvent', () => {
     eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
 
     // @ts-ignore
-    const queue = eventDispatcher.queue;
+    const { queue } = eventDispatcher;
 
     expect(queue.length).toBe(2);
   });
@@ -39,12 +40,12 @@ describe('EventDispatcher - dispatchEvent', () => {
     eventDispatcher.dispatchEvent(EventTypes.UPDATE, ['--help']);
 
     // @ts-ignore
-    const queue = eventDispatcher.queue;
+    const { queue } = eventDispatcher;
 
     await wait(1050);
 
     // @ts-ignore
-    const lock = eventDispatcher.lock;
+    const { lock } = eventDispatcher;
 
     expect(queue.length).toBe(2);
     expect(lock).toBeDefined();
@@ -59,7 +60,7 @@ describe('EventDispatcher - dispatchEvent', () => {
     await wait(1050);
 
     // @ts-ignore
-    const queue = eventDispatcher.queue;
+    const { queue } = eventDispatcher;
 
     expect(queue.length).toBe(0);
   });
@@ -72,7 +73,7 @@ describe('EventDispatcher - dispatchEvent', () => {
     await wait(1050);
 
     // @ts-ignore
-    const queue = eventDispatcher.queue;
+    const { queue } = eventDispatcher;
 
     expect(queue.length).toBe(0);
   });
@@ -161,7 +162,7 @@ describe('EventDispatcher - clearEvent', () => {
     eventDispatcher.clearEvent(event);
 
     // @ts-ignore
-    const queue = eventDispatcher.queue;
+    const { queue } = eventDispatcher;
 
     expect(queue.length).toBe(0);
   });
@@ -174,7 +175,7 @@ describe('EventDispatcher - pollQueue', () => {
     // @ts-ignore
     const id = eventDispatcher.pollQueue();
     // @ts-ignore
-    const interval = eventDispatcher.interval;
+    const { interval } = eventDispatcher;
 
     expect(interval).toBe(123);
     expect(id).toBe(123);
@@ -192,7 +193,7 @@ describe('EventDispatcher - collectLockStatusAndClean', () => {
     eventDispatcher.collectLockStatusAndClean();
 
     // @ts-ignore
-    const lock = eventDispatcher.lock;
+    const { lock } = eventDispatcher;
 
     expect(lock).toBeNull();
   });

+ 1 - 1
packages/system-api/src/core/jobs/__tests__/jobs.test.ts

@@ -1,7 +1,7 @@
 import cron from 'node-cron';
 import { getConfig } from '../../config/TipiConfig';
 import startJobs from '../jobs';
-import { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
+import { eventDispatcher, EventTypes } from '../../config/EventDispatcher';
 
 jest.mock('node-cron');
 jest.mock('child_process');

+ 1 - 1
packages/system-api/src/core/jobs/jobs.ts

@@ -1,6 +1,6 @@
 import cron from 'node-cron';
 import logger from '../../config/logger/logger';
-import { getConfig } from '../../core/config/TipiConfig';
+import { getConfig } from '../config/TipiConfig';
 import { eventDispatcher, EventTypes } from '../config/EventDispatcher';
 
 const startJobs = () => {

+ 1 - 3
packages/system-api/src/core/updates/__tests__/v040.test.ts

@@ -30,9 +30,7 @@ afterAll(async () => {
   await teardownConnection(TEST_SUITE);
 });
 
-const createState = (apps: string[]) => {
-  return JSON.stringify({ installed: apps.join(' ') });
-};
+const createState = (apps: string[]) => JSON.stringify({ installed: apps.join(' ') });
 
 describe('No state/apps.json', () => {
   it('Should do nothing and create the update with status SUCCES', async () => {

+ 17 - 11
packages/system-api/src/core/updates/recover-migrations.ts

@@ -1,9 +1,21 @@
-import { DataSource } from 'typeorm';
+import { BaseEntity, DataSource, DeepPartial } from 'typeorm';
 import logger from '../../config/logger/logger';
 import App from '../../modules/apps/app.entity';
 import User from '../../modules/auth/user.entity';
 import Update from '../../modules/system/update.entity';
 
+const createUser = async (user: DeepPartial<BaseEntity>): Promise<void> => {
+  await User.create(user).save();
+};
+
+const createApp = async (app: DeepPartial<BaseEntity>): Promise<void> => {
+  await App.create(app).save();
+};
+
+const createUpdate = async (update: DeepPartial<BaseEntity>): Promise<void> => {
+  await Update.create(update).save();
+};
+
 const recover = async (datasource: DataSource) => {
   logger.info('Recovering broken database');
 
@@ -18,20 +30,14 @@ const recover = async (datasource: DataSource) => {
   logger.info('running migrations');
   await datasource.runMigrations();
 
-  // create users
-  for (const user of users) {
-    await User.create(user).save();
-  }
+  // recreate users
+  await Promise.all(users.map(createUser));
 
   // create apps
-  for (const app of apps) {
-    await App.create(app).save();
-  }
+  await Promise.all(apps.map(createApp));
 
   // create updates
-  for (const update of updates) {
-    await Update.create(update).save();
-  }
+  await Promise.all(updates.map(createUpdate));
 
   logger.info(`Users recovered ${users.length}`);
   logger.info(`Apps recovered ${apps.length}`);

+ 37 - 33
packages/system-api/src/core/updates/v040.ts

@@ -10,6 +10,41 @@ type AppsState = { installed: string };
 
 const UPDATE_NAME = 'v040';
 
+const migrateApp = async (appId: string): Promise<void> => {
+  const app = await App.findOne({ where: { id: appId } });
+
+  if (!app) {
+    const envFile = readFile(`/app/storage/app-data/${appId}/app.env`).toString();
+    const envVars = envFile.split('\n');
+    const envVarsMap = new Map<string, string>();
+
+    envVars.forEach((envVar) => {
+      const [key, value] = envVar.split('=');
+      envVarsMap.set(key, value);
+    });
+
+    const form: Record<string, string> = {};
+
+    const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
+    configFile?.form_fields?.forEach((field) => {
+      const envVar = field.env_variable;
+      const envVarValue = envVarsMap.get(envVar);
+
+      if (envVarValue) {
+        form[field.env_variable] = envVarValue;
+      }
+    });
+
+    await App.create({ id: appId, status: AppStatusEnum.STOPPED, config: form }).save();
+  } else {
+    logger.info('App already migrated');
+  }
+};
+
+const migrateUser = async (user: { email: string; password: string }): Promise<void> => {
+  await User.create({ username: user.email.trim().toLowerCase(), password: user.password }).save();
+};
+
 export const updateV040 = async (): Promise<void> => {
   try {
     const update = await Update.findOne({ where: { name: UPDATE_NAME } });
@@ -24,36 +59,7 @@ export const updateV040 = async (): Promise<void> => {
       const state: AppsState = await readJsonFile('/runtipi/state/apps.json');
       const installed: string[] = state.installed.split(' ').filter(Boolean);
 
-      for (const appId of installed) {
-        const app = await App.findOne({ where: { id: appId } });
-
-        if (!app) {
-          const envFile = readFile(`/app/storage/app-data/${appId}/app.env`).toString();
-          const envVars = envFile.split('\n');
-          const envVarsMap = new Map<string, string>();
-
-          envVars.forEach((envVar) => {
-            const [key, value] = envVar.split('=');
-            envVarsMap.set(key, value);
-          });
-
-          const form: Record<string, string> = {};
-
-          const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
-          configFile?.form_fields?.forEach((field) => {
-            const envVar = field.env_variable;
-            const envVarValue = envVarsMap.get(envVar);
-
-            if (envVarValue) {
-              form[field.env_variable] = envVarValue;
-            }
-          });
-
-          await App.create({ id: appId, status: AppStatusEnum.STOPPED, config: form }).save();
-        } else {
-          logger.info('App already migrated');
-        }
-      }
+      await Promise.all(installed.map((appId) => migrateApp(appId)));
       deleteFolder('/runtipi/state/apps.json');
     }
 
@@ -61,9 +67,7 @@ export const updateV040 = async (): Promise<void> => {
     if (fileExists('/state/users.json')) {
       const state: { email: string; password: string }[] = await readJsonFile('/runtipi/state/users.json');
 
-      for (const user of state) {
-        await User.create({ username: user.email.trim().toLowerCase(), password: user.password }).save();
-      }
+      await Promise.all(state.map((user) => migrateUser(user)));
       deleteFolder('/runtipi/state/users.json');
     }
 

+ 2 - 1
packages/system-api/src/modules/apps/__tests__/apps.factory.ts

@@ -56,7 +56,7 @@ const createApp = async (props: IProps) => {
     };
   }
 
-  let MockFiles: any = {};
+  const MockFiles: any = {};
   MockFiles['/runtipi/.env'] = 'TEST=test';
   MockFiles['/runtipi/repos/repo-id'] = '';
   MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
@@ -71,6 +71,7 @@ const createApp = async (props: IProps) => {
       status,
       exposed,
       domain,
+      version: 1,
     }).save();
 
     MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';

+ 77 - 3
packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts

@@ -4,7 +4,7 @@ import { DataSource } from 'typeorm';
 import logger from '../../../config/logger/logger';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import App from '../app.entity';
-import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers';
+import { checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers';
 import { AppInfo } from '../apps.types';
 import { createApp } from './apps.factory';
 
@@ -336,15 +336,89 @@ describe('getUpdateInfo', () => {
   });
 
   it('Should return update info', async () => {
-    const updateInfo = await getUpdateInfo(app1.id);
+    const updateInfo = await getUpdateInfo(app1.id, 1);
 
     expect(updateInfo?.latest).toBe(app1.tipi_version);
     expect(updateInfo?.current).toBe(1);
   });
 
   it('Should return null if app is not installed', async () => {
-    const updateInfo = await getUpdateInfo(faker.random.word());
+    const updateInfo = await getUpdateInfo(faker.random.word(), 1);
 
     expect(updateInfo).toBeNull();
   });
 });
+
+describe('Test: ensureAppFolder', () => {
+  beforeEach(() => {
+    const mockFiles = {
+      [`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
+    };
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+  });
+
+  it('should copy the folder from repo', () => {
+    // Act
+    ensureAppFolder('test');
+
+    // Assert
+    const files = fs.readdirSync('/runtipi/apps/test');
+    expect(files).toEqual(['test.yml']);
+  });
+
+  it('should not copy the folder if it already exists', () => {
+    const mockFiles = {
+      [`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
+      '/runtipi/apps/test': ['docker-compose.yml'],
+      '/runtipi/apps/test/docker-compose.yml': 'test',
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    // Act
+    ensureAppFolder('test');
+
+    // Assert
+    const files = fs.readdirSync('/runtipi/apps/test');
+    expect(files).toEqual(['docker-compose.yml']);
+  });
+
+  it('Should overwrite the folder if clean up is true', () => {
+    const mockFiles = {
+      [`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
+      '/runtipi/apps/test': ['docker-compose.yml'],
+      '/runtipi/apps/test/docker-compose.yml': 'test',
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    // Act
+    ensureAppFolder('test', true);
+
+    // Assert
+    const files = fs.readdirSync('/runtipi/apps/test');
+    expect(files).toEqual(['test.yml']);
+  });
+
+  it('Should delete folder if it exists but has no docker-compose.yml file', () => {
+    // Arrange
+    const randomFileName = `${faker.random.word()}.yml`;
+    const mockFiles = {
+      [`/runtipi/repos/repo-id/apps/test`]: [randomFileName],
+      '/runtipi/apps/test': ['test.yml'],
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    // Act
+    ensureAppFolder('test');
+
+    // Assert
+    const files = fs.readdirSync('/runtipi/apps/test');
+    expect(files).toEqual([randomFileName]);
+  });
+});

+ 2 - 2
packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts

@@ -1,6 +1,7 @@
 import { DataSource } from 'typeorm';
-import { setupConnection, teardownConnection } from '../../../test/connection';
 import fs from 'fs-extra';
+import { faker } from '@faker-js/faker';
+import { setupConnection, teardownConnection } from '../../../test/connection';
 import { gcall } from '../../../test/gcall';
 import App from '../app.entity';
 import { getAppQuery, InstalledAppsQuery, listAppInfosQuery } from '../../../test/queries';
@@ -9,7 +10,6 @@ import { AppInfo, AppStatusEnum, ListAppsResonse } from '../apps.types';
 import { createUser } from '../../auth/__tests__/user.factory';
 import User from '../../auth/user.entity';
 import { installAppMutation, startAppMutation, stopAppMutation, uninstallAppMutation, updateAppConfigMutation, updateAppMutation } from '../../../test/mutations';
-import { faker } from '@faker-js/faker';
 import EventDispatcher from '../../../core/config/EventDispatcher';
 
 jest.mock('fs');

+ 16 - 20
packages/system-api/src/modules/apps/__tests__/apps.service.test.ts

@@ -1,10 +1,10 @@
-import AppsService from '../apps.service';
 import fs from 'fs-extra';
+import { DataSource } from 'typeorm';
+import AppsService from '../apps.service';
 import { AppInfo, AppStatusEnum, AppSupportedArchitecturesEnum } from '../apps.types';
 import App from '../app.entity';
 import { createApp } from './apps.factory';
 import { setupConnection, teardownConnection } from '../../../test/connection';
-import { DataSource } from 'typeorm';
 import { getEnvMap } from '../apps.helpers';
 import EventDispatcher, { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
 import { setConfig } from '../../../core/config/TipiConfig';
@@ -56,9 +56,9 @@ describe('Install app', () => {
     const app = await App.findOne({ where: { id: app1.id } });
 
     expect(app).toBeDefined();
-    expect(app!.id).toBe(app1.id);
-    expect(app!.config).toStrictEqual({ TEST_FIELD: 'test' });
-    expect(app!.status).toBe(AppStatusEnum.RUNNING);
+    expect(app?.id).toBe(app1.id);
+    expect(app?.config).toStrictEqual({ TEST_FIELD: 'test' });
+    expect(app?.status).toBe(AppStatusEnum.RUNNING);
   });
 
   it('Should start app if already installed', async () => {
@@ -147,7 +147,7 @@ describe('Install app', () => {
     const app2 = await createApp({ exposable: true });
     const app3 = await createApp({ exposable: true });
     // @ts-ignore
-    fs.__createMockFiles(Object.assign({}, app2.MockFiles, app3.MockFiles));
+    fs.__createMockFiles({ ...app2.MockFiles, ...app3.MockFiles });
 
     await AppsService.installApp(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
 
@@ -203,8 +203,8 @@ describe('Uninstall app', () => {
 
     // Assert
     expect(app).toBeDefined();
-    expect(app!.id).toBe(app1.id);
-    expect(app!.status).toBe(AppStatusEnum.RUNNING);
+    expect(app?.id).toBe(app1.id);
+    expect(app?.status).toBe(AppStatusEnum.RUNNING);
   });
 
   it('Should correctly remove app from database', async () => {
@@ -244,7 +244,7 @@ describe('Uninstall app', () => {
     // Act & Assert
     await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
     const app = await App.findOne({ where: { id: app1.id } });
-    expect(app!.status).toBe(AppStatusEnum.STOPPED);
+    expect(app?.status).toBe(AppStatusEnum.STOPPED);
   });
 });
 
@@ -300,7 +300,7 @@ describe('Start app', () => {
     // Act & Assert
     await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`);
     const app = await App.findOne({ where: { id: app1.id } });
-    expect(app!.status).toBe(AppStatusEnum.STOPPED);
+    expect(app?.status).toBe(AppStatusEnum.STOPPED);
   });
 });
 
@@ -333,7 +333,7 @@ describe('Stop app', () => {
     // Act & Assert
     await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`);
     const app = await App.findOne({ where: { id: app1.id } });
-    expect(app!.status).toBe(AppStatusEnum.RUNNING);
+    expect(app?.status).toBe(AppStatusEnum.RUNNING);
   });
 });
 
@@ -378,17 +378,13 @@ describe('Update app config', () => {
     expect(envMap.get('RANDOM_FIELD')).toBe('test');
   });
 
-  it('Should throw if app is exposed and domain is not provided', () => {
-    return 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('Domain is required'));
 
-  it('Should throw if app is exposed and domain is not valid', () => {
-    return expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid');
-  });
+  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'));
 
-  it('Should throw if app is exposed and config does not allow it', () => {
-    return 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(`App ${app1.id} is not exposable`));
 
   it('Should throw if app is exposed and domain is already used', async () => {
     const app2 = await createApp({ exposable: true, installed: true });

+ 1 - 1
packages/system-api/src/modules/apps/app.entity.ts

@@ -70,7 +70,7 @@ class App extends BaseEntity {
 
   @Field(() => UpdateInfo, { nullable: true })
   updateInfo(): Promise<UpdateInfo | null> {
-    return getUpdateInfo(this.id);
+    return getUpdateInfo(this.id, this.version);
   }
 }
 

+ 6 - 0
packages/system-api/src/modules/apps/app.types.ts

@@ -0,0 +1,6 @@
+export interface AppEntityType {
+  id: string;
+  config: Record<string, string>;
+  exposed: boolean;
+  domain?: string;
+}

+ 24 - 13
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -1,14 +1,12 @@
-import { fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../fs/fs.helpers';
 import crypto from 'crypto';
+import fs from 'fs-extra';
+import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../fs/fs.helpers';
 import { AppInfo, AppStatusEnum } from './apps.types';
 import logger from '../../config/logger/logger';
-import App from './app.entity';
 import { getConfig } from '../../core/config/TipiConfig';
-import fs from 'fs-extra';
+import { AppEntityType } from './app.types';
 
 export const checkAppRequirements = async (appName: string) => {
-  let valid = true;
-
   const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
 
   if (!configFile) {
@@ -19,7 +17,7 @@ export const checkAppRequirements = async (appName: string) => {
     throw new Error(`App ${appName} is not supported on this architecture`);
   }
 
-  return valid;
+  return true;
 };
 
 export const getEnvMap = (appName: string): Map<string, string> => {
@@ -55,7 +53,7 @@ const getEntropy = (name: string, length: number) => {
   return hash.digest('hex').substring(0, length);
 };
 
-export const generateEnvFile = (app: App) => {
+export const generateEnvFile = (app: AppEntityType) => {
   const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
 
   if (!configFile) {
@@ -129,7 +127,8 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
       const configFile: AppInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
       configFile.description = readFile(`/runtipi/apps/${id}/metadata/description.md`).toString();
       return configFile;
-    } else if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
+    }
+    if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
       const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
       configFile.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
 
@@ -145,20 +144,32 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
   }
 };
 
-export const getUpdateInfo = async (id: string) => {
-  const app = await App.findOne({ where: { id } });
-
+export const getUpdateInfo = async (id: string, version: number) => {
   const doesFileExist = fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}`);
 
-  if (!app || !doesFileExist) {
+  if (!doesFileExist) {
     return null;
   }
 
   const repoConfig: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
 
   return {
-    current: app.version,
+    current: version,
     latest: repoConfig.tipi_version,
     dockerVersion: repoConfig.version,
   };
 };
+
+export const ensureAppFolder = (appName: string, cleanup = false) => {
+  if (cleanup && fileExists(`/runtipi/apps/${appName}`)) {
+    deleteFolder(`/runtipi/apps/${appName}`);
+  }
+
+  if (!fileExists(`/runtipi/apps/${appName}/docker-compose.yml`)) {
+    if (fileExists(`/runtipi/apps/${appName}`)) {
+      deleteFolder(`/runtipi/apps/${appName}`);
+    }
+    // Copy from apps repo
+    fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/runtipi/apps/${appName}`);
+  }
+};

+ 6 - 12
packages/system-api/src/modules/apps/apps.service.ts

@@ -1,10 +1,10 @@
 import validator from 'validator';
-import { createFolder, ensureAppFolder, readFile, readJsonFile } from '../fs/fs.helpers';
-import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps } from './apps.helpers';
+import { Not } from 'typeorm';
+import { createFolder, readFile, readJsonFile } from '../fs/fs.helpers';
+import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder } from './apps.helpers';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
 import App from './app.entity';
 import logger from '../../config/logger/logger';
-import { Not } from 'typeorm';
 import { getConfig } from '../../core/config/TipiConfig';
 import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
 
@@ -18,9 +18,7 @@ const filterApp = (app: AppInfo): boolean => {
   return app.supported_architectures.includes(arch);
 };
 
-const filterApps = (apps: AppInfo[]): AppInfo[] => {
-  return apps.sort(sortApps).filter(filterApp);
-};
+const filterApps = (apps: AppInfo[]): AppInfo[] => apps.sort(sortApps).filter(filterApp);
 
 /**
  * Start all apps which had the status RUNNING in the database
@@ -157,11 +155,7 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
 const listApps = async (): Promise<ListAppsResonse> => {
   const folders: string[] = await getAvailableApps();
 
-  const apps: AppInfo[] = folders
-    .map((app) => {
-      return readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
-    })
-    .filter(Boolean);
+  const apps: AppInfo[] = folders.map((app) => readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)).filter(Boolean);
 
   const filteredApps = filterApps(apps).map((app) => {
     const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app.id}/metadata/description.md`);
@@ -254,7 +248,7 @@ const stopApp = async (id: string): Promise<App> => {
  * @returns - the app entity
  */
 const uninstallApp = async (id: string): Promise<App> => {
-  let app = await App.findOne({ where: { id } });
+  const app = await App.findOne({ where: { id } });
 
   if (!app) {
     throw new Error(`App ${id} not found`);

+ 2 - 2
packages/system-api/src/modules/auth/__tests__/auth.resolver.test.ts

@@ -7,7 +7,7 @@ import { setupConnection, teardownConnection } from '../../../test/connection';
 import { gcall } from '../../../test/gcall';
 import { loginMutation, registerMutation } from '../../../test/mutations';
 import { isConfiguredQuery, MeQuery, refreshTokenQuery } from '../../../test/queries';
-import User from '../../auth/user.entity';
+import User from '../user.entity';
 import { TokenResponse } from '../auth.types';
 import { createUser } from './user.factory';
 
@@ -214,7 +214,7 @@ describe('Test: refreshToken', () => {
     const { data } = await gcall<{ refreshToken: TokenResponse }>({
       source: refreshTokenQuery,
       userId: user1.id,
-      session: session,
+      session,
     });
     const decoded = jwt.verify(data?.refreshToken?.token || '', getConfig().jwtSecret) as jwt.JwtPayload;
 

+ 2 - 2
packages/system-api/src/modules/auth/__tests__/auth.service.test.ts

@@ -1,11 +1,11 @@
 import * as argon2 from 'argon2';
 import jwt from 'jsonwebtoken';
+import { faker } from '@faker-js/faker';
+import { DataSource } from 'typeorm';
 import AuthService from '../auth.service';
 import { createUser } from './user.factory';
 import User from '../user.entity';
-import { faker } from '@faker-js/faker';
 import { setupConnection, teardownConnection } from '../../../test/connection';
-import { DataSource } from 'typeorm';
 import { setConfig } from '../../../core/config/TipiConfig';
 import TipiCache from '../../../config/TipiCache';
 

+ 1 - 1
packages/system-api/src/modules/auth/__tests__/user.factory.ts

@@ -1,6 +1,6 @@
-import User from '../user.entity';
 import * as argon2 from 'argon2';
 import { faker } from '@faker-js/faker';
+import User from '../user.entity';
 
 const createUser = async (email?: string) => {
   const hash = await argon2.hash('password');

+ 0 - 1
packages/system-api/src/modules/auth/auth.types.ts

@@ -1,5 +1,4 @@
 import { Field, InputType, ObjectType } from 'type-graphql';
-import User from './user.entity';
 
 @InputType()
 class UsernamePasswordInput {

+ 6 - 82
packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts

@@ -1,7 +1,5 @@
-import { readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, getSeed, ensureAppFolder } from '../fs.helpers';
 import fs from 'fs-extra';
-import { getConfig } from '../../../core/config/TipiConfig';
-import { faker } from '@faker-js/faker';
+import { readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, getSeed } from '../fs.helpers';
 
 jest.mock('fs-extra');
 
@@ -15,7 +13,7 @@ describe('Test: readJsonFile', () => {
     // Arrange
     const rawFile = '{"test": "test"}';
     const mockFiles = {
-      ['/runtipi/test-file.json']: rawFile,
+      '/runtipi/test-file.json': rawFile,
     };
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
@@ -52,7 +50,7 @@ describe('Test: readFile', () => {
   it('should return the file', () => {
     const rawFile = 'test';
     const mockFiles = {
-      ['/runtipi/test-file.txt']: rawFile,
+      '/runtipi/test-file.txt': rawFile,
     };
 
     // @ts-ignore
@@ -69,7 +67,7 @@ describe('Test: readFile', () => {
 describe('Test: readdirSync', () => {
   it('should return the files', () => {
     const mockFiles = {
-      ['/runtipi/test/test-file.txt']: 'test',
+      '/runtipi/test/test-file.txt': 'test',
     };
 
     // @ts-ignore
@@ -86,7 +84,7 @@ describe('Test: readdirSync', () => {
 describe('Test: fileExists', () => {
   it('should return true if the file exists', () => {
     const mockFiles = {
-      ['/runtipi/test-file.txt']: 'test',
+      '/runtipi/test-file.txt': 'test',
     };
 
     // @ts-ignore
@@ -133,7 +131,7 @@ describe('Test: deleteFolder', () => {
 describe('Test: getSeed', () => {
   it('should return the seed', () => {
     const mockFiles = {
-      ['/runtipi/state/seed']: 'test',
+      '/runtipi/state/seed': 'test',
     };
 
     // @ts-ignore
@@ -142,77 +140,3 @@ describe('Test: getSeed', () => {
     expect(getSeed()).toEqual('test');
   });
 });
-
-describe('Test: ensureAppFolder', () => {
-  beforeEach(() => {
-    const mockFiles = {
-      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
-    };
-    // @ts-ignore
-    fs.__createMockFiles(mockFiles);
-  });
-
-  it('should copy the folder from repo', () => {
-    // Act
-    ensureAppFolder('test');
-
-    // Assert
-    const files = fs.readdirSync('/runtipi/apps/test');
-    expect(files).toEqual(['test.yml']);
-  });
-
-  it('should not copy the folder if it already exists', () => {
-    const mockFiles = {
-      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
-      ['/runtipi/apps/test']: ['docker-compose.yml'],
-      ['/runtipi/apps/test/docker-compose.yml']: 'test',
-    };
-
-    // @ts-ignore
-    fs.__createMockFiles(mockFiles);
-
-    // Act
-    ensureAppFolder('test');
-
-    // Assert
-    const files = fs.readdirSync('/runtipi/apps/test');
-    expect(files).toEqual(['docker-compose.yml']);
-  });
-
-  it('Should overwrite the folder if clean up is true', () => {
-    const mockFiles = {
-      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
-      ['/runtipi/apps/test']: ['docker-compose.yml'],
-      ['/runtipi/apps/test/docker-compose.yml']: 'test',
-    };
-
-    // @ts-ignore
-    fs.__createMockFiles(mockFiles);
-
-    // Act
-    ensureAppFolder('test', true);
-
-    // Assert
-    const files = fs.readdirSync('/runtipi/apps/test');
-    expect(files).toEqual(['test.yml']);
-  });
-
-  it('Should delete folder if it exists but has no docker-compose.yml file', () => {
-    // Arrange
-    const randomFileName = `${faker.random.word()}.yml`;
-    const mockFiles = {
-      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: [randomFileName],
-      ['/runtipi/apps/test']: ['test.yml'],
-    };
-
-    // @ts-ignore
-    fs.__createMockFiles(mockFiles);
-
-    // Act
-    ensureAppFolder('test');
-
-    // Assert
-    const files = fs.readdirSync('/runtipi/apps/test');
-    expect(files).toEqual([randomFileName]);
-  });
-});

+ 0 - 15
packages/system-api/src/modules/fs/fs.helpers.ts

@@ -1,5 +1,4 @@
 import fs from 'fs-extra';
-import { getConfig } from '../../core/config/TipiConfig';
 
 export const readJsonFile = (path: string): any => {
   try {
@@ -36,17 +35,3 @@ export const getSeed = () => {
   const seed = readFile('/runtipi/state/seed');
   return seed.toString();
 };
-
-export const ensureAppFolder = (appName: string, cleanup = false) => {
-  if (cleanup && fileExists(`/runtipi/apps/${appName}`)) {
-    deleteFolder(`/runtipi/apps/${appName}`);
-  }
-
-  if (!fileExists(`/runtipi/apps/${appName}/docker-compose.yml`)) {
-    if (fileExists(`/runtipi/apps/${appName}`)) {
-      deleteFolder(`/runtipi/apps/${appName}`);
-    }
-    // Copy from apps repo
-    fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/runtipi/apps/${appName}`);
-  }
-};

+ 1 - 1
packages/system-api/src/modules/system/__tests__/system.service.test.ts

@@ -1,8 +1,8 @@
 import fs from 'fs-extra';
 import semver from 'semver';
 import axios from 'axios';
-import SystemService from '../system.service';
 import { faker } from '@faker-js/faker';
+import SystemService from '../system.service';
 import TipiCache from '../../../config/TipiCache';
 import { setConfig } from '../../../core/config/TipiConfig';
 import logger from '../../../config/logger/logger';

+ 3 - 3
packages/system-api/src/server.ts

@@ -2,9 +2,11 @@ import 'reflect-metadata';
 import express from 'express';
 import { ApolloServerPluginLandingPageGraphQLPlayground as Playground } from 'apollo-server-core';
 import { ApolloServer } from 'apollo-server-express';
+import { createServer } from 'http';
+import { ZodError } from 'zod';
+import cors from 'cors';
 import { createSchema } from './schema';
 import { ApolloLogs } from './config/logger/apollo.logger';
-import { createServer } from 'http';
 import logger from './config/logger/logger';
 import getSessionMiddleware from './core/middlewares/sessionMiddleware';
 import { MyContext } from './types';
@@ -15,10 +17,8 @@ import { runUpdates } from './core/updates/run';
 import recover from './core/updates/recover-migrations';
 import startJobs from './core/jobs/jobs';
 import { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig';
-import { ZodError } from 'zod';
 import systemController from './modules/system/system.controller';
 import { eventDispatcher, EventTypes } from './core/config/EventDispatcher';
-import cors from 'cors';
 
 const applyCustomConfig = () => {
   try {

+ 1 - 1
packages/system-api/src/test/connection.ts

@@ -1,7 +1,7 @@
 import { DataSource } from 'typeorm';
+import pg from 'pg';
 import App from '../modules/apps/app.entity';
 import User from '../modules/auth/user.entity';
-import pg from 'pg';
 import Update from '../modules/system/update.entity';
 
 const HOST = 'localhost';