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

@@ -1,32 +1,42 @@
import { app } from 'electron' import { app } from 'electron'
import logger from '../services/logger/index' import logger from '../services/logger/index'
import { logAudit, closeAuditLogger } from '../services/logger/audit-logger' import { logAudit, closeAuditLogger } from '../services/logger/audit-logger'
import { AuditAction, AuditStatus } from '../types/audit.types'
import { serializeError } from '../services/logger/error-utils' import { serializeError } from '../services/logger/error-utils'
export function setupProcessGuards(): void { export function setupProcessGuards(): void {
process.on('uncaughtException', (err) => { process.on('uncaughtException', (err) => {
logger.error('Uncaught exception', { error: err }) logger.error('Uncaught exception', { error: err })
logAudit('SYSTEM_CRASH', 'system', { try {
username: 'system', logAudit(AuditAction.SYSTEM_CRASH, 'system', {
computerName: process.env.COMPUTERNAME || 'unknown', username: 'system',
resource: 'main-process', computerName: process.env.COMPUTERNAME || 'unknown',
status: 'failure', resource: 'main-process',
metadata: { error: err.message, stack: err.stack } status: AuditStatus.FAILURE,
}) metadata: { error: err.message, stack: err.stack }
setTimeout(() => process.exit(1), 1000) })
} catch (auditError) {
logger.error('Failed to write crash audit log', { error: auditError })
} finally {
setTimeout(() => process.exit(1), 1000)
}
}) })
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
const errorMeta = const errorMeta =
reason instanceof Error ? { error: serializeError(reason) } : { reason: String(reason) } reason instanceof Error ? { error: serializeError(reason) } : { reason: String(reason) }
logger.error('Unhandled Rejection', errorMeta) logger.error('Unhandled Rejection', errorMeta)
logAudit('SYSTEM_ERROR', 'system', { try {
username: 'system', logAudit(AuditAction.SYSTEM_ERROR, 'system', {
computerName: process.env.COMPUTERNAME || 'unknown', username: 'system',
resource: 'main-process', computerName: process.env.COMPUTERNAME || 'unknown',
status: 'failure', resource: 'main-process',
metadata: errorMeta status: AuditStatus.FAILURE,
}) metadata: errorMeta
})
} catch (auditError) {
logger.error('Failed to write unhandled rejection audit log', { error: auditError })
}
}) })
app.on('render-process-gone', (_, webContents, details) => { app.on('render-process-gone', (_, webContents, details) => {

View File

@@ -5,7 +5,8 @@ import { OrderNumberResolver } from '../services/erp/order-resolver'
import { create, type IDatabaseService } from '../services/database' import { create, type IDatabaseService } from '../services/database'
import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao' import { ExtractorOperationHistoryDAO } from '../services/database/extractor-operation-history-dao'
import { createLogger } from '../services/logger' import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger' import { logAuditWithCurrentUser } from '../services/logger/audit-logger'
import { AuditAction, AuditStatus } from '../types/audit.types'
import { SessionManager } from '../services/user/session-manager' import { SessionManager } from '../services/user/session-manager'
import { withErrorHandling, type IpcResult } from './index' import { withErrorHandling, type IpcResult } from './index'
import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors' import { ErpConnectionError, ValidationError, DatabaseQueryError } from '../types/errors'
@@ -278,24 +279,17 @@ export function registerExtractorHandlers(): void {
} }
// Audit log: EXTRACT (non-blocking) // Audit log: EXTRACT (non-blocking)
const os = await import('os')
if (currentUser) { if (currentUser) {
const auditStatus: 'success' | 'failure' | 'partial' = const auditStatus: AuditStatus =
result.errors.length > 0 && result.recordCount > 0 result.errors.length > 0 && result.recordCount > 0
? 'partial' ? AuditStatus.PARTIAL
: result.errors.length > 0 : result.errors.length > 0
? 'failure' ? AuditStatus.FAILURE
: 'success' : AuditStatus.SUCCESS
logAudit('EXTRACT', String(currentUser.id), { logAuditWithCurrentUser(AuditAction.EXTRACT, 'MATERIAL_PLAN', auditStatus, {
username: currentUser.username, orderCount: validOrderNumbers.length,
computerName: os.hostname(), recordCount: result.recordCount,
resource: 'MATERIAL_PLAN', errorCount: result.errors.length
status: auditStatus,
metadata: {
orderCount: validOrderNumbers.length,
recordCount: result.recordCount,
errorCount: result.errors.length
}
}) })
} }

View File

@@ -5,7 +5,8 @@ import { UserErpConfigService } from '../services/user/user-erp-config-service'
import { MySqlService } from '../services/database/mysql' import { MySqlService } from '../services/database/mysql'
import { SqlServerService } from '../services/database/sql-server' import { SqlServerService } from '../services/database/sql-server'
import { createLogger } from '../services/logger' import { createLogger } from '../services/logger'
import { logAudit } from '../services/logger/audit-logger' import { logAuditWithCurrentUser } from '../services/logger/audit-logger'
import { AuditAction, AuditStatus } from '../types/audit.types'
import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../types/settings.types' import type { UserType, ConnectionTestResult, SaveSettingsResult } from '../types/settings.types'
import { IPC_CHANNELS } from '../../shared/ipc-channels' import { IPC_CHANNELS } from '../../shared/ipc-channels'
import { ValidationError } from '../types/errors' import { ValidationError } from '../types/errors'
@@ -67,13 +68,9 @@ export function registerSettingsHandlers(): void {
}) })
// Audit log: SETTINGS_CHANGE (non-blocking) // Audit log: SETTINGS_CHANGE (non-blocking)
const os = await import('os') logAuditWithCurrentUser(AuditAction.SETTINGS_CHANGE, 'ERP_CONFIG', AuditStatus.SUCCESS, {
logAudit('SETTINGS_CHANGE', String(currentUser.id), { changeType: 'erp_credentials',
username: currentUser.username, usernameChanged: !!settings.erp.username
computerName: os.hostname(),
resource: 'ERP_CONFIG',
status: 'success',
metadata: { changeType: 'erp_credentials', usernameChanged: !!settings.erp.username }
}) })
} }

