Smail/backend/hooks/framework/framework_security_test.go
smail f60d3b0880
Some checks are pending
Security Checks / security (push) Waiting to run
整理前后端与测试构建目录并完善配置和安全引导
统一 frontend、backend、test、test-log 和 out 目录,更新构建、部署及测试入口;纳入配置管理兼容与首次安全引导修复;排除本机配置、密钥和测试产物。
2026-09-05 19:19:52 +08:00

232 lines
6.5 KiB
Go

package framework
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"git.pcux.cn/xuans/Smail/dto/parsemail"
)
type controlledResponseWriter struct {
header http.Header
status int
writeN int
writeErr error
}
func (w *controlledResponseWriter) Header() http.Header {
if w.header == nil {
w.header = make(http.Header)
}
return w.header
}
func (w *controlledResponseWriter) WriteHeader(status int) {
w.status = status
}
func (w *controlledResponseWriter) Write(body []byte) (int, error) {
if w.writeN < 0 {
return len(body), w.writeErr
}
return w.writeN, w.writeErr
}
func TestDecodePluginRequestRejectsTrailingJSON(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(`{} {}`))
recorder := httptest.NewRecorder()
var dto HookDTO
if decodePluginRequest(recorder, request, &dto) {
t.Fatal("trailing JSON was accepted")
}
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
}
}
func TestWritePluginResponsePropagatesWriteFailure(t *testing.T) {
writeErr := errors.New("write failed")
writer := &controlledResponseWriter{writeN: 0, writeErr: writeErr}
if err := writePluginResponse(writer, "application/json", []byte("{}")); !errors.Is(err, writeErr) {
t.Fatalf("write error was not propagated: %v", err)
}
}
func TestWritePluginResponseRejectsShortWrite(t *testing.T) {
writer := &controlledResponseWriter{writeN: 1}
if err := writePluginResponse(writer, "application/json", []byte("{}")); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short write was not propagated: %v", err)
}
}
func TestHookDTOIncludesEmailAuthentication(t *testing.T) {
dto := HookDTO{
Email: &parsemail.Email{
Subject: "authentication result",
Authentication: parsemail.NewEmailAuthentication(false, false),
},
}
body, err := json.Marshal(dto)
if err != nil {
t.Fatal(err)
}
var decoded HookDTO
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatal(err)
}
if decoded.Email == nil || decoded.Email.Authentication == nil || !decoded.Email.Authentication.Dangerous {
t.Fatalf("authentication result was not preserved: %+v", decoded.Email)
}
}
func TestOlderPluginShapeIgnoresNewAuthenticationField(t *testing.T) {
dto := HookDTO{
Email: &parsemail.Email{
Subject: "compatibility",
Authentication: parsemail.NewEmailAuthentication(true, false),
},
}
body, err := json.Marshal(dto)
if err != nil {
t.Fatal(err)
}
var oldDTO struct {
Email *struct {
Subject string
}
}
if err := json.Unmarshal(body, &oldDTO); err != nil {
t.Fatal(err)
}
if oldDTO.Email == nil || oldDTO.Email.Subject != "compatibility" {
t.Fatalf("older plugin shape could not read the message: %+v", oldDTO.Email)
}
}
func TestAuthenticatedHandlerRequiresStartupToken(t *testing.T) {
plugin := &Plugin{authToken: "startup-token"}
next := http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
handler := plugin.authenticatedHandler(next)
for _, test := range []struct {
name string
token string
status int
}{
{name: "missing", status: http.StatusUnauthorized},
{name: "wrong", token: "wrong-token", status: http.StatusUnauthorized},
{name: "valid", token: "startup-token", status: http.StatusNoContent},
} {
t.Run(test.name, func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/Health", nil)
if test.token != "" {
request.Header.Set("X-Smail-Plugin-Token", test.token)
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != test.status {
t.Fatalf("status = %d, want %d", recorder.Code, test.status)
}
})
}
}
func TestAuthenticatedHandlerRejectsEmptyConfiguredToken(t *testing.T) {
plugin := &Plugin{}
handler := plugin.authenticatedHandler(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
t.Fatal("unauthenticated request reached handler")
}))
request := httptest.NewRequest(http.MethodPost, "/Health", nil)
request.Header.Set("X-Smail-Plugin-Token", "unexpected")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusUnauthorized)
}
}
func TestDecodePluginRequestRejectsOversizedPayload(t *testing.T) {
body := bytes.NewBufferString(`"` + strings.Repeat("x", int(maxPluginRPCBytes)) + `"`)
request := httptest.NewRequest(http.MethodPost, "/", body)
recorder := httptest.NewRecorder()
var payload json.RawMessage
if decodePluginRequest(recorder, request, &payload) {
t.Fatal("oversized payload was accepted")
}
if recorder.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusRequestEntityTooLarge)
}
}
func TestHandleConfigDoesNotExposeActionError(t *testing.T) {
plugin := &Plugin{}
handler := plugin.handleConfig(func(json.RawMessage) error {
return errors.New("secret-token")
})
request := httptest.NewRequest(http.MethodPost, "/Configure", bytes.NewBufferString(`{}`))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusBadRequest)
}
if strings.Contains(recorder.Body.String(), "secret-token") {
t.Fatal("configuration error exposed secret material")
}
}
func TestLimitedHandlerCapsConcurrentRequests(t *testing.T) {
plugin := &Plugin{}
started := make(chan struct{}, pluginRPCConcurrencyLimit)
release := make(chan struct{})
var active int
var peak int
var mu sync.Mutex
next := http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
mu.Lock()
active++
if active > peak {
peak = active
}
mu.Unlock()
started <- struct{}{}
<-release
mu.Lock()
active--
mu.Unlock()
w.WriteHeader(http.StatusNoContent)
})
handler := plugin.limitedHandler(next)
var wg sync.WaitGroup
for i := 0; i < pluginRPCConcurrencyLimit+8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
request := httptest.NewRequest(http.MethodPost, "/Health", nil)
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
}()
}
for i := 0; i < pluginRPCConcurrencyLimit; i++ {
<-started
}
mu.Lock()
if peak != pluginRPCConcurrencyLimit {
mu.Unlock()
close(release)
wg.Wait()
t.Fatalf("peak concurrency = %d, want %d", peak, pluginRPCConcurrencyLimit)
}
mu.Unlock()
close(release)
wg.Wait()
}