Nicolas Meienberger 3 роки тому
батько
коміт
37662b574b

+ 19 - 1
.github/workflows/ci.yml

@@ -10,6 +10,19 @@ env:
 jobs:
   ci:
     runs-on: ubuntu-latest
+    services:
+      postgres:
+        image: postgres:latest
+        env:
+          POSTGRES_PASSWORD: postgres
+        ports:
+          - 5432:5432
+        # set health checks to wait until postgres has started
+        options: >-
+          --health-cmd pg_isready
+          --health-interval 10s
+          --health-timeout 5s
+          --health-retries 5
     steps:
       - name: Checkout
         uses: actions/checkout@v3
@@ -61,4 +74,9 @@ jobs:
         run: pnpm -r lint
       
       - name: Run tests
-        run: pnpm -r test
+        run: pnpm -r test
+
+      - uses: codecov/codecov-action@v2
+        with:
+          token: ${{ secrets.CODECOV_TOKEN }}
+          files: ./packages/system-api/coverage/clover.xml,./packages/dashboard/coverage/clover.xml

+ 1 - 4
.github/workflows/release-candidate.yml

@@ -40,7 +40,4 @@ jobs:
           push: true
           tags: meienberger/runtipi:rc-${{ steps.meta.outputs.TAG }}
           cache-from: type=registry,ref=meienberger/runtipi:buildcache
-          cache-to: type=registry,ref=meienberger/runtipi:buildcache,mode=max
-
-
-          
+          cache-to: type=registry,ref=meienberger/runtipi:buildcache,mode=max

+ 1 - 3
Dockerfile

@@ -24,7 +24,7 @@ FROM alpine:3.16.0 as app
 WORKDIR /
 
 # Install docker
-RUN apk --no-cache --virtual build-dependencies add docker docker-compose curl nodejs npm bash g++ make
+RUN apk --no-cache add docker docker-compose curl nodejs npm bash g++ make
 
 RUN npm install node-gyp -g
 
@@ -42,6 +42,4 @@ COPY ./packages/system-api /api
 COPY --from=build /dashboard/.next /dashboard/.next
 COPY ./packages/dashboard /dashboard
 
-RUN apk del build-dependencies
-
 WORKDIR /

+ 7 - 1
docker-compose.dev.yml

@@ -14,6 +14,11 @@ services:
       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
       POSTGRES_USER: ${POSTGRES_USERNAME}
       POSTGRES_DB: ${POSTGRES_DBNAME}
+    healthcheck:
+      test: /usr/bin/pg_isready
+      interval: 5s
+      timeout: 10s
+      retries: 120
     networks:
       - tipi_main_network
 
@@ -23,7 +28,8 @@ services:
       dockerfile: Dockerfile.dev
     command: /bin/sh -c "cd /api && npm run dev"
     depends_on:
-      - tipi-db
+      tipi-db:
+        condition: service_healthy
     container_name: api
     ports:
       - 3001:3001

+ 1 - 0
package.json

@@ -11,6 +11,7 @@
     "start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
     "start:prod": "docker-compose --env-file .env up --build",
     "build:common": "cd packages/common && npm run build",
