tests: integration suite
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mailStubPath = "/api/v1/internal/login-code-deliveries"
|
||||
|
||||
// LoginCodeDelivery stores one mail-delivery request received by the external
|
||||
// mail stub.
|
||||
type LoginCodeDelivery struct {
|
||||
// Email identifies the target e-mail address requested by authsession.
|
||||
Email string
|
||||
|
||||
// Code stores the cleartext login code requested by authsession.
|
||||
Code string
|
||||
}
|
||||
|
||||
// MailBehavior overrides one external mail-stub response.
|
||||
type MailBehavior struct {
|
||||
// Delay waits before the stub writes its response.
|
||||
Delay time.Duration
|
||||
|
||||
// StatusCode overrides the HTTP status returned by the stub. Zero keeps the
|
||||
// default `200 OK`.
|
||||
StatusCode int
|
||||
|
||||
// RawBody overrides the exact response body returned by the stub. Empty
|
||||
// value keeps the default JSON payload for the chosen status.
|
||||
RawBody string
|
||||
}
|
||||
|
||||
// MailStub provides one stateful external HTTP mail-service stub.
|
||||
type MailStub struct {
|
||||
server *httptest.Server
|
||||
|
||||
mu sync.Mutex
|
||||
deliveries []LoginCodeDelivery
|
||||
behavior MailBehavior
|
||||
}
|
||||
|
||||
// NewMailStub starts one stateful external HTTP mail-service stub.
|
||||
func NewMailStub(t testing.TB) *MailStub {
|
||||
t.Helper()
|
||||
|
||||
stub := &MailStub{}
|
||||
stub.server = httptest.NewServer(http.HandlerFunc(stub.handle))
|
||||
t.Cleanup(stub.server.Close)
|
||||
return stub
|
||||
}
|
||||
|
||||
// BaseURL returns the stub base URL suitable for service runtime wiring.
|
||||
func (s *MailStub) BaseURL() string {
|
||||
if s == nil || s.server == nil {
|
||||
return ""
|
||||
}
|
||||
return s.server.URL
|
||||
}
|
||||
|
||||
// SetBehavior replaces the current response behavior used by subsequent
|
||||
// requests.
|
||||
func (s *MailStub) SetBehavior(behavior MailBehavior) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.behavior = behavior
|
||||
}
|
||||
|
||||
// RecordedDeliveries returns a snapshot of all delivery requests received by
|
||||
// the stub so far.
|
||||
func (s *MailStub) RecordedDeliveries() []LoginCodeDelivery {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
cloned := make([]LoginCodeDelivery, len(s.deliveries))
|
||||
copy(cloned, s.deliveries)
|
||||
return cloned
|
||||
}
|
||||
|
||||
// Reset clears the recorded deliveries and restores default behavior.
|
||||
func (s *MailStub) Reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.deliveries = nil
|
||||
s.behavior = MailBehavior{}
|
||||
}
|
||||
|
||||
func (s *MailStub) handle(writer http.ResponseWriter, request *http.Request) {
|
||||
if request.Method != http.MethodPost || request.URL.Path != mailStubPath {
|
||||
http.NotFound(writer, request)
|
||||
return
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Email string `json:"email"`
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := decodeStrictJSONRequest(request, &payload); err != nil {
|
||||
http.Error(writer, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.deliveries = append(s.deliveries, LoginCodeDelivery{
|
||||
Email: payload.Email,
|
||||
Code: payload.Code,
|
||||
})
|
||||
behavior := s.behavior
|
||||
s.mu.Unlock()
|
||||
|
||||
if behavior.Delay > 0 {
|
||||
timer := time.NewTimer(behavior.Delay)
|
||||
defer timer.Stop()
|
||||
|
||||
select {
|
||||
case <-request.Context().Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
}
|
||||
|
||||
statusCode := behavior.StatusCode
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusOK
|
||||
}
|
||||
|
||||
body := behavior.RawBody
|
||||
if body == "" {
|
||||
switch statusCode {
|
||||
case http.StatusOK:
|
||||
body = `{"outcome":"sent"}`
|
||||
default:
|
||||
body = `{"error":"stubbed mail failure"}`
|
||||
}
|
||||
}
|
||||
|
||||
writer.Header().Set("Content-Type", "application/json")
|
||||
writer.WriteHeader(statusCode)
|
||||
_, _ = io.WriteString(writer, body)
|
||||
}
|
||||
|
||||
func decodeStrictJSONRequest(request *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(request.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("unexpected trailing JSON input")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeStrictJSONPayload(payload []byte, target any) error {
|
||||
decoder := json.NewDecoder(bytes.NewReader(payload))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
if err == nil {
|
||||
return errors.New("unexpected trailing JSON input")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user