diff --git a/adminforth/commands/createApp/templates/.dockerignore.hbs b/adminforth/commands/createApp/templates/.dockerignore.hbs index 9064a4669..22f170df3 100644 --- a/adminforth/commands/createApp/templates/.dockerignore.hbs +++ b/adminforth/commands/createApp/templates/.dockerignore.hbs @@ -1,4 +1,5 @@ node_modules +.env {{#if sqliteFile}} {{ sqliteFile }} {{/if}} diff --git a/adminforth/commands/createApp/templates/.env.example.hbs b/adminforth/commands/createApp/templates/.env.example.hbs new file mode 100644 index 000000000..9434824e1 --- /dev/null +++ b/adminforth/commands/createApp/templates/.env.example.hbs @@ -0,0 +1,4 @@ +# Secrets live in the gitignored .env; this committed file only lists which ones each developer must create. +# create-app already wrote a random ADMINFORTH_SECRET into .env on the machine that scaffolded the project. +# On a fresh clone, generate your own — see "First-time setup" in README.md (openssl rand -hex 32). +ADMINFORTH_SECRET= diff --git a/adminforth/commands/createApp/templates/.env.hbs b/adminforth/commands/createApp/templates/.env.hbs index fe9eebd5d..bccfba8f4 100644 --- a/adminforth/commands/createApp/templates/.env.hbs +++ b/adminforth/commands/createApp/templates/.env.hbs @@ -1 +1,4 @@ -# Add only sensitive local environment variables here; non-sensitive local variables should go to .env.local \ No newline at end of file +# Add only sensitive local environment variables here; non-sensitive local variables should go to .env.local +# This file is gitignored. Each developer and each deployment needs its own ADMINFORTH_SECRET. + +ADMINFORTH_SECRET={{{adminforthSecret}}} diff --git a/adminforth/commands/createApp/templates/.env.local.hbs b/adminforth/commands/createApp/templates/.env.local.hbs index 3e0a87479..64d9a25f6 100644 --- a/adminforth/commands/createApp/templates/.env.local.hbs +++ b/adminforth/commands/createApp/templates/.env.local.hbs @@ -1,7 +1,6 @@ # Add only non-sensitive local environment variables here so all team members can use them with minimal setup # For sensitive local environment variables, use .env and explain to team members how to set them, ideally with a .env.example -ADMINFORTH_SECRET=123 NODE_ENV=development DEBUG_LEVEL=info AF_DEBUG_LEVEL=info diff --git a/adminforth/commands/createApp/templates/adminuser.ts.hbs b/adminforth/commands/createApp/templates/adminuser.ts.hbs index 78c1837f0..6da9e947d 100644 --- a/adminforth/commands/createApp/templates/adminuser.ts.hbs +++ b/adminforth/commands/createApp/templates/adminuser.ts.hbs @@ -57,7 +57,7 @@ export default { { name: 'role', type: AdminForthDataTypes.STRING, - required: true + required: true, enum: [ { value: 'superadmin', label: 'Super Admin' }, { value: 'user', label: 'User' }, diff --git a/adminforth/commands/createApp/templates/readme.md.hbs b/adminforth/commands/createApp/templates/readme.md.hbs index 2b1c6d495..d1f7d1489 100644 --- a/adminforth/commands/createApp/templates/readme.md.hbs +++ b/adminforth/commands/createApp/templates/readme.md.hbs @@ -7,7 +7,7 @@ Install dependencies: ``` {{#if adminUserTableInstructions}} -Prepare the admin users table in your existing database before starting the app. AdminForth uses this table for back-office authentication, and your own migration tool should own this schema change. The schema below is only an example: +Create the admin users table in your database before starting the app: AdminForth needs it for back-office authentication, and no Prisma migrations were generated for this project. {{{adminUserTableInstructions}}} @@ -23,6 +23,13 @@ Create the initial migration and apply it to the database: ``` {{/if}} +First-time setup on a fresh clone: `create-app` wrote a random `ADMINFORTH_SECRET` into `.env`, but that file is gitignored, so every developer and every deployment creates their own (see `.env.example`). The command below only creates `.env` when it does not exist yet: + +```bash +# On Windows (PowerShell): node -e "require('fs').writeFileSync('.env', 'ADMINFORTH_SECRET=' + require('crypto').randomBytes(32).toString('hex') + '\n', { flag: 'wx' })" +[ -f .env ] || (umask 077; echo "ADMINFORTH_SECRET=$(openssl rand -hex 32)" > .env) +``` + Start the server: ```bash @@ -48,8 +55,12 @@ Your colleagues will need to pull the changes and run `{{packageManagerRun}} mig You have Dockerfile ready for production deployment. You can test the build with: ```bash +# Generate a signing key once and keep it in your secret store (never commit it). +# On Windows without openssl: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +export ADMINFORTH_SECRET="$(openssl rand -hex 32)" + docker build -t {{appName}}-image . -docker run -p 3500:3500 -e ADMINFORTH_SECRET=123 {{#if sqliteFile}}-v $(pwd)/db:/code/db {{/if}}{{appName}}-image +docker run -p 3500:3500 -e ADMINFORTH_SECRET="$ADMINFORTH_SECRET" {{#if sqliteFile}}-v $(pwd)/db:/code/db {{/if}}{{appName}}-image ``` To set non-sensitive environment variables in production, use `.env.prod` file. diff --git a/adminforth/commands/createApp/utils.js b/adminforth/commands/createApp/utils.js index c5879fe43..741d91f56 100644 --- a/adminforth/commands/createApp/utils.js +++ b/adminforth/commands/createApp/utils.js @@ -9,6 +9,7 @@ import { Listr } from 'listr2' import { fileURLToPath, pathToFileURL } from 'url'; import {ConnectionString} from 'connection-string'; import { exec } from 'child_process'; +import crypto from 'crypto'; import Handlebars from 'handlebars'; import { promisify } from 'util'; @@ -152,6 +153,10 @@ async function inspectDatabaseCleanState(options) { const provider = detectDbProvider(connectionString.protocol); const dbConnString = connectionString.toString(); + // `dbInspected` records whether the answer below is a real observation or a fallback + // (connector unavailable), so the caller can avoid claiming the database is empty. + options.dbInspected = true; + // Fast path for SQLite: a missing file is by definition a brand new database. // Avoid connecting (which would otherwise create the file) and avoid pulling the // connector for the common new-project case. @@ -171,6 +176,7 @@ async function inspectDatabaseCleanState(options) { // the normal Prisma flow stays available instead of failing create-app. console.log(chalk.yellow(`\nāš ļø Could not load the database connector to inspect the database (${error.message}). Continuing as a new database.`)); options.existingDb = false; + options.dbInspected = false; return; } @@ -180,6 +186,7 @@ async function inspectDatabaseCleanState(options) { // Connector predates the isDatabaseEmpty() probe (version skew); cannot // determine emptiness, so assume a new database and keep the Prisma flow. options.existingDb = false; + options.dbInspected = false; return; } @@ -247,6 +254,29 @@ export async function promptForMissingOptions(options) { await inspectDatabaseCleanState(resolvedOptions); + // Say which path was taken: the decision is made by inspecting the database, not by whether --db was passed + const prismaCapable = isPrismaMigrationDbUrl(resolvedOptions.db); + const adminUserSql = generateAdminUserTableInstructions(detectDbProvider(parseConnectionString(resolvedOptions.db).protocol)); + const sqlNote = adminUserSql + ? 'The generated README and the instructions printed at the end include the SQL for the required adminuser table.' + : 'No table has to be created up front for this database.'; + const willAskAboutPrisma = resolvedOptions.includePrismaMigrations === undefined && prismaCapable && !resolvedOptions.existingDb; + + if (!resolvedOptions.dbInspected) { + console.log(chalk.yellow('\nšŸ—„ļø Continuing as a new database because it could not be inspected.' + + (prismaCapable ? ' Prisma migrations are therefore not offered by default; answer Yes below only if it is in fact empty.' : '') + + ` ${sqlNote}`)); + } else if (resolvedOptions.existingDb) { + console.log(chalk.cyan(`\nšŸ—„ļø The database already contains data, so no Prisma migrations will be generated for it. ${sqlNote}`)); + } else if (willAskAboutPrisma) { + console.log(chalk.cyan('\nšŸ—„ļø The database is empty. Answer Yes below to let AdminForth manage its schema with Prisma migrations, ' + + `or No to manage it yourself${adminUserSql ? ' (you will get the SQL for the required adminuser table instead)' : ''}.`)); + } else if (prismaCapable && resolvedOptions.includePrismaMigrations) { + console.log(chalk.cyan('\nšŸ—„ļø The database is empty; AdminForth will manage its schema with Prisma migrations.')); + } else { + console.log(chalk.cyan(`\nšŸ—„ļø The database is empty and no Prisma migrations will be generated. ${sqlNote}`)); + } + if ( resolvedOptions.includePrismaMigrations === undefined && isPrismaMigrationDbUrl(resolvedOptions.db) && @@ -260,7 +290,9 @@ export async function promptForMissingOptions(options) { { name: 'Yes', value: true }, { name: 'No', value: false }, ], - default: true, + // only recommend Prisma when the database was actually observed to be empty: on a database + // we could not inspect, pressing Enter must not scaffold migrations over existing data + default: resolvedOptions.dbInspected, }]); resolvedOptions.includePrismaMigrations = prismaAnswer.includePrismaMigrations; } else { @@ -432,7 +464,6 @@ async function scaffoldProject(ctx, options, cwd) { prismaDbUrlProd, appName, provider, - existingDb: options.existingDb, nodeMajor: parseInt(process.versions.node.split('.')[0], 10), sqliteFile: connectionString.protocol.startsWith('sqlite') ? connectionString.host : null, }); @@ -454,9 +485,9 @@ function getPackageManagerTemplateData(useNpm, nodeMajor) { }; } -async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, options) { +export async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, options) { const { - dbUrl, prismaDbUrl, appName, provider, existingDb, nodeMajor, + dbUrl, prismaDbUrl, appName, provider, nodeMajor, dbUrlProd, prismaDbUrlProd, sqliteFile } = options; const packageManagerTemplateData = getPackageManagerTemplateData(useNpm, nodeMajor); @@ -505,8 +536,9 @@ async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, prismaDbUrl: resolvedPrismaDbUrl, appName, sqliteFile, - existingDb, - adminUserTableInstructions: existingDb ? generateAdminUserTableInstructions(provider) : null, + // whenever Prisma migrations will not manage the schema (same rule as skipPrismaSetup in scaffoldProject), + // the user has to create the adminuser table themselves + adminUserTableInstructions: (!includePrismaMigrations || !prismaDbUrl) ? generateAdminUserTableInstructions(provider) : null, }, }, { @@ -540,10 +572,17 @@ async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, data: {}, }, { - // We'll write .env using the same content as .env.sample + // gitignored: holds the JWT signing key, unique per developer and per deployment src: '.env.hbs', dest: '.env', - data: { dbUrl, prismaDbUrl: resolvedPrismaDbUrl }, + data: { adminforthSecret: crypto.randomBytes(32).toString('hex') }, + mode: 0o600, + }, + { + // committed: tells the next developer which secrets to create locally + src: '.env.example.hbs', + dest: '.env.example', + data: {}, }, { src: 'adminuser.ts.hbs', @@ -635,7 +674,11 @@ async function writeTemplateFiles(dirname, cwd, useNpm, includePrismaMigrations, ...packageManagerTemplateData, ...task.data, }); - await fs.promises.writeFile(destPath, compiled); + await fs.promises.writeFile(destPath, compiled, task.mode ? { mode: task.mode } : undefined); + if (task.mode) { + // writeFile's mode is masked by the umask and ignored on overwrite; enforce it unconditionally + await fs.promises.chmod(destPath, task.mode); + } } } } @@ -678,10 +721,10 @@ async function installDependenciesNpm(ctx, cwd) { } } -function generateFinalInstructionsPnpm(skipPrismaSetup, options) { +export function generateFinalInstructionsPnpm(skipPrismaSetup, options) { let instruction = 'ā­ļø Run the following commands to get started:\n'; const provider = detectDbProvider(parseConnectionString(options.db).protocol); - const adminUserTableInstructions = options.existingDb ? generateAdminUserTableInstructions(provider) : null; + const adminUserTableInstructions = skipPrismaSetup ? generateAdminUserTableInstructions(provider) : null; instruction += ` ${chalk.dim('// Go to the project directory')} ${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`; @@ -706,10 +749,10 @@ function generateFinalInstructionsPnpm(skipPrismaSetup, options) { return instruction; } -function generateFinalInstructionsNpm(skipPrismaSetup, options) { +export function generateFinalInstructionsNpm(skipPrismaSetup, options) { let instruction = 'ā­ļø Run the following commands to get started:\n'; const provider = detectDbProvider(parseConnectionString(options.db).protocol); - const adminUserTableInstructions = options.existingDb ? generateAdminUserTableInstructions(provider) : null; + const adminUserTableInstructions = skipPrismaSetup ? generateAdminUserTableInstructions(provider) : null; instruction += ` ${chalk.dim('// Go to the project directory')} ${chalk.dim('$')}${chalk.cyan(` cd ${options.appName}`)}\n`; diff --git a/adminforth/documentation/docs/tutorial/001-gettingStarted.md b/adminforth/documentation/docs/tutorial/001-gettingStarted.md index 03bb66077..67f49a3ed 100644 --- a/adminforth/documentation/docs/tutorial/001-gettingStarted.md +++ b/adminforth/documentation/docs/tutorial/001-gettingStarted.md @@ -34,7 +34,7 @@ Use this path when you already have a database and your own schema or migrations npx adminforth create-app --app-name myadmin --db "postgresql://user:password@localhost:5432/dbname" ``` -When you provide your own database URL, the CLI treats this as your own database. It does not create Prisma schema or Prisma migration scripts for that database. Instead, the generated project README contains the SQL or schema notes for adding the required `adminuser` table with your own migration tool. +The CLI connects to the database and checks whether it already contains tables — passing `--db` alone does not decide the path. If tables exist, the database is treated as your own: no Prisma schema or migration scripts are generated, and both the CLI output and the generated project README contain the SQL for adding the required `adminuser` table with your own migration tool (MongoDB needs no table up front — the collection is created on first write). If a PostgreSQL, MySQL or SQLite database is still empty, the CLI asks `Include Prisma migrations? >` — answer **No** to keep managing the schema yourself and you get the same `adminuser` SQL; answer **Yes** and AdminForth manages the schema for you (see Path 2). The CLI offers Prisma migrations only for PostgreSQL, MySQL and SQLite, so MongoDB and ClickHouse never get the question and always keep their schema yours. If the CLI cannot load the database connector (for example offline, or the installed connector is too old to inspect a database), it warns, continues as if the database were empty and defaults the question to **No**, so pressing Enter never scaffolds migrations over a database that may already hold data; a database it cannot connect to makes `create-app` fail with the connection error. After the project is created, navigate into it and generate resources from your existing tables: @@ -65,12 +65,12 @@ Once the project is created, navigate into its directory: cd myadmin # or any other name you provided ``` -For the new database path, the CLI can scaffold Prisma files and migration scripts for the default SQLite database. +For an empty database (the default SQLite file, or an empty SQLite/PostgreSQL/MySQL database passed with `--db`), the CLI asks `Include Prisma migrations? >`, defaulting to **Yes**. Answer **Yes** to have AdminForth scaffold the Prisma schema and migration scripts; answer **No** to manage the schema yourself — the CLI output and the generated README then contain the SQL for the required `adminuser` table. CLI options: * **`--app-name`** - name for your project. Used in `package.json`, `index.ts` branding, etc. Default value: **`adminforth-app`**. -* **`--db`** - database connection string. Currently PostgreSQL, MongoDB, SQLite, MySQL, Clickhouse and Qdrant (read only) are supported. Default value: **`sqlite://.db.sqlite`** +* **`--db`** - database connection string. `create-app` accepts `sqlite://`, `postgresql://`, `mongodb://`, `mysql://` and `clickhouse://` URLs. Default value: **`sqlite://.db.sqlite`** > ā˜ļø Database Connection String format: > @@ -95,12 +95,13 @@ myadmin/ │ └── tsconfig.json # Tsconfig for Vue project (adds completion for AdminForth core components) ā”œā”€ā”€ resources │ └── adminuser.ts # Example resource file for users management -ā”œā”€ā”€ schema.prisma # Prisma schema file, generated only for the new database path +ā”œā”€ā”€ schema.prisma # Prisma schema file, generated only when you include Prisma migrations ā”œā”€ā”€ index.ts # Main entry point: configures AdminForth & starts the server ā”œā”€ā”€ package.json # Project dependencies ā”œā”€ā”€ pnpm-workspace.yaml ā”œā”€ā”€ tsconfig.json # TypeScript configuration -ā”œā”€ā”€ .env # Env vars like tokens, secrets that should not be in version control +ā”œā”€ā”€ .env # Env vars like tokens, secrets that should not be in version control (ADMINFORTH_SECRET is generated here for you) +ā”œā”€ā”€ .env.example # Committed template listing the secrets each developer must create locally ā”œā”€ā”€ .env.local # General local environment variables └── .gitignore @@ -108,7 +109,7 @@ myadmin/ ### Initial Migration & Future Migrations -For the new database path, the CLI creates Prisma files for managing migrations. Prisma is not required by AdminForth itself, but it is a convenient migration tool for standalone projects that do not have database management yet. +When you answer **Yes** to `Include Prisma migrations? >`, the CLI creates Prisma files for managing migrations. Prisma is not required by AdminForth itself, but it is a convenient migration tool for standalone projects that do not have database management yet. CLI will suggest you a command to initialize the database with Prisma: @@ -126,7 +127,7 @@ pnpm makemigration --name init ; pnpm migrate:local Other developers need to pull migration and run `pnpm migrate:local` to apply any unapplied migrations. -For the existing database path, use your own migration tool instead. The generated project README shows how to add the required `adminuser` table to your database. +When no Prisma migrations were generated — the database already had tables, you answered **No**, or the database is MongoDB or ClickHouse — use your own migration tool instead. The CLI output and the generated project README show how to add the required `adminuser` table to your database (MongoDB needs none). ## Run the Server diff --git a/adminforth/documentation/docs/tutorial/01-helloWorld.md b/adminforth/documentation/docs/tutorial/01-helloWorld.md index d118b9839..9ad7052dc 100644 --- a/adminforth/documentation/docs/tutorial/01-helloWorld.md +++ b/adminforth/documentation/docs/tutorial/01-helloWorld.md @@ -47,12 +47,24 @@ Create two files in your project's root directory: Put the following content to the `.env.local` file: ```bash title="./.env.local" -ADMINFORTH_SECRET=123 NODE_ENV=development DATABASE_URL=sqlite://.db.sqlite PRISMA_DATABASE_URL=file:.db.sqlite ``` +Generate a signing key into the gitignored `.env` file, readable by you only (AdminForth logs a warning at startup if the secret is shorter than 16 characters; on Windows without openssl use `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`): + +```bash +(umask 077; echo "ADMINFORTH_SECRET=$(openssl rand -hex 32)" > .env) +``` + +Make sure `.env` never reaches the repository or a Docker image: + +```bash +echo ".env" >> .gitignore +echo ".env" >> .dockerignore +``` + > ā˜ļø Production best practices: > > 1) Most likely you not need `.env` file at all, instead you should use environment variables (from Docker, Kubernetes, Operating System, etc.) diff --git a/adminforth/documentation/docs/tutorial/03-Customization/02-customFieldRendering.md b/adminforth/documentation/docs/tutorial/03-Customization/02-customFieldRendering.md index 38fc7ac8c..eab7a5dea 100644 --- a/adminforth/documentation/docs/tutorial/03-Customization/02-customFieldRendering.md +++ b/adminforth/documentation/docs/tutorial/03-Customization/02-customFieldRendering.md @@ -533,7 +533,7 @@ If your field has absolute URLs as text strings you can use `URLs` renderer to r ### Relative Time -To format your date fields to display the elapsed time, you can utilize the RelativeTime renderer. +To format your date fields to display the elapsed time, you can utilize the RelativeTime renderer. Empty or invalid values render as an empty cell. ```ts title='./resources/anyResource.ts' columns: [ diff --git a/adminforth/documentation/docs/tutorial/05-deploy.md b/adminforth/documentation/docs/tutorial/05-deploy.md index dee30ae5f..ba452ea8f 100644 --- a/adminforth/documentation/docs/tutorial/05-deploy.md +++ b/adminforth/documentation/docs/tutorial/05-deploy.md @@ -33,8 +33,12 @@ docker build -t myadminapp . And run container with: ```bash +# Generate a signing key once and keep it in your secret store (never commit it). +# On Windows without openssl: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +export ADMINFORTH_SECRET="$(openssl rand -hex 32)" + docker run -p 3500:3500 \ - -e ADMINFORTH_SECRET=CHANGEME \ + -e ADMINFORTH_SECRET="$ADMINFORTH_SECRET" \ -v $(pwd)/db:/code/db \ myadminapp ``` @@ -115,7 +119,7 @@ services: build: ./adminforth-app environment: - NODE_ENV=production - - ADMINFORTH_SECRET=!CHANGEME! # ā˜ļø replace with your secret + - ADMINFORTH_SECRET=${ADMINFORTH_SECRET:?generate one with openssl rand -hex 32} # ā˜ļø export it in the shell or put it in a gitignored .env next to this compose file; compose refuses to start when it is unset labels: - "traefik.enable=true" - "traefik.http.routers.adminforth.tls=true" diff --git a/adminforth/index.ts b/adminforth/index.ts index 8ab980121..cad96d1aa 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -620,6 +620,11 @@ class AdminForth implements IAdminForth { ADMINFORTH_SECRET variable is used to sign JWT tokens `); } + if (adminforthSecret.length < 16) { + afLogger.warn(`ADMINFORTH_SECRET is too short (${adminforthSecret.length} characters). ` + + 'It is the key that signs every auth cookie: a guessable value lets anyone forge a session for any user. ' + + 'Generate one with: openssl rand -hex 32'); + } } async getAllTables(): Promise<{ [dataSourceId: string]: string[] }> { diff --git a/adminforth/spa/src/renderers/RelativeTime.vue b/adminforth/spa/src/renderers/RelativeTime.vue index 9b628150d..7dbde03f7 100644 --- a/adminforth/spa/src/renderers/RelativeTime.vue +++ b/adminforth/spa/src/renderers/RelativeTime.vue @@ -13,6 +13,7 @@ import Tooltip from '@/afcl/Tooltip.vue'; import en from 'javascript-time-ago/locale/en'; import TimeAgo from 'javascript-time-ago'; import dayjs from 'dayjs'; +import { parseRelativeTimeValue } from './relativeTimeValue'; const id = ref(); @@ -24,15 +25,13 @@ const props = defineProps(['column', 'record', 'meta', 'resource', 'adminUser']) const userLocale = ref(navigator.language || 'en-US'); const timeAgoFormatter = new TimeAgo(userLocale.value); const relativeTime = computed(() => { - const value = props.record[props.column.name]; - const date = new Date(value); - return timeAgoFormatter.format(date); + const date = parseRelativeTimeValue(props.record[props.column.name]); + return date ? timeAgoFormatter.format(date) : ''; }); const fullTime = computed(() => { - const value = props.record[props.column.name]; - const date = dayjs(new Date(value)); - return date.format('DD MMM HH:mm'); + const date = parseRelativeTimeValue(props.record[props.column.name]); + return date ? dayjs(date).format('DD MMM HH:mm') : ''; }); onMounted(async () => { diff --git a/adminforth/spa/src/renderers/relativeTimeValue.ts b/adminforth/spa/src/renderers/relativeTimeValue.ts new file mode 100644 index 000000000..974de17ca --- /dev/null +++ b/adminforth/spa/src/renderers/relativeTimeValue.ts @@ -0,0 +1,8 @@ +// new Date(null) is the Unix epoch, so an empty column used to render as "57 years ago" +export function parseRelativeTimeValue(value: unknown): Date | null { + if (value === null || value === undefined || value === '') { + return null; + } + const date = new Date(value as string | number | Date); + return Number.isNaN(date.getTime()) ? null : date; +} diff --git a/dev-demo/.dockerignore b/dev-demo/.dockerignore index 3f0aacfab..5e916ab29 100644 --- a/dev-demo/.dockerignore +++ b/dev-demo/.dockerignore @@ -1,2 +1,3 @@ node_modules .db.sqlite +.env diff --git a/dev-demo/.env.local b/dev-demo/.env.local index 233dff701..52792fce3 100644 --- a/dev-demo/.env.local +++ b/dev-demo/.env.local @@ -1,4 +1,4 @@ -ADMINFORTH_SECRET=123 +ADMINFORTH_SECRET=dev-only-secret-not-for-production-000000000000000000000000 NODE_ENV=development SQLITE_URL=sqlite://migrations/prisma/sqlite/.db.sqlite SQLITE_FILE_URL=file:.db.sqlite diff --git a/dev-demo/README.md b/dev-demo/README.md index 75a790180..9ad79c489 100644 --- a/dev-demo/README.md +++ b/dev-demo/README.md @@ -35,8 +35,12 @@ Your colleagues will need to pull the changes and run `pnpm migrate:local` to ap You have Dockerfile ready for production deployment. You can test the build with: ```bash +# Generate a signing key once and keep it in your secret store (never commit it). +# On Windows without openssl: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +export ADMINFORTH_SECRET="$(openssl rand -hex 32)" + docker build -t dev-demo-image . -docker run -p 3500:3500 -e ADMINFORTH_SECRET=123 -v $(pwd)/db:/code/db dev-demo-image +docker run -p 3500:3500 -e ADMINFORTH_SECRET="$ADMINFORTH_SECRET" -v $(pwd)/db:/code/db dev-demo-image ``` To set non-sensitive environment variables in production, use `.env.prod` file. diff --git a/tests/application/.dockerignore b/tests/application/.dockerignore index 3f0aacfab..5e916ab29 100644 --- a/tests/application/.dockerignore +++ b/tests/application/.dockerignore @@ -1,2 +1,3 @@ node_modules .db.sqlite +.env diff --git a/tests/application/.env.local b/tests/application/.env.local index 9fc036297..25a69e21b 100644 --- a/tests/application/.env.local +++ b/tests/application/.env.local @@ -1,4 +1,4 @@ -ADMINFORTH_SECRET=123 +ADMINFORTH_SECRET=dev-only-secret-not-for-production-000000000000000000000000 NODE_ENV=development SQLITE_URL=sqlite://.db.sqlite SQLITE_FILE_URL=file:.db.sqlite diff --git a/tests/application/README.md b/tests/application/README.md index 022f9b2ca..46e94049f 100644 --- a/tests/application/README.md +++ b/tests/application/README.md @@ -35,8 +35,12 @@ Your colleagues will need to pull the changes and run `pnpm migrate:local` to ap You have Dockerfile ready for production deployment. You can test the build with: ```bash +# Generate a signing key once and keep it in your secret store (never commit it). +# On Windows without openssl: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" +export ADMINFORTH_SECRET="$(openssl rand -hex 32)" + docker build -t application-image . -docker run -p 3500:3500 -e ADMINFORTH_SECRET=123 -v $(pwd)/db:/code/db application-image +docker run -p 3500:3500 -e ADMINFORTH_SECRET="$ADMINFORTH_SECRET" -v $(pwd)/db:/code/db application-image ``` To set non-sensitive environment variables in production, use `.env.prod` file. diff --git a/tests/application/index.ts b/tests/application/index.ts index 95dad0e51..384673bd1 100644 --- a/tests/application/index.ts +++ b/tests/application/index.ts @@ -28,7 +28,7 @@ const appDir = path.dirname(appFilePath); const sqliteDbPath = path.join(appDir, '.db.sqlite'); const customComponentsDir = path.join(appDir, 'custom'); -process.env.ADMINFORTH_SECRET ??= '123'; +process.env.ADMINFORTH_SECRET ??= 'dev-only-secret-not-for-production-000000000000000000000000'; process.env.NODE_ENV ??= 'test'; process.env.SQLITE_URL ??= `sqlite://${sqliteDbPath}`; process.env.SQLITE_FILE_URL ??= `file:${sqliteDbPath}`; diff --git a/tests/jest_tests/create_app_existing_db.test.ts b/tests/jest_tests/create_app_existing_db.test.ts new file mode 100644 index 000000000..949317315 --- /dev/null +++ b/tests/jest_tests/create_app_existing_db.test.ts @@ -0,0 +1,166 @@ +import { jest } from '@jest/globals'; +import fs from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const createAppDir = path.resolve(currentDir, '../../adminforth/commands/createApp'); + +// utils.js imports the CLI entry (cli.js), whose top-level command switch runs on import and +// whose import graph runs dotenv.config({ path: '.env' }) against the cwd; import it with no argv +// command, a muted console and an empty cwd so nothing dispatches, prints or leaks into process.env +const utils = await (async () => { + const argv = process.argv; + const log = console.log; + const cwd = process.cwd(); + process.argv = argv.slice(0, 2); + console.log = () => undefined; + process.chdir(os.tmpdir()); + try { + return await import('../../adminforth/commands/createApp/utils.js'); + } finally { + process.chdir(cwd); + process.argv = argv; + console.log = log; + } +})(); +const { writeTemplateFiles, promptForMissingOptions, generateFinalInstructionsPnpm, generateFinalInstructionsNpm } = utils; + +// The existing-database case needs (a) the sqlite connector the CLI itself will load and (b) a way +// to create a table in a sqlite file: better-sqlite3 through that connector (works on Node 20), +// else the node:sqlite builtin (Node >= 22.13). Without both the case is skipped, not failed. +const cliRequire = createRequire(path.join(createAppDir, 'utils.js')); +const sqliteDrivers: Array<(file: string, sql: string) => void> = []; +try { + const Database = createRequire(cliRequire.resolve('@adminforth/connector-sqlite'))('better-sqlite3'); + sqliteDrivers.push((file, sql) => { const db = new Database(file); db.exec(sql); db.close(); }); +} catch { /* connector or native binding unavailable */ } +try { + const { DatabaseSync } = createRequire(import.meta.url)('node:sqlite'); + sqliteDrivers.push((file, sql) => { const db = new DatabaseSync(file); db.exec(sql); db.close(); }); +} catch { /* Node < 22.13 */ } +// a driver that loads but fails at use (e.g. a broken native binding) falls through to the next one +function createSqliteTable(file: string, sql: string) { + let lastError: unknown; + for (const driver of sqliteDrivers) { + try { return driver(file, sql); } catch (error) { lastError = error; } + } + throw lastError ?? new Error('no sqlite driver available'); +} +const connectorAvailable = (() => { try { cliRequire.resolve('@adminforth/connector-sqlite'); return true; } catch { return false; } })(); +const itWithSqlite = sqliteDrivers.length && connectorAvailable ? it : it.skip; + +const ADMINUSER_DDL = 'CREATE TABLE adminuser'; +const ADMINUSER_SECTION = 'Create the admin users table'; +// scaffolding asks the npm registry for the current version range (with an offline fallback) +const SCAFFOLD_TIMEOUT_MS = 30_000; +const tmpDirs: string[] = []; + +const postgresOptions = { + appName: 'existing-db-demo', + dbUrl: 'postgresql://user:password@localhost:5432/dbname', + dbUrlProd: 'postgresql://user:password@localhost:5432/dbname', + prismaDbUrl: 'postgresql://user:password@localhost:5432/dbname', + prismaDbUrlProd: 'postgresql://user:password@localhost:5432/dbname', + provider: 'postgresql', + nodeMajor: 22, + sqliteFile: null, +}; + +const mongoOptions = { + ...postgresOptions, + dbUrl: 'mongodb://localhost:27017/dbname', + dbUrlProd: 'mongodb://localhost:27017/dbname', + prismaDbUrl: null, + prismaDbUrlProd: null, + provider: 'mongodb', +}; + +async function tmpDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'af-existing-db-')); + tmpDirs.push(dir); + return dir; +} + +async function scaffoldReadme(includePrismaMigrations: boolean, options = postgresOptions): Promise { + const cwd = await tmpDir(); + await writeTemplateFiles(createAppDir, cwd, false, includePrismaMigrations, options); + return fs.readFile(path.join(cwd, 'README.md'), 'utf-8'); +} + +function spyLog() { + return jest.spyOn(console, 'log').mockImplementation(() => undefined); +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +afterAll(async () => { + await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('adminuser table instructions when Prisma will not manage the schema', () => { + it('README carries the adminuser section and DDL when Prisma migrations were declined for an empty database', async () => { + const readme = await scaffoldReadme(false); + + // the positive twin of the MongoDB check below, so that negative cannot pass on a reworded heading + expect(readme).toContain(ADMINUSER_SECTION); + expect(readme).toContain(ADMINUSER_DDL); + }, SCAFFOLD_TIMEOUT_MS); + + it('README omits the DDL when Prisma migrations manage the schema', async () => { + expect(await scaffoldReadme(true)).not.toContain(ADMINUSER_DDL); + }, SCAFFOLD_TIMEOUT_MS); + + it('README has no adminuser section for MongoDB, which needs no table up front', async () => { + const readme = await scaffoldReadme(false, mongoOptions); + + expect(readme).not.toContain(ADMINUSER_SECTION); + expect(readme).not.toContain(ADMINUSER_DDL); + }, SCAFFOLD_TIMEOUT_MS); + + it('final CLI instructions carry the DDL whenever Prisma setup is skipped, for both package managers', () => { + const options = { appName: 'x', db: postgresOptions.dbUrl }; + + expect(generateFinalInstructionsPnpm(true, options)).toContain(ADMINUSER_DDL); + expect(generateFinalInstructionsNpm(true, options)).toContain(ADMINUSER_DDL); + expect(generateFinalInstructionsPnpm(false, options)).not.toContain(ADMINUSER_DDL); + expect(generateFinalInstructionsPnpm(true, { appName: 'x', db: mongoOptions.dbUrl })).not.toContain(ADMINUSER_DDL); + }); +}); + +describe('create-app database inspection', () => { + it('treats a database that is not there yet as new and says so without asking a question it will not ask', async () => { + const log = spyLog(); + const db = `sqlite://${path.join(await tmpDir(), 'missing.db')}`; + + const resolved = await promptForMissingOptions({ appName: 'x', db, useNpm: true, includePrismaMigrations: false }); + const printed = log.mock.calls.flat().join('\n'); + + expect(resolved.existingDb).toBe(false); + expect(resolved.dbInspected).toBe(true); + expect(resolved.includePrismaMigrations).toBe(false); + expect(printed).toMatch(/empty/i); + expect(printed).toContain('adminuser'); + // includePrismaMigrations was preset, so no "Include Prisma migrations?" question follows + expect(printed).not.toMatch(/answer (yes|no)/i); + }, SCAFFOLD_TIMEOUT_MS); + + itWithSqlite('treats a database that already has tables as the user\'s own and skips Prisma', async () => { + const log = spyLog(); + const file = path.join(await tmpDir(), 'owned.db'); + createSqliteTable(file, 'CREATE TABLE orders (id INTEGER PRIMARY KEY, total REAL)'); + + const resolved = await promptForMissingOptions({ appName: 'x', db: `sqlite://${file}`, useNpm: true, includePrismaMigrations: true }); + const printed = log.mock.calls.flat().join('\n'); + + expect(resolved.existingDb).toBe(true); + expect(resolved.dbInspected).toBe(true); + expect(resolved.includePrismaMigrations).toBe(false); + expect(printed).toMatch(/already contains data/i); + expect(printed).toContain('adminuser'); + }, SCAFFOLD_TIMEOUT_MS); +}); diff --git a/tests/jest_tests/create_app_prisma_default.test.ts b/tests/jest_tests/create_app_prisma_default.test.ts new file mode 100644 index 000000000..a9757c83d --- /dev/null +++ b/tests/jest_tests/create_app_prisma_default.test.ts @@ -0,0 +1,103 @@ +import { jest } from '@jest/globals'; +import fs from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const createAppDir = path.resolve(currentDir, '../../adminforth/commands/createApp'); +// mock by absolute path: neither package resolves from this directory, both resolve from utils.js +const cliRequire = createRequire(path.join(createAppDir, 'utils.js')); + +// the path `import` reaches, which is not the one require.resolve reports for a dual package +function esmEntry(pkg: string) { + const manifest = cliRequire.resolve(`${pkg}/package.json`); + const json = JSON.parse(readFileSync(manifest, 'utf8')); + const root = json.exports?.['.']; + return path.join(path.dirname(manifest), root?.import?.default ?? root?.import ?? root?.default ?? json.main); +} + +// answer every question with its own default, which is what pressing Enter does +const prompt = jest.fn(async (questions: any[]) => + Object.fromEntries(questions.map((question) => [question.name, question.default]))); +jest.unstable_mockModule(esmEntry('inquirer'), () => ({ default: { prompt } })); + +// a connector with no isDatabaseEmpty() is how version skew looks: the CLI cannot tell whether +// the database already has tables +jest.unstable_mockModule(esmEntry('@adminforth/connector-sqlite'), () => ({ + default: class { async setupClient() {} async close() {} }, +})); + +const utils = await (async () => { + const argv = process.argv; + const log = console.log; + const cwd = process.cwd(); + process.argv = argv.slice(0, 2); + console.log = () => undefined; + process.chdir(os.tmpdir()); + try { + return await import('../../adminforth/commands/createApp/utils.js'); + } finally { + process.chdir(cwd); + process.argv = argv; + console.log = log; + } +})(); +const { promptForMissingOptions } = utils as any; + +const tmpDirs: string[] = []; +async function tmpDir() { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'af-prisma-default-')); + tmpDirs.push(dir); + return dir; +} + +let logSpy: any; +beforeEach(() => { + prompt.mockClear(); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => undefined); +}); + +afterEach(() => { + jest.restoreAllMocks(); +}); + +afterAll(async () => { + await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +function prismaQuestion() { + const call = prompt.mock.calls.find(([questions]: any) => questions[0]?.name === 'includePrismaMigrations'); + return call?.[0][0]; +} + +describe('create-app Prisma migrations default', () => { + it('offers Prisma by default when the database was inspected and is empty', async () => { + const db = `sqlite://${path.join(await tmpDir(), 'missing.db')}`; + + const resolved = await promptForMissingOptions({ appName: 'x', db, useNpm: true }); + + expect(resolved.dbInspected).toBe(true); + expect(resolved.existingDb).toBe(false); + expect(prismaQuestion()?.default).toBe(true); + expect(resolved.includePrismaMigrations).toBe(true); + }); + + it('does not offer Prisma by default when the database could not be inspected', async () => { + const file = path.join(await tmpDir(), 'unknown.db'); + await fs.writeFile(file, ''); + + const resolved = await promptForMissingOptions({ appName: 'x', db: `sqlite://${file}`, useNpm: true }); + + expect(resolved.dbInspected).toBe(false); + expect(prismaQuestion()?.default).toBe(false); + // pressing Enter must not scaffold migrations for a database that may already hold data + expect(resolved.includePrismaMigrations).toBe(false); + const printed = logSpy.mock.calls.flat().join('\n'); + expect(printed).toMatch(/could not be inspected/i); + // the notice must not tell the user to pick what is already the default + expect(printed).not.toMatch(/answer no/i); + }); +}); diff --git a/tests/jest_tests/create_app_secret.test.ts b/tests/jest_tests/create_app_secret.test.ts new file mode 100644 index 000000000..447161a5c --- /dev/null +++ b/tests/jest_tests/create_app_secret.test.ts @@ -0,0 +1,142 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const createAppDir = path.resolve(currentDir, '../../adminforth/commands/createApp'); + +// utils.js imports the CLI entry (cli.js), whose top-level command switch runs on import and +// whose import graph runs dotenv.config({ path: '.env' }) against the cwd; import it with no argv +// command, a muted console and an empty cwd so nothing dispatches, prints or leaks into process.env +const { writeTemplateFiles } = await (async () => { + const argv = process.argv; + const log = console.log; + const cwd = process.cwd(); + process.argv = argv.slice(0, 2); + console.log = () => undefined; + process.chdir(os.tmpdir()); + try { + return await import('../../adminforth/commands/createApp/utils.js'); + } finally { + process.chdir(cwd); + process.argv = argv; + console.log = log; + } +})(); + +const options = { + appName: 'secret-demo', + dbUrl: 'sqlite://.db.sqlite', + dbUrlProd: 'sqlite:///code/db/.db.sqlite', + prismaDbUrl: null, + prismaDbUrlProd: null, + provider: 'sqlite', + nodeMajor: 22, + sqliteFile: '.db.sqlite', +}; + +const HEX_64 = /^ADMINFORTH_SECRET=([0-9a-f]{64})$/m; +// the scaffolder asks the npm registry for the current version range (with an offline fallback) +const SCAFFOLD_TIMEOUT_MS = 30_000; +const tmpDirs: string[] = []; + +async function scaffold(): Promise { + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'af-create-app-')); + tmpDirs.push(cwd); + await writeTemplateFiles(createAppDir, cwd, false, false, options); + return cwd; +} + +function secretOf(env: string): string { + const match = env.match(HEX_64); + if (!match) { + throw new Error(`no 64-hex ADMINFORTH_SECRET in:\n${env}`); + } + return match[1]; +} + +let app: string; +let savedUmask: number; +// normalise CRLF so a Windows checkout with autocrlf does not break the line-anchored assertions +const read = async (file: string) => (await fs.readFile(path.join(app, file), 'utf-8')).replace(/\r\n/g, '\n'); + +beforeAll(async () => { + // a permissive umask makes the owner-only assertion below meaningful on any host + savedUmask = process.umask(0o022); + app = await scaffold(); +}, SCAFFOLD_TIMEOUT_MS); + +afterAll(async () => { + process.umask(savedUmask); + await Promise.all(tmpDirs.map((dir) => fs.rm(dir, { recursive: true, force: true }))); +}); + +describe('create-app scaffolds ADMINFORTH_SECRET', () => { + it('writes a random 32-byte hex secret into .env', async () => { + expect(secretOf(await read('.env'))).toHaveLength(64); + }); + + it('keeps .env out of git and out of the Docker build context', async () => { + expect((await read('.gitignore')).split('\n')).toContain('.env'); + expect((await read('.dockerignore')).split('\n')).toContain('.env'); + }); + + it('makes .env readable by its owner only', async () => { + if (process.platform === 'win32') { + return; + } + const modeOf = async (file: string) => ((await fs.stat(path.join(app, file))).mode & 0o777).toString(8); + + expect(await modeOf('.env')).toBe('600'); + // the tightened mode is specific to the secret file, not applied to every generated file + expect(await modeOf('.env.example')).toBe('644'); + }); + + it('tightens a pre-existing world-readable .env to owner-only', async () => { + if (process.platform === 'win32') { + return; + } + // writeFile's mode is ignored when the file already exists; the explicit chmod must still apply + const cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'af-create-app-')); + tmpDirs.push(cwd); + await fs.writeFile(path.join(cwd, '.env'), 'ADMINFORTH_SECRET=stale\n', { mode: 0o644 }); + + await writeTemplateFiles(createAppDir, cwd, false, false, options); + + expect(((await fs.stat(path.join(cwd, '.env'))).mode & 0o777).toString(8)).toBe('600'); + }, SCAFFOLD_TIMEOUT_MS); + + it('generates a different secret for every scaffolded app', async () => { + const other = await scaffold(); + + expect(secretOf(await read('.env'))).not.toEqual(secretOf(await fs.readFile(path.join(other, '.env'), 'utf-8'))); + }, SCAFFOLD_TIMEOUT_MS); + + it('keeps the secret out of the committed .env.local', async () => { + expect(await read('.env.local')).not.toContain('ADMINFORTH_SECRET'); + }); + + it('ships a committed .env.example so a cloning teammate knows what to create', async () => { + const example = await read('.env.example'); + + expect(example).toMatch(/^ADMINFORTH_SECRET=$/m); + expect(example).toContain('openssl rand -hex 32'); + }); + + it('tells a cloning teammate how to create their own secret before the first start', async () => { + const readme = await read('README.md'); + + // owner-only file, and never truncating an .env that already exists + expect(readme).toContain('[ -f .env ] || (umask 077; echo "ADMINFORTH_SECRET=$(openssl rand -hex 32)" > .env)'); + }); + + it('does not hardcode the secret into the README deployment command', async () => { + const readme = await read('README.md'); + + expect(readme).not.toContain('ADMINFORTH_SECRET=123'); + expect(readme).toContain('-e ADMINFORTH_SECRET="$ADMINFORTH_SECRET"'); + // the export line must be valid shell when pasted as-is (no redirection) + expect(readme).toContain('export ADMINFORTH_SECRET="$(openssl rand -hex 32)"'); + }); +}); diff --git a/tests/jest_tests/jest.config.js b/tests/jest_tests/jest.config.js index fb4010c7c..727b218c4 100644 --- a/tests/jest_tests/jest.config.js +++ b/tests/jest_tests/jest.config.js @@ -1,6 +1,12 @@ export default { preset: 'ts-jest/presets/default-esm', testEnvironment: 'node', + // suites share tests/application/.db.sqlite and AdminForth allows one instance per process + maxWorkers: 1, + moduleNameMapper: { + // ESM syntax in a CommonJS-declared package; jest cannot load it + '^country-flag-svg$': '/stubs/country-flag-svg.cjs', + }, extensionsToTreatAsEsm: ['.ts'], resolver: './resolver.cjs', transform: { diff --git a/tests/jest_tests/relative_time_renderer.test.ts b/tests/jest_tests/relative_time_renderer.test.ts new file mode 100644 index 000000000..4b81a78dd --- /dev/null +++ b/tests/jest_tests/relative_time_renderer.test.ts @@ -0,0 +1,15 @@ +import { parseRelativeTimeValue } from '../../adminforth/spa/src/renderers/relativeTimeValue'; + +describe('RelativeTime renderer date parsing', () => { + it.each([[null], [undefined], ['']])('treats %p as no date, not the Unix epoch', (value) => { + expect(parseRelativeTimeValue(value)).toBeNull(); + }); + + it('treats an unparsable string as no date', () => { + expect(parseRelativeTimeValue('not a date')).toBeNull(); + }); + + it('keeps a real timestamp', () => { + expect(parseRelativeTimeValue('2026-09-08T10:00:00Z')?.toISOString()).toBe('2026-09-08T10:00:00.000Z'); + }); +}); diff --git a/tests/jest_tests/secret_length.test.ts b/tests/jest_tests/secret_length.test.ts new file mode 100644 index 000000000..266f2dfaf --- /dev/null +++ b/tests/jest_tests/secret_length.test.ts @@ -0,0 +1,94 @@ +import { jest } from '@jest/globals'; +import { execFile } from 'node:child_process'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const currentDir = path.dirname(fileURLToPath(import.meta.url)); +const applicationDir = path.resolve(currentDir, '../application'); +const sqliteDbPath = path.join(applicationDir, '.db.sqlite'); + +// same env the application harness uses; only the DB schema is needed here, not the app +process.env.SQLITE_URL ??= `sqlite://${sqliteDbPath}`; +process.env.SQLITE_FILE_URL ??= `file:${sqliteDbPath}`; +process.env.NODE_ENV ??= 'test'; +await execFileAsync('pnpm', ['migrate:local'], { cwd: applicationDir, env: process.env }); + +// resolves to the BUILT package (adminforth/dist) the test application runs on; +// rebuild it (`npx tsc` in adminforth/) after editing adminforth/index.ts or the assertions below test stale code +const require = createRequire(import.meta.url); +const adminforthEntry = require.resolve('adminforth', { paths: [applicationDir] }); +const { default: AdminForth, afLogger } = await import(pathToFileURL(adminforthEntry).href); + +const savedSecret = process.env.ADMINFORTH_SECRET; + +function minimalApp() { + // AdminForth allows one instance per process; each test needs a fresh one + delete (globalThis as any).adminforth; + return new AdminForth({ + baseUrl: '', + auth: { + usersResourceId: 'adminuser', + usernameField: 'email', + passwordHashField: 'password_hash', + }, + dataSources: [{ id: 'sqlite', url: process.env.SQLITE_URL }], + resources: [{ + resourceId: 'adminuser', + table: 'adminuser', + dataSource: 'sqlite', + columns: [ + { name: 'id', primaryKey: true }, + { name: 'email', required: true }, + { name: 'password_hash', backendOnly: true }, + { name: 'role' }, + ], + }], + menu: [{ label: 'Users', resourceId: 'adminuser' }], + }); +} + +afterEach(() => { + jest.restoreAllMocks(); + if (savedSecret === undefined) { + delete process.env.ADMINFORTH_SECRET; + } else { + process.env.ADMINFORTH_SECRET = savedSecret; + } +}); + +const spyWarn = () => jest.spyOn(afLogger, 'warn').mockImplementation(() => undefined); +const tooShort = expect.stringMatching(/too short/); + +describe('ADMINFORTH_SECRET strength check', () => { + it('still starts with a secret shorter than 16 characters, but warns', async () => { + process.env.ADMINFORTH_SECRET = '123'; + const warn = spyWarn(); + + await expect(minimalApp().discoverDatabases()).resolves.not.toThrow(); + expect(warn).toHaveBeenCalledWith(tooShort); + }); + + it('warns at 15 characters and not at 16', async () => { + process.env.ADMINFORTH_SECRET = 'x'.repeat(15); + const warnAt15 = spyWarn(); + await minimalApp().discoverDatabases(); + expect(warnAt15).toHaveBeenCalledWith(tooShort); + jest.restoreAllMocks(); + + process.env.ADMINFORTH_SECRET = 'x'.repeat(16); + const warnAt16 = spyWarn(); + await minimalApp().discoverDatabases(); + expect(warnAt16).not.toHaveBeenCalledWith(tooShort); + }); + + it('starts silently with a 32-byte hex secret', async () => { + process.env.ADMINFORTH_SECRET = 'a'.repeat(64); + const warn = spyWarn(); + + await expect(minimalApp().discoverDatabases()).resolves.not.toThrow(); + expect(warn).not.toHaveBeenCalledWith(tooShort); + }); +}); diff --git a/tests/jest_tests/stubs/country-flag-svg.cjs b/tests/jest_tests/stubs/country-flag-svg.cjs new file mode 100644 index 000000000..82bc3043a --- /dev/null +++ b/tests/jest_tests/stubs/country-flag-svg.cjs @@ -0,0 +1,5 @@ +// country-flag-svg ships ESM syntax as CommonJS; the plugin only uses it for flag emoji +module.exports = function getFlagEmoji() { + return ''; +}; +module.exports.default = module.exports; diff --git a/tests/jest_tests/testApp.ts b/tests/jest_tests/testApp.ts index 7f7011653..ec7a40042 100644 --- a/tests/jest_tests/testApp.ts +++ b/tests/jest_tests/testApp.ts @@ -12,7 +12,7 @@ const sqliteDbPath = path.join(applicationDir, '.db.sqlite'); const testEnv = { ...process.env, - ADMINFORTH_SECRET: process.env.ADMINFORTH_SECRET ?? '123', + ADMINFORTH_SECRET: process.env.ADMINFORTH_SECRET ?? 'dev-only-secret-not-for-production-000000000000000000000000', NODE_ENV: process.env.NODE_ENV ?? 'test', SQLITE_URL: process.env.SQLITE_URL ?? `sqlite://${sqliteDbPath}`, SQLITE_FILE_URL: process.env.SQLITE_FILE_URL ?? `file:${sqliteDbPath}`,