+    "start:pg": "docker run --name test-db -p 5432:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
     "version": "echo $npm_package_version"
   },
   "devDependencies": {

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

@@ -4,9 +4,8 @@ module.exports = {
   verbose: true,
   testEnvironment: 'node',
   testMatch: ['**/__tests__/**/*.test.ts'],
-  setupFiles: ['<rootDir>/tests/dotenv-config.ts'],
+  setupFiles: ['<rootDir>/src/test/dotenv-config.ts'],
   collectCoverage: true,
   collectCoverageFrom: ['src/**/*.{ts,tsx}'],
-  coverageProvider: 'v8',
   passWithNoTests: true,
 };

+ 2 - 0
packages/system-api/package.json

@@ -57,6 +57,7 @@
     "winston": "^3.7.2"
   },
   "devDependencies": {
+    "@faker-js/faker": "^7.3.0",
     "@types/compression": "^1.7.2",
     "@types/cookie-parser": "^1.4.3",
     "@types/cors": "^2.8.12",
@@ -67,6 +68,7 @@
     "@types/mock-fs": "^4.13.1",
     "@types/passport": "^1.0.7",
     "@types/passport-http-bearer": "^1.0.37",
+    "@types/pg": "^8.6.5",
     "@types/session-file-store": "^1.2.2",
     "@types/tcp-port-used": "^1.0.1",
     "@types/validator": "^13.7.2",

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

@@ -50,7 +50,7 @@ const config: IConfig = {
     password: POSTGRES_PASSWORD,
     port: 5432,
     logging: !__prod__,
-    synchronize: true,
+    synchronize: !__prod__,
     entities: [App, User],
   },
   NODE_ENV,

+ 0 - 4
packages/system-api/src/declarations.d.ts

@@ -1,4 +0,0 @@
-declare module 'su-exec' {
-  export function execFile(path: string, args: string[], options: {}, callback?: any): void;
-  export function init(): void;
-}

+ 49 - 0
packages/system-api/src/modules/apps/__tests__/apps.factory.ts

@@ -0,0 +1,49 @@
+import { faker } from '@faker-js/faker';
+import { AppCategoriesEnum, AppInfo, AppStatusEnum, FieldTypes } from '../apps.types';
+import config from '../../../config';
+import App from '../app.entity';
+
+const createApp = async (installed = false) => {
+  const categories = Object.values(AppCategoriesEnum);
+
+  const appInfo: AppInfo = {
+    id: faker.random.word().toLowerCase().trim(),
+    port: faker.datatype.number({ min: 3000, max: 5000 }),
+    available: true,
+    form_fields: [
+      {
+        type: FieldTypes.text,
+        label: faker.random.word(),
+        required: true,
+        env_variable: 'TEST_FIELD',
+      },
+    ],
+    name: faker.random.word(),
+    description: faker.random.words(),
+    image: faker.internet.url(),
+    short_desc: faker.random.words(),
+    author: faker.name.firstName(),
+    source: faker.internet.url(),
+    categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]],
+  };
+
+  let MockFiles: any = {};
+  MockFiles[`${config.ROOT_FOLDER}/.env`] = 'TEST=test';
+  MockFiles[`${config.ROOT_FOLDER}/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
+  MockFiles[`${config.ROOT_FOLDER}/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
+
+  if (installed) {
+    await App.create({
+      id: appInfo.id,
+      config: { TEST_FIELD: 'test' },
+      status: AppStatusEnum.RUNNING,
+    }).save();
+
+    MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}`] = '';
+    MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
+  }
+
+  return { appInfo, MockFiles };
+};
+
+export { createApp };

+ 145 - 124
packages/system-api/src/modules/apps/__tests__/apps.service.test.ts

@@ -2,266 +2,287 @@ import AppsService from '../apps.service';
 import fs from 'fs';
 import config from '../../../config';
 import childProcess from 'child_process';
-import { AppConfig, FieldTypes } from '../apps.types';
+import { AppInfo, AppStatusEnum } from '../apps.types';
+import App from '../app.entity';
+import { createApp } from './apps.factory';
+import { setupConnection, teardownConnection } from '../../../test/connection';
+import { DataSource } from 'typeorm';
 
 jest.mock('fs');
 jest.mock('child_process');
 
-beforeEach(() => {
+let db: DataSource | null = null;
+const TEST_SUITE = 'appsservice';
+
+beforeAll(async () => {
+  db = await setupConnection(TEST_SUITE);
+});
+
+beforeEach(async () => {
   jest.resetModules();
   jest.resetAllMocks();
+  await App.clear();
 });
 
-const testApp: Partial<AppConfig> = {
-  id: 'test-app',
-  port: 3000,
-  available: true,
-  form_fields: [
-    {
-      type: FieldTypes.text,
-      label: 'Test field',
-      required: true,
-      env_variable: 'TEST_FIELD',
-    },
-    {
-      type: FieldTypes.text,
-      label: 'Test field 2',
-      required: false,
-      env_variable: 'TEST_FIELD_2',
-    },
-  ],
-};
-
-const testApp2: Partial<AppConfig> = {
-  available: true,
-  id: 'test-app2',
-};
-
-const testApp3: Partial<AppConfig> = {
-  id: 'test-app3',
-};
-
-const MOCK_FILE_EMPTY = {
-  [`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
-  [`${config.ROOT_FOLDER}/apps/test-app/metadata/description.md`]: 'md desc',
-  [`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
-  [`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": ""}',
-};
-
-const MOCK_FILE_INSTALLED = {
-  [`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
-  [`${config.ROOT_FOLDER}/apps/test-app/metadata/description.md`]: 'md desc',
-  [`${config.ROOT_FOLDER}/apps/test-app2/config.json`]: JSON.stringify(testApp2),
-  [`${config.ROOT_FOLDER}/apps/test-app2/metadata/description.md`]: 'md desc',
-  [`${config.ROOT_FOLDER}/apps/test-app3/config.json`]: JSON.stringify(testApp3),
-  [`${config.ROOT_FOLDER}/apps/test-app3/metadata/description.md`]: 'md desc',
-  [`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
-  [`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": "test-app"}',
-  [`${config.ROOT_FOLDER}/app-data/test-app`]: '',
-  [`${config.ROOT_FOLDER}/app-data/test-app/app.env`]: 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test',
-};
+afterAll(async () => {
+  await db?.destroy();
+  await teardownConnection(TEST_SUITE);
+});
 
 describe('Install app', () => {
-  beforeEach(() => {
+  let app1: AppInfo;
+
+  beforeEach(async () => {
+    const { MockFiles, appInfo } = await createApp();
+    app1 = appInfo;
     // @ts-ignore
-    fs.__createMockFiles(MOCK_FILE_EMPTY);
+    fs.__createMockFiles(MockFiles);
   });
 
   it('Should correctly generate env file for app', async () => {
-    await AppsService.installApp('test-app', { test: 'test' });
-
-    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
+    await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
+    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString();
 
-    expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
+    expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test`);
   });
 
-  it('Should add app to state file', async () => {
-    await AppsService.installApp('test-app', { test: 'test' });
+  it('Should add app in database', async () => {
+    await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
 
-    const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
+    const app = await App.findOne({ where: { id: app1.id } });
 
-    expect(stateFile.installed).toBe('test-app');
+    expect(app).toBeDefined();
+    expect(app!.id).toBe(app1.id);
+    expect(app!.config).toStrictEqual({ TEST_FIELD: 'test' });
+    expect(app!.status).toBe(AppStatusEnum.RUNNING);
   });
 
   it('Should correctly run app script', async () => {
     const spy = jest.spyOn(childProcess, 'execFile');
+    await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
 
-    await AppsService.installApp('test-app', { test: 'test' });
-
-    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app', '/tipi'], {}, expect.any(Function)]);
-
+    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', app1.id, '/tipi'], {}, expect.any(Function)]);
     spy.mockRestore();
   });
 
   it('Should start app if already installed', async () => {
     const spy = jest.spyOn(childProcess, 'execFile');
 
-    await AppsService.installApp('test-app', { test: 'test' });
-    await AppsService.installApp('test-app', { test: 'test' });
+    await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
+    await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
 
     expect(spy.mock.calls.length).toBe(2);
-    expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app', '/tipi'], {}, expect.any(Function)]);
-    expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app', '/tipi'], {}, expect.any(Function)]);
+    expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', app1.id, '/tipi'], {}, expect.any(Function)]);
+    expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app1.id, '/tipi'], {}, expect.any(Function)]);
 
     spy.mockRestore();
   });
 
   it('Should throw if required form fields are missing', async () => {
-    await expect(AppsService.installApp('test-app', {})).rejects.toThrowError('Variable test is required');
+    await expect(AppsService.installApp(app1.id, {})).rejects.toThrowError('Variable TEST_FIELD is required');
   });
 });
 
 describe('Uninstall app', () => {
-  beforeEach(() => {
+  let app1: AppInfo;
+
+  beforeEach(async () => {
+    const app1create = await createApp(true);
+    app1 = app1create.appInfo;
     // @ts-ignore
-    fs.__createMockFiles(MOCK_FILE_INSTALLED);
+    fs.__createMockFiles(Object.assign(app1create.MockFiles));
+  });
+
+  it('App should be installed by default', async () => {
+    const app = await App.findOne({ where: { id: app1.id } });
+    expect(app).toBeDefined();
+    expect(app!.id).toBe(app1.id);
+    expect(app!.status).toBe(AppStatusEnum.RUNNING);
   });
 
-  it('Should correctly remove app from state file', async () => {
-    await AppsService.uninstallApp('test-app');
+  it('Should correctly remove app from database', async () => {
+    await AppsService.uninstallApp(app1.id);
 
-    const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
+    const app = await App.findOne({ where: { id: app1.id } });
 
-    expect(stateFile.installed).toBe('');
+    expect(app).toBeNull();
   });
 
   it('Should correctly run app script', async () => {
     const spy = jest.spyOn(childProcess, 'execFile');
 
-    await AppsService.uninstallApp('test-app');
+    await AppsService.uninstallApp(app1.id);
 
-    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', 'test-app', '/tipi'], {}, expect.any(Function)]);
+    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', app1.id, '/tipi'], {}, expect.any(Function)]);
+
+    spy.mockRestore();
+  });
+
+  it('Should stop app if it is running', async () => {
+    const spy = jest.spyOn(childProcess, 'execFile');
+
+    await AppsService.uninstallApp(app1.id);
+
+    expect(spy.mock.calls.length).toBe(2);
+    expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', app1.id, '/tipi'], {}, expect.any(Function)]);
+    expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', app1.id, '/tipi'], {}, expect.any(Function)]);
 
     spy.mockRestore();
   });
 
   it('Should throw if app is not installed', async () => {
-    await expect(AppsService.uninstallApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
+    await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found');
   });
 });
 
 describe('Start app', () => {
-  beforeEach(() => {
+  let app1: AppInfo;
+
+  beforeEach(async () => {
+    const app1create = await createApp(true);
+    app1 = app1create.appInfo;
     // @ts-ignore
-    fs.__createMockFiles(MOCK_FILE_INSTALLED);
+    fs.__createMockFiles(Object.assign(app1create.MockFiles));
   });
 
   it('Should correctly run app script', async () => {
     const spy = jest.spyOn(childProcess, 'execFile');
 
-    await AppsService.startApp('test-app');
+    await AppsService.startApp(app1.id);
 
-    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app', '/tipi'], {}, expect.any(Function)]);
+    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app1.id, '/tipi'], {}, expect.any(Function)]);
 
     spy.mockRestore();
   });
 
   it('Should throw if app is not installed', async () => {
-    await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
+    await expect(AppsService.startApp('any')).rejects.toThrowError('App any not found');
   });
 
   it('Should restart if app is already running', async () => {
     const spy = jest.spyOn(childProcess, 'execFile');
 
-    await AppsService.startApp('test-app');
+    await AppsService.startApp(app1.id);
     expect(spy.mock.calls.length).toBe(1);
-    await AppsService.startApp('test-app');
+    await AppsService.startApp(app1.id);
     expect(spy.mock.calls.length).toBe(2);
 
     spy.mockRestore();
   });
 
-  it('Should throw if app is not installed', async () => {
-    await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
-  });
-
   it('Regenerate env file', async () => {
-    fs.writeFile(`${config.ROOT_FOLDER}/app-data/test-app/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
+    fs.writeFile(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
 
-    await AppsService.startApp('test-app');
+    await AppsService.startApp(app1.id);
 
-    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
+    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString();
 
-    expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
+    expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test`);
   });
 });
 
 describe('Stop app', () => {
-  beforeEach(() => {
+  let app1: AppInfo;
+
+  beforeEach(async () => {
+    const app1create = await createApp(true);
+    app1 = app1create.appInfo;
     // @ts-ignore
-    fs.__createMockFiles(MOCK_FILE_INSTALLED);
+    fs.__createMockFiles(Object.assign(app1create.MockFiles));
   });
 
   it('Should correctly run app script', async () => {
     const spy = jest.spyOn(childProcess, 'execFile');
 
-    await AppsService.stopApp('test-app');
+    await AppsService.stopApp(app1.id);
 
-    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', 'test-app', '/tipi'], {}, expect.any(Function)]);
+    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', app1.id, '/tipi'], {}, expect.any(Function)]);
   });
 
   it('Should throw if app is not installed', async () => {
-    await expect(AppsService.stopApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
+    await expect(AppsService.stopApp('any')).rejects.toThrowError('App any not found');
   });
 });
 
 describe('Update app config', () => {
-  beforeEach(() => {
+  let app1: AppInfo;
+
+  beforeEach(async () => {
+    const app1create = await createApp(true);
+    app1 = app1create.appInfo;
     // @ts-ignore
-    fs.__createMockFiles(MOCK_FILE_INSTALLED);
+    fs.__createMockFiles(Object.assign(app1create.MockFiles));
   });
 
   it('Should correctly update app config', async () => {
-    await AppsService.updateAppConfig('test-app', { test: 'test', test2: 'test2' });
+    await AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' });
 
-    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
+    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString();
 
-    expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test\nTEST_FIELD_2=test2');
+    expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test`);
   });
 
-  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 installed');
+  it('Should throw if required field is missing', async () => {
+    await expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: '' })).rejects.toThrowError('Variable TEST_FIELD is required');
   });
 
-  it('Should throw if required form fields are missing', async () => {
-    await expect(AppsService.updateAppConfig('test-app', {})).rejects.toThrowError('Variable test is required');
+  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');
   });
 });
 
 describe('Get app config', () => {
-  beforeEach(() => {
+  let app1: AppInfo;
+
+  beforeEach(async () => {
+    const app1create = await createApp(true);
+    app1 = app1create.appInfo;
     // @ts-ignore
-    fs.__createMockFiles(MOCK_FILE_INSTALLED);
+    fs.__createMockFiles(Object.assign(app1create.MockFiles));
   });
 
   it('Should correctly get app config', async () => {
-    const appconfig = await AppsService.getAppInfo('test-app');
+    const app = await AppsService.getApp(app1.id);
 
-    expect(appconfig).toEqual({ ...testApp, installed: true, status: 'stopped', description: 'md desc' });
+    expect(app).toBeDefined();
+    expect(app.config).toStrictEqual({ TEST_FIELD: 'test' });
+    expect(app.id).toBe(app1.id);
+    expect(app.status).toBe(AppStatusEnum.RUNNING);
   });
 
-  it('Should have installed false if app is not installed', async () => {
-    const appconfig = await AppsService.getAppInfo('test-app2');
+  it('Should return default values if app is not installed', async () => {
+    const appconfig = await AppsService.getApp('test-app2');
 
-    expect(appconfig).toEqual({ ...testApp2, installed: false, status: 'stopped', description: 'md desc' });
+    expect(appconfig).toBeDefined();
+    expect(appconfig.id).toBe('test-app2');
+    expect(appconfig.config).toStrictEqual({});
+    expect(appconfig.status).toBe(AppStatusEnum.MISSING);
   });
 });
 
 describe('List apps', () => {
-  beforeEach(() => {
+  let app1: AppInfo;
+  let app2: AppInfo;
+
+  beforeEach(async () => {
+    const app1create = await createApp(true);
+    const app2create = await createApp();
+    app1 = app1create.appInfo;
+    app2 = app2create.appInfo;
     // @ts-ignore
-    fs.__createMockFiles(MOCK_FILE_INSTALLED);
+    fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
   });
 
-  it('Should correctly list apps', async () => {
-    const apps = await AppsService.listApps();
+  it('Should correctly list apps sorted by name', async () => {
+    const { apps } = await AppsService.listApps();
+
+    const sortedApps = [app1, app2].sort((a, b) => a.name.localeCompare(b.name));
 
-    expect(apps).toEqual([
-      { ...testApp, installed: true, status: 'stopped', description: 'md desc' },
-      { ...testApp2, installed: false, status: 'stopped', description: 'md desc' },
-    ]);
+    expect(apps).toBeDefined();
+    expect(apps.length).toBe(2);
     expect(apps.length).toBe(2);
-    expect(apps[0].id).toBe('test-app');
-    expect(apps[1].id).toBe('test-app2');
+    expect(apps[0].id).toBe(sortedApps[0].id);
+    expect(apps[1].id).toBe(sortedApps[1].id);
+    expect(apps[0].description).toBe('md desc');
   });
 });

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

@@ -5,8 +5,6 @@ import InternalIp from 'internal-ip';
 import config from '../../config';
 import { AppInfo } from './apps.types';
 
-type AppsState = { installed: string };
-
 export const checkAppRequirements = async (appName: string) => {
   let valid = true;
   const configFile: AppInfo = readJsonFile(`/apps/${appName}/config.json`);
@@ -106,10 +104,6 @@ export const generateEnvFile = (appName: string, form: Record<string, string>) =
   writeFile(`/app-data/${appName}/app.env`, envFile);
 };
 
-export const getStateFile = (): AppsState => {
-  return readJsonFile('/state/apps.json');
-};
-
 export const getAvailableApps = (): string[] => {
   const apps: string[] = [];
 
@@ -131,10 +125,6 @@ export const getAvailableApps = (): string[] => {
 export const getAppInfo = (id: string): AppInfo => {
   try {
     const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
-
-    const state = getStateFile();
-    const installed: string[] = state.installed.split(' ').filter(Boolean);
-    configFile.installed = installed.includes(id);
     configFile.description = readFile(`/apps/${id}/metadata/description.md`);
 
     return configFile;

+ 9 - 8
packages/system-api/src/modules/apps/apps.service.ts

@@ -1,5 +1,5 @@
 import { createFolder, readFile, readJsonFile } from '../fs/fs.helpers';
-import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, getStateFile, runAppScript } from './apps.helpers';
+import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, runAppScript } from './apps.helpers';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
 import App from './app.entity';
 
@@ -10,11 +10,11 @@ const startApp = async (appName: string): Promise<App> => {
     throw new Error(`App ${appName} not found`);
   }
 
-  checkEnvFile(appName);
-
   // Regenerate env file
   generateEnvFile(appName, app.config);
 
+  checkEnvFile(appName);
+
   await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
   // Run script
   await runAppScript(['start', appName]);
@@ -65,15 +65,11 @@ const listApps = async (): Promise<ListAppsResonse> => {
     })
     .filter(Boolean);
 
-  const state = getStateFile();
-  const installed: string[] = state.installed.split(' ').filter(Boolean);
-
   apps.forEach((app) => {
-    app.installed = installed.includes(app.id);
     app.description = readFile(`/apps/${app.id}/metadata/description.md`);
   });
 
-  return { apps, total: apps.length };
+  return { apps: apps.sort((a, b) => a.name.localeCompare(b.name)), total: apps.length };
 };
 
 const updateAppConfig = async (id: string, form: Record<string, string>): Promise<App> => {
@@ -93,6 +89,11 @@ const updateAppConfig = async (id: string, form: Record<string, string>): Promis
 
 const stopApp = async (id: string): Promise<App> => {
   let app = await App.findOne({ where: { id } });
+
+  if (!app) {
+    throw new Error(`App ${id} not found`);
+  }
+
   // Run script
   await App.update({ id }, { status: AppStatusEnum.STOPPING });
   await runAppScript(['stop', id]);

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

@@ -106,9 +106,6 @@ class AppInfo {
   @Field(() => String)
   source!: string;
 
-  @Field(() => Boolean)
-  installed!: boolean;
-
   @Field(() => [AppCategoriesEnum])
   categories!: AppCategoriesEnum[];
 

+ 0 - 147
packages/system-api/src/modules/auth/__tests__/auth.controller.test.ts

@@ -1,147 +0,0 @@
-import { Request, Response } from 'express';
-import fs from 'fs';
-import * as argon2 from 'argon2';
-import config from '../../../config';
-import AuthController from '../auth.controller';
-
-let user: any;
-
-jest.mock('fs');
-
-const next = jest.fn();
-
-const MOCK_USER_REGISTERED = () => ({
-  [`${config.ROOT_FOLDER}/state/users.json`]: `[${user}]`,
-});
-
-const MOCK_NO_USER = {
-  [`${config.ROOT_FOLDER}/state/users.json`]: '[]',
-};
-
-beforeAll(async () => {
-  const hash = await argon2.hash('password');
-  user = JSON.stringify({
-    email: 'username',
-    password: hash,
-  });
-});
-
-describe('Login', () => {
-  beforeEach(() => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_USER_REGISTERED());
-  });
-
-  it('Should put cookie in response after login', async () => {
-    const json = jest.fn();
-    const res = { cookie: jest.fn(), status: jest.fn(() => ({ json })), json: jest.fn() } as unknown as Response;
-    const req = { body: { email: 'username', password: 'password' } } as Request;
-
-    await AuthController.login(req, res, next);
-
-    expect(res.cookie).toHaveBeenCalledWith('tipi_token', expect.any(String), expect.any(Object));
-    expect(res.status).toHaveBeenCalledWith(200);
-    expect(json).toHaveBeenCalledWith({ token: expect.any(String) });
-    expect(next).not.toHaveBeenCalled();
-  });
-
-  it('Should throw if username is not provided in request', async () => {
-    const res = { cookie: jest.fn(), status: jest.fn(), json: jest.fn() } as unknown as Response;
-    const req = { body: { password: 'password' } } as Request;
-
-    await AuthController.login(req, res, next);
-
-    expect(res.cookie).not.toHaveBeenCalled();
-    expect(next).toHaveBeenCalledWith(expect.any(Error));
-  });
-
-  it('Should throw if password is not provided in request', async () => {
-    const res = { cookie: jest.fn(), status: jest.fn(), json: jest.fn() } as unknown as Response;
-    const req = { body: { email: 'username' } } as Request;
-
-    await AuthController.login(req, res, next);
-
-    expect(res.cookie).not.toHaveBeenCalled();
-    expect(next).toHaveBeenCalledWith(expect.any(Error));
-  });
-});
-
-describe('Register', () => {
-  beforeEach(() => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_NO_USER);
-  });
-
-  it('Should put cookie in response after register', async () => {
-    const json = jest.fn();
-    const res = { cookie: jest.fn(), status: jest.fn(() => ({ json })), json: jest.fn() } as unknown as Response;
-    const req = { body: { email: 'username', password: 'password', name: 'name' } } as Request;
-
-    await AuthController.register(req, res, next);
-
-    expect(res.cookie).toHaveBeenCalledWith('tipi_token', expect.any(String), expect.any(Object));
-    expect(res.status).toHaveBeenCalledWith(200);
-    expect(json).toHaveBeenCalledWith({ token: expect.any(String) });
-  });
-});
-
-describe('Me', () => {
-  beforeEach(() => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_USER_REGISTERED());
-  });
-
-  it('Should return user if present in request', async () => {
-    const json = jest.fn();
-    const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
-    const req = { user } as unknown as Request;
-
-    await AuthController.me(req, res, next);
-
-    expect(res.status).toHaveBeenCalledWith(200);
-    expect(json).toHaveBeenCalledWith({ user });
-  });
-
-  it('Should return null if user is not present in request', async () => {
-    const json = jest.fn();
-    const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
-    const req = {} as Request;
-
-    await AuthController.me(req, res, next);
-
-    expect(res.status).toHaveBeenCalledWith(200);
-    expect(json).toHaveBeenCalledWith({ user: null });
-  });
-});
-
-describe('isConfigured', () => {
-  beforeEach(() => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_NO_USER);
-  });
-
-  it('Should return false if no user is registered', async () => {
-    const json = jest.fn();
-    const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
-    const req = {} as Request;
-
-    await AuthController.isConfigured(req, res, next);
-
-    expect(res.status).toHaveBeenCalledWith(200);
-    expect(json).toHaveBeenCalledWith({ configured: false });
-  });
-
-  it('Should return true if user is registered', async () => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_USER_REGISTERED());
-
-    const json = jest.fn();
-    const res = { status: jest.fn(() => ({ json })) } as unknown as Response;
-    const req = { user } as unknown as Request;
-
-    await AuthController.isConfigured(req, res, next);
-
-    expect(res.status).toHaveBeenCalledWith(200);
-    expect(json).toHaveBeenCalledWith({ configured: true });
-  });
-});

+ 0 - 71
packages/system-api/src/modules/auth/__tests__/auth.helpers.test.ts

@@ -1,71 +0,0 @@
-import * as argon2 from 'argon2';
-import fs from 'fs';
-import config from '../../../config';
-import { IUser } from '../../../config/types';
-import AuthHelpers from '../auth.helpers';
-
-let user: IUser;
-
-beforeAll(async () => {
-  const hash = await argon2.hash('password');
-  user = { email: 'username', password: hash, name: 'name' };
-});
-
-jest.mock('fs');
-
-const MOCK_USER_REGISTERED = () => ({
-  [`${config.ROOT_FOLDER}/state/users.json`]: `[${JSON.stringify(user)}]`,
-});
-
-describe('TradeTokenForUser', () => {
-  beforeEach(() => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_USER_REGISTERED());
-  });
-
-  it('Should return null if token is invalid', () => {
-    const result = AuthHelpers.tradeTokenForUser('invalid token');
-    expect(result).toBeNull();
-  });
-
-  it('Should return user if token is valid', async () => {
-    const token = await AuthHelpers.getJwtToken(user, 'password');
-    const result = AuthHelpers.tradeTokenForUser(token);
-
-    expect(result).toEqual(user);
-  });
-});
-
-describe('GetJwtToken', () => {
-  beforeEach(() => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_USER_REGISTERED());
-  });
-
-  it('Should return token if user and password are valid', async () => {
-    const token = await AuthHelpers.getJwtToken(user, 'password');
-    expect(token).toBeDefined();
-  });
-
-  it('Should throw if password is invalid', async () => {
-    await expect(AuthHelpers.getJwtToken(user, 'invalid password')).rejects.toThrow('Wrong password');
-  });
-});
-
-describe('getUser', () => {
-  beforeEach(() => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_USER_REGISTERED());
-  });
-
-  it('Should return null if user is not found', () => {
-    const result = AuthHelpers.getUser('invalid token');
-    expect(result).toBeUndefined();
-  });
-
-  it('Should return user if token is valid', async () => {
-    const result = AuthHelpers.getUser('username');
-
-    expect(result).toEqual(user);
-  });
-});

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

@@ -1,102 +1,88 @@
-import fs from 'fs';
-import jsonwebtoken from 'jsonwebtoken';
 import * as argon2 from 'argon2';
-import config from '../../../config';
 import AuthService from '../auth.service';
-import { IUser } from '../../../config/types';
+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';
 
-jest.mock('fs');
+let db: DataSource | null = null;
+const TEST_SUITE = 'authservice';
 
-let user: any;
-
-const MOCK_USER_REGISTERED = () => ({
-  [`${config.ROOT_FOLDER}/state/users.json`]: `[${user}]`,
+beforeAll(async () => {
+  db = await setupConnection(TEST_SUITE);
 });
 
-const MOCK_NO_USER = {
-  [`${config.ROOT_FOLDER}/state/users.json`]: '[]',
-};
+beforeEach(async () => {
+  await User.clear();
+});
 
-beforeAll(async () => {
-  const hash = await argon2.hash('password');
-  user = JSON.stringify({
-    email: 'username',
-    password: hash,
-  });
+afterAll(async () => {
+  await db?.destroy();
+  await teardownConnection(TEST_SUITE);
 });
 
 describe('Login', () => {
-  beforeEach(() => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_USER_REGISTERED());
-  });
+  it('Should return user after login', async () => {
+    const email = faker.internet.email();
+    await createUser(email);
 
-  it('Should return token after login', async () => {
-    const token = await AuthService.login('username', 'password');
+    const { user } = await AuthService.login({ username: email, password: 'password' });
 
-    const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
-
-    expect(token).toBeDefined();
-    expect(email).toBe('username');
+    expect(user).toBeDefined();
+    expect(user?.id).toBe(1);
   });
 
   it('Should throw if user does not exist', async () => {
-    await expect(AuthService.login('username1', 'password')).rejects.toThrowError('User not found');
+    await expect(AuthService.login({ username: 'test', password: 'test' })).rejects.toThrowError('User not found');
   });
 
   it('Should throw if password is incorrect', async () => {
-    await expect(AuthService.login('username', 'password1')).rejects.toThrowError('Wrong password');
+    const email = faker.internet.email();
+    await createUser(email);
+    await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
   });
 });
 
 describe('Register', () => {
-  beforeEach(() => {
-    // @ts-ignore
-    fs.__createMockFiles(MOCK_NO_USER);
-  });
-
-  it('Should return token after register', async () => {
-    const token = await AuthService.register('username', 'password', 'name');
+  it('Should return new user after register', async () => {
+    const email = faker.internet.email();
+    const { user } = await AuthService.register({ username: email, password: 'test' });
 
-    const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
-
-    expect(token).toBeDefined();
-    expect(email).toBe('username');
+    expect(user).toBeDefined();
   });
 
-  it('Should correctly write user to file', async () => {
-    await AuthService.register('username', 'password', 'name');
-
-    const users: IUser[] = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/users.json`, 'utf8'));
-
-    expect(users.length).toBe(1);
-    expect(users[0].email).toBe('username');
-    expect(users[0].name).toBe('name');
+  it('Should correctly trim and lowercase email', async () => {
+    const email = faker.internet.email();
+    await AuthService.register({ username: email, password: 'test' });
 
-    const valid = await argon2.verify(users[0].password, 'password');
+    const user = await User.findOne({ where: { username: email.toLowerCase().trim() } });
 
-    expect(valid).toBeTruthy();
+    expect(user).toBeDefined();
+    expect(user?.username).toBe(email.toLowerCase().trim());
   });
 
   it('Should throw if user already exists', async () => {
-    await AuthService.register('username', 'password', 'name');
+    const email = faker.internet.email();
 
-    await expect(AuthService.register('username', 'password', 'name')).rejects.toThrowError('There is already an admin user');
+    await createUser(email);
+    await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('User already exists');
   });
 
   it('Should throw if email is not provided', async () => {
-    await expect(AuthService.register('', 'password', 'name')).rejects.toThrowError('Missing email or password');
+    await expect(AuthService.register({ username: '', password: 'test' })).rejects.toThrowError('Missing email or password');
   });
 
   it('Should throw if password is not provided', async () => {
-    await expect(AuthService.register('username', '', 'name')).rejects.toThrowError('Missing email or password');
+    await expect(AuthService.register({ username: faker.internet.email(), password: '' })).rejects.toThrowError('Missing email or password');
   });
 
-  it('Does not throw if name is not provided', async () => {
-    await AuthService.register('username', 'password', '');
+  it('Password is correctly hashed', async () => {
+    const email = faker.internet.email();
+    const { user } = await AuthService.register({ username: email, password: 'test' });
 
-    const users: IUser[] = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/users.json`, 'utf8'));
+    const isPasswordValid = await argon2.verify(user?.password || '', 'test');
 
-    expect(users.length).toBe(1);
+    expect(isPasswordValid).toBe(true);
   });
 });

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

@@ -0,0 +1,16 @@
+import User from '../user.entity';
+import * as argon2 from 'argon2';
+import { faker } from '@faker-js/faker';
+
+const createUser = async (email?: string) => {
+  const hash = await argon2.hash('password');
+
+  const user = await User.create({
+    username: email?.toLowerCase().trim() || faker.internet.email().toLowerCase().trim(),
+    password: hash,
+  }).save();
+
+  return user;
+};
+
+export { createUser };

+ 0 - 46
packages/system-api/src/modules/auth/auth.helpers.ts

@@ -1,46 +0,0 @@
-import jsonwebtoken from 'jsonwebtoken';
-import * as argon2 from 'argon2';
-import { IUser, Maybe } from '../../config/types';
-import { readJsonFile } from '../fs/fs.helpers';
-import config from '../../config';
-import User from './user.entity';
-
-const getUser = (email: string): Maybe<IUser> => {
-  const savedUser: IUser[] = readJsonFile('/state/users.json');
-
-  return savedUser.find((u) => u.email === email);
-};
-
-const compareHashPassword = (password: string, hash = ''): Promise<boolean> => {
-  return argon2.verify(hash, password);
-};
-
-const getJwtToken = async (user: User, password: string) => {
-  const validPassword = await compareHashPassword(password, user.password);
-
-  if (validPassword) {
-    if (config.JWT_SECRET) {
-      return jsonwebtoken.sign({ email: user.username }, config.JWT_SECRET, {
-        expiresIn: '7d',
-      });
-    } else {
-      throw new Error('JWT_SECRET is not set');
-    }
-  }
-
-  throw new Error('Wrong password');
-};
-
-const tradeTokenForUser = (token: string): Maybe<IUser> => {
-  try {
-    const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string };
-
-    const users: IUser[] = readJsonFile('/state/users.json');
-
-    return users.find((user) => user.email === email);
-  } catch (error) {
-    return null;
-  }
-};
-
-export default { tradeTokenForUser, getJwtToken, getUser };

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

@@ -9,7 +9,13 @@ export const readJsonFile = (path: string): any => {
   return JSON.parse(rawFile);
 };
 
-export const readFile = (path: string): string => fs.readFileSync(getAbsolutePath(path)).toString();
+export const readFile = (path: string): string => {
+  try {
+    return fs.readFileSync(getAbsolutePath(path)).toString();
+  } catch {
+    return '';
+  }
+};
 
 export const readdirSync = (path: string): string[] => fs.readdirSync(getAbsolutePath(path));
 

+ 40 - 0
packages/system-api/src/test/connection.ts

@@ -0,0 +1,40 @@
+import { DataSource } from 'typeorm';
+import App from '../modules/apps/app.entity';
+import User from '../modules/auth/user.entity';
+import pg from 'pg';
+
+const pgClient = new pg.Client({
+  user: 'postgres',
+  host: 'localhost',
+  database: 'postgres',
+  password: 'postgres',
+  port: 5432,
+});
+
+export const setupConnection = async (testsuite: string): Promise<DataSource> => {
+  await pgClient.connect();
+
+  await pgClient.query(`DROP DATABASE IF EXISTS ${testsuite}`);
+  await pgClient.query(`CREATE DATABASE ${testsuite}`);
+
+  const AppDataSource = new DataSource({
+    name: 'default',
+    type: 'postgres',
+    host: 'localhost',
+    port: 5432,
+    username: 'postgres',
+    password: 'postgres',
+    database: testsuite,
+    dropSchema: true,
+    logging: false,
+    synchronize: true,
+    entities: [App, User],
+  });
+
+  return AppDataSource.initialize();
+};
+
+export const teardownConnection = async (testsuite: string): Promise<void> => {
+  await pgClient.query(`DROP DATABASE IF EXISTS ${testsuite}`);
+  await pgClient.end();
+};

+ 0 - 0
packages/system-api/tests/dotenv-config.ts → packages/system-api/src/test/dotenv-config.ts


+ 22 - 14
pnpm-lock.yaml

@@ -141,7 +141,7 @@ importers:
 
   packages/system-api:
     specifiers:
-      '@runtipi/common': file:../common
+      '@faker-js/faker': ^7.3.0
       '@types/compression': ^1.7.2
       '@types/cookie-parser': ^1.4.3
       '@types/cors': ^2.8.12
@@ -152,6 +152,7 @@ importers:
       '@types/mock-fs': ^4.13.1
       '@types/passport': ^1.0.7
       '@types/passport-http-bearer': ^1.0.37
+      '@types/pg': ^8.6.5
       '@types/session-file-store': ^1.2.2
       '@types/tcp-port-used': ^1.0.1
       '@types/validator': ^13.7.2
@@ -204,7 +205,6 @@ importers:
       typescript: 4.6.4
       winston: ^3.7.2
     dependencies:
-      '@runtipi/common': file:packages/common
       apollo-server-core: 3.9.0_graphql@15.8.0
       apollo-server-express: 3.9.0_jfj6k5cqxqbusbdzwqjdzioxzm
       argon2: 0.28.5
@@ -239,6 +239,7 @@ importers:
       typeorm: 0.3.6_pg@8.7.3
       winston: 3.7.2
     devDependencies:
+      '@faker-js/faker': 7.3.0
       '@types/compression': 1.7.2
       '@types/cookie-parser': 1.4.3
       '@types/cors': 2.8.12
@@ -249,6 +250,7 @@ importers:
       '@types/mock-fs': 4.13.1
       '@types/passport': 1.0.7
       '@types/passport-http-bearer': 1.0.37
+      '@types/pg': 8.6.5
       '@types/session-file-store': 1.2.2
       '@types/tcp-port-used': 1.0.1
       '@types/validator': 13.7.2
@@ -2154,6 +2156,11 @@ packages:
       - supports-color
     dev: true
 
+  /@faker-js/faker/7.3.0:
+    resolution: {integrity: sha512-1W0PZezq2rxlAssoWemi9gFRD8IQxvf0FPL5Km3TOmGHFG7ib0TbFBJ0yC7D/1NsxunjNTK6WjUXV8ao/mKZ5w==}
+    engines: {node: '>=14.0.0', npm: '>=6.0.0'}
+    dev: true
+
   /@fontsource/open-sans/4.5.8:
     resolution: {integrity: sha512-3b94XDdRLqL7OlE7OjWg/4pgG825Juw8PLVEDm6h5pio0gMU89ICxfatGxHsBxMGfqad+wnvdmUweZWlELDFpQ==}
     dev: false
@@ -3513,6 +3520,14 @@ packages:
       '@types/express': 4.17.13
     dev: true
 
+  /@types/pg/8.6.5:
+    resolution: {integrity: sha512-tOkGtAqRVkHa/PVZicq67zuujI4Oorfglsr2IbKofDwBSysnaqSx7W1mDqFqdkGE6Fbgh+PZAl0r/BWON/mozw==}
+    dependencies:
+      '@types/node': 17.0.31
+      pg-protocol: 1.5.0
+      pg-types: 2.2.0
+    dev: true
+
   /@types/prettier/2.6.0:
     resolution: {integrity: sha512-G/AdOadiZhnJp0jXCaBQU449W2h716OW/EoXeYkCytxKL06X1WCXB4DZpp8TpZ8eyIJVS1cw4lrlkkSYU21cDw==}
     dev: true
@@ -5058,7 +5073,7 @@ packages:
     dev: true
 
   /console-control-strings/1.1.0:
-    resolution: {integrity: sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=}
+    resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
     dev: false
 
   /constant-case/3.0.4:
@@ -5376,7 +5391,7 @@ packages:
     dev: true
 
   /delegates/1.0.0:
-    resolution: {integrity: sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=}
+    resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
     dev: false
 
   /depd/2.0.0:
@@ -6034,7 +6049,7 @@ packages:
       eslint-import-resolver-webpack:
         optional: true
     dependencies:
-      '@typescript-eslint/parser': 5.22.0_uhoeudlwl7kc47h4kncsfowede
+      '@typescript-eslint/parser': 5.22.0_hcfsmds2fshutdssjqluwm76uu
       debug: 3.2.7
       eslint-import-resolver-node: 0.3.6
       find-up: 2.1.0
@@ -7110,7 +7125,7 @@ packages:
     dev: true
 
   /has-unicode/2.0.1:
-    resolution: {integrity: sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=}
+    resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==}
     dev: false
 
   /has-yarn/2.1.0:
@@ -7671,7 +7686,7 @@ packages:
     dev: false
 
   /isexe/2.0.0:
-    resolution: {integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=}
+    resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
 
   /isomorphic-fetch/3.0.0:
     resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==}
@@ -10003,7 +10018,6 @@ packages:
   /pg-int8/1.0.1:
     resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
     engines: {node: '>=4.0.0'}
-    dev: false
 
   /pg-pool/3.5.1_pg@8.7.3:
     resolution: {integrity: sha512-6iCR0wVrro6OOHFsyavV+i6KYL4lVNyYAB9RD18w66xSzN+d8b66HiwuP30Gp1SH5O9T82fckkzsRjlrhD0ioQ==}
@@ -10015,7 +10029,6 @@ packages:
 
   /pg-protocol/1.5.0:
     resolution: {integrity: sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==}
-    dev: false
 
   /pg-types/2.2.0:
     resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
@@ -10026,7 +10039,6 @@ packages:
       postgres-bytea: 1.0.0
       postgres-date: 1.0.7
       postgres-interval: 1.2.0
-    dev: false
 
   /pg/8.7.3:
     resolution: {integrity: sha512-HPmH4GH4H3AOprDJOazoIcpI49XFsHCe8xlrjHkWiapdbHK+HLtbm/GQzXYAZwmPju/kzKhjaSfMACG+8cgJcw==}
@@ -10151,24 +10163,20 @@ packages:
   /postgres-array/2.0.0:
     resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
     engines: {node: '>=4'}
-    dev: false
 
   /postgres-bytea/1.0.0:
     resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
     engines: {node: '>=0.10.0'}
-    dev: false
 
   /postgres-date/1.0.7:
     resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
     engines: {node: '>=0.10.0'}
-    dev: false
 
   /postgres-interval/1.2.0:
     resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
     engines: {node: '>=0.10.0'}
     dependencies:
       xtend: 4.0.2
-    dev: false
 
   /prelude-ls/1.2.1:
     resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}