ソースを参照

test(wip): app resolvers

Nicolas Meienberger 2 年 前
コミット
3036bedf81

+ 2 - 0
packages/system-api/.eslintrc.cjs

@@ -16,5 +16,7 @@ module.exports = {
     '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: '^_' }],
   },
 };

+ 11 - 0
packages/system-api/__mocks__/internal-ip.ts

@@ -0,0 +1,11 @@
+import { faker } from '@faker-js/faker';
+
+const internalIp: { v4: typeof v4Mock } = jest.genMockFromModule('internal-ip');
+
+const v4Mock = () => {
+  return faker.internet.ipv4();
+};
+
+internalIp.v4 = v4Mock;
+
+module.exports = internalIp;

+ 9 - 0
packages/system-api/__mocks__/tcp-port-used.ts

@@ -0,0 +1,9 @@
+import portUsed, { TcpPortUsedOptions } from 'tcp-port-used';
+
+const internalIp: { check: typeof portUsed.check } = jest.genMockFromModule('tcp-port-used');
+
+internalIp.check = async (_: number | TcpPortUsedOptions, __?: string | undefined) => {
+  return true;
+};
+
+module.exports = internalIp;

+ 1 - 1
packages/system-api/jest.config.cjs

@@ -6,7 +6,7 @@ module.exports = {
   testMatch: ['**/__tests__/**/*.test.ts'],
   setupFiles: ['<rootDir>/src/test/dotenv-config.ts'],
   collectCoverage: true,
-  collectCoverageFrom: ['src/**/*.{ts,tsx}'],
+  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/migrations/**/*.{ts,tsx}', '!**/config/**/*.{ts,tsx}'],
   passWithNoTests: true,
   transform: {
     '^.+\\.graphql$': 'graphql-import-node/jest',

+ 9 - 1
packages/system-api/src/core/middlewares/authChecker.ts

@@ -1,7 +1,8 @@
 import { AuthChecker } from 'type-graphql';
+import User from '../../modules/auth/user.entity';
 import { MyContext } from '../../types';
 
-export const customAuthChecker: AuthChecker<MyContext> = ({ context }) => {
+export const customAuthChecker: AuthChecker<MyContext> = async ({ context }) => {
   // here we can read the user from context
   // and check his permission in the db against the `roles` argument
   // that comes from the `@Authorized` decorator, eg. ["ADMIN", "MODERATOR"]
@@ -9,5 +10,12 @@ export const customAuthChecker: AuthChecker<MyContext> = ({ context }) => {
     return false;
   }
 
+  const { userId } = context.req.session;
+  const user = await User.findOne({ where: { id: userId } });
+
+  if (!user) {
+    return false;
+  }
+
   return true;
 };

+ 0 - 21
packages/system-api/src/helpers/error.types.ts

@@ -1,21 +0,0 @@
-import { ObjectType, Field } from 'type-graphql';
-
-@ObjectType()
-class FieldError {
-  @Field()
-  code!: number;
-
-  @Field()
-  message!: string;
-
-  @Field({ nullable: true })
-  field?: string;
-}
-
-@ObjectType()
-class ErrorResponse {
-  @Field(() => [FieldError], { nullable: true })
-  errors?: FieldError[];
-}
-
-export { FieldError, ErrorResponse };

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

@@ -3,7 +3,7 @@ import { AppCategoriesEnum, AppInfo, AppStatusEnum, FieldTypes } from '../apps.t
 import config from '../../../config';
 import App from '../app.entity';
 
-const createApp = async (installed = false, status = AppStatusEnum.RUNNING) => {
+const createApp = async (installed = false, status = AppStatusEnum.RUNNING, requiredPort?: number) => {
   const categories = Object.values(AppCategoriesEnum);
 
   const appInfo: AppInfo = {
@@ -18,6 +18,11 @@ const createApp = async (installed = false, status = AppStatusEnum.RUNNING) => {
         env_variable: 'TEST_FIELD',
       },
     ],
+    requirements: requiredPort
+      ? {
+          ports: [requiredPort],
+        }
+      : undefined,
     name: faker.random.word(),
     description: faker.random.words(),
     image: faker.internet.url(),

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

@@ -3,11 +3,17 @@ import { setupConnection, teardownConnection } from '../../../test/connection';
 import fs from 'fs';
 import { gcall } from '../../../test/gcall';
 import App from '../app.entity';
-import { getAppQuery, listAppInfosQuery } from '../../../test/queries';
+import { getAppQuery, InstalledAppsQuery, listAppInfosQuery } from '../../../test/queries';
 import { createApp } from './apps.factory';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from '../apps.types';
+import { createUser } from '../../auth/__tests__/user.factory';
+import User from '../../auth/user.entity';
+import { installAppMutation } from '../../../test/mutations';
 
 jest.mock('fs');
+jest.mock('child_process');
+jest.mock('internal-ip');
+jest.mock('tcp-port-used');
 
 type TApp = App & {
   info: AppInfo;
@@ -30,6 +36,7 @@ beforeEach(async () => {
   jest.resetAllMocks();
   jest.restoreAllMocks();
   await App.clear();
+  await User.clear();
 });
 
 describe('ListAppsInfos', () => {
@@ -97,3 +104,134 @@ describe('GetApp', () => {
     expect(data?.getApp).toBeUndefined();
   });
 });
+
+describe('InstalledApps', () => {
+  let app1: AppInfo;
+
+  beforeEach(async () => {
+    const app1create = await createApp(true);
+    app1 = app1create.appInfo;
+    // @ts-ignore
+    fs.__createMockFiles(app1create.MockFiles);
+  });
+
+  it('Can list installed apps', async () => {
+    const user = await createUser();
+
+    const { data } = await gcall<{ installedApps: TApp[] }>({ source: InstalledAppsQuery, userId: user.id });
+
+    expect(data?.installedApps.length).toBe(1);
+
+    const app = data?.installedApps[0];
+
+    expect(app?.id).toBe(app1.id);
+    expect(app?.info.author).toBe(app1.author);
+    expect(app?.info.name).toBe(app1.name);
+  });
+
+  it("Should return an error if user doesn't exist", async () => {
+    const { data, errors } = await gcall<{ installedApps: TApp[] }>({
+      source: InstalledAppsQuery,
+      userId: 1,
+    });
+
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.installedApps).toBeUndefined();
+  });
+
+  it('Should throw an error if no userId is provided', async () => {
+    const { data, errors } = await gcall<{ installedApps: TApp[] }>({
+      source: InstalledAppsQuery,
+    });
+
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.installedApps).toBeUndefined();
+  });
+});
+
+describe('InstallApp', () => {
+  let app1: AppInfo;
+
+  beforeEach(async () => {
+    const app1create = await createApp();
+    app1 = app1create.appInfo;
+    // @ts-ignore
+    fs.__createMockFiles(app1create.MockFiles);
+  });
+
+  it('Can install app', async () => {
+    const user = await createUser();
+
+    const { data } = await gcall<{ installApp: TApp }>({
+      source: installAppMutation,
+      userId: user.id,
+      variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' } } },
+    });
+
+    expect(data?.installApp.info.id).toBe(app1.id);
+    expect(data?.installApp.status).toBe(AppStatusEnum.RUNNING.toUpperCase());
+  });
+
+  it("Should return an error if app doesn't exist", async () => {
+    const user = await createUser();
+
+    const { data, errors } = await gcall<{ installApp: TApp }>({
+      source: installAppMutation,
+      userId: user.id,
+      variableValues: { input: { id: 'not-existing', form: { TEST_FIELD: 'hello' } } },
+    });
+
+    expect(errors?.[0].message).toBe('App not-existing not found');
+    expect(data?.installApp).toBeUndefined();
+  });
+
+  it("Should throw an error if user doesn't exist", async () => {
+    const { data, errors } = await gcall<{ installApp: TApp }>({
+      source: installAppMutation,
+      variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' } } },
+    });
+
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.installApp).toBeUndefined();
+  });
+
+  it('Should throw an error if no userId is provided', async () => {
+    const { data, errors } = await gcall<{ installApp: TApp }>({
+      source: installAppMutation,
+      variableValues: { input: { id: app1.id, form: { TEST_FIELD: 'hello' } } },
+    });
+
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.installApp).toBeUndefined();
+  });
+
+  it('Should throw an error if a required field is missing in form', async () => {
+    const user = await createUser();
+
+    const { data, errors } = await gcall<{ installApp: TApp }>({
+      source: installAppMutation,
+      userId: user.id,
+      variableValues: { input: { id: app1.id, form: {} } },
+    });
+
+    expect(errors?.[0].message).toBe(`Variable ${app1.form_fields?.[0].env_variable} is required`);
+    expect(data?.installApp).toBeUndefined();
+  });
+
+  it('Should throw an error if the requirements are not met', async () => {
+    const { appInfo, MockFiles } = await createApp(false, undefined, 400);
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const user = await createUser();
+
+    const { data, errors } = await gcall<{ installApp: TApp }>({
+      source: installAppMutation,
+      userId: user.id,
+      variableValues: { input: { id: appInfo.id, form: { TEST_FIELD: 'hello' } } },
+    });
+
+    expect(errors?.[0].message).toBe(`App ${appInfo.id} requirements not met`);
+    expect(data?.installApp).toBeUndefined();
+  });
+});

+ 4 - 0
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -9,6 +9,10 @@ export const checkAppRequirements = async (appName: string) => {
   let valid = true;
   const configFile: AppInfo = readJsonFile(`/apps/${appName}/config.json`);
 
+  if (!configFile) {
+    throw new Error(`App ${appName} not found`);
+  }
+
   if (configFile?.requirements?.ports) {
     for (const port of configFile.requirements.ports) {
       const ip = await InternalIp.v4();

+ 6 - 1
packages/system-api/src/modules/fs/fs.helpers.ts

@@ -5,7 +5,12 @@ import config from '../../config';
 export const getAbsolutePath = (path: string) => `${config.ROOT_FOLDER}${path}`;
 
 export const readJsonFile = (path: string): any => {
-  const rawFile = fs.readFileSync(getAbsolutePath(path)).toString();
+  const rawFile = fs.readFileSync(getAbsolutePath(path))?.toString();
+
+  if (!rawFile) {
+    return null;
+  }
+
   return JSON.parse(rawFile);
 };
 

+ 7 - 0
packages/system-api/src/test/mutations/index.ts

@@ -0,0 +1,7 @@
+/* eslint-disable import/no-extraneous-dependencies */
+import 'graphql-import-node';
+import { print } from 'graphql/language/printer';
+
+import * as installApp from './installApp.graphql';
+
+export const installAppMutation = print(installApp);

+ 26 - 0
packages/system-api/src/test/mutations/installApp.graphql

@@ -0,0 +1,26 @@
+mutation InstallApp($input: AppInputType!) {
+  installApp(input: $input) {
+    id
+    status
+    config
+    info {
+      id
+      available
+      port
+      name
+      description
+      version
+      author
+      source
+      categories
+      url_suffix
+      form_fields {
+        max
+        min
+        required
+        env_variable
+      }
+      requirements
+    }
+  }
+}

+ 2 - 0
packages/system-api/src/test/queries/index.ts

@@ -4,6 +4,8 @@ import { print } from 'graphql/language/printer';
 
 import * as listAppInfos from './listAppInfos.graphql';
 import * as getApp from './getApp.graphql';
+import * as InstalledApps from './InstalledApps.graphql';
 
 export const listAppInfosQuery = print(listAppInfos);
 export const getAppQuery = print(getApp);
+export const InstalledAppsQuery = print(InstalledApps);

+ 30 - 0
packages/system-api/src/test/queries/installedApps.graphql

@@ -0,0 +1,30 @@
+query {
+  installedApps {
+    id
+    status
+    lastOpened
+    numOpened
+    config
+    createdAt
+    updatedAt
+    info {
+      id
+      available
+      port
+      name
+      description
+      version
+      author
+      source
+      categories
+      url_suffix
+      form_fields {
+        max
+        min
+        required
+        env_variable
+      }
+      requirements
+    }
+  }
+}

+ 6 - 0
packages/system-api/src/test/queries/me.graphql

@@ -0,0 +1,6 @@
+{
+  me {
+    id
+    username
+  }
+}