Motivation: platform-certificate mode is being phased out by WeChat (2024-10+,
newly-provisioned merchants already cannot download platform certificates at
all), and wxpay config errors currently surface only when an order is being
created — admins have no feedback at save time. Also, errors were returned as
natural-language strings, leaving the frontend no way to localize them.
Changes:
- backend/internal/payment/provider/wxpay.go
- Replace fmt.Errorf with structured infraerrors.BadRequest errors:
- WXPAY_CONFIG_MISSING_KEY (metadata: key)
- WXPAY_CONFIG_INVALID_KEY_LENGTH (metadata: key, expected, actual)
- WXPAY_CONFIG_INVALID_KEY (metadata: key) for malformed PEMs
- Parse privateKey and publicKey PEMs in NewWxpay so malformed keys fail
at save time instead of at order creation.
- Keep the pubkey verifier (NewSHA256WithRSAPubkeyVerifier) as the single
supported verifier; no more loadKeyPair helper.
- backend/internal/service/payment_order.go invokeProvider
- If CreateProvider or CreatePayment returns a structured ApplicationError,
pass it through (optionally enriching metadata with provider/instance_id)
instead of wrapping it as generic PAYMENT_GATEWAY_ERROR — so clients see
the actual reason code (e.g. WXPAY_CONFIG_MISSING_KEY) and can localize.
- Simplify a few messages (TOO_MANY_PENDING, DAILY_LIMIT_EXCEEDED,
PAYMENT_GATEWAY_ERROR, NO_AVAILABLE_INSTANCE) to keyword form with
metadata for template variables.
- backend/internal/service/payment_config_providers.go
- New helper validateProviderConfig calls provider.CreateProvider at save
time. Enabled instances are validated on both Create and Update so admins
see config errors immediately in the dialog, not later at order creation.
- Disabled instances are not validated (half-filled drafts are allowed).
- backend/internal/payment/provider/wxpay_test.go
- Add generateTestKeyPair helper that produces valid RSA-2048 PKCS8/PKIX
PEMs per test, used by the valid-config baseline (prior fake strings no
longer pass the eager PEM check).
- Cover each structured-error branch (missing/invalid-length/malformed PEM).
303 lines
7.2 KiB
Go
303 lines
7.2 KiB
Go
//go:build unit
|
|
|
|
package provider
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/payment"
|
|
)
|
|
|
|
// generateTestKeyPair returns a fresh RSA 2048 key pair as PEM strings.
|
|
// The wechatpay-go SDK expects PKCS8 private keys and PKIX public keys.
|
|
func generateTestKeyPair(t *testing.T) (privPEM, pubPEM string) {
|
|
t.Helper()
|
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
if err != nil {
|
|
t.Fatalf("generate rsa key: %v", err)
|
|
}
|
|
privDER, err := x509.MarshalPKCS8PrivateKey(key)
|
|
if err != nil {
|
|
t.Fatalf("marshal pkcs8: %v", err)
|
|
}
|
|
pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
|
if err != nil {
|
|
t.Fatalf("marshal pkix: %v", err)
|
|
}
|
|
return string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})),
|
|
string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER}))
|
|
}
|
|
|
|
func TestMapWxState(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{
|
|
name: "SUCCESS maps to paid",
|
|
input: wxpayTradeStateSuccess,
|
|
want: payment.ProviderStatusPaid,
|
|
},
|
|
{
|
|
name: "REFUND maps to refunded",
|
|
input: wxpayTradeStateRefund,
|
|
want: payment.ProviderStatusRefunded,
|
|
},
|
|
{
|
|
name: "CLOSED maps to failed",
|
|
input: wxpayTradeStateClosed,
|
|
want: payment.ProviderStatusFailed,
|
|
},
|
|
{
|
|
name: "PAYERROR maps to failed",
|
|
input: wxpayTradeStatePayError,
|
|
want: payment.ProviderStatusFailed,
|
|
},
|
|
{
|
|
name: "unknown state maps to pending",
|
|
input: "NOTPAY",
|
|
want: payment.ProviderStatusPending,
|
|
},
|
|
{
|
|
name: "empty string maps to pending",
|
|
input: "",
|
|
want: payment.ProviderStatusPending,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := mapWxState(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("mapWxState(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWxSV(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
input *string
|
|
want string
|
|
}{
|
|
{
|
|
name: "nil pointer returns empty string",
|
|
input: nil,
|
|
want: "",
|
|
},
|
|
{
|
|
name: "non-nil pointer returns value",
|
|
input: strPtr("hello"),
|
|
want: "hello",
|
|
},
|
|
{
|
|
name: "pointer to empty string returns empty string",
|
|
input: strPtr(""),
|
|
want: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := wxSV(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("wxSV() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func strPtr(s string) *string {
|
|
return &s
|
|
}
|
|
|
|
func TestFormatPEM(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
key string
|
|
keyType string
|
|
want string
|
|
}{
|
|
{
|
|
name: "raw key gets wrapped with headers",
|
|
key: "MIIBIjANBgkqhki...",
|
|
keyType: "PUBLIC KEY",
|
|
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
|
|
},
|
|
{
|
|
name: "already formatted key is returned as-is",
|
|
key: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
|
|
keyType: "PRIVATE KEY",
|
|
want: "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg...\n-----END PRIVATE KEY-----",
|
|
},
|
|
{
|
|
name: "key with leading/trailing whitespace is trimmed before check",
|
|
key: " \n MIIBIjANBgkqhki... \n ",
|
|
keyType: "PUBLIC KEY",
|
|
want: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhki...\n-----END PUBLIC KEY-----",
|
|
},
|
|
{
|
|
name: "already formatted key with whitespace is trimmed and returned",
|
|
key: " -----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY----- ",
|
|
keyType: "RSA PRIVATE KEY",
|
|
want: "-----BEGIN RSA PRIVATE KEY-----\ndata\n-----END RSA PRIVATE KEY-----",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got := formatPEM(tt.key, tt.keyType)
|
|
if got != tt.want {
|
|
t.Errorf("formatPEM(%q, %q) =\n%s\nwant:\n%s", tt.key, tt.keyType, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNewWxpay(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
privPEM, pubPEM := generateTestKeyPair(t)
|
|
validConfig := map[string]string{
|
|
"appId": "wx1234567890",
|
|
"mchId": "1234567890",
|
|
"privateKey": privPEM,
|
|
"apiV3Key": "12345678901234567890123456789012", // exactly 32 bytes
|
|
"publicKey": pubPEM,
|
|
"publicKeyId": "PUB_KEY_ID_TEST",
|
|
"certSerial": "SERIAL001",
|
|
}
|
|
|
|
// helper to clone and override config fields
|
|
withOverride := func(overrides map[string]string) map[string]string {
|
|
cfg := make(map[string]string, len(validConfig))
|
|
for k, v := range validConfig {
|
|
cfg[k] = v
|
|
}
|
|
for k, v := range overrides {
|
|
cfg[k] = v
|
|
}
|
|
return cfg
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
config map[string]string
|
|
wantErr bool
|
|
errSubstr string
|
|
}{
|
|
{
|
|
name: "valid config succeeds",
|
|
config: validConfig,
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "missing appId",
|
|
config: withOverride(map[string]string{"appId": ""}),
|
|
wantErr: true,
|
|
errSubstr: "appId",
|
|
},
|
|
{
|
|
name: "missing mchId",
|
|
config: withOverride(map[string]string{"mchId": ""}),
|
|
wantErr: true,
|
|
errSubstr: "mchId",
|
|
},
|
|
{
|
|
name: "missing privateKey",
|
|
config: withOverride(map[string]string{"privateKey": ""}),
|
|
wantErr: true,
|
|
errSubstr: "privateKey",
|
|
},
|
|
{
|
|
name: "missing apiV3Key",
|
|
config: withOverride(map[string]string{"apiV3Key": ""}),
|
|
wantErr: true,
|
|
errSubstr: "apiV3Key",
|
|
},
|
|
{
|
|
name: "missing certSerial",
|
|
config: withOverride(map[string]string{"certSerial": ""}),
|
|
wantErr: true,
|
|
errSubstr: "certSerial",
|
|
},
|
|
{
|
|
name: "missing publicKey",
|
|
config: withOverride(map[string]string{"publicKey": ""}),
|
|
wantErr: true,
|
|
errSubstr: "publicKey",
|
|
},
|
|
{
|
|
name: "missing publicKeyId",
|
|
config: withOverride(map[string]string{"publicKeyId": ""}),
|
|
wantErr: true,
|
|
errSubstr: "publicKeyId",
|
|
},
|
|
{
|
|
name: "malformed privateKey PEM",
|
|
config: withOverride(map[string]string{"privateKey": "not-a-valid-pem"}),
|
|
wantErr: true,
|
|
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
|
},
|
|
{
|
|
name: "malformed publicKey PEM",
|
|
config: withOverride(map[string]string{"publicKey": "not-a-valid-pem"}),
|
|
wantErr: true,
|
|
errSubstr: "WXPAY_CONFIG_INVALID_KEY",
|
|
},
|
|
{
|
|
name: "apiV3Key too short",
|
|
config: withOverride(map[string]string{"apiV3Key": "short"}),
|
|
wantErr: true,
|
|
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
|
},
|
|
{
|
|
name: "apiV3Key too long",
|
|
config: withOverride(map[string]string{"apiV3Key": "123456789012345678901234567890123"}), // 33 bytes
|
|
wantErr: true,
|
|
errSubstr: "WXPAY_CONFIG_INVALID_KEY_LENGTH",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := NewWxpay("test-instance", tt.config)
|
|
if tt.wantErr {
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
if tt.errSubstr != "" && !strings.Contains(err.Error(), tt.errSubstr) {
|
|
t.Errorf("error %q should contain %q", err.Error(), tt.errSubstr)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got == nil {
|
|
t.Fatal("expected non-nil Wxpay instance")
|
|
}
|
|
if got.instanceID != "test-instance" {
|
|
t.Errorf("instanceID = %q, want %q", got.instanceID, "test-instance")
|
|
}
|
|
})
|
|
}
|
|
}
|