Files
gongxue-base/apps/server/src/database/attendance-fk-restrict.spec.ts
wangziqi cc4f4dae4e 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>
2026-07-12 22:59:03 +08:00

381 lines
13 KiB
TypeScript

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);
});
});