Browse Source

test(apps): test domain linking

test(apps): test linking domain
Nicolas Meienberger 2 năm trước cách đây
mục cha
commit
60ef5816a7

+ 1 - 1
README.md

@@ -99,7 +99,7 @@ If you want to link a domain to your dashboard, you can do so by providing the `
 sudo ./scripts/start.sh --domain mydomain.com
 ```
 
-A Let's Encrypt certificate will be generated and installed automatically. Make sure to have port 443 open on your firewall and that your domain has an **A** record pointing to your server IP.
+A Let's Encrypt certificate will be generated and installed automatically. Make sure to have ports 80 and 443 open on your firewall and that your domain has an **A** record pointing to your server IP.
 
 ## ❤️ Contributing
 

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

@@ -8,10 +8,13 @@ interface IProps {
   status?: AppStatusEnum;
   requiredPort?: number;
   randomField?: boolean;
+  exposed?: boolean;
+  domain?: string;
+  exposable?: boolean;
 }
 
 const createApp = async (props: IProps) => {
-  const { installed = false, status = AppStatusEnum.RUNNING, requiredPort, randomField = false } = props;
+  const { installed = false, status = AppStatusEnum.RUNNING, requiredPort, randomField = false, exposed = false, domain = '', exposable = false } = props;
 
   const categories = Object.values(AppCategoriesEnum);
 
@@ -34,6 +37,7 @@ const createApp = async (props: IProps) => {
     author: faker.name.firstName(),
     source: faker.internet.url(),
     categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]],
