This commit is contained in:
parent
42a173f742
commit
ae224649f3
@ -12,7 +12,8 @@ import (
|
||||
func TestConfigManagerSwitchDatabaseCommitsSQLiteReference(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRuntimeFixture(t, root, validRuntimeConfig())
|
||||
manager, err := NewConfigManager(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}})
|
||||
audit := &MemoryAuditWriter{}
|
||||
manager, err := NewConfigManagerWithSecurity(root, ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}, ConfigSecurity{Audit: audit})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -29,7 +30,6 @@ func TestConfigManagerSwitchDatabaseCommitsSQLiteReference(t *testing.T) {
|
||||
if len(status.PendingRestart) != 1 || status.PendingRestart[0] != ServiceDatabase {
|
||||
t.Fatalf("pending restart services = %+v", status.PendingRestart)
|
||||
}
|
||||
audit := manager.security.Audit.(*FileAuditWriter)
|
||||
events, err := audit.Query(context.Background(), ConfigAuditQuery{Operation: ConfigOperationMigration, Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@ -45,7 +45,8 @@ func TestConfigManagerSwitchAcrossTypesPersistsAndRedactsAudit(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRuntimeFixture(t, root, validRuntimeConfig())
|
||||
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
|
||||
manager, err := NewConfigManager(root, options)
|
||||
audit := &MemoryAuditWriter{}
|
||||
manager, err := NewConfigManagerWithSecurity(root, options, ConfigSecurity{Audit: audit})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@ -64,7 +65,7 @@ func TestConfigManagerSwitchAcrossTypesPersistsAndRedactsAudit(t *testing.T) {
|
||||
if got := reloaded.Snapshot(); got.Generation != 2 || got.Root.Database != target {
|
||||
t.Fatal("target configuration was not persisted")
|
||||
}
|
||||
events, err := manager.security.Audit.(*FileAuditWriter).Query(context.Background(), ConfigAuditQuery{Operation: ConfigOperationMigration, Limit: 10})
|
||||
events, err := audit.Query(context.Background(), ConfigAuditQuery{Operation: ConfigOperationMigration, Limit: 10})
|
||||
if err != nil || len(events) != 1 {
|
||||
t.Fatal("migration audit missing")
|
||||
}
|
||||
|
||||
@ -135,15 +135,7 @@ type ConfigManager struct {
|
||||
}
|
||||
|
||||
func NewConfigManager(root string, options ConfigLoaderOptions) (*ConfigManager, error) {
|
||||
audit, err := NewFileAuditWriter(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newConfigManager(root, options, ConfigSecurity{
|
||||
Authorize: DefaultConfigAuthorization,
|
||||
Confirm: DefaultConfigConfirmation,
|
||||
Audit: audit,
|
||||
})
|
||||
return NewConfigManagerWithSecurity(root, options, ConfigSecurity{})
|
||||
}
|
||||
|
||||
func NewConfigManagerWithSecurity(root string, options ConfigLoaderOptions, security ConfigSecurity) (*ConfigManager, error) {
|
||||
@ -154,11 +146,7 @@ func NewConfigManagerWithSecurity(root string, options ConfigLoaderOptions, secu
|
||||
security.Confirm = DefaultConfigConfirmation
|
||||
}
|
||||
if security.Audit == nil {
|
||||
audit, err := NewFileAuditWriter(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
security.Audit = audit
|
||||
security.Audit = RuntimeConfigLogWriter{}
|
||||
}
|
||||
return newConfigManager(root, options, security)
|
||||
}
|
||||
|
||||
37
backend/config/operation_log.go
Normal file
37
backend/config/operation_log.go
Normal file
@ -0,0 +1,37 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// RuntimeConfigLogWriter 复用运行日志记录配置结果,不生成独立审计文件或数据库记录。
|
||||
// 只保留定位操作需要的元数据,不输出配置值、凭据、用户名称或完整变更载荷。
|
||||
type RuntimeConfigLogWriter struct{}
|
||||
|
||||
func (RuntimeConfigLogWriter) WriteConfigAudit(_ context.Context, event ConfigAuditEvent) error {
|
||||
event, err := normalizeAuditEvent(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
action := map[ConfigOperation]string{
|
||||
ConfigOperationUpdate: "保存配置",
|
||||
ConfigOperationRollback: "回滚配置",
|
||||
ConfigOperationMigration: "迁移配置",
|
||||
}[event.Operation]
|
||||
entry := log.WithFields(log.Fields{
|
||||
"operation": event.Operation,
|
||||
"section": event.Section,
|
||||
"actorId": event.ActorID,
|
||||
"fromGeneration": event.FromGeneration,
|
||||
"toGeneration": event.ToGeneration,
|
||||
"result": event.Result,
|
||||
})
|
||||
if event.Result == "failure" {
|
||||
entry.Warn(action + "失败")
|
||||
} else {
|
||||
entry.Info(action + "成功")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -94,7 +94,7 @@ func GetLogs(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request)
|
||||
response.NewSuccessResponse(page).FPrint(w)
|
||||
return
|
||||
}
|
||||
if query.Kind != "config" && query.Kind != "operation" && query.Kind != "mail" {
|
||||
if query.Kind != "operation" && query.Kind != "mail" {
|
||||
writeConfigError(w, config.ErrInvalidAuditQuery)
|
||||
return
|
||||
}
|
||||
|
||||
@ -223,31 +223,12 @@ func configSectionRoute(path, operation string) bool {
|
||||
}
|
||||
func configAuditRoute(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
controllers.EnsureConfigRequestID(w, r)
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
controllers.ConfigAuditQuery(ctx, w, r)
|
||||
case http.MethodPost:
|
||||
controllers.ExportConfigAudit(ctx, w, r)
|
||||
default:
|
||||
allowRouteMethod(w, r, http.MethodGet, http.MethodPost)
|
||||
}
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
http.Error(w, "独立配置审计已停用,请查看运行日志", http.StatusGone)
|
||||
}
|
||||
|
||||
func configAuditSubRoute(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
controllers.EnsureConfigRequestID(w, r)
|
||||
path := strings.TrimSuffix(r.URL.Path, "/")
|
||||
switch path {
|
||||
case "/api/v1/audits/config/export":
|
||||
if !allowRouteMethod(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
controllers.ExportConfigAuditFormal(ctx, w, r)
|
||||
default:
|
||||
if !allowRouteMethod(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
controllers.GetConfigAuditDetail(ctx, w, r)
|
||||
}
|
||||
configAuditRoute(ctx, w, r)
|
||||
}
|
||||
|
||||
func databaseMigrationSubRoute(ctx *context.Context, w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@ -76,15 +76,6 @@ func startConfigManager() error {
|
||||
return nil
|
||||
}
|
||||
security := config.ConfigSecurity{}
|
||||
if store := logstore.Current(); store != nil {
|
||||
audit := &logstore.AuditWriter{Store: store}
|
||||
importCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if err := audit.ImportLegacy(importCtx, config.ROOT_PATH); err != nil {
|
||||
return fmt.Errorf("迁移旧配置审计失败,源文件已保留: %w", err)
|
||||
}
|
||||
security.Audit = audit
|
||||
}
|
||||
manager, err := config.NewConfigManagerWithSecurity(config.ROOT_PATH, config.ConfigLoaderOptions{Validation: config.RuntimeValidationOptions{
|
||||
HasSuperAdmin: true,
|
||||
Production: !config.DevelopmentMode,
|
||||
|
||||
@ -12,7 +12,6 @@ import (
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/db/migration"
|
||||
"git.pcux.cn/xuans/Smail/services/logstore"
|
||||
)
|
||||
|
||||
// PortableExecutor 使用版本化目标结构复制业务数据,不拼接用户提供的 SQL。
|
||||
@ -221,13 +220,8 @@ func (e *PortableExecutor) Run(ctx context.Context, plan Plan, update func(Statu
|
||||
if err := update(StatusSwitching); err != nil {
|
||||
return err
|
||||
}
|
||||
// 源库此时仍持有写锁,切换审计必须写入已复制且校验完成的目标库。
|
||||
logStore, err := logstore.New(target, dialect)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switchContext := config.WithMigrationAuditWriter(ctx, &logstore.AuditWriter{Store: logStore})
|
||||
err = e.switchConfig(switchContext, plan.expectedGeneration, plan.targetConfig, plan.actor)
|
||||
// 配置切换结果只写运行日志,不再依赖源库或目标库保存独立审计。
|
||||
err = e.switchConfig(ctx, plan.expectedGeneration, plan.targetConfig, plan.actor)
|
||||
if err != nil && isKnownSwitchRejection(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
@ -5,28 +5,13 @@ import (
|
||||
"encoding/json"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/config"
|
||||
"git.pcux.cn/xuans/Smail/utils/logpolicy"
|
||||
)
|
||||
|
||||
type AuditWriter struct{ Store *Store }
|
||||
|
||||
func (w *AuditWriter) WriteConfigAudit(ctx context.Context, event config.ConfigAuditEvent) error {
|
||||
if w == nil || w.Store == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
normalized, err := config.NormalizeConfigAuditEvent(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Store.Write(ctx, Entry{
|
||||
ID: normalized.ID, CreatedAt: normalized.CreatedAt, Kind: "config",
|
||||
ActorID: normalized.ActorID, Action: string(normalized.Operation),
|
||||
Section: string(normalized.Section), Result: normalized.Result, Payload: payload,
|
||||
})
|
||||
// 旧调用方也转到运行日志,避免再次接入数据库审计。
|
||||
return (config.RuntimeConfigLogWriter{}).WriteConfigAudit(ctx, event)
|
||||
}
|
||||
|
||||
func (w *AuditWriter) Query(ctx context.Context, query config.ConfigAuditQuery) ([]config.ConfigAuditEvent, error) {
|
||||
@ -70,15 +55,7 @@ func (w *AuditWriter) ExportCSV(ctx context.Context, query config.ConfigAuditQue
|
||||
return config.ExportConfigAuditCSV(items)
|
||||
}
|
||||
|
||||
func (w *AuditWriter) ImportLegacy(ctx context.Context, root string) error {
|
||||
if w == nil || w.Store == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
legacy, err := config.NewFileAuditWriter(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return legacy.Visit(ctx, logpolicy.Cutoff(w.Store.now()), func(event config.ConfigAuditEvent) error {
|
||||
return w.WriteConfigAudit(ctx, event)
|
||||
})
|
||||
func (w *AuditWriter) ImportLegacy(_ context.Context, _ string) error {
|
||||
// 独立配置审计已停用,历史文件留在原处,不再导入数据库或重复输出。
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -92,6 +92,9 @@ func replacePlaceholders(statement string, index *int) string {
|
||||
|
||||
// Write 只接受业务点构造的脱敏载荷,禁止直接传入 HTTP 请求或邮件正文。
|
||||
func (s *Store) Write(ctx context.Context, entry Entry) error {
|
||||
if entry.Kind == "config" {
|
||||
return ErrInvalid
|
||||
}
|
||||
if s == nil || s.db == nil {
|
||||
return ErrUnavailable
|
||||
}
|
||||
|
||||
@ -1,5 +1,15 @@
|
||||
# 日志存储与可视化改造
|
||||
|
||||
## 2026-09-09 取消独立配置审计
|
||||
|
||||
- [✓] 需求确认:取消配置审计分类、维护菜单及查询导出;配置修改仅在现有运行日志文件记录简短结果,操作记录与收发信存储方式不变。
|
||||
- [✓] 修改前检查点 `42a173f` 已提交并推送;不删除历史审计文件或清空数据库。
|
||||
- [✓] 默认配置管理器改用运行日志,停止数据库审计写入与历史审计导入;旧审计接口返回已停用,不再返回记录或下载文件。
|
||||
- [✓] 配置保存、回滚、迁移仍使用原有事务、备份、权限与二次验证;运行日志不写配置前后值、密钥、密码或用户名称。跨数据库迁移不再创建目标库审计写入器,保留复制校验和源库写锁。
|
||||
- [✓] 全量回归 `test-log/run-2026-09-08T17-37-37-092Z-35572`:前端 152 项、部署 4 项及后端全包通过。并发检测 `test-log/run-2026-09-08T17-42-33-063Z-34044`:配置、数据库日志、数据库迁移、HTTP 和运行日志包通过。
|
||||
- [~] 完整编译部署,浏览器 MCP 验证三个日志分类、配置维护菜单和停用接口。
|
||||
- [ ] 更新 `yunx-test` 并记录最终提交、运行版本与验证结果。
|
||||
|
||||
## 2026-09-09 倒序查询与写入前过滤
|
||||
|
||||
- [✓] 需求确认:四类日志统一倒序、每页十条、支持起止时间,默认最近二十四小时;翻页固定查询时段。
|
||||
|
||||
@ -9,7 +9,6 @@ export function useMaintenance(pageKey) {
|
||||
page.rollbackPreview.value = null
|
||||
page.rollbackConfirmed.value = false
|
||||
}
|
||||
const auditPagination = usePagination(page.auditItems, 6)
|
||||
const backupPagination = usePagination(page.backups, 6)
|
||||
return { ...page, selectBackup, auditPagination, backupPagination }
|
||||
return { ...page, selectBackup, backupPagination }
|
||||
}
|
||||
|
||||
@ -149,36 +149,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="pageKey === 'audit' && canAudit" class="maintenance-panel audit-panel">
|
||||
<div class="maintenance-body">
|
||||
<div class="panel-heading">
|
||||
<p class="heading-note">展示最近 50 条,导出最多 500 条记录。</p>
|
||||
<div class="audit-actions">
|
||||
<el-button size="small" :disabled="!!working" :loading="auditLoading" @click="loadAudit">查询</el-button>
|
||||
<el-button v-if="canExportAudit" size="small" type="primary" plain :disabled="!!working" :loading="working === 'export'" @click="exportAudit('json')">导出 JSON</el-button>
|
||||
<el-button v-if="canExportAudit" size="small" plain :disabled="!!working" :loading="working === 'export-csv'" @click="exportAudit('csv')">导出 CSV</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-alert v-if="auditError" :title="auditError" type="warning" :closable="false" show-icon />
|
||||
<div v-else-if="auditItems.length" class="audit-list">
|
||||
<div v-for="{ entry: item } in auditRows" :key="item.id || `${item.createdAt}-${item.operation}`" class="audit-item">
|
||||
<div>
|
||||
<strong>{{ operationLabel(item.operation) }}</strong>
|
||||
<span>{{ sectionLabel(item.section) }} · {{ item.actorName || item.actorId || '系统' }}</span>
|
||||
<details v-if="item.changes?.length" class="audit-changes">
|
||||
<summary>{{ item.changes.length }} 个字段变更</summary>
|
||||
<p v-for="change in item.changes" :key="change.field">
|
||||
{{ change.field }}:{{ change.before }} → {{ change.after }}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
<time>{{ formatTime(item.createdAt) }}</time>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else-if="!auditLoading" description="暂无配置审计记录" :image-size="72" />
|
||||
<ConfigPagination v-model:current-page="auditPage" :page-count="auditPageCount" :total="auditItems.length" label="审计记录" />
|
||||
</div>
|
||||
</section>
|
||||
<el-empty v-else description="当前账号无权访问此页面" />
|
||||
</template>
|
||||
</div>
|
||||
@ -201,10 +171,8 @@ const {
|
||||
canRestart, serviceLabel, restartPreview, previewRestart, requestRestart,
|
||||
canRollback, backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed,
|
||||
rollbackPreview, loadBackups, selectBackup, previewRollback, executeRollback,
|
||||
canAudit, canExportAudit, auditLoading, auditError, auditItems, loadAudit, exportAudit,
|
||||
formatTime, sectionLabel, operationLabel, auditPagination, backupPagination
|
||||
formatTime, sectionLabel, backupPagination
|
||||
} = useMaintenance(props.pageKey)
|
||||
const { currentPage: auditPage, pageCount: auditPageCount, pageItems: auditRows } = auditPagination
|
||||
const { currentPage: backupPage, pageCount: backupPageCount, pageItems: backupRows } = backupPagination
|
||||
|
||||
defineExpose({ beforeLeave })
|
||||
|
||||
@ -19,8 +19,7 @@ export function useNavigation() {
|
||||
const tools = [
|
||||
{ value: 'migration', label: '数据库迁移', capability: 'database.migrate' },
|
||||
{ value: 'restart', label: '服务重启', capability: 'service.restart' },
|
||||
{ value: 'backups', label: '备份与回滚', capability: 'config.rollback' },
|
||||
{ value: 'audit', label: '配置审计', capability: 'audit.read' }
|
||||
{ value: 'backups', label: '备份与回滚', capability: 'config.rollback' }
|
||||
]
|
||||
const sectionOptions = computed(() => store.hasCapability('config.read') ? pages : [])
|
||||
const maintenanceOptions = computed(() => store.hasCapability('config.read')
|
||||
|
||||
@ -177,20 +177,15 @@
|
||||
.confirmation-check :deep(.el-checkbox__input) {
|
||||
margin-top: 3px;
|
||||
}
|
||||
.action-row,
|
||||
.audit-actions {
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.action-row > .el-button,
|
||||
.audit-actions > .el-button {
|
||||
.action-row > .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
.audit-actions {
|
||||
margin-top: 0;
|
||||
}
|
||||
.maintenance-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
@ -315,37 +310,6 @@
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.audit-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.audit-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--pm-radius-sm);
|
||||
background: var(--pm-bg-primary);
|
||||
}
|
||||
.audit-item div {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.audit-item strong {
|
||||
color: var(--pm-text-primary);
|
||||
}
|
||||
.audit-item span,
|
||||
.audit-item time {
|
||||
color: var(--pm-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
.audit-item time {
|
||||
flex: none;
|
||||
}
|
||||
@container config-main (max-width: 620px) {
|
||||
.secret-editor {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
@ -362,12 +326,7 @@
|
||||
.maintenance-summary {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.audit-item {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
.action-row .el-button,
|
||||
.audit-actions .el-button {
|
||||
.action-row .el-button {
|
||||
flex: 1;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
@ -26,14 +26,6 @@ const serviceLabels = {
|
||||
}
|
||||
|
||||
const sectionLabels = Object.fromEntries(sections.map(item => [item.value, item.label]))
|
||||
const operationLabels = {
|
||||
validate: '验证配置',
|
||||
update: '保存配置',
|
||||
apply: '应用配置',
|
||||
rollback_preview: '预览回滚',
|
||||
rollback_ticket: '签发回滚票据',
|
||||
rollback: '执行回滚'
|
||||
}
|
||||
|
||||
export function usePage(pageKey = 'base') {
|
||||
const isConfigPage = sections.some(item => item.value === pageKey)
|
||||
@ -45,9 +37,7 @@ export function usePage(pageKey = 'base') {
|
||||
const saveFlow = ref('')
|
||||
const busy = computed(() => !!working.value || !!saveFlow.value)
|
||||
const backupsLoading = ref(false)
|
||||
const auditLoading = ref(false)
|
||||
const backupsError = ref('')
|
||||
const auditError = ref('')
|
||||
const status = reactive({
|
||||
setupState: '',
|
||||
tlsMode: '',
|
||||
@ -116,7 +106,6 @@ export function usePage(pageKey = 'base') {
|
||||
const rollbackGeneration = ref(0)
|
||||
const rollbackConfirmed = ref(false)
|
||||
const rollbackPreview = ref(null)
|
||||
const auditItems = ref([])
|
||||
const databaseConfig = reactive({ type: '', name: '', localRef: '' })
|
||||
const migrationTargetName = ref('')
|
||||
const migrationTarget = reactive({ type: 'sqlite', host: '127.0.0.1', port: 3306, username: '', password: '' })
|
||||
@ -161,8 +150,6 @@ export function usePage(pageKey = 'base') {
|
||||
|
||||
const canWrite = computed(() => globalStatus.hasCapability('config.write_global'))
|
||||
const canRollback = computed(() => globalStatus.hasCapability('config.rollback'))
|
||||
const canAudit = computed(() => globalStatus.hasCapability('audit.read'))
|
||||
const canExportAudit = computed(() => globalStatus.hasCapability('audit.export'))
|
||||
const canMigrate = computed(() => globalStatus.hasCapability('database.migrate'))
|
||||
const canRestart = computed(() => globalStatus.hasCapability('service.restart'))
|
||||
const stateLabel = computed(() => ({ new: '未初始化', bootstrap: '初始化中', ready: '就绪', degraded: '降级' }[status.setupState] || status.setupState || '未知'))
|
||||
@ -208,7 +195,6 @@ export function usePage(pageKey = 'base') {
|
||||
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
|
||||
const serviceLabel = value => serviceLabels[value] || value
|
||||
const sectionLabel = value => sectionLabels[value] || value || '-'
|
||||
const operationLabel = value => operationLabels[value] || value || '-'
|
||||
const migrationStatusLabels = {
|
||||
previewed: '已生成预览',
|
||||
created: '已创建',
|
||||
@ -346,22 +332,6 @@ export function usePage(pageKey = 'base') {
|
||||
}
|
||||
}
|
||||
|
||||
const loadAudit = async () => {
|
||||
if (!canAudit.value) return
|
||||
auditLoading.value = true
|
||||
auditError.value = ''
|
||||
try {
|
||||
const response = await http.get('/api/v1/audits/config?limit=50')
|
||||
notifyResponseError(response, '配置审计读取失败')
|
||||
auditItems.value = Array.isArray(response.data) ? response.data : []
|
||||
} catch (error) {
|
||||
auditItems.value = []
|
||||
auditError.value = error.message || '配置审计读取失败'
|
||||
} finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const previewDatabaseMigration = async () => {
|
||||
if (!canMigrate.value || working.value || sectionError.value || !migrationReady.value) return
|
||||
working.value = 'migration-preview'
|
||||
@ -499,7 +469,6 @@ export function usePage(pageKey = 'base') {
|
||||
if (isConfigPage || (pageKey === 'migration' && canMigrate.value)) await loadSection()
|
||||
if (isConfigPage) await loadPending()
|
||||
if (pageKey === 'backups') await loadBackups()
|
||||
if (pageKey === 'audit') await loadAudit()
|
||||
} catch (error) {
|
||||
availabilityError.value = error.message || '配置管理数据读取失败'
|
||||
} finally {
|
||||
@ -723,38 +692,6 @@ export function usePage(pageKey = 'base') {
|
||||
}
|
||||
}
|
||||
|
||||
const exportAudit = async format => {
|
||||
if (!canExportAudit.value || working.value) return
|
||||
working.value = format === 'csv' ? 'export-csv' : 'export'
|
||||
try {
|
||||
const response = await requestWithRecentAuthentication(() => http.post(
|
||||
'/api/v1/audits/config', { format, limit: 500 }, { responseType: 'blob' }
|
||||
))
|
||||
if (!(response instanceof Blob)) {
|
||||
throw new Error(response?.errorMsg || '审计导出响应格式不正确')
|
||||
}
|
||||
// Blob 请求也可能返回 JSON 业务错误,不能把错误响应当作审计文件下载。
|
||||
if (response.type.includes('json') || format === 'json') {
|
||||
const payload = JSON.parse(await response.text())
|
||||
if (payload && !Array.isArray(payload) && Object.hasOwn(payload, 'errorNo') && payload.errorNo !== 0) {
|
||||
throw new Error(payload.errorMsg || '审计导出失败')
|
||||
}
|
||||
}
|
||||
const blob = response
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = `smail-config-audit.${format}`
|
||||
anchor.click()
|
||||
URL.revokeObjectURL(url)
|
||||
ElMessage.success('审计记录已导出')
|
||||
} catch (error) {
|
||||
if (!isRecentAuthenticationCancelled(error)) ElMessage.error(error.message || '审计导出失败')
|
||||
} finally {
|
||||
working.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const beforeLeave = async () => {
|
||||
if (busy.value) {
|
||||
ElMessage.warning('当前操作尚未完成,请稍后再切换页面')
|
||||
@ -795,11 +732,11 @@ export function usePage(pageKey = 'base') {
|
||||
loading, availabilityError, working, status, stateLabel, stateTagType, sectionOptions: sections,
|
||||
selectedSection, currentSection, sectionLoading, editorText, formConfig, formErrors, updateField, updateSecret,
|
||||
sectionError, saveError, sensitiveHint, secretField, secretValue,
|
||||
confirmed, savedGeneration, pendingCandidate, managePending, validationResult, canWrite, canRollback, canAudit, canExportAudit,
|
||||
confirmed, savedGeneration, pendingCandidate, managePending, validationResult, canWrite, canRollback,
|
||||
backupsLoading, backupsError, backups, rollbackGeneration, rollbackConfirmed, rollbackPreview,
|
||||
auditLoading, auditError, auditItems, loadAll, loadSection, validateConfig, saveConfig, applyConfig, saveAndApply, continuePending,
|
||||
loadBackups, previewRollback, executeRollback, loadAudit, exportAudit, formatTime,
|
||||
serviceLabel, sectionLabel, operationLabel, databaseConfig, databaseTypeLabel, canMigrate,
|
||||
loadAll, loadSection, validateConfig, saveConfig, applyConfig, saveAndApply, continuePending,
|
||||
loadBackups, previewRollback, executeRollback, formatTime,
|
||||
serviceLabel, sectionLabel, databaseConfig, databaseTypeLabel, canMigrate,
|
||||
migrationTargetName, migrationPreview, migrationToken, migrationRecord, migrationStatusLabel, migrationStatusType,
|
||||
migrationTarget, migrationReady, migrationError, invalidateMigration, changeMigrationType,
|
||||
previewDatabaseMigration, loadMigrationStatus, executeDatabaseMigration, canRestart, restartPreview,
|
||||
|
||||
@ -3,7 +3,7 @@ import { http } from '@/utils/axios'
|
||||
import { parseRuntimeLine, summarizeRuntime, timeParts } from './RuntimeLog.js'
|
||||
|
||||
export function useLogs() {
|
||||
const category = ref('config')
|
||||
const category = ref('operation')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const items = ref([])
|
||||
@ -25,7 +25,6 @@ export function useLogs() {
|
||||
let appliedFilters = {}
|
||||
|
||||
const categories = [
|
||||
{ label: '配置审计', value: 'config' },
|
||||
{ label: '操作记录', value: 'operation' },
|
||||
{ label: '收发信记录', value: 'mail' },
|
||||
{ label: '运行日志', value: 'runtime' }
|
||||
@ -36,7 +35,6 @@ export function useLogs() {
|
||||
const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value)
|
||||
const displayActor = item => item.payload?.actorName || (item.actorId && item.actorId !== '0' ? item.actorId : '系统')
|
||||
const details = computed(() => detail.value?.payload || {})
|
||||
const changes = computed(() => details.value.changes || [])
|
||||
const runtimeRows = computed(() => lines.value.map((line, index) => parseRuntimeLine(line, offset.value + index + 1)))
|
||||
const runtimeSummary = computed(() => summarizeRuntime(runtimeRows.value))
|
||||
const visibleCount = computed(() => isRuntime.value ? lines.value.length : items.value.length)
|
||||
@ -125,7 +123,7 @@ export function useLogs() {
|
||||
|
||||
return {
|
||||
category, categories, loading, error, items, lines, offset,
|
||||
hasMore, actorId, dateRange, detail, details, changes, isRuntime,
|
||||
hasMore, actorId, dateRange, detail, details, isRuntime,
|
||||
runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange,
|
||||
currentCategory, resultType, runtimeRowClass, timeParts,
|
||||
formatTime, resultLabel, actionLabel, displayActor, search, recent, next, previous
|
||||
|
||||
@ -129,15 +129,7 @@
|
||||
<el-dialog :model-value="Boolean(detail)" title="日志详情" width="min(760px, 92vw)" append-to-body @close="detail = null">
|
||||
<template v-if="detail">
|
||||
<p>{{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }}</p>
|
||||
<el-table v-if="changes.length" :data="changes" max-height="440">
|
||||
<el-table-column prop="field" label="变更字段" min-width="200" />
|
||||
<el-table-column prop="before" label="修改前" min-width="150" />
|
||||
<el-table-column prop="after" label="修改后" min-width="150" />
|
||||
</el-table>
|
||||
<p v-else-if="category === 'config'">
|
||||
{{ detail.id.startsWith('legacy-') || !details.actorName ? '此记录未保存字段前后值,不补造历史详情。' : '此次保存没有业务字段变更。' }}
|
||||
</p>
|
||||
<dl v-else-if="category === 'mail'" class="log-detail-grid">
|
||||
<dl v-if="category === 'mail'" class="log-detail-grid">
|
||||
<dt>发件人</dt><dd>{{ details.sender || '—' }}</dd>
|
||||
<dt>收件人数</dt><dd>{{ details.recipientCount }}</dd>
|
||||
<dt>邮件大小</dt><dd>{{ details.bytes }} 字节</dd>
|
||||
@ -157,7 +149,7 @@ import { useLogs } from './LogSettings.js'
|
||||
|
||||
const {
|
||||
category, categories, loading, error, items, offset,
|
||||
hasMore, actorId, dateRange, detail, details, changes, isRuntime,
|
||||
hasMore, actorId, dateRange, detail, details, isRuntime,
|
||||
runtimeDetail, runtimeRows, runtimeSummary, pageRange,
|
||||
currentCategory, resultType, runtimeRowClass, timeParts,
|
||||
formatTime, resultLabel, actionLabel, displayActor, search, recent, next, previous
|
||||
|
||||
110
test/backend/config_runtime_log_test.go
Normal file
110
test/backend/config_runtime_log_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pcux.cn/xuans/Smail/services/runtimelog"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func TestDefaultConfigManagerUsesRuntimeLogAndPreservesLegacyFile(t *testing.T) {
|
||||
for _, withSecurity := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "默认构造", true: "默认安全配置"}[withSecurity], func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
writeRuntimeFixture(t, root, validRuntimeConfig())
|
||||
legacy, err := NewFileAuditWriter(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
history := []byte("保留旧审计原文,不解析、不导入、不追加\n")
|
||||
if err := os.WriteFile(legacy.path, history, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
logger := log.StandardLogger()
|
||||
oldHooks, oldOutput, oldFormatter, oldLevel, oldCaller := logger.Hooks, logger.Out, logger.Formatter, logger.GetLevel(), logger.ReportCaller
|
||||
var output bytes.Buffer
|
||||
logger.ReplaceHooks(make(log.LevelHooks))
|
||||
logger.SetOutput(&output)
|
||||
logger.SetFormatter(&logFormatter{})
|
||||
logger.SetLevel(log.InfoLevel)
|
||||
logger.SetReportCaller(true)
|
||||
t.Cleanup(func() {
|
||||
logger.ReplaceHooks(oldHooks)
|
||||
logger.SetOutput(oldOutput)
|
||||
logger.SetFormatter(oldFormatter)
|
||||
logger.SetLevel(oldLevel)
|
||||
logger.SetReportCaller(oldCaller)
|
||||
})
|
||||
writer, err := runtimelog.Start(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = writer.Close() })
|
||||
options := ConfigLoaderOptions{Validation: RuntimeValidationOptions{HasSuperAdmin: true, Production: true}}
|
||||
var manager *ConfigManager
|
||||
if withSecurity {
|
||||
manager, err = NewConfigManagerWithSecurity(root, options, ConfigSecurity{})
|
||||
} else {
|
||||
manager, err = NewConfigManager(root, options)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := manager.security.Audit.(RuntimeConfigLogWriter); !ok {
|
||||
t.Fatal("默认写入器仍使用独立配置审计")
|
||||
}
|
||||
payload, err := json.Marshal(manager.Snapshot().Email)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err := manager.Update(context.Background(), UpdateRequest{Section: SectionEmail, ExpectedGeneration: 1, Payload: payload, Actor: authorizedConfigActor()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := manager.Apply(context.Background(), updated.Generation); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if manager.Status().SetupState != SetupStateReady {
|
||||
t.Fatal("取消审计影响了配置保存")
|
||||
}
|
||||
if _, err := manager.AuditQuery(context.Background(), ConfigAuditQuery{}); err != ErrAuditQueryUnavailable {
|
||||
t.Fatal("默认管理器仍提供审计查询")
|
||||
}
|
||||
event := ConfigAuditEvent{Operation: ConfigOperationRollback, ActorID: "1", ActorRole: "super_admin", ActorName: "不应记录的名称", FromGeneration: 2, ToGeneration: 3, Result: "failure", CreatedAt: time.Now(), Changes: []ConfigAuditChange{{Field: "root.database.password", Before: "不应记录的旧值", After: "不应记录的新值"}}}
|
||||
if err := (RuntimeConfigLogWriter{}).WriteConfigAudit(context.Background(), event); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page, err := writer.Query(context.Background(), runtimelog.Query{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := strings.Join(page.Lines, "\n")
|
||||
if len(page.Lines) != 2 || !strings.Contains(text, "保存配置成功") || !strings.Contains(text, "回滚配置失败") || !strings.Contains(text, "[warning]") {
|
||||
t.Fatalf("运行日志缺少配置结果:%s", text)
|
||||
}
|
||||
if strings.Contains(text, "不应记录") || strings.Contains(text, "password") || strings.Contains(text, "\"changes\"") {
|
||||
t.Fatal("运行日志泄露配置详情")
|
||||
}
|
||||
unchanged, err := os.ReadFile(legacy.path)
|
||||
if err != nil || !bytes.Equal(unchanged, history) {
|
||||
t.Fatal("旧审计文件被修改")
|
||||
}
|
||||
files, err := os.ReadDir(filepath.Join(root, "log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, file := range files {
|
||||
if strings.Contains(file.Name(), "audit") {
|
||||
t.Fatal("生成了独立审计文件")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -39,7 +40,7 @@ func testStore(t *testing.T) *Store {
|
||||
func TestLogStoreEmptyEntriesAreNotInserted(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
for _, kind := range []string{"config", "operation", "mail"} {
|
||||
for _, kind := range []string{"operation", "mail"} {
|
||||
for _, payload := range []string{`{}`, `null`, `[]`, `" "`, `{"message":"***"}`} {
|
||||
if err := s.Write(ctx, Entry{Kind: kind, CreatedAt: time.Now(), Action: "---", Payload: []byte(payload)}); err != nil {
|
||||
t.Fatal(err)
|
||||
@ -129,7 +130,7 @@ func TestLogRetentionBoundariesAndBusinessSafety(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogDialectStatementsAndAuditReplay(t *testing.T) {
|
||||
func TestLogDialectStatementsAndRetiredAuditWriter(t *testing.T) {
|
||||
s := testStore(t)
|
||||
for _, dialect := range []migration.Dialect{migration.DialectSQLite, migration.DialectMySQL, migration.DialectPostgres} {
|
||||
s.dialect = dialect
|
||||
@ -158,11 +159,38 @@ func TestLogDialectStatementsAndAuditReplay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
items, err := audit.Query(context.Background(), config.ConfigAuditQuery{})
|
||||
if err != nil || len(items) != 1 || items[0].Changes[0].After != "debug" {
|
||||
t.Fatalf("audit: %+v, %v", items, err)
|
||||
if err != nil || len(items) != 0 {
|
||||
t.Fatalf("已停用的配置审计仍写入数据库:%+v, %v", items, err)
|
||||
}
|
||||
data, err := audit.ExportCSV(context.Background(), config.ConfigAuditQuery{})
|
||||
if err != nil || !strings.Contains(string(data), "tester") || !strings.Contains(string(data), "debug") {
|
||||
t.Fatalf("csv: %s, %v", data, err)
|
||||
if err := s.Write(context.Background(), Entry{Kind: "config", CreatedAt: time.Now(), Payload: []byte(`{}`)}); err != ErrInvalid {
|
||||
t.Fatal("数据库写入入口必须拒绝配置审计")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetiredAuditImportPreservesLegacyFiles(t *testing.T) {
|
||||
s := testStore(t)
|
||||
root := t.TempDir()
|
||||
directory := filepath.Join(root, "log")
|
||||
if err := os.MkdirAll(directory, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(directory, "config-audit-v2.jsonl")
|
||||
history := []byte("旧审计内容不再解析或导入\n")
|
||||
if err := os.WriteFile(path, history, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
audit := &AuditWriter{Store: s}
|
||||
for index := 0; index < 2; index++ {
|
||||
if err := audit.ImportLegacy(context.Background(), root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || string(data) != string(history) {
|
||||
t.Fatal("旧审计文件被修改")
|
||||
}
|
||||
var count int
|
||||
if err := s.db.QueryRow("SELECT COUNT(*) FROM operation_log").Scan(&count); err != nil || count != 0 {
|
||||
t.Fatal("已停用的旧审计导入仍写入数据库")
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,7 +69,7 @@ func TestLogsAPIRejectsUnscopedAccessAndValidatesQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, query := range []string{"kind=unknown", "kind=mail&limit=501", "kind=mail&offset=-1", "kind=mail&from=invalid", "kind=mail&from=2026-09-08T00:00:00Z&to=2026-09-01T00:00:00Z"} {
|
||||
for _, query := range []string{"kind=config", "kind=unknown", "kind=mail&limit=501", "kind=mail&offset=-1", "kind=mail&from=invalid", "kind=mail&from=2026-09-08T00:00:00Z&to=2026-09-01T00:00:00Z"} {
|
||||
if got := call(access.RoleSuperAdmin, query); got.ErrorNo != response.ParamsError {
|
||||
t.Fatalf("invalid query accepted: %s => %d", query, got.ErrorNo)
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@ -16,6 +17,23 @@ import (
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestRetiredConfigAuditRoutesReturnGone(t *testing.T) {
|
||||
for _, path := range []string{"/api/v1/audits/config", "/api/v1/audits/config/export", "/api/v1/audits/config/audit-00000000000000000000000000000000"} {
|
||||
for _, method := range []string{http.MethodGet, http.MethodPost} {
|
||||
request := httptest.NewRequest(method, path, nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
if path == "/api/v1/audits/config" {
|
||||
configAuditRoute(&appcontext.Context{}, recorder, request)
|
||||
} else {
|
||||
configAuditSubRoute(&appcontext.Context{}, recorder, request)
|
||||
}
|
||||
if recorder.Code != http.StatusGone || recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("Content-Disposition") != "" {
|
||||
t.Fatalf("已停用入口仍可查询或导出:%s %s", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperationLogUsesCompletedIdentityWithoutSensitivePayload(t *testing.T) {
|
||||
database, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "operations.db"))
|
||||
if err != nil {
|
||||
|
||||
@ -31,8 +31,11 @@ test('配置管理只保留服务配置与维护入口,不再包含插件菜
|
||||
await page.selectSection('invalid')
|
||||
assert.equal(page.selectedSection.value, 'base')
|
||||
allowed.add('audit.read')
|
||||
assert.deepEqual(page.maintenanceOptions.value.map(item => item.value), ['audit'])
|
||||
assert.deepEqual(page.maintenanceOptions.value.map(item => item.value), [])
|
||||
await page.selectSection('audit')
|
||||
assert.equal(page.selectedSection.value, 'base')
|
||||
allowed.add('config.rollback')
|
||||
await page.selectSection('backups')
|
||||
assert.equal(page.activePage.value.component.name, 'ConfigMaintenance')
|
||||
allowed.delete('config.read')
|
||||
assert.equal(page.activePage.value, undefined)
|
||||
|
||||
@ -60,61 +60,46 @@ function deferred() {
|
||||
}
|
||||
|
||||
for (const cancelled of [false, true]) {
|
||||
test(`审计导出身份过期后确认密码再重试,取消不误报失败:取消=${cancelled}`, async context => {
|
||||
const originalDocument = globalThis.document
|
||||
let downloads = 0
|
||||
globalThis.document = { createElement: () => ({ click: () => { downloads++ } }) }
|
||||
context.after(() => { globalThis.document = originalDocument })
|
||||
test(`共享身份确认保留单次重试,移除审计不影响其他操作:取消=${cancelled}`, async () => {
|
||||
const requests = []
|
||||
let prompts = 0
|
||||
const { page, messages, successes } = fixture(() => {}, 'audit', {
|
||||
const helpers = compileRecentAuthentication({
|
||||
prompt: async () => {
|
||||
prompts++
|
||||
if (cancelled) throw 'cancel'
|
||||
return { value: 'test-password' }
|
||||
},
|
||||
http: {
|
||||
post: async (url, body) => {
|
||||
requests.push(url)
|
||||
if (url.endsWith('/reauth')) {
|
||||
assert.equal(body.password, 'test-password')
|
||||
return { errorNo: 0 }
|
||||
}
|
||||
if (requests.length === 1) {
|
||||
throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
|
||||
}
|
||||
return new Blob(['action\nupdate'], { type: 'text/csv' })
|
||||
}
|
||||
}
|
||||
}, {
|
||||
post: async (url, body) => {
|
||||
requests.push(url)
|
||||
assert.equal(body.password, 'test-password')
|
||||
return { errorNo: 0 }
|
||||
}
|
||||
})
|
||||
await page.exportAudit('csv')
|
||||
let attempts = 0
|
||||
const action = helpers.requestWithRecentAuthentication(async () => {
|
||||
if (++attempts === 1) throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
|
||||
return { errorNo: 0 }
|
||||
})
|
||||
if (cancelled) await assert.rejects(action, error => helpers.isRecentAuthenticationCancelled(error))
|
||||
else assert.equal((await action).errorNo, 0)
|
||||
assert.equal(prompts, 1)
|
||||
assert.equal(downloads, cancelled ? 0 : 1)
|
||||
assert.deepEqual(messages, [])
|
||||
assert.equal(successes.length, cancelled ? 0 : 1)
|
||||
assert.equal(page.working.value, '')
|
||||
assert.deepEqual(requests, cancelled
|
||||
? ['/api/v1/audits/config']
|
||||
: ['/api/v1/audits/config', '/api/v1/security/reauth', '/api/v1/audits/config'])
|
||||
assert.equal(attempts, cancelled ? 1 : 2)
|
||||
assert.deepEqual(requests, cancelled ? [] : ['/api/v1/security/reauth'])
|
||||
})
|
||||
}
|
||||
|
||||
test('审计导出确认身份后仍被拒绝时只重试一次,不能下载错误文件', async () => {
|
||||
let exports = 0
|
||||
const { page, messages } = fixture(() => {}, 'audit', {
|
||||
prompt: async () => ({ value: 'test-password' }),
|
||||
http: {
|
||||
post: async url => {
|
||||
if (url.endsWith('/reauth')) return { errorNo: 0 }
|
||||
exports++
|
||||
throw Object.assign(new Error('需要最近认证'), { apiErrorCode: 'RECENT_AUTH_REQUIRED' })
|
||||
}
|
||||
}
|
||||
})
|
||||
await page.exportAudit('csv')
|
||||
assert.equal(exports, 2)
|
||||
assert.deepEqual(messages, ['需要最近认证'])
|
||||
assert.equal(page.working.value, '')
|
||||
test('移除配置审计查询、导出和维护面板,不保留隐藏请求', async () => {
|
||||
const requests = []
|
||||
const { page } = fixture(async path => {
|
||||
requests.push(path)
|
||||
return { errorNo: 0, data: { available: true } }
|
||||
}, 'audit')
|
||||
await page.loadAll()
|
||||
assert.deepEqual(requests, ['/api/v1/config/status'])
|
||||
assert.equal(page.loadAudit, undefined)
|
||||
assert.equal(page.exportAudit, undefined)
|
||||
assert.doesNotMatch(source, /audits\/config|auditItems|canExportAudit/)
|
||||
})
|
||||
|
||||
test('证书页从状态接口读取生效反代模式,不额外加载基础配置', async () => {
|
||||
@ -210,12 +195,9 @@ test('配置页不加载备份或审计,维护页失败只显示本页错误',
|
||||
assert.equal(page.editorText.value, '{}')
|
||||
assert.deepEqual(requests, ['/api/v1/config/status', '/api/v1/config/base'])
|
||||
const backup = fixture(get, 'backups').page
|
||||
const audit = fixture(get, 'audit').page
|
||||
await Promise.all([backup.loadAll(), audit.loadAll()])
|
||||
await backup.loadAll()
|
||||
assert.equal(backup.backupsError.value, '配置服务暂时不可用')
|
||||
assert.equal(audit.auditError.value, '配置服务暂时不可用')
|
||||
assert.equal(backup.editorText.value, '')
|
||||
assert.equal(audit.editorText.value, '')
|
||||
assert.deepEqual(messages, [])
|
||||
await page.loadBackups()
|
||||
assert.deepEqual(messages, [])
|
||||
@ -229,24 +211,19 @@ test('附属模块失败清除旧数据和回滚选择,重试成功清除错
|
||||
return { errorNo: 0, data: [{ generation: 2 }] }
|
||||
})
|
||||
page.backups.value = [{ generation: 1 }]
|
||||
page.auditItems.value = [{ id: 'old' }]
|
||||
page.rollbackGeneration.value = 1
|
||||
page.rollbackPreview.value = { targetGeneration: 1 }
|
||||
page.rollbackConfirmed.value = true
|
||||
await Promise.all([page.loadBackups(), page.loadAudit()])
|
||||
await page.loadBackups()
|
||||
assert.deepEqual(page.backups.value, [])
|
||||
assert.deepEqual(page.auditItems.value, [])
|
||||
assert.equal(page.rollbackGeneration.value, 0)
|
||||
assert.equal(page.rollbackPreview.value, null)
|
||||
assert.equal(page.rollbackConfirmed.value, false)
|
||||
fail = false
|
||||
await Promise.all([page.loadBackups(), page.loadAudit()])
|
||||
await page.loadBackups()
|
||||
assert.equal(page.backupsError.value, '')
|
||||
assert.equal(page.auditError.value, '')
|
||||
assert.equal(page.backups.value.length, 1)
|
||||
assert.equal(page.auditItems.value.length, 1)
|
||||
assert.equal(page.backupsLoading.value, false)
|
||||
assert.equal(page.auditLoading.value, false)
|
||||
})
|
||||
|
||||
test('状态请求失败后可重试恢复,不保留不可用提示', async () => {
|
||||
@ -478,10 +455,10 @@ test('独立页模板可编译,编辑器不再连接维护面板且权限绑
|
||||
assert.match(template, /v-if="canWrite" class="editor-footer"/)
|
||||
assert.doesNotMatch(template, /v-model="confirmed"/)
|
||||
assert.doesNotMatch(template, /maintenance-panel|migration-panel|rollback-panel|audit-panel/)
|
||||
for (const [key, capability] of [['migration', 'canMigrate'], ['restart', 'canRestart'], ['backups', 'canRollback'], ['audit', 'canAudit']]) {
|
||||
for (const [key, capability] of [['migration', 'canMigrate'], ['restart', 'canRestart'], ['backups', 'canRollback']]) {
|
||||
assert.ok(maintenance.includes(`pageKey === '${key}' && ${capability}`))
|
||||
}
|
||||
assert.match(maintenance, /v-if="canExportAudit"/)
|
||||
assert.doesNotMatch(maintenance, /audit-panel|canExportAudit|loadAudit/)
|
||||
for (const name of ['BaseConfig', 'EmailConfig', 'SslConfig', 'PluginsConfig', 'ConfigMaintenance']) {
|
||||
const page = readFileSync(new URL(`${name}/${name}.vue`, root), 'utf8')
|
||||
const { descriptor, errors } = parse(page)
|
||||
|
||||
@ -25,6 +25,13 @@ test('日志页面使用共享 HTTP 客户端的命名导出', () => {
|
||||
assert.match(read('LogSettings.js'), /import\s*\{\s*http\s*\}\s*from\s*['"]@\/utils\/axios['"]/)
|
||||
})
|
||||
|
||||
test('日志分类移除配置审计,保留操作、收发信与运行日志', () => {
|
||||
const { page } = fixture(async () => ({ errorNo: 0, data: {} }))
|
||||
assert.deepEqual(page.categories.map(item => item.value), ['operation', 'mail', 'runtime'])
|
||||
assert.equal(page.category.value, 'operation')
|
||||
assert.doesNotMatch(read('LogSettings.vue'), /category === 'config'|changes\.length/)
|
||||
})
|
||||
|
||||
test('日志页面模板和样式能编译,详情不使用原始 HTML 渲染', () => {
|
||||
const require = createRequire(new URL('../../frontend/package.json', import.meta.url))
|
||||
const { parse, compileScript, compileStyle } = require('@vue/compiler-sfc')
|
||||
|
||||
@ -61,6 +61,7 @@ if ((suite === 'all' || suite === 'backend') && result === 0) {
|
||||
'backend/config/audit_changes_test.go': 'test/backend/audit_changes_test.go',
|
||||
'backend/config/json_format_test.go': 'test/backend/json_format_test.go',
|
||||
'backend/config/log_formatter_test.go': 'test/backend/log_formatter_test.go',
|
||||
'backend/config/config_runtime_log_test.go': 'test/backend/config_runtime_log_test.go',
|
||||
'backend/config/deployment_state_test.go': 'test/backend/deployment_state_test.go',
|
||||
'backend/config/ssl_retention_test.go': 'test/backend/ssl_retention_test.go',
|
||||
'backend/utils/runtimepath/layout_test.go': 'test/backend/runtime_layout_test.go',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user