forked from wangziqi/gongxue-base
fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal file
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import Database from 'better-sqlite3';
|
||||
type SqliteDB = InstanceType<typeof Database>;
|
||||
|
||||
/**
|
||||
* Real SQLite foreign-key constraint tests.
|
||||
*
|
||||
* These tests use the `better-sqlite3` driver directly (in-memory) to verify
|
||||
* that ON DELETE RESTRICT is enforced at the database level, not just in
|
||||
* application-layer guards.
|
||||
*/
|
||||
describe('attendance_sessions FK RESTRICT — real SQLite', () => {
|
||||
let db: SqliteDB;
|
||||
|
||||
function createSchema(): void {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
is_archived INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS class_schedule (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER,
|
||||
week_day INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status TEXT DEFAULT 'in_progress',
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
createSchema();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('blocks class deletion when attendance sessions reference it', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM classes WHERE id = 1');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('allows class deletion when no attendance sessions reference it', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM classes WHERE id = 1');
|
||||
}).not.toThrow();
|
||||
|
||||
const remaining = db.prepare('SELECT COUNT(*) as cnt FROM classes').get() as {
|
||||
cnt: number;
|
||||
};
|
||||
expect(remaining.cnt).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks schedule deletion when attendance sessions reference it', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM class_schedule WHERE id = 1');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('PRAGMA foreign_key_list confirms both FKs are present', () => {
|
||||
// Use raw SQL PRAGMA to avoid better-sqlite3 pragma API quirks
|
||||
const rows = db.prepare("PRAGMA foreign_key_list('attendance_sessions')").all() as Array<{
|
||||
id: number;
|
||||
seq: number;
|
||||
table: string;
|
||||
from: string;
|
||||
to: string;
|
||||
on_update: string;
|
||||
on_delete: string;
|
||||
match: string;
|
||||
}>;
|
||||
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
const scheduleFk = rows.find((fk) => fk.from === 'schedule_id');
|
||||
expect(scheduleFk).toBeDefined();
|
||||
expect(scheduleFk!.table).toBe('class_schedule');
|
||||
expect(scheduleFk!.on_delete).toBe('RESTRICT');
|
||||
|
||||
const classFk = rows.find((fk) => fk.from === 'class_id');
|
||||
expect(classFk).toBeDefined();
|
||||
expect(classFk!.table).toBe('classes');
|
||||
expect(classFk!.on_delete).toBe('RESTRICT');
|
||||
});
|
||||
|
||||
it('FK pragma respects ON DELETE RESTRICT for class_id — data survives failed delete', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
|
||||
// Verify the session exists
|
||||
const session = db
|
||||
.prepare('SELECT * FROM attendance_sessions WHERE class_id = 1')
|
||||
.get() as Record<string, unknown>;
|
||||
expect(session).toBeDefined();
|
||||
|
||||
// Delete should fail
|
||||
expect(() => db.exec('DELETE FROM classes WHERE id = 1')).toThrow();
|
||||
|
||||
// Session should still exist after failed delete
|
||||
const after = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE class_id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(after.cnt).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration test: simulate the protectAttendanceHistory SQLite migration.
|
||||
*
|
||||
* Creates tables WITHOUT foreign keys (pre-migration state), inserts parent
|
||||
* session and child attendance_record, runs the table-rebuild migration
|
||||
* (PRAGMA foreign_keys=OFF, rebuild both tables, PRAGMA foreign_keys=ON,
|
||||
* foreign_key_check), then verifies:
|
||||
* - attendance_record.attendance_session_id is preserved
|
||||
* - RESTRICT still blocks class/schedule deletion
|
||||
*/
|
||||
describe('protectAttendanceHistory SQLite migration — integration', () => {
|
||||
let db: SqliteDB;
|
||||
|
||||
function createPreMigrationSchema(): void {
|
||||
// Schema WITHOUT foreign keys on attendance_sessions (pre-migration)
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
is_archived INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS class_schedule (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER,
|
||||
week_day INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
// attendance_sessions WITHOUT foreign keys
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status TEXT DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
// Legacy columns came first; course-attendance columns were appended later.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
class_id INTEGER,
|
||||
attendance_date DATE NOT NULL,
|
||||
session VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
remark VARCHAR(200),
|
||||
source VARCHAR(20) DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
schedule_id INTEGER,
|
||||
attendance_session_id INTEGER
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function runMigration(): void {
|
||||
// Step 1: PRAGMA foreign_keys = OFF outside transaction
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
// Rebuild attendance_sessions with FKs
|
||||
db.exec(`
|
||||
CREATE TABLE attendance_sessions_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
db.exec(
|
||||
'INSERT INTO attendance_sessions_new SELECT * FROM attendance_sessions',
|
||||
);
|
||||
db.exec('DROP TABLE attendance_sessions');
|
||||
db.exec(
|
||||
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
|
||||
);
|
||||
db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||
);
|
||||
|
||||
// Rebuild attendance_records with FK on attendance_session_id
|
||||
const recordsFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_records')")
|
||||
.all() as Array<{ from: string }>;
|
||||
const hasSessionFk = recordsFk.some((r) => r.from === 'attendance_session_id');
|
||||
if (!hasSessionFk) {
|
||||
db.exec(`
|
||||
CREATE TABLE attendance_records_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
class_id INTEGER,
|
||||
schedule_id INTEGER,
|
||||
attendance_session_id INTEGER,
|
||||
attendance_date DATE NOT NULL,
|
||||
session VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
remark VARCHAR(200),
|
||||
source VARCHAR(20) DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO attendance_records_new (
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
FROM attendance_records
|
||||
`);
|
||||
db.exec('DROP TABLE attendance_records');
|
||||
db.exec(
|
||||
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
|
||||
);
|
||||
db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||
);
|
||||
}
|
||||
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
|
||||
// Run foreign_key_check — should be clean
|
||||
const checkRows = db.prepare('PRAGMA foreign_key_check').all();
|
||||
if (checkRows.length > 0) {
|
||||
throw new Error(
|
||||
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
createPreMigrationSchema();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('preserves attendance_record.session_id after migration', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
db.exec(
|
||||
"INSERT INTO attendance_records (id, student_id, class_id, attendance_session_id, attendance_date, session, status) VALUES (1, 1, 1, 1, '2026-01-01', 'morning', 'present')",
|
||||
);
|
||||
|
||||
// Verify pre-migration state
|
||||
const preSessionFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
|
||||
.all();
|
||||
expect(preSessionFk.length).toBe(0);
|
||||
|
||||
const preRecordsFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_records')")
|
||||
.all();
|
||||
expect(preRecordsFk.length).toBe(0);
|
||||
|
||||
// Run migration
|
||||
runMigration();
|
||||
|
||||
// Verify attendance_record still has correct attendance_session_id
|
||||
const record = db
|
||||
.prepare('SELECT * FROM attendance_records WHERE id = 1')
|
||||
.get() as Record<string, unknown>;
|
||||
expect(record).toBeDefined();
|
||||
expect(record.attendance_session_id).toBe(1);
|
||||
expect(record.attendance_date).toBe('2026-01-01');
|
||||
expect(record.session).toBe('morning');
|
||||
expect(record.status).toBe('present');
|
||||
|
||||
// Verify FKs now exist on both tables
|
||||
const postSessionFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
|
||||
.all();
|
||||
expect(postSessionFk.length).toBe(2);
|
||||
|
||||
const postRecordsFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_records')")
|
||||
.all() as Array<{ from: string; table: string; on_delete: string }>;
|
||||
const sessionFk = postRecordsFk.find((r) => r.from === 'attendance_session_id');
|
||||
expect(sessionFk).toBeDefined();
|
||||
expect(sessionFk!.table).toBe('attendance_sessions');
|
||||
expect(sessionFk!.on_delete).toBe('SET NULL');
|
||||
|
||||
// RESTRICT still blocks class/schedule deletion
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM classes WHERE id = 1');
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM class_schedule WHERE id = 1');
|
||||
}).toThrow();
|
||||
|
||||
// Verify data survived the failed deletes
|
||||
const sessionAfter = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(sessionAfter.cnt).toBe(1);
|
||||
|
||||
const recordAfter = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM attendance_records WHERE id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(recordAfter.cnt).toBe(1);
|
||||
|
||||
const classAfter = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM classes WHERE id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(classAfter.cnt).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user