mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 22:06:38 +08:00
33a1c32cf8
Reorganize internal packages into platform/infra/shared layers and update imports, docs, and seed-count tests to match current system configs.
147 lines
4.8 KiB
Go
147 lines
4.8 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package cap
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
|
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
|
"github.com/Rain-kl/Wavelet/internal/testhelper"
|
|
pkgcap "github.com/Rain-kl/Wavelet/pkg/cap"
|
|
)
|
|
|
|
func decodeAPIResponse[T any](t *testing.T, body []byte) T {
|
|
t.Helper()
|
|
var envelope struct {
|
|
ErrorMsg string `json:"error_msg"`
|
|
Data T `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
t.Fatalf("failed to unmarshal API envelope: %v", err)
|
|
}
|
|
if envelope.ErrorMsg != "" {
|
|
t.Fatalf("unexpected API error_msg: %s", envelope.ErrorMsg)
|
|
}
|
|
return envelope.Data
|
|
}
|
|
|
|
func TestCapEndpointsAndMiddleware(t *testing.T) {
|
|
sqliteDB, _, cleanup := testhelper.SetupTestEnvironment(t)
|
|
defer cleanup()
|
|
|
|
r := testhelper.NewTestGinEngine()
|
|
|
|
// Mount CAPTCHA API endpoints
|
|
capGroup := r.Group("/api/cap")
|
|
{
|
|
capGroup.POST("/challenge", Challenge)
|
|
capGroup.POST("/redeem", Redeem)
|
|
}
|
|
|
|
r.POST("/api/v1/user/login", VerifyMiddleware(GetDefaultManager(), "login"), func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, response.OK("login success"))
|
|
})
|
|
|
|
// Ensure CAPTCHA is disabled initially for step 2
|
|
if err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "false").Error; err != nil {
|
|
t.Fatalf("failed to disable cap_login_enabled in DB: %v", err)
|
|
}
|
|
if err := repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled); err != nil {
|
|
t.Fatalf("InvalidateSystemConfigCache() error = %v", err)
|
|
}
|
|
InvalidateRuntimeSettings()
|
|
|
|
// 1. Test challenge generation
|
|
w := httptest.NewRecorder()
|
|
req, _ := http.NewRequest("POST", "/api/cap/challenge", nil)
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK, got %d. Body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
challengeResp := decodeAPIResponse[pkgcap.ChallengeResponse](t, w.Body.Bytes())
|
|
|
|
if challengeResp.Token == "" {
|
|
t.Fatalf("expected token in challenge response")
|
|
}
|
|
|
|
// 2. Test login with CAPTCHA disabled (should pass)
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK when CAPTCHA is disabled, got %d. Body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 3. Enable CAPTCHA in DB and invalidate runtime snapshot
|
|
err := sqliteDB.Model(&model.SystemConfig{}).Where("key = ?", model.ConfigKeyCapLoginEnabled).Update("value", "true").Error
|
|
if err != nil {
|
|
t.Fatalf("failed to enable cap_login_enabled in DB: %v", err)
|
|
}
|
|
if err := repository.InvalidateSystemConfigCache(context.Background(), model.ConfigKeyCapLoginEnabled); err != nil {
|
|
t.Fatalf("InvalidateSystemConfigCache() error = %v", err)
|
|
}
|
|
InvalidateRuntimeSettings()
|
|
|
|
// 4. Test login with CAPTCHA enabled but no header (should be blocked)
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 Unauthorized, got %d. Body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 5. Solve the challenge
|
|
solutions := pkgcap.Solve(challengeResp.Token, challengeResp.Challenge.C, challengeResp.Challenge.S, challengeResp.Challenge.D)
|
|
|
|
// 6. Redeem solutions
|
|
redeemReqPayload := redeemRequest{
|
|
Token: challengeResp.Token,
|
|
Solutions: solutions,
|
|
}
|
|
bodyBytes, _ := json.Marshal(redeemReqPayload)
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("POST", "/api/cap/redeem", bytes.NewBuffer(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK for redeem, got %d. Body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
redeemResp := decodeAPIResponse[RedeemResponse](t, w.Body.Bytes())
|
|
|
|
if !redeemResp.Success || redeemResp.Token == "" {
|
|
t.Fatalf("redeem failed or returned empty token: %+v", redeemResp)
|
|
}
|
|
|
|
// 7. Login with valid redeem token (should pass)
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
|
|
req.Header.Set("X-Cap-Token", redeemResp.Token)
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200 OK with valid cap token, got %d. Body: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// 8. Replay attack: Login with the same redeem token again (should be blocked as it is single-use)
|
|
w = httptest.NewRecorder()
|
|
req, _ = http.NewRequest("POST", "/api/v1/user/login", nil)
|
|
req.Header.Set("X-Cap-Token", redeemResp.Token)
|
|
r.ServeHTTP(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("expected 401 Unauthorized on replayed token, got %d. Body: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|