+    exposable,
   };
 
   if (randomField) {
@@ -63,6 +67,8 @@ const createApp = async (props: IProps) => {
       id: appInfo.id,
       config: { TEST_FIELD: 'test' },
       status,
+      exposed,
+      domain,
     }).save();
 
     MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}`] = '';

+ 44 - 8
packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts

@@ -195,6 +195,46 @@ describe('generateEnvFile', () => {
       expect(e.message).toBe('App not-existing-app not found');
     }
   });
+
+  it('Should add APP_EXPOSED to env file', async () => {
+    const domain = faker.internet.domainName();
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, exposed: true, domain });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    generateEnvFile(appEntity);
+
+    const envmap = await getEnvMap(appInfo.id);
+
+    expect(envmap.get('APP_EXPOSED')).toBe('true');
+    expect(envmap.get('APP_DOMAIN')).toBe(domain);
+  });
+
+  it('Should not add APP_EXPOSED if domain is not provided', async () => {
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, exposed: true });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    generateEnvFile(appEntity);
+
+    const envmap = await getEnvMap(appInfo.id);
+
+    expect(envmap.get('APP_EXPOSED')).toBeUndefined();
+    expect(envmap.get('APP_DOMAIN')).toBeUndefined();
+  });
+
+  it('Should not add APP_EXPOSED if app is not exposed', async () => {
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, domain: faker.internet.domainName() });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    generateEnvFile(appEntity);
+
+    const envmap = await getEnvMap(appInfo.id);
+
+    expect(envmap.get('APP_EXPOSED')).toBeUndefined();
+    expect(envmap.get('APP_DOMAIN')).toBeUndefined();
+  });
 });
 
 describe('getAvailableApps', () => {
@@ -239,14 +279,10 @@ describe('getAppInfo', () => {
     expect(app?.id).toEqual(appInfo.id);
   });
 
-  it('Should throw an error if app does not exist', async () => {
-    try {
-      await getAppInfo('not-existing-app');
-      expect(true).toBe(false);
-    } catch (e: any) {
-      expect(e).toBeDefined();
-      expect(e.message).toBe('Error loading app not-existing-app');
-    }
+  it('Should return null if app does not exist', async () => {
+    const app = await getAppInfo(faker.random.word());
+
+    expect(app).toBeNull();
   });
 });
 

+ 28 - 0
packages/system-api/src/modules/apps/__tests__/apps.service.test.ts

@@ -135,6 +135,22 @@ describe('Install app', () => {
     expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/test.yml`)).toBe(false);
     expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/docker-compose.yml`)).toBe(true);
   });
+
+  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');
+  });
+
+  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`);
+  });
+
+  it('Should throw if app is exposed and domain is not valid', async () => {
+    const { MockFiles, appInfo } = await createApp({ exposable: true });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid');
+  });
 });
 
 describe('Uninstall app', () => {
@@ -334,6 +350,18 @@ 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 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 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`);
+  });
 });
 
 describe('Get app config', () => {

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

@@ -139,7 +139,7 @@ export const getAppInfo = (id: string): AppInfo | null => {
       const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
       configFile.description = readFile(`/apps/${id}/metadata/description.md`).toString();
       return configFile;
-    } else if (fileExists(`/repos/${repoId}`)) {
+    } else if (fileExists(`/repos/${repoId}/apps/${id}/config.json`)) {
       const configFile: AppInfo = readJsonFile(`/repos/${repoId}/apps/${id}/config.json`);
       configFile.description = readFile(`/repos/${repoId}/apps/${id}/metadata/description.md`);
 
@@ -150,6 +150,7 @@ export const getAppInfo = (id: string): AppInfo | null => {
 
     return null;
   } catch (e) {
+    console.error(e);
     throw new Error(`Error loading app ${id}`);
   }
 };

+ 15 - 0
packages/system-api/src/modules/apps/apps.service.ts

@@ -1,3 +1,4 @@
+import validator from 'validator';
 import { createFolder, ensureAppFolder, readFile, readJsonFile } from '../fs/fs.helpers';
 import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, runAppScript } from './apps.helpers';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
@@ -69,6 +70,10 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
       throw new Error('Domain is required if app is exposed');
     }
 
+    if (domain && !validator.isFQDN(domain)) {
+      throw new Error(`Domain ${domain} is not valid`);
+    }
+
     ensureAppFolder(id, true);
     const appIsValid = await checkAppRequirements(id);
 
@@ -130,6 +135,16 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
     throw new Error('Domain is required if app is exposed');
   }
 
+  if (domain && !validator.isFQDN(domain)) {
+    throw new Error(`Domain ${domain} is not valid`);
+  }
+
+  const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
+
+  if (!appInfo?.exposable && exposed) {
+    throw new Error(`App ${id} is not exposable`);
+  }
+
   let app = await App.findOne({ where: { id } });
 
   if (!app) {

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

@@ -0,0 +1,173 @@
+import { faker } from '@faker-js/faker';
+import { DataSource } from 'typeorm';
+import { setupConnection, teardownConnection } from '../../../test/connection';
+import { gcall } from '../../../test/gcall';
+import { loginMutation, registerMutation } from '../../../test/mutations';
+import { isConfiguredQuery, MeQuery } from '../../../test/queries';
+import User from '../../auth/user.entity';
+import { UserResponse } from '../auth.types';
+import { createUser } from './user.factory';
+
+let db: DataSource | null = null;
+const TEST_SUITE = 'authresolver';
+
+beforeAll(async () => {
+  db = await setupConnection(TEST_SUITE);
+});
+
+afterAll(async () => {
+  await db?.destroy();
+  await teardownConnection(TEST_SUITE);
+});
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+  jest.restoreAllMocks();
+  await User.clear();
+});
+
+describe('Test: me', () => {
+  const email = faker.internet.email();
+  let user1: User;
+
+  beforeEach(async () => {
+    user1 = await createUser(email);
+  });
+
+  it('should return null if no user is logged in', async () => {
+    const { data } = await gcall<{ me: User }>({
+      source: MeQuery,
+    });
+
+    expect(data?.me).toBeNull();
+  });
+
+  it('should return the user if a user is logged in', async () => {
+    const { data } = await gcall<{ me: User | null }>({
+      source: MeQuery,
+      userId: user1.id,
+    });
+
+    expect(data?.me?.username).toEqual(user1.username);
+  });
+});
+
+describe('Test: register', () => {
+  const email = faker.internet.email();
+  const password = faker.internet.password();
+
+  it('should register a user', async () => {
+    const { data } = await gcall<{ register: UserResponse }>({
+      source: registerMutation,
+      variableValues: {
+        input: { username: email, password },
+      },
+    });
+
+    expect(data?.register.user?.username).toEqual(email.toLowerCase());
+  });
+
+  it('should not register a user with an existing username', async () => {
+    await createUser(email);
+
+    const { errors } = await gcall<{ register: UserResponse }>({
+      source: registerMutation,
+      variableValues: {
+        input: { username: email, password },
+      },
+    });
+
+    expect(errors?.[0].message).toEqual('User already exists');
+  });
+
+  it('should not register a user with a malformed email', async () => {
+    const { errors } = await gcall<{ register: UserResponse }>({
+      source: registerMutation,
+      variableValues: {
+        input: { username: 'not an email', password },
+      },
+    });
+
+    expect(errors?.[0].message).toEqual('Invalid username');
+  });
+});
+
+describe('Test: login', () => {
+  const email = faker.internet.email();
+
+  beforeEach(async () => {
+    await createUser(email);
+  });
+
+  it('should login a user', async () => {
+    const { data } = await gcall<{ login: UserResponse }>({
+      source: loginMutation,
+      variableValues: {
+        input: { username: email, password: 'password' },
+      },
+    });
+
+    expect(data?.login.user?.username).toEqual(email.toLowerCase());
+  });
+
+  it('should not login a user with an incorrect password', async () => {
+    const { errors } = await gcall<{ login: UserResponse }>({
+      source: loginMutation,
+      variableValues: {
+        input: { username: email, password: 'wrong password' },
+      },
+    });
+
+    expect(errors?.[0].message).toEqual('Wrong password');
+  });
+
+  it('should not login a user with a malformed email', async () => {
+    const { errors } = await gcall<{ login: UserResponse }>({
+      source: loginMutation,
+      variableValues: {
+        input: { username: 'not an email', password: 'password' },
+      },
+    });
+
+    expect(errors?.[0].message).toEqual('User not found');
+  });
+});
+
+describe('Test: logout', () => {
+  const email = faker.internet.email();
+  let user1: User;
+
+  beforeEach(async () => {
+    user1 = await createUser(email);
+  });
+
+  it('should logout a user', async () => {
+    const { data } = await gcall<{ logout: boolean }>({
+      source: 'mutation { logout }',
+      userId: user1.id,
+    });
+
+    expect(data?.logout).toBeTruthy();
+  });
+});
+
+describe('Test: isConfigured', () => {
+  it('should return false if no users exist', async () => {
+    const { data } = await gcall<{ isConfigured: boolean }>({
+      source: isConfiguredQuery,
+    });
+
+    expect(data?.isConfigured).toBeFalsy();
+  });
+
+  it('should return true if a user exists', async () => {
+    await createUser(faker.internet.email());
+
+    const { data } = await gcall<{ isConfigured: boolean }>({
+      source: isConfiguredQuery,
+    });
+
+    expect(data?.isConfigured).toBeTruthy();
+  });
+});

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

@@ -1,4 +1,5 @@
 import * as argon2 from 'argon2';
+import validator from 'validator';
 import { UsernamePasswordInput, UserResponse } from './auth.types';
 import User from './user.entity';
 
@@ -22,19 +23,24 @@ const login = async (input: UsernamePasswordInput): Promise<UserResponse> => {
 
 const register = async (input: UsernamePasswordInput): Promise<UserResponse> => {
   const { password, username } = input;
+  const email = username.trim().toLowerCase();
 
   if (!username || !password) {
     throw new Error('Missing email or password');
   }
 
-  const user = await User.findOne({ where: { username: username.trim().toLowerCase() } });
+  if (username.length < 3 || !validator.isEmail(email)) {
+    throw new Error('Invalid username');
+  }
+
+  const user = await User.findOne({ where: { username: email } });
 
   if (user) {
     throw new Error('User already exists');
   }
 
   const hash = await argon2.hash(password);
-  const newUser = await User.create({ username: username.trim().toLowerCase(), password: hash }).save();
+  const newUser = await User.create({ username: email, password: hash }).save();
 
   return { user: newUser };
 };