feat(notices): add v3014-whitespace-collision admin notice

Adds a dismissible banner for admins on v3.0.14+ that fires only when
the whitespace-trimming migration detected a username/email collision
(stored in app_settings as whitespace_migration_collision=true).

Notice conditions: existingUserBeforeVersion(3.0.14) + role=admin +
custom predicate reading the app_settings flag. Predicate registered in
registry.ts; migration step writes the flag when hadCollision=true.

All 15 translation files updated with title/body keys.
7 integration tests added (SN-COLLISION-1 through -7) covering all
condition branches: shown when all conditions met, hidden when flag
absent/false, hidden for non-admin, hidden for new user, hidden below
min app version, hidden after dismissal.
This commit is contained in:
jubnl
2026-05-03 17:29:18 +02:00
parent 6fd045e0c1
commit 6197072c7b
18 changed files with 213 additions and 3 deletions
+13 -2
View File
@@ -1,8 +1,10 @@
import Database from 'better-sqlite3';
import { encrypt_api_key } from '../services/apiKeyCrypto';
export function trimUserWhitespace(db: Database.Database): void {
/** Returns true if any collision was encountered (renamed row). */
export function trimUserWhitespace(db: Database.Database): boolean {
type DirtyRow = { id: number; username?: string; email?: string };
let hadCollision = false;
const dirtyUsernames = db.prepare(
`SELECT id, username FROM users WHERE username != TRIM(username)`
@@ -16,6 +18,7 @@ export function trimUserWhitespace(db: Database.Database): void {
const final = collision ? `${trimmed}__migrated_${row.id}` : trimmed;
if (collision) {
hadCollision = true;
console.warn(
`[migration] WHITESPACE COLLISION username: user id=${row.id} ` +
`original=${JSON.stringify(row.username)} trimmed="${trimmed}" ` +
@@ -43,6 +46,7 @@ export function trimUserWhitespace(db: Database.Database): void {
let final = trimmed;
if (collision) {
hadCollision = true;
const at = trimmed.lastIndexOf('@');
final = at > 0
? `${trimmed.slice(0, at)}__migrated_${row.id}${trimmed.slice(at)}`
@@ -61,6 +65,8 @@ export function trimUserWhitespace(db: Database.Database): void {
}
db.prepare(`UPDATE users SET email = ? WHERE id = ?`).run(final, row.id);
}
return hadCollision;
}
function runMigrations(db: Database.Database): void {
@@ -2210,7 +2216,12 @@ function runMigrations(db: Database.Database): void {
db.exec(`INSERT INTO app_settings (key, value) VALUES ('app_version', '${process.env.APP_VERSION || '3.0.14'}')`);
},
// trim leading/trailing whitespace from stored usernames and emails
() => trimUserWhitespace(db),
() => {
const hadCollision = trimUserWhitespace(db);
if (hadCollision) {
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
}
},
];
if (currentVersion < migrations.length) {
+27
View File
@@ -1,4 +1,11 @@
import type { SystemNotice } from './types.js';
import { registerPredicate } from './conditions.js';
import { db } from '../db/database.js';
registerPredicate('whitespace-collision-detected', () => {
const row = db.prepare("SELECT value FROM app_settings WHERE key = 'whitespace_migration_collision'").get() as { value: string } | undefined;
return row?.value === 'true';
});
/**
* SYSTEM NOTICE REGISTRY
@@ -124,6 +131,26 @@ export const SYSTEM_NOTICES: SystemNotice[] = [
maxVersion: '4.0.0',
},
// ── 3.0.14 admin notice — whitespace migration collision ───────────────────
{
id: 'v3014-whitespace-collision',
display: 'banner',
severity: 'warn',
icon: 'AlertTriangle',
titleKey: 'system_notice.v3014_whitespace_collision.title',
bodyKey: 'system_notice.v3014_whitespace_collision.body',
dismissible: true,
conditions: [
{ kind: 'existingUserBeforeVersion', version: '3.0.14' },
{ kind: 'role', roles: ['admin'] },
{ kind: 'custom', id: 'whitespace-collision-detected' },
],
publishedAt: '2026-05-03T00:00:00Z',
priority: 85,
minVersion: '3.0.14',
},
// ── Onboarding ─────────────────────────────────────────────────────────────
{
+127 -1
View File
@@ -39,7 +39,7 @@ import { createApp } from '../../src/app';
import { createTables } from '../../src/db/schema';
import { runMigrations } from '../../src/db/migrations';
import { resetTestDb } from '../helpers/test-db';
import { createUser } from '../helpers/factories';
import { createUser, createAdmin } from '../helpers/factories';
import { authCookie } from '../helpers/auth';
import { SYSTEM_NOTICES } from '../../src/systemNotices/registry';
import type { SystemNotice } from '../../src/systemNotices/types';
@@ -242,3 +242,129 @@ describe('POST /api/system-notices/:id/dismiss', () => {
}
});
});
// ─────────────────────────────────────────────────────────────────────────────
// v3014-whitespace-collision notice
// ─────────────────────────────────────────────────────────────────────────────
/**
* Helper: creates an admin user whose first_seen_version is before 3.0.14
* (so existingUserBeforeVersion('3.0.14') passes) and whose login_count is
* high enough to suppress the firstLogin and v3-upgrade notice conditions.
*/
function setupCollisionAdmin() {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
return user;
}
describe('v3014-whitespace-collision notice', () => {
const NOTICE_ID = 'v3014-whitespace-collision';
const originalAppVersion = process.env.APP_VERSION;
beforeEach(() => {
process.env.APP_VERSION = '3.0.14';
});
afterEach(() => {
if (originalAppVersion === undefined) {
delete process.env.APP_VERSION;
} else {
process.env.APP_VERSION = originalAppVersion;
}
});
it('SN-COLLISION-1 — shown to admin when collision flag is set and user predates 3.0.14', async () => {
const user = setupCollisionAdmin();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined();
});
it('SN-COLLISION-2 — hidden when collision flag is absent', async () => {
const user = setupCollisionAdmin();
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
});
it('SN-COLLISION-3 — hidden when collision flag is explicitly false', async () => {
const user = setupCollisionAdmin();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'false')").run();
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
});
it('SN-COLLISION-4 — hidden for non-admin user even when collision flag is set', async () => {
const { user } = createUser(testDb);
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.0', user.id);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
});
it('SN-COLLISION-5 — hidden for user whose first_seen_version is >= 3.0.14 (new account)', async () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET login_count = 5, first_seen_version = ? WHERE id = ?').run('3.0.14', user.id);
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
});
it('SN-COLLISION-6 — hidden when app version is below 3.0.14', async () => {
process.env.APP_VERSION = '3.0.13';
const user = setupCollisionAdmin();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
const res = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(res.status).toBe(200);
expect(res.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
});
it('SN-COLLISION-7 — hidden after admin dismisses it', async () => {
const user = setupCollisionAdmin();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('whitespace_migration_collision', 'true')").run();
const before = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(before.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeDefined();
const dismiss = await request(app)
.post(`/api/system-notices/${NOTICE_ID}/dismiss`)
.set('Cookie', authCookie(user.id));
expect(dismiss.status).toBe(204);
const after = await request(app)
.get('/api/system-notices/active')
.set('Cookie', authCookie(user.id));
expect(after.body.find((n: { id: string }) => n.id === NOTICE_ID)).toBeUndefined();
});
});