mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user