refactor(audit): type-safe enums, expanded coverage, and crash-safe logging

Replace magic strings with AuditAction/AuditStatus enums across all consumers,
add logAuditWithCurrentUser() convenience wrapper, extend audit coverage to
data import, result export, app update, and ERP credentials operations, and
harden crash handlers with try/catch to prevent audit failures from cascading.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-04-06 17:40:30 +08:00
parent d0c745e243
commit abad61758c
17 changed files with 269 additions and 157 deletions

View File

@@ -419,11 +419,11 @@ export class AuditLogFactory {
*
* @example
* // Successful login audit
* const entry = AuditLogFactory.createAuditLog('LOGIN', 'SUCCESS')
* const entry = AuditLogFactory.createAuditLog(AuditAction.LOGIN, AuditStatus.SUCCESS)
*
* @example
* // Failed extract audit
* const entry = AuditLogFactory.createAuditLog('EXTRACT', 'FAILURE', { resource: 'Order SC123' })
* const entry = AuditLogFactory.createAuditLog(AuditAction.EXTRACT, AuditStatus.FAILURE, { resource: 'Order SC123' })
*/
static createAuditLog(
action: AuditAction = AuditAction.LOGIN,
@@ -431,13 +431,15 @@ export class AuditLogFactory {
overrides?: Partial<AuditEntry>
): AuditEntry {
return {
timestamp: new Date(),
timestamp: new Date().toISOString(),
action,
userId: 'USR-001',
username: 'test_user',
computerName: 'TEST-PC',
appVersion: '1.0.0',
resource: 'test-resource',
status,
metadata: {},
...overrides
}
}

View File

@@ -91,7 +91,7 @@ describe('AuditLogFactory', () => {
it('creates audit log with default values', () => {
const entry = AuditLogFactory.createAuditLog()
expect(entry.timestamp).toBeInstanceOf(Date)
expect(typeof entry.timestamp).toBe('string')
expect(entry.action).toBe(AuditAction.LOGIN)
expect(entry.status).toBe(AuditStatus.SUCCESS)
expect(entry.userId).toBe('USR-001')

View File

@@ -8,6 +8,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { AuditAction, AuditStatus } from '../../src/main/types/audit.types'
describe('Audit Logger', () => {
let auditLoggerModule: typeof import('../../src/main/services/logger/audit-logger')
@@ -32,11 +33,11 @@ describe('Audit Logger', () => {
const { logAudit, applyAuditConfig } = auditLoggerModule
applyAuditConfig(30)
logAudit('LOGIN', 'user-001', {
logAudit(AuditAction.LOGIN, 'user-001', {
username: 'alice',
computerName: 'PC-001',
resource: 'ERP_SYSTEM',
status: 'success',
status: AuditStatus.SUCCESS,
metadata: { sessionId: 'abc' }
})
@@ -60,26 +61,26 @@ describe('Audit Logger', () => {
applyAuditConfig(30)
logAudit('EXTRACT', 'user1', {
logAudit(AuditAction.EXTRACT, 'user1', {
username: 'extractor',
computerName: 'PC-001',
resource: 'materials',
status: 'success'
status: AuditStatus.SUCCESS
})
logAudit('DELETE', 'user2', {
logAudit(AuditAction.CLEAN, 'user2', {
username: 'cleaner',
computerName: 'PC-002',
resource: 'temp_files',
status: 'failure',
status: AuditStatus.FAILURE,
metadata: { error: 'Permission denied' }
})
logAudit('UPDATE', 'user3', {
logAudit(AuditAction.APP_UPDATE, 'user3', {
username: 'updater',
computerName: 'PC-003',
resource: 'config',
status: 'partial',
status: AuditStatus.PARTIAL,
metadata: { updated: 5, failed: 2 }
})
@@ -95,11 +96,11 @@ describe('Audit Logger', () => {
applyAuditConfig(30)
logAudit('PING', 'user-no-meta', {
logAudit(AuditAction.SYSTEM_ERROR, 'user-no-meta', {
username: 'tester',
computerName: 'PC-001',
resource: 'ERP',
status: 'success'
status: AuditStatus.SUCCESS
})
const entry = JSON.parse(infoSpy.mock.calls[0][0])
@@ -111,11 +112,11 @@ describe('Audit Logger', () => {
applyAuditConfig(30)
logAudit('LOGIN_ATTEMPT', 'user-special', {
logAudit(AuditAction.LOGIN, 'user-special', {
username: 'user.name+test@example.com',
computerName: 'DESKTOP-特殊字符-001',
resource: 'ERP/子系统',
status: 'failure',
status: AuditStatus.FAILURE,
metadata: { reason: '密码错误', attempt: 3 }
})

View File

@@ -132,7 +132,10 @@ describe('MySqlService Unit Tests', () => {
it('should execute SELECT and return rows with columns', async () => {
await service.connect()
mockExecute.mockResolvedValue([
[{ id: 1, name: 'test' }, { id: 2, name: 'foo' }],
[
{ id: 1, name: 'test' },
{ id: 2, name: 'foo' }
],
[{ name: 'id' }, { name: 'name' }]
])

View File

@@ -168,10 +168,7 @@ describe('PostgreSqlService Unit Tests', () => {
await service.connect()
mockPgClient.query.mockResolvedValue({ rows: [] })
await service.transaction([
{ sql: 'SELECT 1', params: [1] },
{ sql: 'SELECT 2' }
])
await service.transaction([{ sql: 'SELECT 1', params: [1] }, { sql: 'SELECT 2' }])
// BEGIN + 2 queries + COMMIT
expect(mockPgClient.query).toHaveBeenCalledTimes(4)
@@ -187,9 +184,9 @@ describe('PostgreSqlService Unit Tests', () => {
.mockRejectedValueOnce(new Error('constraint violation')) // query fails
.mockResolvedValueOnce({ rows: [] }) // ROLLBACK
await expect(
service.transaction([{ sql: 'SELECT 1', params: [1] }])
).rejects.toThrow('PostgreSQL transaction failed')
await expect(service.transaction([{ sql: 'SELECT 1', params: [1] }])).rejects.toThrow(
'PostgreSQL transaction failed'
)
expect(mockPgClient.query).toHaveBeenCalledWith('ROLLBACK')
expect(mockPgClient.release).toHaveBeenCalled()
@@ -234,8 +231,7 @@ describe('prepareSql', () => {
})
it('should quote column names in INSERT', () => {
const sql =
'INSERT INTO "dbo"."BIPUsers" (UserName, Password, UserType) VALUES ($1, $2, $3)'
const sql = 'INSERT INTO "dbo"."BIPUsers" (UserName, Password, UserType) VALUES ($1, $2, $3)'
const result = prepareSql(sql)
expect(result).toBe(
'INSERT INTO "dbo"."BIPUsers" ("UserName", "Password", "UserType") VALUES ($1, $2, $3)'
@@ -303,9 +299,7 @@ describe('prepareSql', () => {
it('should quote underscore-containing column names', () => {
const sql = 'SELECT ERP_URL, ERP_Username, ERP_Password FROM "dbo"."BIPUsers"'
const result = prepareSql(sql)
expect(result).toBe(
'SELECT "ERP_URL", "ERP_Username", "ERP_Password" FROM "dbo"."BIPUsers"'
)
expect(result).toBe('SELECT "ERP_URL", "ERP_Username", "ERP_Password" FROM "dbo"."BIPUsers"')
})
it('should handle ON CONFLICT DO UPDATE SET with EXCLUDED', () => {

View File

@@ -149,7 +149,10 @@ describe('SqlServerService Unit Tests', () => {
it('should execute SELECT and return rows with columns', async () => {
await service.connect()
mockRequestQuery.mockResolvedValue({
recordset: [{ ID: 1, Name: 'test' }, { ID: 2, Name: 'foo' }],
recordset: [
{ ID: 1, Name: 'test' },
{ ID: 2, Name: 'foo' }
],
rowsAffected: [2]
})
@@ -209,9 +212,9 @@ describe('SqlServerService Unit Tests', () => {
describe('queryWithParams', () => {
it('should throw error when not connected', async () => {
await expect(
service.queryWithParams('SELECT @p0', { p0: { value: 1 } })
).rejects.toThrow('Not connected to SQL Server')
await expect(service.queryWithParams('SELECT @p0', { p0: { value: 1 } })).rejects.toThrow(
'Not connected to SQL Server'
)
})
it('should add typed params via request.input', async () => {