package service import ( "testing" "affiliate_dash/internal/model" "affiliate_dash/internal/testdb" "gorm.io/gorm" ) // TestReencryptSecrets 覆盖密钥轮换的三种行:旧密钥密文、已是新密钥的密文、以及无法解密的坏数据。 // 同时验证预演(dry-run)不写库、写入模式会真正换密钥、第二张表也被覆盖、重复执行幂等。 func TestReencryptSecrets(t *testing.T) { db := testdb.New(t) oldCodec, err := NewSecretCodec("old-master-key-for-rotation-test") if err != nil { t.Fatalf("初始化旧密钥失败: %v", err) } newCodec, err := NewSecretCodec("new-master-key-for-rotation-test") if err != nil { t.Fatalf("初始化新密钥失败: %v", err) } encryptWith := func(codec *SecretCodec, plain string) string { ciphertext, err := codec.Encrypt(plain) if err != nil { t.Fatalf("加密 %q 失败: %v", plain, err) } return ciphertext } const corrupt = "not-a-valid-ciphertext" oldClientCipher := encryptWith(oldCodec, "client-secret-a1") newClientCipher := encryptWith(newCodec, "client-secret-a2") oldCallbackCipher := encryptWith(oldCodec, "callback-secret-c1") clients := []model.APIClient{ {MerchantID: 1, Name: "旧密钥客户端", AppKey: "key-a1", SecretCiphertext: oldClientCipher, Scopes: "orders:read"}, {MerchantID: 1, Name: "已是新密钥", AppKey: "key-a2", SecretCiphertext: newClientCipher, Scopes: "orders:read"}, {MerchantID: 1, Name: "坏数据", AppKey: "key-a3", SecretCiphertext: corrupt, Scopes: "orders:read"}, } for i := range clients { if err := db.Create(&clients[i]).Error; err != nil { t.Fatalf("写入 api_clients 失败: %v", err) } } subscription := model.CallbackSubscription{ MerchantID: 1, Name: "回调", URL: "https://example.com/callback", Events: "order.delivered", SecretCiphertext: oldCallbackCipher, } if err := db.Create(&subscription).Error; err != nil { t.Fatalf("写入 callback_subscriptions 失败: %v", err) } // ---- 第一轮:预演,必须只统计不写库 ---- dryRun, err := ReencryptSecrets(db, oldCodec, newCodec, false) if err != nil { t.Fatalf("预演失败: %v", err) } assertTableReport(t, dryRun, "api_clients", SecretReencryptReport{Scanned: 3, Reencrypted: 1, AlreadyNewKey: 1, Failed: 1}) assertTableReport(t, dryRun, "callback_subscriptions", SecretReencryptReport{Scanned: 1, Reencrypted: 1}) if got := TotalFailed(dryRun); got != 1 { t.Fatalf("预演期望 1 行失败,实际 %d", got) } var afterDryRun model.APIClient if err := db.First(&afterDryRun, clients[0].ID).Error; err != nil { t.Fatalf("读取客户端失败: %v", err) } if afterDryRun.SecretCiphertext != oldClientCipher { t.Fatal("预演不应修改数据库,但密文已被改写") } // ---- 第二轮:真正写入 ---- applied, err := ReencryptSecrets(db, oldCodec, newCodec, true) if err != nil { t.Fatalf("写入失败: %v", err) } assertTableReport(t, applied, "api_clients", SecretReencryptReport{Scanned: 3, Reencrypted: 1, AlreadyNewKey: 1, Failed: 1}) assertTableReport(t, applied, "callback_subscriptions", SecretReencryptReport{Scanned: 1, Reencrypted: 1}) // 换过密钥的行必须能被新密钥解开,且旧密钥已解不开。 assertStoredCipherDecryptsTo(t, db, newCodec, "api_clients", clients[0].ID, "client-secret-a1") assertStoredCipherDecryptsTo(t, db, newCodec, "callback_subscriptions", subscription.ID, "callback-secret-c1") var migrated model.APIClient if err := db.First(&migrated, clients[0].ID).Error; err != nil { t.Fatalf("读取客户端失败: %v", err) } if _, err := oldCodec.Decrypt(migrated.SecretCiphertext); err == nil { t.Fatal("换密钥后旧密钥不应还能解密") } // 坏数据必须原样保留,等待人工排查。 var broken model.APIClient if err := db.First(&broken, clients[2].ID).Error; err != nil { t.Fatalf("读取坏数据行失败: %v", err) } if broken.SecretCiphertext != corrupt { t.Fatalf("坏数据行不应被改写,实际变为 %q", broken.SecretCiphertext) } // ---- 第三轮:重复执行必须幂等,全部识别为已是新密钥 ---- again, err := ReencryptSecrets(db, oldCodec, newCodec, true) if err != nil { t.Fatalf("重复执行失败: %v", err) } assertTableReport(t, again, "api_clients", SecretReencryptReport{Scanned: 3, AlreadyNewKey: 2, Failed: 1}) assertTableReport(t, again, "callback_subscriptions", SecretReencryptReport{Scanned: 1, AlreadyNewKey: 1}) } func assertTableReport(t *testing.T, results []SecretReencryptTable, table string, want SecretReencryptReport) { t.Helper() for _, r := range results { if r.Table == table { if r.Report != want { t.Fatalf("%s 统计不符:期望 %+v,实际 %+v", table, want, r.Report) } return } } t.Fatalf("结果中缺少表 %s", table) } // assertStoredCipherDecryptsTo 从库里读回密文,确认其能被指定密钥解出期望明文。 func assertStoredCipherDecryptsTo(t *testing.T, db *gorm.DB, codec *SecretCodec, table string, id uint, wantPlain string) { t.Helper() var stored string query := "SELECT secret_ciphertext FROM " + table + " WHERE id = ?" if err := db.Raw(query, id).Scan(&stored).Error; err != nil { t.Fatalf("读取 %s#%d 密文失败: %v", table, id, err) } plain, err := codec.Decrypt(stored) if err != nil { t.Fatalf("%s#%d 无法用新密钥解密: %v", table, id, err) } if plain != wantPlain { t.Fatalf("%s#%d 明文不符:期望 %q,实际 %q", table, id, wantPlain, plain) } }