diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25f2e1c4..7cc2dec6 100644 --- a/.github/workflows/ci.yml +++ b/.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 \ No newline at end of file + 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 \ No newline at end of file diff --git a/.github/workflows/release-candidate.yml b/.github/workflows/release-candidate.yml index f4905aec..928773a4 100644 --- a/.github/workflows/release-candidate.yml +++ b/.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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index f038daa0..22b43c8c 100644 --- a/Dockerfile +++ b/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 / diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 15a7e78c..ab9f5f58 100644 --- a/docker-compose.dev.yml +++ b/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 diff --git a/package.json b/package.json index 29452b13..27eb782c 100644 --- a/package.json +++ b/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": { diff --git a/packages/system-api/jest.config.cjs b/packages/system-api/jest.config.cjs index 6e8a0781..c4c43b97 100644 --- a/packages/system-api/jest.config.cjs +++ b/packages/system-api/jest.config.cjs @@ -4,9 +4,8 @@ module.exports = { verbose: true, testEnvironment: 'node', testMatch: ['**/__tests__/**/*.test.ts'], - setupFiles: ['/tests/dotenv-config.ts'], + setupFiles: ['/src/test/dotenv-config.ts'], collectCoverage: true, collectCoverageFrom: ['src/**/*.{ts,tsx}'], - coverageProvider: 'v8', passWithNoTests: true, }; diff --git a/packages/system-api/package.json b/packages/system-api/package.json index 1ad4b69d..3bc2e3e8 100644 --- a/packages/system-api/package.json +++ b/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", diff --git a/packages/system-api/src/config/config.ts b/packages/system-api/src/config/config.ts index 94ca2826..df1bd34c 100644 --- a/packages/system-api/src/config/config.ts +++ b/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, diff --git a/packages/system-api/src/declarations.d.ts b/packages/system-api/src/declarations.d.ts deleted file mode 100644 index 537d51fd..00000000 --- a/packages/system-api/src/declarations.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module 'su-exec' { - export function execFile(path: string, args: string[], options: {}, callback?: any): void; - export function init(): void; -} diff --git a/packages/system-api/src/modules/apps/__tests__/apps.factory.ts b/packages/system-api/src/modules/apps/__tests__/apps.factory.ts new file mode 100644 index 00000000..86324ca3 --- /dev/null +++ b/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 }; diff --git a/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts b/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts index ee2f38f3..9905ace7 100644 --- a/packages/system-api/src/modules/apps/__tests__/apps.service.test.ts +++ b/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(() => { - jest.resetModules(); - jest.resetAllMocks(); +let db: DataSource | null = null; +const TEST_SUITE = 'appsservice'; + +beforeAll(async () => { + db = await setupConnection(TEST_SUITE); }); -const testApp: Partial = { - 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', - }, - ], -}; +beforeEach(async () => { + jest.resetModules(); + jest.resetAllMocks(); + await App.clear(); +}); -const testApp2: Partial = { - available: true, - id: 'test-app2', -}; - -const testApp3: Partial = { - 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' }); + await AppsService.installApp(app1.id, { TEST_FIELD: 'test' }); + const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString(); - const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/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('Should correctly remove app from state file', async () => { - await AppsService.uninstallApp('test-app'); + 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); + }); - const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString()); + it('Should correctly remove app from database', async () => { + await AppsService.uninstallApp(app1.id); - expect(stateFile.installed).toBe(''); + const app = await App.findOne({ where: { id: app1.id } }); + + 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 required field is missing', async () => { + await expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: '' })).rejects.toThrowError('Variable TEST_FIELD 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 installed'); - }); - - it('Should throw if required form fields are missing', async () => { - await expect(AppsService.updateAppConfig('test-app', {})).rejects.toThrowError('Variable test is required'); + 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(); - expect(apps).toEqual([ - { ...testApp, installed: true, status: 'stopped', description: 'md desc' }, - { ...testApp2, installed: false, status: 'stopped', description: 'md desc' }, - ]); + const sortedApps = [app1, app2].sort((a, b) => a.name.localeCompare(b.name)); + + expect(apps).toBeDefined(); expect(apps.length).toBe(2); - expect(apps[0].id).toBe('test-app'); - expect(apps[1].id).toBe('test-app2'); + expect(apps.length).toBe(2); + expect(apps[0].id).toBe(sortedApps[0].id); + expect(apps[1].id).toBe(sortedApps[1].id); + expect(apps[0].description).toBe('md desc'); }); }); diff --git a/packages/system-api/src/modules/apps/apps.helpers.ts b/packages/system-api/src/modules/apps/apps.helpers.ts index 3ae6d6dc..56997b26 100644 --- a/packages/system-api/src/modules/apps/apps.helpers.ts +++ b/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) = 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; diff --git a/packages/system-api/src/modules/apps/apps.service.ts b/packages/system-api/src/modules/apps/apps.service.ts index b2fb0aff..c88c5cd3 100644 --- a/packages/system-api/src/modules/apps/apps.service.ts +++ b/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 => { 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 => { }) .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): Promise => { @@ -93,6 +89,11 @@ const updateAppConfig = async (id: string, form: Record): Promis const stopApp = async (id: string): Promise => { 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]); diff --git a/packages/system-api/src/modules/apps/apps.types.ts b/packages/system-api/src/modules/apps/apps.types.ts index d630decc..0ff4753c 100644 --- a/packages/system-api/src/modules/apps/apps.types.ts +++ b/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[]; diff --git a/packages/system-api/src/modules/auth/__tests__/auth.controller.test.ts b/packages/system-api/src/modules/auth/__tests__/auth.controller.test.ts deleted file mode 100644 index 37b2b96f..00000000 --- a/packages/system-api/src/modules/auth/__tests__/auth.controller.test.ts +++ /dev/null @@ -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 }); - }); -}); diff --git a/packages/system-api/src/modules/auth/__tests__/auth.helpers.test.ts b/packages/system-api/src/modules/auth/__tests__/auth.helpers.test.ts deleted file mode 100644 index a67ab190..00000000 --- a/packages/system-api/src/modules/auth/__tests__/auth.helpers.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/packages/system-api/src/modules/auth/__tests__/auth.service.test.ts b/packages/system-api/src/modules/auth/__tests__/auth.service.test.ts index b22720cf..c9703677 100644 --- a/packages/system-api/src/modules/auth/__tests__/auth.service.test.ts +++ b/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 user: any; - -const MOCK_USER_REGISTERED = () => ({ - [`${config.ROOT_FOLDER}/state/users.json`]: `[${user}]`, -}); - -const MOCK_NO_USER = { - [`${config.ROOT_FOLDER}/state/users.json`]: '[]', -}; +let db: DataSource | null = null; +const TEST_SUITE = 'authservice'; beforeAll(async () => { - const hash = await argon2.hash('password'); - user = JSON.stringify({ - email: 'username', - password: hash, - }); + db = await setupConnection(TEST_SUITE); +}); + +beforeEach(async () => { + await User.clear(); +}); + +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 new user after register', async () => { + const email = faker.internet.email(); + const { user } = await AuthService.register({ username: email, password: 'test' }); + + expect(user).toBeDefined(); }); - it('Should return token after register', async () => { - const token = await AuthService.register('username', 'password', 'name'); + it('Should correctly trim and lowercase email', async () => { + const email = faker.internet.email(); + await AuthService.register({ username: email, password: 'test' }); - const { email } = jsonwebtoken.verify(token, config.JWT_SECRET) as { email: string }; + const user = await User.findOne({ where: { username: email.toLowerCase().trim() } }); - expect(token).toBeDefined(); - expect(email).toBe('username'); - }); - - 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'); - - const valid = await argon2.verify(users[0].password, 'password'); - - 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); }); }); diff --git a/packages/system-api/src/modules/auth/__tests__/user.factory.ts b/packages/system-api/src/modules/auth/__tests__/user.factory.ts new file mode 100644 index 00000000..25f56ee0 --- /dev/null +++ b/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 }; diff --git a/packages/system-api/src/modules/auth/auth.helpers.ts b/packages/system-api/src/modules/auth/auth.helpers.ts deleted file mode 100644 index 1bb71f1a..00000000 --- a/packages/system-api/src/modules/auth/auth.helpers.ts +++ /dev/null @@ -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 => { - const savedUser: IUser[] = readJsonFile('/state/users.json'); - - return savedUser.find((u) => u.email === email); -}; - -const compareHashPassword = (password: string, hash = ''): Promise => { - 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 => { - 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 }; diff --git a/packages/system-api/src/modules/fs/fs.helpers.ts b/packages/system-api/src/modules/fs/fs.helpers.ts index 407801a2..713ca68c 100644 --- a/packages/system-api/src/modules/fs/fs.helpers.ts +++ b/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)); diff --git a/packages/system-api/src/test/connection.ts b/packages/system-api/src/test/connection.ts new file mode 100755 index 00000000..65c51e0a --- /dev/null +++ b/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 => { + 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 => { + await pgClient.query(`DROP DATABASE IF EXISTS ${testsuite}`); + await pgClient.end(); +}; diff --git a/packages/system-api/tests/dotenv-config.ts b/packages/system-api/src/test/dotenv-config.ts similarity index 100% rename from packages/system-api/tests/dotenv-config.ts rename to packages/system-api/src/test/dotenv-config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1182bea4..08e3a573 100644 --- a/pnpm-lock.yaml +++ b/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==}