From ffc4f2c2d979cf27bb358d391065efaf228ac3c1 Mon Sep 17 00:00:00 2001 From: Syngnat Date: Tue, 28 Apr 2026 09:42:21 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix(jvm):=20=E5=BC=BA=E5=8C=96?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E7=A1=AE=E8=AE=A4=E4=BB=A4=E7=89=8C=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 JVM 变更确认从可重算校验值升级为服务端发放的一次性令牌,避免未预览、重放或上下文变更后继续执行高风险变更。 --- frontend/wailsjs/go/models.ts | 2 + internal/app/app.go | 37 +- internal/app/methods_jvm.go | 221 +++++- internal/app/methods_jvm_test.go | 1141 +++++++++++++++++++++++++++++- internal/jvm/guard.go | 91 ++- internal/jvm/guard_test.go | 289 ++++++++ internal/jvm/types.go | 16 +- 7 files changed, 1742 insertions(+), 55 deletions(-) diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index bebcbf9..fff3fea 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -948,6 +948,7 @@ export namespace jvm { reason: string; source?: string; expectedVersion?: string; + confirmationToken?: string; payload?: Record; static createFrom(source: any = {}) { @@ -962,6 +963,7 @@ export namespace jvm { this.reason = source["reason"]; this.source = source["source"]; this.expectedVersion = source["expectedVersion"]; + this.confirmationToken = source["confirmationToken"]; this.payload = source["payload"]; } } diff --git a/internal/app/app.go b/internal/app/app.go index a5cf390..7404d05 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -55,17 +55,20 @@ type queryContext struct { // App struct type App struct { - ctx context.Context - startedAt time.Time - dbCache map[string]cachedDatabase // Cache for DB connections - connectFailures map[string]cachedConnectFailure - mu sync.RWMutex // Mutex for cache access - updateMu sync.Mutex - updateState updateState - queryMu sync.RWMutex - configDir string - secretStore secretstore.SecretStore - runningQueries map[string]queryContext // queryID -> cancelFunc and start time + ctx context.Context + startedAt time.Time + dbCache map[string]cachedDatabase // Cache for DB connections + connectFailures map[string]cachedConnectFailure + mu sync.RWMutex // Mutex for cache access + updateMu sync.Mutex + updateState updateState + queryMu sync.RWMutex + configDir string + secretStore secretstore.SecretStore + runningQueries map[string]queryContext // queryID -> cancelFunc and start time + jvmPreviewTokenMu sync.Mutex + jvmPreviewTokens map[string]jvmPreviewConfirmationToken + jvmPreviewTokenTTL time.Duration } // NewApp creates a new App application struct @@ -78,11 +81,13 @@ func NewAppWithSecretStore(store secretstore.SecretStore) *App { store = secretstore.NewUnavailableStore("secret store unavailable") } return &App{ - dbCache: make(map[string]cachedDatabase), - connectFailures: make(map[string]cachedConnectFailure), - runningQueries: make(map[string]queryContext), - configDir: resolveAppConfigDir(), - secretStore: store, + dbCache: make(map[string]cachedDatabase), + connectFailures: make(map[string]cachedConnectFailure), + runningQueries: make(map[string]queryContext), + configDir: resolveAppConfigDir(), + secretStore: store, + jvmPreviewTokens: make(map[string]jvmPreviewConfirmationToken), + jvmPreviewTokenTTL: defaultJVMPreviewConfirmationTokenTTL, } } diff --git a/internal/app/methods_jvm.go b/internal/app/methods_jvm.go index cef2196..7e0c94f 100644 --- a/internal/app/methods_jvm.go +++ b/internal/app/methods_jvm.go @@ -1,15 +1,44 @@ package app import ( + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "fmt" "path/filepath" "strings" + "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/jvm" + "github.com/google/uuid" ) var newJVMProvider = jvm.NewProvider +const defaultJVMPreviewConfirmationTokenTTL = 10 * time.Minute + +type jvmPreviewConfirmationToken struct { + contextHash string + expiresAt time.Time +} + +type jvmPreviewConfirmationContext struct { + ConfigHash string `json:"configHash"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Source string `json:"source"` + ExpectedVersion string `json:"expectedVersion"` + PayloadHash string `json:"payloadHash"` + PreviewChecksum string `json:"previewChecksum"` + RiskLevel string `json:"riskLevel"` + BeforeVersion string `json:"beforeVersion"` + AfterVersion string `json:"afterVersion"` +} + func buildJVMCapabilityError(mode string, cfg connection.ConnectionConfig, err error) jvm.Capability { probeCfg := cfg probeCfg.JVM.PreferredMode = mode @@ -40,6 +69,119 @@ func resolveJVMProviderForMode(cfg connection.ConnectionConfig, mode string) (co return normalized, provider, nil } +func (a *App) issueJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) { + contextHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview) + if err != nil { + return "", err + } + + token := uuid.NewString() + now := time.Now() + ttl := a.jvmPreviewTokenTTL + if ttl <= 0 { + ttl = defaultJVMPreviewConfirmationTokenTTL + } + + a.jvmPreviewTokenMu.Lock() + defer a.jvmPreviewTokenMu.Unlock() + if a.jvmPreviewTokens == nil { + a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken) + } + a.pruneExpiredJVMPreviewConfirmationTokensLocked(now) + a.jvmPreviewTokens[token] = jvmPreviewConfirmationToken{ + contextHash: contextHash, + expiresAt: now.Add(ttl), + } + return token, nil +} + +func (a *App) consumeJVMPreviewConfirmationToken(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) error { + if !preview.RequiresConfirmation { + return nil + } + + if strings.TrimSpace(preview.ConfirmationToken) == "" { + return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交") + } + + token := strings.TrimSpace(req.ConfirmationToken) + if token == "" { + return fmt.Errorf("缺少确认令牌,请先完成预览确认") + } + + expectedHash, err := buildJVMPreviewConfirmationContextHash(cfg, req, preview) + if err != nil { + return err + } + + now := time.Now() + a.jvmPreviewTokenMu.Lock() + if a.jvmPreviewTokens == nil { + a.jvmPreviewTokens = make(map[string]jvmPreviewConfirmationToken) + } + a.pruneExpiredJVMPreviewConfirmationTokensLocked(now) + entry, ok := a.jvmPreviewTokens[token] + if ok { + delete(a.jvmPreviewTokens, token) + } + a.jvmPreviewTokenMu.Unlock() + + if !ok { + return fmt.Errorf("确认令牌已失效,请重新预览并确认") + } + if !entry.expiresAt.After(now) { + return fmt.Errorf("确认令牌已过期,请重新预览并确认") + } + if subtle.ConstantTimeCompare([]byte(entry.contextHash), []byte(expectedHash)) != 1 { + return fmt.Errorf("确认令牌不匹配,请重新预览并确认") + } + return nil +} + +func (a *App) pruneExpiredJVMPreviewConfirmationTokensLocked(now time.Time) { + for token, entry := range a.jvmPreviewTokens { + if !entry.expiresAt.After(now) { + delete(a.jvmPreviewTokens, token) + } + } +} + +func buildJVMPreviewConfirmationContextHash(cfg connection.ConnectionConfig, req jvm.ChangeRequest, preview jvm.ChangePreview) (string, error) { + configHash, err := hashJSONValue(cfg) + if err != nil { + return "", fmt.Errorf("生成 JVM 预览上下文失败: %w", err) + } + payloadHash, err := hashJSONValue(req.Payload) + if err != nil { + return "", fmt.Errorf("生成 JVM 预览载荷摘要失败: %w", err) + } + + input := jvmPreviewConfirmationContext{ + ConfigHash: configHash, + ProviderMode: strings.TrimSpace(cfg.JVM.PreferredMode), + ResourceID: strings.TrimSpace(req.ResourceID), + Action: strings.TrimSpace(req.Action), + Reason: strings.TrimSpace(req.Reason), + Source: strings.TrimSpace(req.Source), + ExpectedVersion: strings.TrimSpace(req.ExpectedVersion), + PayloadHash: payloadHash, + PreviewChecksum: strings.TrimSpace(preview.ConfirmationToken), + RiskLevel: strings.TrimSpace(preview.RiskLevel), + BeforeVersion: strings.TrimSpace(preview.Before.Version), + AfterVersion: strings.TrimSpace(preview.After.Version), + } + return hashJSONValue(input) +} + +func hashJSONValue(value any) (string, error) { + encoded, err := json.Marshal(value) + if err != nil { + return "", err + } + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} + func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult { normalized, provider, err := resolveJVMProvider(cfg) if err != nil { @@ -97,6 +239,13 @@ func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRe if err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } + if preview.Allowed && preview.RequiresConfirmation { + token, err := a.issueJVMPreviewConfirmationToken(normalized, req, preview) + if err != nil { + return connection.QueryResult{Success: false, Message: err.Error()} + } + preview.ConfirmationToken = token + } return connection.QueryResult{Success: true, Data: preview} } @@ -124,26 +273,66 @@ func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequ } return connection.QueryResult{Success: false, Message: message} } - - result, err := provider.ApplyChange(a.ctx, normalized, req) - if err != nil { + if err := a.consumeJVMPreviewConfirmationToken(normalized, req, preview); err != nil { return connection.QueryResult{Success: false, Message: err.Error()} } - if err := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")).Append(jvm.AuditRecord{ - ConnectionID: normalized.ID, - ProviderMode: normalized.JVM.PreferredMode, - ResourceID: req.ResourceID, - Action: req.Action, - Reason: req.Reason, - Source: req.Source, - Result: result.Status, - }); err != nil { - if strings.TrimSpace(result.Message) == "" { - result.Message = "变更已执行,但审计记录写入失败: " + err.Error() - } else { - result.Message += ";审计记录写入失败: " + err.Error() + auditStore := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")) + appendAuditRecord := func(record jvm.AuditRecord) error { + return auditStore.Append(record) + } + appendAudit := func(result string, timestamp int64) error { + return appendAuditRecord(jvm.AuditRecord{ + Timestamp: timestamp, + ConnectionID: normalized.ID, + ProviderMode: normalized.JVM.PreferredMode, + ResourceID: req.ResourceID, + Action: req.Action, + Reason: req.Reason, + Source: req.Source, + Result: result, + }) + } + appendWarning := func(message string, warning string) string { + message = strings.TrimSpace(message) + warning = strings.TrimSpace(warning) + if warning == "" { + return message } + if message == "" { + return warning + } + return message + ";" + warning + } + + pendingTimestamp := time.Now().UnixMilli() + terminalAuditTimestamp := func() int64 { + ts := time.Now().UnixMilli() + if ts <= pendingTimestamp { + return pendingTimestamp + 1 + } + return ts + } + + if err := appendAudit("pending", pendingTimestamp); err != nil { + return connection.QueryResult{Success: false, Message: "审计记录写入失败,已阻止 JVM 变更: " + err.Error()} + } + + result, err := provider.ApplyChange(a.ctx, normalized, req) + if err != nil { + if auditErr := appendAudit("failed", terminalAuditTimestamp()); auditErr != nil { + return connection.QueryResult{Success: false, Message: err.Error() + ";失败审计写入失败: " + auditErr.Error()} + } + return connection.QueryResult{Success: false, Message: err.Error()} + } + + terminalResult := strings.TrimSpace(result.Status) + if terminalResult == "" { + terminalResult = "applied" + } + if err := appendAudit(terminalResult, terminalAuditTimestamp()); err != nil { + result.Message = appendWarning(result.Message, "终态审计写入失败: "+err.Error()) + return connection.QueryResult{Success: true, Message: result.Message, Data: result} } return connection.QueryResult{Success: true, Data: result} diff --git a/internal/app/methods_jvm_test.go b/internal/app/methods_jvm_test.go index 5bd21a6..b1c58d5 100644 --- a/internal/app/methods_jvm_test.go +++ b/internal/app/methods_jvm_test.go @@ -3,10 +3,12 @@ package app import ( "context" "errors" + "fmt" "os" "path/filepath" "strings" "testing" + "time" "GoNavi-Wails/internal/connection" "GoNavi-Wails/internal/jvm" @@ -25,6 +27,7 @@ type fakeJVMProvider struct { previewErr error apply jvm.ApplyResult applyErr error + applyFn func(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) previewReq *jvm.ChangeRequest applyReq *jvm.ChangeRequest } @@ -51,10 +54,13 @@ func (f fakeJVMProvider) PreviewChange(_ context.Context, _ connection.Connectio } return f.preview, f.previewErr } -func (f fakeJVMProvider) ApplyChange(_ context.Context, _ connection.ConnectionConfig, req jvm.ChangeRequest) (jvm.ApplyResult, error) { +func (f fakeJVMProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req jvm.ChangeRequest) (jvm.ApplyResult, error) { if f.applyReq != nil { *f.applyReq = req } + if f.applyFn != nil { + return f.applyFn(ctx, cfg, req) + } return f.apply, f.applyErr } @@ -64,6 +70,21 @@ func swapJVMProviderFactory(factory func(mode string) (jvm.Provider, error)) fun return func() { newJVMProvider = prev } } +func forceAuditAppendFailureAfterPending(t *testing.T, auditDir string) { + t.Helper() + + auditPath := filepath.Join(auditDir, "jvm_audit.jsonl") + if err := os.Remove(auditPath); err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("Remove audit file returned error: %v", err) + } + if err := os.RemoveAll(auditDir); err != nil { + t.Fatalf("RemoveAll audit dir returned error: %v", err) + } + if err := os.WriteFile(auditDir, []byte("blocker"), 0o600); err != nil { + t.Fatalf("WriteFile audit dir blocker returned error: %v", err) + } +} + func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) { app := NewAppWithSecretStore(nil) var gotMode string @@ -457,6 +478,65 @@ func TestJVMGetValueReturnsProviderPayload(t *testing.T) { } } +func TestJVMApplyChangeRequiresConfirmationTokenForHighRiskPreview(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + var applyReq jvm.ChangeRequest + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + Summary: "risky change", + RiskLevel: "high", + }, + applyReq: &applyReq, + apply: jvm.ApplyResult{ + Status: "applied", + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if res.Success { + t.Fatalf("expected missing confirmation token to fail, got %+v", res) + } + if !strings.Contains(res.Message, "确认") && !strings.Contains(res.Message, "重新预览") { + t.Fatalf("expected confirmation guidance message, got %q", res.Message) + } + if applyReq.ResourceID != "" { + t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq) + } +} + func TestJVMApplyChangeReturnsProviderPayload(t *testing.T) { app := NewAppWithSecretStore(nil) app.configDir = t.TempDir() @@ -521,6 +601,324 @@ func TestJVMApplyChangeReturnsProviderPayload(t *testing.T) { } } +func TestJVMApplyChangePreviewTokenAllowsConfirmedApply(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + Summary: "risky change", + RiskLevel: "high", + }, + apply: jvm.ApplyResult{ + Status: "applied", + UpdatedValue: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "ready", + }, + }, + }, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + Environment: jvm.EnvPROD, + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + } + + req := jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + } + + previewRes := app.JVMPreviewChange(cfg, req) + if !previewRes.Success { + t.Fatalf("expected preview success, got %+v", previewRes) + } + preview, ok := previewRes.Data.(jvm.ChangePreview) + if !ok { + t.Fatalf("expected preview payload, got %#v", previewRes.Data) + } + if strings.TrimSpace(preview.ConfirmationToken) == "" { + t.Fatalf("expected confirmation token, got %#v", preview) + } + + req.ConfirmationToken = preview.ConfirmationToken + applyRes := app.JVMApplyChange(cfg, req) + if !applyRes.Success { + t.Fatalf("expected apply success with confirmation token, got %+v", applyRes) + } + + listRes := app.JVMListAuditRecords("conn-orders", 10) + if !listRes.Success { + t.Fatalf("expected audit list success, got %+v", listRes) + } + records, ok := listRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected audit record slice, got %#v", listRes.Data) + } + var hasPending, hasApplied bool + for _, record := range records { + switch record.Result { + case "pending": + hasPending = true + case "applied": + hasApplied = true + } + } + if !hasPending || !hasApplied { + t.Fatalf("expected pending+applied records, got %#v", records) + } +} + +func TestJVMApplyChangeRejectsUnissuedDeterministicConfirmationToken(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + var applyReq jvm.ChangeRequest + + provider := fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + Summary: "risky change", + RiskLevel: "high", + }, + applyReq: &applyReq, + apply: jvm.ApplyResult{ + Status: "applied", + }, + } + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return provider, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + Environment: jvm.EnvPROD, + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + } + req := jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + } + + preview, err := jvm.BuildChangePreview(context.Background(), provider, cfg, req) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if strings.TrimSpace(preview.ConfirmationToken) == "" { + t.Fatalf("expected deterministic preview token, got %#v", preview) + } + + req.ConfirmationToken = preview.ConfirmationToken + res := app.JVMApplyChange(cfg, req) + if res.Success { + t.Fatalf("expected unissued confirmation token to fail, got %+v", res) + } + if applyReq.ResourceID != "" { + t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq) + } +} + +func TestJVMApplyChangeRejectsReplayedPreviewConfirmationToken(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + applyCalls := 0 + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + Summary: "risky change", + RiskLevel: "high", + }, + applyFn: func(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) { + applyCalls++ + return jvm.ApplyResult{Status: "applied"}, nil + }, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + Environment: jvm.EnvPROD, + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + } + req := jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + } + + previewRes := app.JVMPreviewChange(cfg, req) + if !previewRes.Success { + t.Fatalf("expected preview success, got %+v", previewRes) + } + preview, ok := previewRes.Data.(jvm.ChangePreview) + if !ok { + t.Fatalf("expected preview payload, got %#v", previewRes.Data) + } + req.ConfirmationToken = preview.ConfirmationToken + + firstRes := app.JVMApplyChange(cfg, req) + if !firstRes.Success { + t.Fatalf("expected first apply success, got %+v", firstRes) + } + secondRes := app.JVMApplyChange(cfg, req) + if secondRes.Success { + t.Fatalf("expected replayed confirmation token to fail, got %+v", secondRes) + } + if applyCalls != 1 { + t.Fatalf("expected exactly one provider ApplyChange call, got %d", applyCalls) + } +} + +func TestJVMApplyChangeRejectsExpiredPreviewConfirmationToken(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + app.jvmPreviewTokenTTL = time.Nanosecond + readOnly := false + applyCalls := 0 + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + previewSet: true, + preview: jvm.ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + Summary: "risky change", + RiskLevel: "high", + }, + applyFn: func(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) { + applyCalls++ + return jvm.ApplyResult{Status: "applied"}, nil + }, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + Environment: jvm.EnvPROD, + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + } + req := jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + } + + previewRes := app.JVMPreviewChange(cfg, req) + if !previewRes.Success { + t.Fatalf("expected preview success, got %+v", previewRes) + } + preview, ok := previewRes.Data.(jvm.ChangePreview) + if !ok { + t.Fatalf("expected preview payload, got %#v", previewRes.Data) + } + time.Sleep(time.Millisecond) + req.ConfirmationToken = preview.ConfirmationToken + + res := app.JVMApplyChange(cfg, req) + if res.Success { + t.Fatalf("expected expired confirmation token to fail, got %+v", res) + } + if applyCalls != 0 { + t.Fatalf("expected provider ApplyChange not to run, got %d calls", applyCalls) + } +} + func TestJVMApplyChangePersistsAuditSource(t *testing.T) { app := NewAppWithSecretStore(nil) app.configDir = t.TempDir() @@ -578,11 +976,23 @@ func TestJVMApplyChangePersistsAuditSource(t *testing.T) { t.Fatalf("expected audit list success, got %+v", listRes) } records, ok := listRes.Data.([]jvm.AuditRecord) - if !ok || len(records) != 1 { - t.Fatalf("expected one audit record, got %#v", listRes.Data) + if !ok || len(records) < 2 { + t.Fatalf("expected at least two audit records (pending+terminal), got %#v", listRes.Data) } - if records[0].Source != "ai-plan" { - t.Fatalf("expected audit source %q, got %#v", "ai-plan", records[0]) + var hasPending, hasApplied bool + for _, record := range records { + if record.Source != "ai-plan" { + t.Fatalf("expected audit source %q, got %#v", "ai-plan", record) + } + switch record.Result { + case "pending": + hasPending = true + case "applied": + hasApplied = true + } + } + if !hasPending || !hasApplied { + t.Fatalf("expected pending and applied audit records, got %#v", records) } } @@ -647,11 +1057,20 @@ func TestJVMApplyChangeNormalizesRequestBeforeProviderAndAudit(t *testing.T) { t.Fatalf("expected audit list success, got %+v", listRes) } records, ok := listRes.Data.([]jvm.AuditRecord) - if !ok || len(records) != 1 { - t.Fatalf("expected one audit record, got %#v", listRes.Data) + if !ok || len(records) < 2 { + t.Fatalf("expected at least two audit records (pending+terminal), got %#v", listRes.Data) } - if records[0].ProviderMode != "endpoint" || records[0].ResourceID != "/cache/orders" || records[0].Action != "put" || records[0].Reason != "repair cache" || records[0].Source != "manual" { - t.Fatalf("expected normalized audit record, got %#v", records[0]) + var matchedTerminal bool + for _, record := range records { + if record.ProviderMode != "endpoint" || record.ResourceID != "/cache/orders" || record.Action != "put" || record.Reason != "repair cache" || record.Source != "manual" { + t.Fatalf("expected normalized audit record, got %#v", record) + } + if record.Result == "applied" { + matchedTerminal = true + } + } + if !matchedTerminal { + t.Fatalf("expected applied terminal audit record, got %#v", records) } } @@ -711,7 +1130,7 @@ func TestJVMListAuditRecordsReturnsLatestRecords(t *testing.T) { } } -func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) { +func TestJVMApplyChangeFailsClosedWhenInitialAuditWriteFails(t *testing.T) { app := NewAppWithSecretStore(nil) tempDir := t.TempDir() blockerPath := filepath.Join(tempDir, "audit-blocker") @@ -721,6 +1140,7 @@ func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) { app.configDir = blockerPath readOnly := false + var applyReq jvm.ChangeRequest restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { return fakeJVMProvider{ value: jvm.ValueSnapshot{ @@ -731,6 +1151,7 @@ func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) { "status": "stale", }, }, + applyReq: &applyReq, apply: jvm.ApplyResult{ Status: "applied", }, @@ -757,14 +1178,706 @@ func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) { }, }) + if res.Success { + t.Fatalf("expected fail-closed when initial audit write fails, got %+v", res) + } + if !strings.Contains(res.Message, "审计") { + t.Fatalf("expected audit failure message, got %q", res.Message) + } + if applyReq.ResourceID != "" { + t.Fatalf("expected provider ApplyChange not to run, got %#v", applyReq) + } +} + +func TestJVMApplyChangeLatestAuditRecordIsTerminal(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + Value: map[string]any{ + "status": "stale", + }, + }, + apply: jvm.ApplyResult{Status: "applied"}, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) if !res.Success { - t.Fatalf("expected success despite audit failure, got %+v", res) + t.Fatalf("expected apply success, got %+v", res) + } + + latestRes := app.JVMListAuditRecords("conn-orders", 1) + if !latestRes.Success { + t.Fatalf("expected list success, got %+v", latestRes) + } + latestRecords, ok := latestRes.Data.([]jvm.AuditRecord) + if !ok || len(latestRecords) != 1 { + t.Fatalf("expected one latest audit record, got %#v", latestRes.Data) + } + if latestRecords[0].Result != "applied" { + t.Fatalf("expected latest record applied, got %#v", latestRecords[0]) + } +} + +func TestJVMApplyChangeApplySuccessKeepsSuccessWhenTerminalAuditFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + terminalAuditFailed := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + }, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + if !terminalAuditFailed { + terminalAuditFailed = true + forceAuditAppendFailureAfterPending(t, auditDir) + } + return jvm.ApplyResult{Status: "applied", Message: "ok"}, nil + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if !res.Success { + t.Fatalf("expected success when apply succeeded, got %+v", res) } result, ok := res.Data.(jvm.ApplyResult) if !ok { - t.Fatalf("expected apply result, got %#v", res.Data) + t.Fatalf("expected apply result data, got %#v", res.Data) } - if !strings.Contains(result.Message, "审计记录写入失败") { - t.Fatalf("expected audit failure message, got %#v", result) + if result.Status != "applied" { + t.Fatalf("expected applied status, got %#v", result) + } + if !strings.Contains(result.Message, "终态审计写入失败") { + t.Fatalf("expected terminal audit warning in result message, got %#v", result) + } +} + +func TestJVMApplyChangeApplyFailureReportsFailedAuditWriteError(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + failedAuditBlocked := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + }, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + if !failedAuditBlocked { + failedAuditBlocked = true + forceAuditAppendFailureAfterPending(t, auditDir) + } + return jvm.ApplyResult{}, errors.New("provider apply failed") + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if res.Success { + t.Fatalf("expected failure when apply fails, got %+v", res) + } + if !strings.Contains(res.Message, "provider apply failed") { + t.Fatalf("expected provider failure in message, got %q", res.Message) + } + if !strings.Contains(res.Message, "失败审计写入失败") { + t.Fatalf("expected failed audit write failure in message, got %q", res.Message) + } +} + +func TestJVMApplyChangeApplyFailureKeepsProviderErrorWhenFailedAuditSucceeds(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + }, + applyErr: errors.New("provider apply failed"), + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if res.Success { + t.Fatalf("expected failure when provider apply fails, got %+v", res) + } + if res.Message != "provider apply failed" { + t.Fatalf("expected provider failure only when failed audit succeeds, got %q", res.Message) + } +} + +func TestJVMApplyChangeUsesProviderErrorWhenFailedAuditAlsoFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ + ResourceID: "/cache/orders", + Kind: "entry", + Format: "json", + }, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + forceAuditAppendFailureAfterPending(t, auditDir) + return jvm.ApplyResult{}, errors.New("provider apply failed") + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: "jmx", + AllowedModes: []string{"jmx"}, + }, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{ + "status": "ready", + }, + }) + + if res.Success { + t.Fatalf("expected failure when provider apply fails, got %+v", res) + } + if !strings.HasPrefix(res.Message, "provider apply failed") { + t.Fatalf("expected provider error prefix, got %q", res.Message) + } + if !strings.Contains(res.Message, "失败审计写入失败") { + t.Fatalf("expected failed audit write failure in message, got %q", res.Message) + } +} + +func TestJVMApplyChangeTerminalAuditWarningAppendsToExistingResultMessage(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + terminalAuditFailed := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + if !terminalAuditFailed { + terminalAuditFailed = true + forceAuditAppendFailureAfterPending(t, auditDir) + } + return jvm.ApplyResult{Status: "applied", Message: "provider message"}, nil + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("expected success when apply succeeded, got %+v", res) + } + result, ok := res.Data.(jvm.ApplyResult) + if !ok { + t.Fatalf("expected apply result data, got %#v", res.Data) + } + if !strings.Contains(result.Message, "provider message") || !strings.Contains(result.Message, "终态审计写入失败") { + t.Fatalf("expected provider message with terminal audit warning, got %#v", result) + } +} + +func TestJVMApplyChangeTerminalAuditWarningUsesStandaloneMessageWhenResultMessageEmpty(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + terminalAuditFailed := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + if !terminalAuditFailed { + terminalAuditFailed = true + forceAuditAppendFailureAfterPending(t, auditDir) + } + return jvm.ApplyResult{Status: "applied"}, nil + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("expected success when apply succeeded, got %+v", res) + } + result, ok := res.Data.(jvm.ApplyResult) + if !ok { + t.Fatalf("expected apply result data, got %#v", res.Data) + } + if !strings.Contains(result.Message, "终态审计写入失败") { + t.Fatalf("expected standalone terminal audit warning, got %#v", result) + } +} + +func TestJVMApplyChangeFailedAuditFailureMessageIncludesUnderlyingError(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + forceAuditAppendFailureAfterPending(t, auditDir) + return jvm.ApplyResult{}, errors.New("provider apply failed") + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if res.Success { + t.Fatalf("expected failure when apply fails, got %+v", res) + } + if !strings.Contains(res.Message, "失败审计写入失败") { + t.Fatalf("expected failed audit failure marker, got %q", res.Message) + } + if !strings.Contains(strings.ToLower(res.Message), "not a directory") { + t.Fatalf("expected underlying audit failure detail in message, got %q", res.Message) + } +} + +func TestJVMApplyChangeFailureMessageSeparatorUsesChineseSemicolon(t *testing.T) { + app := NewAppWithSecretStore(nil) + tempDir := t.TempDir() + auditDir := filepath.Join(tempDir, "audit") + if err := os.MkdirAll(auditDir, 0o755); err != nil { + t.Fatalf("MkdirAll returned error: %v", err) + } + app.configDir = auditDir + + readOnly := false + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + forceAuditAppendFailureAfterPending(t, auditDir) + return jvm.ApplyResult{}, errors.New("provider apply failed") + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if res.Success { + t.Fatalf("expected failure when apply fails, got %+v", res) + } + if !strings.Contains(res.Message, ";失败审计写入失败") { + t.Fatalf("expected chinese semicolon separator in failure message, got %q", res.Message) + } +} + +func TestJVMApplyChangeLatestAuditRecordIsFailedWhenApplyFails(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyErr: errors.New("provider apply failed"), + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if res.Success { + t.Fatalf("expected apply failure, got %+v", res) + } + + latestRes := app.JVMListAuditRecords("conn-orders", 10) + if !latestRes.Success { + t.Fatalf("expected list success, got %+v", latestRes) + } + records, ok := latestRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected records slice, got %#v", latestRes.Data) + } + if len(records) < 2 { + t.Fatalf("expected at least pending and failed records, got %#v", records) + } + if records[0].Result != "failed" { + t.Fatalf("expected latest record failed, got %#v", records[0]) + } + + var pendingTs, failedTs int64 + for _, record := range records { + switch record.Result { + case "pending": + pendingTs = record.Timestamp + case "failed": + failedTs = record.Timestamp + } + } + if pendingTs == 0 || failedTs == 0 { + t.Fatalf("expected pending and failed records, got %#v", records) + } + if failedTs <= pendingTs { + t.Fatalf("expected failed timestamp > pending timestamp, pending=%d failed=%d records=%#v", pendingTs, failedTs, records) + } +} + +func TestJVMApplyChangePendingAndTerminalAuditTimestampsAreStrictlyIncreasing(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + apply: jvm.ApplyResult{Status: "applied"}, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache", + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("expected apply success, got %+v", res) + } + + listRes := app.JVMListAuditRecords("conn-orders", 10) + if !listRes.Success { + t.Fatalf("expected list success, got %+v", listRes) + } + records, ok := listRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected records slice, got %#v", listRes.Data) + } + var pendingTs, terminalTs int64 + for _, record := range records { + switch record.Result { + case "pending": + pendingTs = record.Timestamp + case "applied": + terminalTs = record.Timestamp + } + } + if pendingTs == 0 || terminalTs == 0 { + t.Fatalf("expected pending and applied records, got %#v", records) + } + if terminalTs <= pendingTs { + t.Fatalf("expected terminal timestamp > pending timestamp, pending=%d terminal=%d records=%#v", pendingTs, terminalTs, records) + } +} + +func TestJVMApplyChangeTerminalAuditTimestampReflectsApplyCompletion(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + applyFn: func(_ context.Context, _ connection.ConnectionConfig, _ jvm.ChangeRequest) (jvm.ApplyResult, error) { + time.Sleep(15 * time.Millisecond) + return jvm.ApplyResult{Status: "applied"}, nil + }, + }, nil + }) + defer restore() + + res := app.JVMApplyChange(connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + }, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: "repair cache delayed", + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("expected apply success, got %+v", res) + } + + listRes := app.JVMListAuditRecords("conn-orders", 10) + if !listRes.Success { + t.Fatalf("expected list success, got %+v", listRes) + } + records, ok := listRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected records slice, got %#v", listRes.Data) + } + var pendingTs, terminalTs int64 + for _, record := range records { + if record.Reason != "repair cache delayed" { + continue + } + switch record.Result { + case "pending": + pendingTs = record.Timestamp + case "applied": + terminalTs = record.Timestamp + } + } + if pendingTs == 0 || terminalTs == 0 { + t.Fatalf("expected pending and applied records for delayed apply, got %#v", records) + } + if terminalTs <= pendingTs+1 { + t.Fatalf("expected delayed terminal timestamp to be strictly greater than pending+1, pending=%d terminal=%d records=%#v", pendingTs, terminalTs, records) + } +} + +func TestJVMApplyChangeTimestampGuaranteeHoldsAcrossMultipleApplies(t *testing.T) { + app := NewAppWithSecretStore(nil) + app.configDir = t.TempDir() + readOnly := false + + restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { + return fakeJVMProvider{ + value: jvm.ValueSnapshot{ResourceID: "/cache/orders", Kind: "entry", Format: "json"}, + apply: jvm.ApplyResult{Status: "applied"}, + }, nil + }) + defer restore() + + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-orders", + Host: "orders.internal", + JVM: connection.JVMConfig{ReadOnly: &readOnly, PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, + } + for i := 0; i < 3; i++ { + res := app.JVMApplyChange(cfg, jvm.ChangeRequest{ + ProviderMode: "jmx", + ResourceID: "/cache/orders", + Action: "put", + Reason: fmt.Sprintf("repair cache %d", i), + Payload: map[string]any{"status": "ready"}, + }) + if !res.Success { + t.Fatalf("apply %d expected success, got %+v", i, res) + } + } + + listRes := app.JVMListAuditRecords("conn-orders", 20) + if !listRes.Success { + t.Fatalf("expected list success, got %+v", listRes) + } + records, ok := listRes.Data.([]jvm.AuditRecord) + if !ok { + t.Fatalf("expected records slice, got %#v", listRes.Data) + } + reasonLatestTs := map[string]int64{} + for _, record := range records { + if strings.HasPrefix(record.Reason, "repair cache ") { + reasonLatestTs[record.Reason+":"+record.Result] = record.Timestamp + } + } + for i := 0; i < 3; i++ { + reason := fmt.Sprintf("repair cache %d", i) + pendingTs := reasonLatestTs[reason+":pending"] + appliedTs := reasonLatestTs[reason+":applied"] + if pendingTs == 0 || appliedTs == 0 { + t.Fatalf("expected pending+applied for %s, got %#v", reason, records) + } + if appliedTs <= pendingTs { + t.Fatalf("expected applied ts > pending ts for %s, pending=%d applied=%d", reason, pendingTs, appliedTs) + } } } diff --git a/internal/jvm/guard.go b/internal/jvm/guard.go index bde86c9..fc86a16 100644 --- a/internal/jvm/guard.go +++ b/internal/jvm/guard.go @@ -2,6 +2,9 @@ package jvm import ( "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "fmt" "strings" @@ -76,7 +79,17 @@ func BuildChangePreview( } } - if !preview.Allowed || provider == nil { + if !preview.Allowed { + return preview, nil + } + if provider == nil { + if preview.RequiresConfirmation { + confirmationToken, tokenErr := buildChangeConfirmationToken(normalized, req, preview) + if tokenErr != nil { + return ChangePreview{}, tokenErr + } + preview.ConfirmationToken = confirmationToken + } return preview, nil } @@ -106,6 +119,16 @@ func BuildChangePreview( if hasSnapshotOverride(providerPreview.After) { preview.After = mergeValueSnapshot(preview.After, providerPreview.After) } + if strings.EqualFold(strings.TrimSpace(preview.RiskLevel), "high") { + preview.RequiresConfirmation = true + } + if preview.Allowed && preview.RequiresConfirmation { + confirmationToken, tokenErr := buildChangeConfirmationToken(normalized, req, preview) + if tokenErr != nil { + return ChangePreview{}, tokenErr + } + preview.ConfirmationToken = confirmationToken + } return preview, nil } @@ -118,6 +141,7 @@ func NormalizeChangeRequest(req ChangeRequest) (ChangeRequest, error) { normalized.Reason = strings.TrimSpace(normalized.Reason) normalized.Source = strings.TrimSpace(normalized.Source) normalized.ExpectedVersion = strings.TrimSpace(normalized.ExpectedVersion) + normalized.ConfirmationToken = strings.TrimSpace(normalized.ConfirmationToken) if normalized.ResourceID == "" { return ChangeRequest{}, fmt.Errorf("resource id is required") @@ -138,7 +162,8 @@ func hasSnapshotOverride(snapshot ValueSnapshot) bool { strings.TrimSpace(snapshot.Format) != "" || strings.TrimSpace(snapshot.Version) != "" || snapshot.Value != nil || - snapshot.Metadata != nil + snapshot.Metadata != nil || + snapshot.Sensitive } func mergeValueSnapshot(base ValueSnapshot, override ValueSnapshot) ValueSnapshot { @@ -161,5 +186,67 @@ func mergeValueSnapshot(base ValueSnapshot, override ValueSnapshot) ValueSnapsho if override.Metadata != nil { merged.Metadata = override.Metadata } + if override.Sensitive { + merged.Sensitive = true + } return merged } + +func ValidateChangeConfirmation(preview ChangePreview, req ChangeRequest) error { + if !preview.RequiresConfirmation { + return nil + } + + previewToken := strings.TrimSpace(preview.ConfirmationToken) + requestToken := strings.TrimSpace(req.ConfirmationToken) + if previewToken == "" { + return fmt.Errorf("预览确认令牌缺失,请重新预览后再提交") + } + if requestToken == "" { + return fmt.Errorf("缺少确认令牌,请先完成预览确认") + } + if previewToken != requestToken { + return fmt.Errorf("确认令牌不匹配,请重新预览并确认") + } + return nil +} + +type confirmationTokenInput struct { + ConnectionID string `json:"connectionId"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Source string `json:"source"` + ExpectedVersion string `json:"expectedVersion"` + Payload map[string]any `json:"payload"` + Summary string `json:"summary"` + RiskLevel string `json:"riskLevel"` + BeforeVersion string `json:"beforeVersion"` + AfterVersion string `json:"afterVersion"` +} + +func buildChangeConfirmationToken(cfg connection.ConnectionConfig, req ChangeRequest, preview ChangePreview) (string, error) { + input := confirmationTokenInput{ + ConnectionID: strings.TrimSpace(cfg.ID), + ProviderMode: strings.TrimSpace(cfg.JVM.PreferredMode), + ResourceID: strings.TrimSpace(req.ResourceID), + Action: strings.TrimSpace(req.Action), + Reason: strings.TrimSpace(req.Reason), + Source: strings.TrimSpace(req.Source), + ExpectedVersion: strings.TrimSpace(req.ExpectedVersion), + Payload: req.Payload, + Summary: strings.TrimSpace(preview.Summary), + RiskLevel: strings.TrimSpace(preview.RiskLevel), + BeforeVersion: strings.TrimSpace(preview.Before.Version), + AfterVersion: strings.TrimSpace(preview.After.Version), + } + + encoded, err := json.Marshal(input) + if err != nil { + return "", fmt.Errorf("生成 JVM 变更确认令牌失败: %w", err) + } + + sum := sha256.Sum256(encoded) + return hex.EncodeToString(sum[:]), nil +} diff --git a/internal/jvm/guard_test.go b/internal/jvm/guard_test.go index 9c8f0a1..73d6807 100644 --- a/internal/jvm/guard_test.go +++ b/internal/jvm/guard_test.go @@ -68,6 +68,9 @@ func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) { if preview.BlockingReason == "" || !strings.Contains(preview.BlockingReason, "只读") { t.Fatalf("expected readonly blocking reason, got %#v", preview) } + if strings.TrimSpace(preview.ConfirmationToken) != "" { + t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview) + } if preview.Before.ResourceID != "/cache/orders" { t.Fatalf("expected before snapshot resource id to be preserved, got %#v", preview.Before) } @@ -169,6 +172,93 @@ func TestPreviewChangeMarksProdWritesAsConfirmationRequired(t *testing.T) { } } +func TestPreviewChangeMarksHighRiskWritesAsConfirmationRequired(t *testing.T) { + readOnly := false + + preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "provider high risk preview", + RiskLevel: "high", + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-writable", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if !preview.RequiresConfirmation { + t.Fatalf("expected high risk preview to require confirmation, got %#v", preview) + } + if strings.TrimSpace(preview.ConfirmationToken) == "" { + t.Fatalf("expected high risk preview to include confirmation token, got %#v", preview) + } +} + +func TestPreviewChangeMergesProviderSensitiveFlag(t *testing.T) { + readOnly := false + + preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + before: ValueSnapshot{ + ResourceID: "/cache/orders/password", + Kind: "attribute", + Format: "string", + Value: "old-secret", + }, + preview: ChangePreview{ + Allowed: true, + Summary: "provider preview", + RiskLevel: "high", + Before: ValueSnapshot{ + Value: "old-secret", + Sensitive: true, + }, + After: ValueSnapshot{ + Value: "new-secret", + Sensitive: true, + }, + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-writable", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/cache/orders/password", + Action: "set", + Reason: "rotate secret", + Payload: map[string]any{ + "value": "new-secret", + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if !preview.Before.Sensitive || !preview.After.Sensitive { + t.Fatalf("expected merged preview snapshots to preserve sensitive flag, got %#v", preview) + } +} + func TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults(t *testing.T) { readOnly := false @@ -224,3 +314,202 @@ func TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults(t *testing. t.Fatalf("expected after snapshot defaults to be preserved, got %#v", preview.After) } } + +func TestBuildChangePreviewAddsConfirmationTokenWhenRequired(t *testing.T) { + readOnly := false + + preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "invoke resize", + RiskLevel: "high", + RequiresConfirmation: true, + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-prod", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + Environment: EnvPROD, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if !preview.RequiresConfirmation { + t.Fatalf("expected confirmation requirement, got %#v", preview) + } + if strings.TrimSpace(preview.ConfirmationToken) == "" { + t.Fatalf("expected confirmation token, got %#v", preview) + } +} + +func TestBuildChangePreviewUsesNormalizedProviderModeForConfirmationToken(t *testing.T) { + readOnly := false + cfg := connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-prod", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + Environment: EnvPROD, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + } + + previewWithoutRequestedMode, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "invoke resize", + RiskLevel: "high", + RequiresConfirmation: true, + }, + }, cfg, ChangeRequest{ + ProviderMode: "", + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error for empty provider mode: %v", err) + } + if strings.TrimSpace(previewWithoutRequestedMode.ConfirmationToken) == "" { + t.Fatalf("expected confirmation token for empty requested provider mode, got %#v", previewWithoutRequestedMode) + } + + previewWithRequestedMode, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "invoke resize", + RiskLevel: "high", + RequiresConfirmation: true, + }, + }, cfg, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error for explicit provider mode: %v", err) + } + if strings.TrimSpace(previewWithRequestedMode.ConfirmationToken) == "" { + t.Fatalf("expected confirmation token for explicit requested provider mode, got %#v", previewWithRequestedMode) + } + + if previewWithoutRequestedMode.ConfirmationToken != previewWithRequestedMode.ConfirmationToken { + t.Fatalf("expected tokens to match when normalized mode is the same, got %q vs %q", previewWithoutRequestedMode.ConfirmationToken, previewWithRequestedMode.ConfirmationToken) + } +} + +func TestBuildChangePreviewBlockedByProviderDoesNotGenerateConfirmationToken(t *testing.T) { + readOnly := false + + preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: false, + RequiresConfirmation: true, + BlockingReason: "provider denied write", + Summary: "blocked by provider", + RiskLevel: "high", + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-prod", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + Environment: EnvPROD, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "args": []any{}, + }, + }) + if err != nil { + t.Fatalf("BuildChangePreview returned error: %v", err) + } + if preview.Allowed { + t.Fatalf("expected provider-blocked preview, got %#v", preview) + } + if strings.TrimSpace(preview.ConfirmationToken) != "" { + t.Fatalf("expected blocked preview to not include confirmation token, got %#v", preview) + } +} + +func TestBuildChangePreviewFailsClosedWhenTokenMarshalFails(t *testing.T) { + readOnly := false + _, err := BuildChangePreview(context.Background(), fakeGuardProvider{ + preview: ChangePreview{ + Allowed: true, + Summary: "invoke resize", + RiskLevel: "high", + RequiresConfirmation: true, + }, + }, connection.ConnectionConfig{ + Type: "jvm", + ID: "conn-prod", + Host: "orders.internal", + JVM: connection.JVMConfig{ + ReadOnly: &readOnly, + Environment: EnvPROD, + PreferredMode: ModeJMX, + AllowedModes: []string{ModeJMX}, + }, + }, ChangeRequest{ + ProviderMode: ModeJMX, + ResourceID: "/mbean/java.lang:type=Memory/operation/gc", + Action: "invoke", + Reason: "manual maintenance", + Payload: map[string]any{ + "invalid": func() {}, + }, + }) + if err == nil { + t.Fatal("expected BuildChangePreview to fail when confirmation token marshal fails") + } + if !strings.Contains(err.Error(), "确认令牌") { + t.Fatalf("expected error to mention confirmation token, got %v", err) + } +} + +func TestValidateChangeConfirmationRejectsMissingOrMismatchedToken(t *testing.T) { + preview := ChangePreview{ + Allowed: true, + RequiresConfirmation: true, + ConfirmationToken: "token-a", + } + if err := ValidateChangeConfirmation(preview, ChangeRequest{}); err == nil { + t.Fatal("expected missing confirmation token to be rejected") + } + if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-b"}); err == nil { + t.Fatal("expected mismatched confirmation token to be rejected") + } + if err := ValidateChangeConfirmation(preview, ChangeRequest{ConfirmationToken: "token-a"}); err != nil { + t.Fatalf("expected matching confirmation token to pass, got %v", err) + } +} diff --git a/internal/jvm/types.go b/internal/jvm/types.go index 9f54f87..ecc7135 100644 --- a/internal/jvm/types.go +++ b/internal/jvm/types.go @@ -58,18 +58,20 @@ type ValueSnapshot struct { } type ChangeRequest struct { - ProviderMode string `json:"providerMode"` - ResourceID string `json:"resourceId"` - Action string `json:"action"` - Reason string `json:"reason"` - Source string `json:"source,omitempty"` - ExpectedVersion string `json:"expectedVersion,omitempty"` - Payload map[string]any `json:"payload,omitempty"` + ProviderMode string `json:"providerMode"` + ResourceID string `json:"resourceId"` + Action string `json:"action"` + Reason string `json:"reason"` + Source string `json:"source,omitempty"` + ExpectedVersion string `json:"expectedVersion,omitempty"` + ConfirmationToken string `json:"confirmationToken,omitempty"` + Payload map[string]any `json:"payload,omitempty"` } type ChangePreview struct { Allowed bool `json:"allowed"` RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` + ConfirmationToken string `json:"confirmationToken,omitempty"` Summary string `json:"summary"` RiskLevel string `json:"riskLevel"` BlockingReason string `json:"blockingReason,omitempty"`