diff --git a/scripts/start-dev.sh b/scripts/start-dev.sh index 46d352c3..321483dd 100755 --- a/scripts/start-dev.sh +++ b/scripts/start-dev.sh @@ -31,6 +31,7 @@ POSTGRES_HOST=tipi-db REDIS_HOST=tipi-redis TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version) INTERNAL_IP=localhost +DEMO_MODE=false storage_path="${ROOT_FOLDER}" STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')" if [[ "$ARCHITECTURE" == "aarch64" ]]; then @@ -157,6 +158,7 @@ for template in ${ENV_FILE}; do sed "${sed_args[@]}" "s//${POSTGRES_PORT}/g" "${template}" sed "${sed_args[@]}" "s//${POSTGRES_HOST}/g" "${template}" sed "${sed_args[@]}" "s//${REDIS_HOST}/g" "${template}" + sed "${sed_args[@]}" "s//${DEMO_MODE}/g" "${template}" done mv -f "$ENV_FILE" "$ROOT_FOLDER/.env.dev" diff --git a/scripts/start.sh b/scripts/start.sh index 376cc322..0b1c390a 100755 --- a/scripts/start.sh +++ b/scripts/start.sh @@ -57,6 +57,7 @@ TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version) storage_path="${ROOT_FOLDER}" STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')" REDIS_HOST=tipi-redis +DEMO_MODE=false INTERNAL_IP= if [[ "$ARCHITECTURE" == "aarch64" ]] || [[ "$ARCHITECTURE" == "armv8"* ]]; then @@ -78,6 +79,7 @@ while [ -n "${1-}" ]; do case "$1" in --rc) rc="true" ;; --ci) ci="true" ;; + --demo) DEMO_MODE=true ;; --port) port="${2-}" @@ -269,6 +271,7 @@ for template in ${ENV_FILE}; do sed -i "s//${DOMAIN}/g" "${template}" sed -i "s//${STORAGE_PATH_ESCAPED}/g" "${template}" sed -i "s//${REDIS_HOST}/g" "${template}" + sed -i "s//${DEMO_MODE}/g" "${template}" done mv -f "$ENV_FILE" "$ROOT_FOLDER/.env" diff --git a/src/server/core/TipiConfig/TipiConfig.test.ts b/src/server/core/TipiConfig/TipiConfig.test.ts index 70a28384..7daf9ccd 100644 --- a/src/server/core/TipiConfig/TipiConfig.test.ts +++ b/src/server/core/TipiConfig/TipiConfig.test.ts @@ -1,57 +1,87 @@ import { faker } from '@faker-js/faker'; import fs from 'fs-extra'; -import { getConfig, setConfig, TipiConfig } from '.'; +import { getConfig, setConfig, getSettings, setSettings, TipiConfig } from '.'; import { readJsonFile } from '../../common/fs.helpers'; beforeEach(async () => { - jest.resetModules(); - jest.resetAllMocks(); // @ts-expect-error - We are mocking fs fs.__resetAllMocks(); jest.mock('fs-extra'); }); -describe('Test: getConfig', () => { - it('It should return config from .env', () => { - const config = getConfig(); +jest.mock('next/config', () => + jest.fn(() => ({ + serverRuntimeConfig: { + DNS_IP: '1.1.1.1', + }, + })), +); + +// eslint-disable-next-line +import nextConfig from 'next/config'; + +describe('Test: process.env', () => { + it('should return config from .env', () => { + const config = new TipiConfig().getConfig(); + expect(config).toBeDefined(); + expect(config.dnsIp).toBe('1.1.1.1'); + }); + + it('should throw an error if there are invalid values', () => { + // @ts-expect-error - We are mocking next/config + nextConfig.mockImplementationOnce(() => ({ + serverRuntimeConfig: { + DNS_IP: 'invalid', + }, + })); + + expect(() => new TipiConfig().getConfig()).toThrow(); + }); +}); + +describe('Test: getConfig', () => { + it('It should return config from .env', () => { + // arrange + const config = getConfig(); + + // assert expect(config).toBeDefined(); expect(config.NODE_ENV).toBe('test'); - expect(config.dnsIp).toBe('9.9.9.9'); expect(config.rootFolder).toBe('/runtipi'); expect(config.internalIp).toBe('localhost'); }); it('It should overrides config from settings.json file', () => { + // arrange const settingsJson = { appsRepoUrl: faker.internet.url(), appsRepoId: faker.random.word(), domain: faker.random.word(), }; - const MockFiles = { '/runtipi/state/settings.json': JSON.stringify(settingsJson), }; - // @ts-expect-error - We are mocking fs fs.__createMockFiles(MockFiles); + // act const config = new TipiConfig().getConfig(); + // assert expect(config).toBeDefined(); - expect(config.appsRepoUrl).toBe(settingsJson.appsRepoUrl); expect(config.appsRepoId).toBe(settingsJson.appsRepoId); expect(config.domain).toBe(settingsJson.domain); }); it('Should not be able to apply an invalid value from json config', () => { + // arrange const settingsJson = { appsRepoUrl: faker.random.word(), appsRepoId: faker.random.word(), domain: 10, }; - const MockFiles = { '/runtipi/state/settings.json': JSON.stringify(settingsJson), }; @@ -59,16 +89,21 @@ describe('Test: getConfig', () => { // @ts-expect-error - We are mocking fs fs.__createMockFiles(MockFiles); + // act & assert expect(() => new TipiConfig().getConfig()).toThrow(); }); }); describe('Test: setConfig', () => { it('It should be able set config', () => { + // arrange const randomWord = faker.internet.url(); + + // act setConfig('appsRepoUrl', randomWord); const config = getConfig(); + // assert expect(config).toBeDefined(); expect(config.appsRepoUrl).toBe(randomWord); }); @@ -115,7 +150,7 @@ describe('Test: getSettings', () => { fs.__createMockFiles(MockFiles); // act - const settings = new TipiConfig().getSettings(); + const settings = getSettings(); // assert expect(settings).toBeDefined(); @@ -147,11 +182,10 @@ describe('Test: setSettings', () => { }; // act - new TipiConfig().setSettings(fakeSettings); - - // assert + setSettings(fakeSettings); const settingsJson = readJsonFile('/runtipi/state/settings.json') as { [key: string]: string }; + // assert expect(settingsJson).toBeDefined(); expect(settingsJson.appsRepoUrl).toBe(fakeSettings.appsRepoUrl); }); @@ -161,12 +195,77 @@ describe('Test: setSettings', () => { const fakeSettings = { appsRepoUrl: 10 }; // act - new TipiConfig().setSettings(fakeSettings as object); - - // assert + setSettings(fakeSettings as object); const settingsJson = (readJsonFile('/runtipi/state/settings.json') || {}) as { [key: string]: string }; + // assert expect(settingsJson).toBeDefined(); expect(settingsJson.appsRepoUrl).not.toBe(fakeSettings.appsRepoUrl); }); + + it('should throw and error if demo mode is enabled', async () => { + // arrange + let error; + const fakeSettings = { appsRepoUrl: faker.internet.url() }; + const tipiConf = new TipiConfig(); + tipiConf.setConfig('demoMode', true); + + // act + try { + await tipiConf.setSettings(fakeSettings); + } catch (e) { + error = e; + } + + // assert + expect(error).toBeDefined(); + }); + + it('should replace empty string with undefined if storagePath is empty', async () => { + // arrange + const fakeSettings = { storagePath: '' }; + const tipiConf = new TipiConfig(); + + // act + await tipiConf.setSettings(fakeSettings); + + // assert + expect(tipiConf.getConfig().storagePath).toBeUndefined(); + }); + + it('should trim storagePath if it is not empty', async () => { + // arrange + const fakeSettings = { storagePath: ' /tmp ' }; + const tipiConf = new TipiConfig(); + + // act + await tipiConf.setSettings(fakeSettings); + + // assert + expect(tipiConf.getConfig().storagePath).toBe('/tmp'); + }); + + it('should trim storagePath and return undefined if it is empty', async () => { + // arrange + const fakeSettings = { storagePath: ' ' }; + const tipiConf = new TipiConfig(); + + // act + await tipiConf.setSettings(fakeSettings); + + // assert + expect(tipiConf.getConfig().storagePath).toBeUndefined(); + }); + + it('should remove all whitespaces from storagePath', async () => { + // arrange + const fakeSettings = { storagePath: ' /tmp /test ' }; + const tipiConf = new TipiConfig(); + + // act + await tipiConf.setSettings(fakeSettings); + + // assert + expect(tipiConf.getConfig().storagePath).toBe('/tmp/test'); + }); }); diff --git a/src/server/core/TipiConfig/TipiConfig.ts b/src/server/core/TipiConfig/TipiConfig.ts index adea1e2b..62faff21 100644 --- a/src/server/core/TipiConfig/TipiConfig.ts +++ b/src/server/core/TipiConfig/TipiConfig.ts @@ -11,26 +11,7 @@ export const ARCHITECTURES = { } as const; export type Architecture = (typeof ARCHITECTURES)[keyof typeof ARCHITECTURES]; -const conf = { ...process.env, ...nextConfig()?.serverRuntimeConfig }; -const { - NODE_ENV, - JWT_SECRET, - INTERNAL_IP, - TIPI_VERSION, - APPS_REPO_ID, - APPS_REPO_URL, - DOMAIN, - REDIS_HOST, - STORAGE_PATH, - ARCHITECTURE = 'amd64', - POSTGRES_HOST, - POSTGRES_DBNAME, - POSTGRES_USERNAME, - POSTGRES_PASSWORD, - POSTGRES_PORT = 5432, -} = conf; - -export const configSchema = z.object({ +const configSchema = z.object({ NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]), REDIS_HOST: z.string(), status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]), @@ -56,12 +37,21 @@ export const configSchema = z.object({ postgresUsername: z.string(), postgresPassword: z.string(), postgresPort: z.number(), + demoMode: z + .string() + .or(z.boolean()) + .optional() + .transform((value) => { + if (typeof value === 'boolean') return value; + return value === 'true'; + }), }); export const settingsSchema = configSchema.partial().pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true }); + export type TipiSettingsType = z.infer; -export const formatErrors = (errors: { fieldErrors: Record }) => +const formatErrors = (errors: { fieldErrors: Record }) => Object.entries(errors.fieldErrors) .map(([name, value]) => `${name}: ${value[0]}`) .filter(Boolean) @@ -73,25 +63,27 @@ export class TipiConfig { private config: z.infer; constructor() { + const conf = { ...process.env, ...nextConfig()?.serverRuntimeConfig }; const envConfig: z.infer = { - postgresHost: POSTGRES_HOST, - postgresDatabase: POSTGRES_DBNAME, - postgresUsername: POSTGRES_USERNAME, - postgresPassword: POSTGRES_PASSWORD, - postgresPort: Number(POSTGRES_PORT), - REDIS_HOST, - NODE_ENV, - architecture: ARCHITECTURE as z.infer['architecture'], + postgresHost: conf.POSTGRES_HOST, + postgresDatabase: conf.POSTGRES_DBNAME, + postgresUsername: conf.POSTGRES_USERNAME, + postgresPassword: conf.POSTGRES_PASSWORD, + postgresPort: Number(conf.POSTGRES_PORT || 5432), + REDIS_HOST: conf.REDIS_HOST, + NODE_ENV: conf.NODE_ENV, + architecture: conf.ARCHITECTURE || 'amd64', rootFolder: '/runtipi', - internalIp: INTERNAL_IP, - version: TIPI_VERSION, - jwtSecret: JWT_SECRET, - appsRepoId: APPS_REPO_ID, - appsRepoUrl: APPS_REPO_URL, - domain: DOMAIN, - dnsIp: '9.9.9.9', + internalIp: conf.INTERNAL_IP, + version: conf.TIPI_VERSION, + jwtSecret: conf.JWT_SECRET, + appsRepoId: conf.APPS_REPO_ID, + appsRepoUrl: conf.APPS_REPO_URL, + domain: conf.DOMAIN, + dnsIp: conf.DNS_IP || '9.9.9.9', status: 'RUNNING', - storagePath: STORAGE_PATH, + storagePath: conf.STORAGE_PATH, + demoMode: conf.DEMO_MODE, }; const fileConfig = readJsonFile('/runtipi/state/settings.json') || {}; @@ -156,6 +148,10 @@ export class TipiConfig { } public async setSettings(settings: TipiSettingsType) { + if (this.config.demoMode) { + throw new Error('Cannot update settings in demo mode'); + } + const newConf: z.infer = { ...this.getConfig() }; const parsed = settingsSchema.safeParse(settings); diff --git a/src/server/services/system/system.service.test.ts b/src/server/services/system/system.service.test.ts index f6e79827..8318af8a 100644 --- a/src/server/services/system/system.service.test.ts +++ b/src/server/services/system/system.service.test.ts @@ -122,6 +122,14 @@ describe('Test: restart', () => { // Assert expect(restart).toBeTruthy(); }); + + it('should throw an error in demo mode', async () => { + // Arrange + await setConfig('demoMode', true); + + // Act & Assert + await expect(SystemService.restart()).rejects.toThrow('Cannot restart in demo mode'); + }); }); describe('Test: update', () => { diff --git a/src/server/services/system/system.service.ts b/src/server/services/system/system.service.ts index 09952012..45d8831c 100644 --- a/src/server/services/system/system.service.ts +++ b/src/server/services/system/system.service.ts @@ -105,6 +105,10 @@ export class SystemServiceClass { throw new Error('Cannot restart in development mode'); } + if (TipiConfig.getConfig().demoMode) { + throw new Error('Cannot restart in demo mode'); + } + TipiConfig.setConfig('status', 'RESTARTING'); this.dispatcher.dispatchEventAsync('restart'); diff --git a/templates/env-sample b/templates/env-sample index 58bfa693..8b475cbb 100644 --- a/templates/env-sample +++ b/templates/env-sample @@ -20,3 +20,4 @@ POSTGRES_USERNAME= POSTGRES_PASSWORD= POSTGRES_PORT= REDIS_HOST= +DEMO_MODE=