Adapt tests

This commit is contained in:
Nicolas Meienberger 2022-06-28 22:01:06 +02:00
parent 82fb49a684
commit 37662b574b
23 changed files with 371 additions and 504 deletions

View file

@ -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

View file

@ -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

View file

@ -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 /

View file

@ -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

View file

@ -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": {

View file

@ -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,
};

View file

@ -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",

View file

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

View file

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

View file

@ -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 };

View file

@ -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<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',
},
],
};
beforeEach(async () => {
jest.resetModules();
jest.resetAllMocks();
await App.clear();
});
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' });
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');
});
});

View file

@ -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;

View file

@ -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]);

View file

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

View file

@ -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 });
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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 };

View file

@ -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 };

View file

@ -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));

View file

@ -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();
};

View file

@ -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==}