View File

@@ -3,6 +3,7 @@ import { SessionManager } from '../user/session-manager'
import { UpdateService } from '../update/update-service' import { UpdateService } from '../update/update-service'
import { createLogger, run, getRequestId, getContext } from '../logger' import { createLogger, run, getRequestId, getContext } from '../logger'
import { logAudit } from '../logger/audit-logger' import { logAudit } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import { ValidationError } from '../../types/errors' import { ValidationError } from '../../types/errors'
import type { UserInfo } from '../../types/user.types' import type { UserInfo } from '../../types/user.types'
import type { import type {
@@ -73,11 +74,11 @@ export class AuthApplicationService {
userId: userInfo.id userId: userInfo.id
}) })
this.writeAuditLog('LOGIN', String(userInfo.id), { this.writeAuditLog(AuditAction.LOGIN, String(userInfo.id), {
username: userInfo.username, username: userInfo.username,
computerName: hostname(), computerName: hostname(),
resource: 'ERP_SYSTEM', resource: 'ERP_SYSTEM',
status: 'success', status: AuditStatus.SUCCESS,
metadata: { loginType: 'silent', userType: userInfo.userType } metadata: { loginType: 'silent', userType: userInfo.userType }
}) })
@@ -127,11 +128,11 @@ export class AuthApplicationService {
const userInfo = this.sessionManager.getUserInfo() const userInfo = this.sessionManager.getUserInfo()
if (!success || !userInfo) { if (!success || !userInfo) {
this.writeAuditLog('LOGIN', '0', { this.writeAuditLog(AuditAction.LOGIN, '0', {
username, username,
computerName: hostname(), computerName: hostname(),
resource: 'ERP_SYSTEM', resource: 'ERP_SYSTEM',
status: 'failure', status: AuditStatus.FAILURE,
metadata: { loginType: 'credentials', reason: 'invalid_credentials' } metadata: { loginType: 'credentials', reason: 'invalid_credentials' }
}) })
@@ -155,11 +156,11 @@ export class AuthApplicationService {
}) })
await this.updateService.setUserContext(userInfo.userType) await this.updateService.setUserContext(userInfo.userType)
this.writeAuditLog('LOGIN', String(userInfo.id), { this.writeAuditLog(AuditAction.LOGIN, String(userInfo.id), {
username: userInfo.username, username: userInfo.username,
computerName: hostname(), computerName: hostname(),
resource: 'ERP_SYSTEM', resource: 'ERP_SYSTEM',
status: 'success', status: AuditStatus.SUCCESS,
metadata: { loginType: 'credentials', userType: userInfo.userType } metadata: { loginType: 'credentials', userType: userInfo.userType }
}) })
@@ -204,11 +205,11 @@ export class AuthApplicationService {
}) })
if (userInfo) { if (userInfo) {
this.writeAuditLog('LOGOUT', String(userInfo.id), { this.writeAuditLog(AuditAction.LOGOUT, String(userInfo.id), {
username: userInfo.username, username: userInfo.username,
computerName: hostname(), computerName: hostname(),
resource: 'ERP_SYSTEM', resource: 'ERP_SYSTEM',
status: 'success', status: AuditStatus.SUCCESS,
metadata: { userType: userInfo.userType } metadata: { userType: userInfo.userType }
}) })
} }
@@ -310,7 +311,7 @@ export class AuthApplicationService {
} }
private writeAuditLog( private writeAuditLog(
action: 'LOGIN' | 'LOGOUT', action: AuditAction.LOGIN | AuditAction.LOGOUT,
actorId: string, actorId: string,
payload: Parameters<typeof logAudit>[2] payload: Parameters<typeof logAudit>[2]
): void { ): void {

View File

@@ -13,7 +13,8 @@ import { RustfsService } from '../rustfs'
import { SessionManager } from '../user/session-manager' import { SessionManager } from '../user/session-manager'
import { UserErpConfigService } from '../user/user-erp-config-service' import { UserErpConfigService } from '../user/user-erp-config-service'
import { createLogger } from '../logger' import { createLogger } from '../logger'
import { logAudit } from '../logger/audit-logger' import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import { IPC_CHANNELS } from '../../../shared/ipc-channels' import { IPC_CHANNELS } from '../../../shared/ipc-channels'
import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors' import { DatabaseQueryError, ErpConnectionError, ValidationError } from '../../types/errors'
import type { import type {
@@ -278,27 +279,21 @@ export class CleanerApplicationService {
return return
} }
const status: 'success' | 'failure' | 'partial' = const status: AuditStatus =
result.errors.length > 0 && result.materialsDeleted > 0 result.errors.length > 0 && result.materialsDeleted > 0
? 'partial' ? AuditStatus.PARTIAL
: result.errors.length > 0 : result.errors.length > 0
? 'failure' ? AuditStatus.FAILURE
: 'success' : AuditStatus.SUCCESS
logAudit('CLEAN', String(currentUser.id), { logAuditWithCurrentUser(AuditAction.CLEAN, 'MATERIAL_PLAN', status, {
username: currentUser.username, orderCount,
computerName: (await import('os')).hostname(), dryRun: input.dryRun ?? false,
resource: 'MATERIAL_PLAN', queryBatchSize: input.queryBatchSize ?? 100,
status, processConcurrency: input.processConcurrency ?? 1,
metadata: { materialsDeleted: result.materialsDeleted,
orderCount, materialsSkipped: result.materialsSkipped,
dryRun: input.dryRun ?? false, errorCount: result.errors.length
queryBatchSize: input.queryBatchSize ?? 100,
processConcurrency: input.processConcurrency ?? 1,
materialsDeleted: result.materialsDeleted,
materialsSkipped: result.materialsSkipped,
errorCount: result.errors.length
}
}) })
} }

View File

@@ -10,6 +10,8 @@
*/ */
import { createLogger } from '../logger' import { createLogger } from '../logger'
import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import { DiscreteMaterialPlanDAO, type MaterialPlanRecord } from './discrete-material-plan-dao' import { DiscreteMaterialPlanDAO, type MaterialPlanRecord } from './discrete-material-plan-dao'
const log = createLogger('DataImportService') const log = createLogger('DataImportService')
@@ -148,6 +150,20 @@ export class DataImportService {
} }
} }
// Audit log: DATA_IMPORT
logAuditWithCurrentUser(
AuditAction.DATA_IMPORT,
'MATERIAL_PLAN',
result.success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
{
recordsRead: result.recordsRead,
recordsDeleted: result.recordsDeleted,
recordsImported: result.recordsImported,
uniqueSourceNumbers: result.uniqueSourceNumbers,
errorCount: result.errors.length
}
)
return result return result
} }

