From 6c287ed4cb5661f4b2f0e3ee8eb60dbfed81236f Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:49:43 +0800 Subject: [PATCH 1/3] fix(test): refuse to drop the database outside tests seedTestDb and resetTestDb operate on the shared dataSource, which points at whatever NODE_ENV selects, and neither checked where it was pointing before dropping every table and seeding an admin account with a hardcoded password hash. Both now refuse unless NODE_ENV is test or ALLOW_DB_RESET is set, which cypress:prepare passes because it seeds the on-disk dev database on purpose. --- server/scripts/prepareTestDb.ts | 1 + server/utils/seedTestDb.test.ts | 29 +++++++++++++++++++++++++++++ server/utils/seedTestDb.ts | 16 ++++++++++++++++ 3 files changed, 46 insertions(+) create mode 100644 server/utils/seedTestDb.test.ts diff --git a/server/scripts/prepareTestDb.ts b/server/scripts/prepareTestDb.ts index ccde8bb74b..bd917c447d 100644 --- a/server/scripts/prepareTestDb.ts +++ b/server/scripts/prepareTestDb.ts @@ -12,6 +12,7 @@ const prepareDb = async () => { await seedTestDb({ preserveDb: process.env.PRESERVE_DB === 'true', withMigrations: process.env.WITH_MIGRATIONS === 'true', + allowOutsideTest: true, }); }; diff --git a/server/utils/seedTestDb.test.ts b/server/utils/seedTestDb.test.ts new file mode 100644 index 0000000000..2176bb9868 --- /dev/null +++ b/server/utils/seedTestDb.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import { resetTestDb, seedTestDb } from '@server/utils/seedTestDb'; + +// NODE_ENV is typed readonly, so it is swapped through the whole env object +function setNodeEnv(value: string | undefined) { + Object.assign(process.env, { NODE_ENV: value }); +} + +describe('test database guard', () => { + const originalNodeEnv = process.env.NODE_ENV; + + afterEach(() => { + setNodeEnv(originalNodeEnv); + }); + + it('refuses to seed when NODE_ENV is not test', async () => { + setNodeEnv('production'); + + await assert.rejects(() => seedTestDb(), /Refusing to seed/); + }); + + it('refuses to reset when NODE_ENV is not test', async () => { + setNodeEnv('production'); + + await assert.rejects(() => resetTestDb(), /Refusing to reset/); + }); +}); diff --git a/server/utils/seedTestDb.ts b/server/utils/seedTestDb.ts index 266169d45c..b91d1abf40 100644 --- a/server/utils/seedTestDb.ts +++ b/server/utils/seedTestDb.ts @@ -8,6 +8,8 @@ export interface SeedDbOptions { preserveDb?: boolean; /** If true, runs migrations instead of synchronizing schema */ withMigrations?: boolean; + /** If true, permits seeding while NODE_ENV is not test */ + allowOutsideTest?: boolean; } // Precomputed bcrypt hash of 'test1234'. We precompute this to avoid @@ -15,6 +17,16 @@ export interface SeedDbOptions { const TEST_USER_PASSWORD_HASH = '$2b$12$Z5V2P5HZgmx4/AnWFMZN1.aD5AM1NucNi.mhNTSQ9oVtmdzu7Le/a'; +function assertTestDatabase(operation: string, allowOutsideTest = false): void { + if (allowOutsideTest || process.env.NODE_ENV === 'test') { + return; + } + + throw new Error( + `Refusing to ${operation} while NODE_ENV is not test: this drops every table and seeds accounts with a known password.` + ); +} + /** * Seeds test users into the database. * Assumes the database schema is already set up. @@ -68,6 +80,8 @@ async function seedTestUsers(): Promise { * Used by both Cypress tests and Vitest unit tests. */ export async function seedTestDb(options: SeedDbOptions = {}): Promise { + assertTestDatabase('seed the test database', options.allowOutsideTest); + const dbConnection = dataSource.isInitialized ? dataSource : await dataSource.initialize(); @@ -91,6 +105,8 @@ export async function seedTestDb(options: SeedDbOptions = {}): Promise { * Assumes DB has been initialized. */ export async function resetTestDb(): Promise { + assertTestDatabase('reset the test database'); + await dataSource.synchronize(true); await seedTestUsers(); } From 25a2a45d841956139c9f8fd06f6d827ee19a77ce Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:52:37 +0800 Subject: [PATCH 2/3] build(server): keep test files out of dist The build compiled every .ts under server, so twelve test files shipped in dist. Anything under dist/entity or dist/subscriber gets loaded by the production entity and subscriber globs, which means a test file in one of those directories would run at boot. The build now uses its own tsconfig that excludes *.test.ts, while typecheck:server keeps using the base config so the test files are still type checked. --- package.json | 2 +- server/tsconfig.build.json | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 server/tsconfig.build.json diff --git a/package.json b/package.json index 5a68c7a65b..9d73e68bfb 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "preinstall": "npx only-allow pnpm", "postinstall": "next telemetry disable", "dev": "nodemon -e ts,json,yml --watch server --watch seerr-api.yml --exec 'ts-node -r tsconfig-paths/register --files --project server/tsconfig.json server/index.ts'", - "build:server": "tsc --project server/tsconfig.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 \"server/i18n/locale/*.json\" dist/i18n && tsc-alias -p server/tsconfig.json", + "build:server": "tsc --project server/tsconfig.build.json && copyfiles -u 2 server/templates/**/*.{html,pug} dist/templates && copyfiles -u 2 \"server/i18n/locale/*.json\" dist/i18n && tsc-alias -p server/tsconfig.build.json", "build:next": "next build", "build": "pnpm build:next && pnpm build:server", "lint": "eslint \"./server/**/*.{ts,tsx}\" \"./src/**/*.{ts,tsx}\" --cache", diff --git a/server/tsconfig.build.json b/server/tsconfig.build.json new file mode 100644 index 0000000000..d472b2c2aa --- /dev/null +++ b/server/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["**/*.test.ts"] +} From da2175afa755052750c9b129b8b6df0e70143b18 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:59:54 +0800 Subject: [PATCH 3/3] fix(datasource): register entities and subscribers explicitly The entity and subscriber options were globs over a whole directory, so every file in server/entity and server/subscriber was required at initialize, test files included. A test file there would run its module body inside any process that opens the datasource, including a production boot. Both options now take the classes directly, which also removes the duplicate ts and js patterns across the five config variants. Migrations stay globbed since they are ordered by filename and there are hundreds of them. --- server/datasource.ts | 62 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 52 insertions(+), 10 deletions(-) diff --git a/server/datasource.ts b/server/datasource.ts index a27798fb41..26f564d19c 100644 --- a/server/datasource.ts +++ b/server/datasource.ts @@ -1,3 +1,21 @@ +import { Blocklist } from '@server/entity/Blocklist'; +import DiscoverSlider from '@server/entity/DiscoverSlider'; +import Issue from '@server/entity/Issue'; +import IssueComment from '@server/entity/IssueComment'; +import Media from '@server/entity/Media'; +import { MediaRequest } from '@server/entity/MediaRequest'; +import OverrideRule from '@server/entity/OverrideRule'; +import Season from '@server/entity/Season'; +import SeasonRequest from '@server/entity/SeasonRequest'; +import { Session } from '@server/entity/Session'; +import { User } from '@server/entity/User'; +import { UserPushSubscription } from '@server/entity/UserPushSubscription'; +import { UserSettings } from '@server/entity/UserSettings'; +import { Watchlist } from '@server/entity/Watchlist'; +import { IssueCommentSubscriber } from '@server/subscriber/IssueCommentSubscriber'; +import { IssueSubscriber } from '@server/subscriber/IssueSubscriber'; +import { MediaRequestSubscriber } from '@server/subscriber/MediaRequestSubscriber'; +import { MediaSubscriber } from '@server/subscriber/MediaSubscriber'; import fs from 'fs'; import type { TlsOptions } from 'tls'; import type { DataSourceOptions, EntityTarget, Repository } from 'typeorm'; @@ -5,6 +23,30 @@ import { DataSource } from 'typeorm'; const DB_SSL_PREFIX = 'DB_SSL_'; +const entities = [ + Blocklist, + DiscoverSlider, + Issue, + IssueComment, + Media, + MediaRequest, + OverrideRule, + Season, + SeasonRequest, + Session, + User, + UserPushSubscription, + UserSettings, + Watchlist, +]; + +const subscribers = [ + IssueCommentSubscriber, + IssueSubscriber, + MediaRequestSubscriber, + MediaSubscriber, +]; + function boolFromEnv(envVar: string, defaultVal = false) { if (process.env[envVar]) { return process.env[envVar]?.toLowerCase() === 'true'; @@ -53,9 +95,9 @@ const testConfig: DataSourceOptions = { synchronize: true, dropSchema: true, logging: boolFromEnv('DB_LOG_QUERIES'), - entities: ['server/entity/**/*.ts'], + entities, migrations: ['server/migration/sqlite/**/*.ts'], - subscribers: ['server/subscriber/**/*.ts'], + subscribers, }; const devConfig: DataSourceOptions = { @@ -67,9 +109,9 @@ const devConfig: DataSourceOptions = { migrationsRun: false, logging: boolFromEnv('DB_LOG_QUERIES'), enableWAL: true, - entities: ['server/entity/**/*.ts'], + entities, migrations: ['server/migration/sqlite/**/*.ts'], - subscribers: ['server/subscriber/**/*.ts'], + subscribers, }; const prodConfig: DataSourceOptions = { @@ -81,9 +123,9 @@ const prodConfig: DataSourceOptions = { migrationsRun: false, logging: boolFromEnv('DB_LOG_QUERIES'), enableWAL: true, - entities: ['dist/entity/**/*.js'], + entities, migrations: ['dist/migration/sqlite/**/*.js'], - subscribers: ['dist/subscriber/**/*.js'], + subscribers, }; const postgresDevConfig: DataSourceOptions = { @@ -102,9 +144,9 @@ const postgresDevConfig: DataSourceOptions = { synchronize: false, migrationsRun: true, logging: boolFromEnv('DB_LOG_QUERIES'), - entities: ['server/entity/**/*.ts'], + entities, migrations: ['server/migration/postgres/**/*.ts'], - subscribers: ['server/subscriber/**/*.ts'], + subscribers, }; const postgresProdConfig: DataSourceOptions = { @@ -123,9 +165,9 @@ const postgresProdConfig: DataSourceOptions = { synchronize: false, migrationsRun: false, logging: boolFromEnv('DB_LOG_QUERIES'), - entities: ['dist/entity/**/*.js'], + entities, migrations: ['dist/migration/postgres/**/*.js'], - subscribers: ['dist/subscriber/**/*.js'], + subscribers, }; export const isPgsql = process.env.DB_TYPE === 'postgres';