From 626a82aaa3da4f13dcf16c4072743e69c89c85da 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/4] 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 c070fdb3a1639b7864b474e3a06f6b1c852e0a0b 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/4] 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 8a5e3dcc11..a632214cac 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 85984f762f8b3305e70b7d649fe5f57adee19f55 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/4] 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 699c0bfeb7..20841b18eb 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 = { @@ -100,9 +142,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 = { @@ -119,9 +161,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'; From 8dbc24440496116c89ce6ce0424c229048915b15 Mon Sep 17 00:00:00 2001 From: fallenbagel <98979876+Fallenbagel@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:49:58 +0800 Subject: [PATCH 4/4] fix(settings): show the restart modal as soon as network settings save StatusChecker polls /api/v1/status?checkUpdateAvailable=false, but saving network settings revalidated /api/v1/status, which is a different SWR key and nothing subscribes to it. The keys stopped matching in #3137 when the version check toggle was added, so since then the restart modal has only appeared on the next sixty second poll or on a fresh page load. The general settings cypress spec covered this and kept passing because earlier specs left the restart flag dirty, so the modal was already up when the spec loaded the page. Cleaning that up in #3368 removed the stale flag the spec was leaning on and the real gap surfaced. --- src/components/Settings/SettingsNetwork/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Settings/SettingsNetwork/index.tsx b/src/components/Settings/SettingsNetwork/index.tsx index dd4fd5fe50..23a5674117 100644 --- a/src/components/Settings/SettingsNetwork/index.tsx +++ b/src/components/Settings/SettingsNetwork/index.tsx @@ -175,7 +175,8 @@ const SettingsNetwork = () => { apiRequestTimeout: Number(values.apiRequestTimeout) * 1000, }); mutate('/api/v1/settings/public'); - mutate('/api/v1/status'); + // the key StatusChecker polls on, so the restart modal shows at once + mutate('/api/v1/status?checkUpdateAvailable=false'); addToast(intl.formatMessage(messages.toastSettingsSuccess), { autoDismiss: true,