package codes_test import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "strconv" "testing" "time" "github.com/wangjia/pangolin/server/internal/codes" ) // --- HMAC helper used across tests --- func signBody(secret string, body []byte) string { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) return "sha256=" + hex.EncodeToString(mac.Sum(nil)) } // newWebhookHandlerNoRedis creates a WebhookHandler with a nil Redis client. // Safe only for tests where the handler returns before the nonce check. func newWebhookHandlerNoRedis(secret string) *codes.WebhookHandler { return codes.NewWebhookHandler(nil, nil, secret, 5*time.Minute, 15*time.Minute) } // --- Signature validation tests (no Redis required) --- // TestWebhookSignatureRejected verifies that a bad HMAC causes a 401. // The signature check fires before any Redis or DB access. func TestWebhookSignatureRejected(t *testing.T) { code, _ := codes.GenerateCode() payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} body, _ := json.Marshal(payload) r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") // Wrong secret. r.Header.Set("X-Pangolin-Signature", signBody("wrong-secret", body)) r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) r.Header.Set("X-Pangolin-Nonce", "test-nonce-1") h := newWebhookHandlerNoRedis("correct-secret") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String()) } assertErrorCode(t, w, "WEBHOOK_INVALID_SIGNATURE") } // TestWebhookMissingSignatureHeader verifies that a missing signature causes a 401. func TestWebhookMissingSignatureHeader(t *testing.T) { code, _ := codes.GenerateCode() payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} body, _ := json.Marshal(payload) r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") // No X-Pangolin-Signature. r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) r.Header.Set("X-Pangolin-Nonce", "test-nonce-nosig") h := newWebhookHandlerNoRedis("any-secret") w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", w.Code) } } // TestWebhookTimestampRejected verifies that a stale timestamp causes a 401. // The timestamp check fires after signature but before Redis. func TestWebhookTimestampRejected(t *testing.T) { secret := "test-secret" code, _ := codes.GenerateCode() payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} body, _ := json.Marshal(payload) r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("X-Pangolin-Signature", signBody(secret, body)) // Timestamp is 10 minutes in the past – outside ±5 min window. staleTs := time.Now().Add(-10 * time.Minute).Unix() r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(staleTs, 10)) r.Header.Set("X-Pangolin-Nonce", "test-nonce-stale") h := newWebhookHandlerNoRedis(secret) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d; body=%s", w.Code, w.Body.String()) } assertErrorCode(t, w, "WEBHOOK_TIMESTAMP_EXPIRED") } // TestWebhookFutureTimestampRejected verifies that a far-future timestamp is also rejected. func TestWebhookFutureTimestampRejected(t *testing.T) { secret := "test-secret" code, _ := codes.GenerateCode() payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} body, _ := json.Marshal(payload) r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("X-Pangolin-Signature", signBody(secret, body)) futureTs := time.Now().Add(10 * time.Minute).Unix() r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(futureTs, 10)) r.Header.Set("X-Pangolin-Nonce", "test-nonce-future") h := newWebhookHandlerNoRedis(secret) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", w.Code) } } // TestWebhookMissingNonce verifies that a missing nonce causes a 400. // Missing nonce is checked before Redis access (nil nonce → BadRequest). func TestWebhookMissingNonce(t *testing.T) { secret := "test-secret" code, _ := codes.GenerateCode() payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} body, _ := json.Marshal(payload) r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("X-Pangolin-Signature", signBody(secret, body)) r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) // No X-Pangolin-Nonce header. h := newWebhookHandlerNoRedis(secret) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusBadRequest { t.Errorf("expected 400, got %d; body=%s", w.Code, w.Body.String()) } } // TestWebhookHMACConstantTime verifies that a correct-length HMAC with wrong // bytes is still rejected – i.e., hmac.Equal (constant-time) is used, not ==. func TestWebhookHMACConstantTime(t *testing.T) { secret := "test-secret" code, _ := codes.GenerateCode() payload := codes.WebhookPayload{Code: code, Plan: "pro", DurationDays: 30} body, _ := json.Marshal(payload) // Produce a MAC of the correct length but with the last byte flipped. mac := hmac.New(sha256.New, []byte(secret)) mac.Write(body) correctMAC := mac.Sum(nil) correctMAC[len(correctMAC)-1] ^= 0xFF wrongSig := "sha256=" + hex.EncodeToString(correctMAC) r := httptest.NewRequest(http.MethodPost, "/webhook/store/codes", bytes.NewReader(body)) r.Header.Set("Content-Type", "application/json") r.Header.Set("X-Pangolin-Signature", wrongSig) r.Header.Set("X-Pangolin-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) r.Header.Set("X-Pangolin-Nonce", "test-nonce-ct") h := newWebhookHandlerNoRedis(secret) w := httptest.NewRecorder() h.ServeHTTP(w, r) if w.Code != http.StatusUnauthorized { t.Errorf("expected 401, got %d", w.Code) } } // -------------------------------------------------------------------------- // Helper // -------------------------------------------------------------------------- // assertErrorCode decodes the response body and checks the "code" field. func assertErrorCode(t *testing.T, w *httptest.ResponseRecorder, wantCode string) { t.Helper() var body struct { Code string `json:"code"` } if err := json.NewDecoder(w.Body).Decode(&body); err != nil { t.Errorf("decode error body: %v", err) return } if body.Code != wantCode { t.Errorf("error code = %q, want %q", body.Code, wantCode) } }