View File

@@ -3,6 +3,8 @@ import path from 'path'
import { app } from 'electron' import { app } from 'electron'
import fs from 'fs' import fs from 'fs'
import { createLogger } from '../logger' import { createLogger } from '../logger'
import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import type { ExportResultItem, ExportResultResponse } from '../../types/cleaner.types' import type { ExportResultItem, ExportResultResponse } from '../../types/cleaner.types'
const log = createLogger('ResultExporter') const log = createLogger('ResultExporter')
@@ -37,8 +39,8 @@ export class ResultExporter {
* @returns Export result with file path or error * @returns Export result with file path or error
*/ */
async exportValidationResults(items: ExportResultItem[]): Promise<ExportResultResponse> { async exportValidationResults(items: ExportResultItem[]): Promise<ExportResultResponse> {
const filePath = path.join(this.exportDir, this.fileName)
try { try {
const filePath = path.join(this.exportDir, this.fileName)
log.info('Exporting validation results', { count: items.length, path: filePath }) log.info('Exporting validation results', { count: items.length, path: filePath })
const workbook = new ExcelJS.Workbook() const workbook = new ExcelJS.Workbook()
@@ -97,6 +99,12 @@ export class ResultExporter {
await workbook.xlsx.writeFile(filePath) await workbook.xlsx.writeFile(filePath)
log.info('Export completed', { path: filePath, rows: items.length }) log.info('Export completed', { path: filePath, rows: items.length })
// Audit log: RESULT_EXPORT success
logAuditWithCurrentUser(AuditAction.RESULT_EXPORT, 'VALIDATION_RESULT', AuditStatus.SUCCESS, {
itemCount: items.length,
filePath
})
return { return {
success: true, success: true,
filePath filePath
@@ -104,6 +112,14 @@ export class ResultExporter {
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error) const errorMessage = error instanceof Error ? error.message : String(error)
log.error('Export failed', { error: errorMessage }) log.error('Export failed', { error: errorMessage })
// Audit log: RESULT_EXPORT failure
logAuditWithCurrentUser(AuditAction.RESULT_EXPORT, 'VALIDATION_RESULT', AuditStatus.FAILURE, {
itemCount: items.length,
filePath,
error: errorMessage
})
return { return {
success: false, success: false,
error: errorMessage error: errorMessage

View File

@@ -6,33 +6,12 @@
import winston from 'winston' import winston from 'winston'
import DailyRotateFile from 'winston-daily-rotate-file' import DailyRotateFile from 'winston-daily-rotate-file'
import path from 'path' import path from 'path'
import { hostname } from 'os'
import { app } from 'electron' import { app } from 'electron'
import { getLogDir } from './shared' import { getLogDir } from './shared'
import { SessionManager } from '../user/session-manager'
/** import type { AuditEntry } from '../../types/audit.types'
* Audit log entry structure import { AuditAction, AuditStatus } from '../../types/audit.types'
* All 8 required fields for comprehensive audit tracking
*/
export interface AuditEntry {
/** ISO 8601 timestamp of the audit event */
timestamp: string
/** The action that was performed (e.g., 'LOGIN', 'EXTRACT', 'DELETE') */
action: string
/** User ID who performed the action */
userId: string
/** Username of the user who performed the action */
username: string
/** Computer name from which the action was performed */
computerName: string
/** Application version when the action was performed */
appVersion: string
/** The resource that was affected (e.g., table name, file path) */
resource: string
/** Status of the action: 'success' | 'failure' | 'partial' */
status: 'success' | 'failure' | 'partial'
/** Additional metadata about the audit event */
metadata: Record<string, unknown>
}
/** /**
* JSONL formatter - outputs one JSON object per line * JSONL formatter - outputs one JSON object per line
@@ -91,31 +70,58 @@ export function applyAuditConfig(retentionDays: number): void {
* @param details - Additional details including username, computerName, resource, status, and optional metadata * @param details - Additional details including username, computerName, resource, status, and optional metadata
*/ */
export function logAudit( export function logAudit(
action: string, action: AuditAction,
userId: string, userId: string,
details: { details: {
username: string username: string
computerName: string computerName: string
resource: string resource: string
status: 'success' | 'failure' | 'partial' status: AuditStatus
metadata?: Record<string, unknown> metadata?: Record<string, unknown>
} }
): void { ): void {
const entry: AuditEntry = { try {
timestamp: new Date().toISOString(), const entry: AuditEntry = {
action, timestamp: new Date().toISOString(),
userId, action,
username: details.username, userId,
computerName: details.computerName, username: details.username,
appVersion: app.getVersion(), computerName: details.computerName,
resource: details.resource, appVersion: app.getVersion(),
status: details.status, resource: details.resource,
metadata: details.metadata || {} status: details.status,
} metadata: details.metadata ?? {}
}
// Write as JSONL - one JSON object per line // Write as JSONL - one JSON object per line
// Using info level with the entry stringified as the message // Using info level with the entry stringified as the message
auditLogger.info(JSON.stringify(entry)) auditLogger.info(JSON.stringify(entry))
} catch (error) {
console.error('Audit logging failed:', error)
}
}
/** Cached hostname — invariant for the app lifecycle */
const cachedHostname = hostname()
/**
* Audit log shortcut that auto-resolves the current user context.
* Falls back to 'anonymous' if no user is logged in, so the record is always written.
*/
export function logAuditWithCurrentUser(
action: AuditAction,
resource: string,
status: AuditStatus,
metadata?: Record<string, unknown>
): void {
const user = SessionManager.getInstance().getUserInfo()
logAudit(action, user ? String(user.id) : 'anonymous', {
username: user?.username ?? 'anonymous',
computerName: cachedHostname,
resource,
status,
metadata: metadata ?? {}
})
} }
/** /**

View File

@@ -1,6 +1,8 @@
import * as fs from 'fs' import * as fs from 'fs'
import { ConfigManager } from '../config/config-manager' import { ConfigManager } from '../config/config-manager'
import { createLogger } from '../logger' import { createLogger } from '../logger'
import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
import type { UpdateConfig } from '../../types/config.schema' import type { UpdateConfig } from '../../types/config.schema'
import type { UserType } from '../../types/user.types' import type { UserType } from '../../types/user.types'
import type { import type {
@@ -281,10 +283,21 @@ export class UpdateService {
log.error('Update package hash mismatch', { log.error('Update package hash mismatch', {
version: request.version, version: request.version,
channel: request.channel, channel: request.channel,
expectedHash: request.sha256, expectedHash: request.sha256.substring(0, 16),
actualHash: hash actualHash: hash.substring(0, 16)
}) })
await fs.promises.rm(downloadPath, { force: true }) await fs.promises.rm(downloadPath, { force: true })
// Audit log: APP_UPDATE download hash mismatch
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.FAILURE, {
version: request.version,
channel: request.channel,
phase: 'download',
error: 'Hash mismatch',
expectedHash: request.sha256.substring(0, 16),
actualHash: hash.substring(0, 16)
})
throw new Error('更新包校验失败,文件哈希不匹配') throw new Error('更新包校验失败,文件哈希不匹配')
} }
@@ -294,6 +307,13 @@ export class UpdateService {
downloadPath downloadPath
}) })
// Audit log: APP_UPDATE download success
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.SUCCESS, {
version: request.version,
channel: request.channel,
phase: 'download'
})
this.publishStatus({ this.publishStatus({
phase: 'downloaded', phase: 'downloaded',
progress: 100, progress: 100,
@@ -336,11 +356,29 @@ export class UpdateService {
error: undefined error: undefined
}) })
await this.installer.installDownloadedRelease(downloaded) try {
log.info('Update installation completed', { await this.installer.installDownloadedRelease(downloaded)
version: downloaded.version, log.info('Update installation completed', {
channel: downloaded.channel version: downloaded.version,
}) channel: downloaded.channel
})
// Audit log: APP_UPDATE install success
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.SUCCESS, {
version: downloaded.version,
channel: downloaded.channel,
phase: 'install'
})
} catch (installError) {
const msg = installError instanceof Error ? installError.message : String(installError)
logAuditWithCurrentUser(AuditAction.APP_UPDATE, 'UPDATE_PACKAGE', AuditStatus.FAILURE, {
version: downloaded.version,
channel: downloaded.channel,
phase: 'install',
error: msg
})
throw installError
}
} }
private ensureInitialized(): void { private ensureInitialized(): void {

View File

@@ -14,6 +14,8 @@
import { BIPUsersDAO } from './bip-users-dao' import { BIPUsersDAO } from './bip-users-dao'
import { SessionManager } from './session-manager' import { SessionManager } from './session-manager'
import { createLogger } from '../logger' import { createLogger } from '../logger'
import { logAuditWithCurrentUser } from '../logger/audit-logger'
import { AuditAction, AuditStatus } from '../../types/audit.types'
const log = createLogger('UserErpConfigService') const log = createLogger('UserErpConfigService')
@@ -131,9 +133,22 @@ export class UserErpConfigService {
log.error('Failed to update ERP credentials', { username: currentUser.username }) log.error('Failed to update ERP credentials', { username: currentUser.username })
} }
logAuditWithCurrentUser(
AuditAction.ERP_CREDENTIALS_UPDATE,
'ERP_CREDENTIALS',
success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
{ targetUsername: currentUser.username, updateType: 'self' }
)
return success return success
} catch (error) { } catch (error) {
log.error('Error updating current user ERP credentials', { error }) log.error('Error updating current user ERP credentials', { error })
logAuditWithCurrentUser(
AuditAction.ERP_CREDENTIALS_UPDATE,
'ERP_CREDENTIALS',
AuditStatus.FAILURE,
{ targetUsername: 'unknown', updateType: 'self', error: String(error) }
)
return false return false
} }
} }
@@ -159,9 +174,22 @@ export class UserErpConfigService {
log.error('Failed to update ERP credentials', { username }) log.error('Failed to update ERP credentials', { username })
} }
logAuditWithCurrentUser(
AuditAction.ERP_CREDENTIALS_UPDATE,
'ERP_CREDENTIALS',
success ? AuditStatus.SUCCESS : AuditStatus.FAILURE,
{ targetUsername: username, updateType: 'admin' }
)
return success return success
} catch (error) { } catch (error) {
log.error('Error updating user ERP credentials', { error }) log.error('Error updating user ERP credentials', { error })
logAuditWithCurrentUser(
AuditAction.ERP_CREDENTIALS_UPDATE,
'ERP_CREDENTIALS',
AuditStatus.FAILURE,
{ targetUsername: username, updateType: 'admin', error: String(error) }
)
return false return false
} }
} }

View File

@@ -10,23 +10,31 @@ export enum AuditAction {
LOGOUT = 'LOGOUT', LOGOUT = 'LOGOUT',
EXTRACT = 'EXTRACT', EXTRACT = 'EXTRACT',
CLEAN = 'CLEAN', CLEAN = 'CLEAN',
SETTINGS_CHANGE = 'SETTINGS_CHANGE' SETTINGS_CHANGE = 'SETTINGS_CHANGE',
SYSTEM_CRASH = 'SYSTEM_CRASH',
SYSTEM_ERROR = 'SYSTEM_ERROR',
DATA_IMPORT = 'DATA_IMPORT',
RESULT_EXPORT = 'RESULT_EXPORT',
APP_UPDATE = 'APP_UPDATE',
ERP_CREDENTIALS_UPDATE = 'ERP_CREDENTIALS_UPDATE'
} }
/** /**
* Audit status enumeration * Audit status enumeration
* Note: Values are lowercase to match JSON logging conventions
*/ */
export enum AuditStatus { export enum AuditStatus {
SUCCESS = 'SUCCESS', SUCCESS = 'success',
FAILURE = 'FAILURE' FAILURE = 'failure',
PARTIAL = 'partial'
} }
/** /**
* Audit entry interface * Audit entry interface
*/ */
export interface AuditEntry { export interface AuditEntry {
/** Timestamp of the action */ /** ISO timestamp of the action */
timestamp: Date timestamp: string
/** Action performed */ /** Action performed */
action: AuditAction action: AuditAction
/** User ID who performed the action */ /** User ID who performed the action */
@@ -38,9 +46,9 @@ export interface AuditEntry {
/** Application version when action was performed */ /** Application version when action was performed */
appVersion: string appVersion: string
/** Resource affected by the action */ /** Resource affected by the action */
resource?: string resource: string
/** Status of the action */ /** Status of the action */
status: AuditStatus status: AuditStatus
/** Additional metadata in JSON format */ /** Additional metadata */
metadata?: string metadata: Record<string, unknown>
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -168,10 +168,7 @@ describe('PostgreSqlService Unit Tests', () => {
await service.connect() await service.connect()
mockPgClient.query.mockResolvedValue({ rows: [] }) mockPgClient.query.mockResolvedValue({ rows: [] })
await service.transaction([ await service.transaction([{ sql: 'SELECT 1', params: [1] }, { sql: 'SELECT 2' }])
{ sql: 'SELECT 1', params: [1] },
{ sql: 'SELECT 2' }
])
// BEGIN + 2 queries + COMMIT // BEGIN + 2 queries + COMMIT
expect(mockPgClient.query).toHaveBeenCalledTimes(4) expect(mockPgClient.query).toHaveBeenCalledTimes(4)
@@ -187,9 +184,9 @@ describe('PostgreSqlService Unit Tests', () => {
.mockRejectedValueOnce(new Error('constraint violation')) // query fails .mockRejectedValueOnce(new Error('constraint violation')) // query fails
.mockResolvedValueOnce({ rows: [] }) // ROLLBACK .mockResolvedValueOnce({ rows: [] }) // ROLLBACK
await expect( await expect(service.transaction([{ sql: 'SELECT 1', params: [1] }])).rejects.toThrow(
service.transaction([{ sql: 'SELECT 1', params: [1] }]) 'PostgreSQL transaction failed'
).rejects.toThrow('PostgreSQL transaction failed') )
expect(mockPgClient.query).toHaveBeenCalledWith('ROLLBACK') expect(mockPgClient.query).toHaveBeenCalledWith('ROLLBACK')
expect(mockPgClient.release).toHaveBeenCalled() expect(mockPgClient.release).toHaveBeenCalled()
@@ -234,8 +231,7 @@ describe('prepareSql', () => {
}) })
it('should quote column names in INSERT', () => { it('should quote column names in INSERT', () => {
const sql = const sql = 'INSERT INTO "dbo"."BIPUsers" (UserName, Password, UserType) VALUES ($1, $2, $3)'
'INSERT INTO "dbo"."BIPUsers" (UserName, Password, UserType) VALUES ($1, $2, $3)'
const result = prepareSql(sql) const result = prepareSql(sql)
expect(result).toBe( expect(result).toBe(
'INSERT INTO "dbo"."BIPUsers" ("UserName", "Password", "UserType") VALUES ($1, $2, $3)' 'INSERT INTO "dbo"."BIPUsers" ("UserName", "Password", "UserType") VALUES ($1, $2, $3)'
@@ -303,9 +299,7 @@ describe('prepareSql', () => {
it('should quote underscore-containing column names', () => { it('should quote underscore-containing column names', () => {
const sql = 'SELECT ERP_URL, ERP_Username, ERP_Password FROM "dbo"."BIPUsers"' const sql = 'SELECT ERP_URL, ERP_Username, ERP_Password FROM "dbo"."BIPUsers"'
const result = prepareSql(sql) const result = prepareSql(sql)
expect(result).toBe( expect(result).toBe('SELECT "ERP_URL", "ERP_Username", "ERP_Password" FROM "dbo"."BIPUsers"')
'SELECT "ERP_URL", "ERP_Username", "ERP_Password" FROM "dbo"."BIPUsers"'
)
}) })
it('should handle ON CONFLICT DO UPDATE SET with EXCLUDED', () => { 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 () => { it('should execute SELECT and return rows with columns', async () => {
await service.connect() await service.connect()
mockRequestQuery.mockResolvedValue({ mockRequestQuery.mockResolvedValue({
recordset: [{ ID: 1, Name: 'test' }, { ID: 2, Name: 'foo' }], recordset: [
{ ID: 1, Name: 'test' },
{ ID: 2, Name: 'foo' }
],
rowsAffected: [2] rowsAffected: [2]
}) })
@@ -209,9 +212,9 @@ describe('SqlServerService Unit Tests', () => {
describe('queryWithParams', () => { describe('queryWithParams', () => {
it('should throw error when not connected', async () => { it('should throw error when not connected', async () => {
await expect( await expect(service.queryWithParams('SELECT @p0', { p0: { value: 1 } })).rejects.toThrow(
service.queryWithParams('SELECT @p0', { p0: { value: 1 } }) 'Not connected to SQL Server'
).rejects.toThrow('Not connected to SQL Server') )
}) })
it('should add typed params via request.input', async () => { it('should add typed params via request.input', async () => {