feat: mail service

This commit is contained in:
Ilia Denisov
2026-04-17 18:39:16 +02:00
committed by GitHub
parent 23ffcb7535
commit 5b7593e6f6
183 changed files with 31215 additions and 248 deletions
+34 -4
View File
@@ -8,6 +8,12 @@ Each suite must raise real service processes, speak only over public HTTP/gRPC/R
```text
integration/
├── README.md
├── authsessionmail/
│ ├── authsession_mail_test.go
│ └── harness_test.go
├── gatewayauthsessionmail/
│ ├── gateway_authsession_mail_test.go
│ └── harness_test.go
├── authsessionuser/
│ ├── authsession_user_test.go
│ └── harness_test.go
@@ -33,6 +39,8 @@ integration/
├── keys.go
├── mail_stub.go
├── process.go
├── redis_container.go
├── smtp_capture.go
└── user_stub.go
```
@@ -48,11 +56,20 @@ integration/
- `gatewayauthsession` verifies the integration boundary between real `Edge Gateway` and real `Auth / Session Service`.
- `authsessionuser` verifies the integration boundary between real `Auth / Session Service` and real `User Service`.
- `authsessionmail` verifies the integration boundary between real `Auth / Session Service` and real `Mail Service`.
- `gatewayauthsessionmail` verifies the public auth flow across real `Edge Gateway`, real `Auth / Session Service`, and real `Mail Service`.
- `gatewayuser` verifies the direct authenticated self-service boundary between real `Edge Gateway` and real `User Service`.
- `gatewayauthsessionuser` verifies the full public-auth plus authenticated-account chain across real `Edge Gateway`, real `Auth / Session Service`, and real `User Service`.
The current fast suites use one isolated `miniredis` instance plus either
The current fast suites still use one isolated `miniredis` instance plus either
real downstream processes or external stateful HTTP stubs where appropriate.
`authsessionmail` and `gatewayauthsessionmail` are the deliberate exceptions:
they use one real Redis container through `testcontainers-go`, because those
boundaries must exercise the real Redis-backed `Mail Service` runtime.
`authsessionmail` additionally contains one targeted SMTP-capture scenario for
the real `smtp` provider path, while `gatewayauthsessionmail` keeps `Mail
Service` in `stub` mode and extracts the confirmation code through the trusted
operator delivery surface.
## Running
@@ -62,6 +79,8 @@ Run from the module directory:
cd integration
go test ./gatewayauthsession/...
go test ./authsessionuser/...
go test ./authsessionmail/...
go test ./gatewayauthsessionmail/...
go test ./gatewayuser/...
go test ./gatewayauthsessionuser/...
```
@@ -71,6 +90,8 @@ Useful regression commands after boundary changes:
```bash
go test ./gatewayauthsession/...
go test ./authsessionuser/...
go test ./authsessionmail/...
go test ./gatewayauthsessionmail/...
go test ./gatewayuser/...
go test ./gatewayauthsessionuser/...
cd ../gateway && go test ./...
@@ -88,8 +109,17 @@ Do not use `go test ./...` from the repository root. The repository is organized
4. Add new helpers to `internal/contracts/<contract>/` only when they describe a reusable public wire contract.
5. Prefer fast deterministic infrastructure by default: in-memory test doubles, `httptest` stubs, and `miniredis`.
## Future Real Redis Smoke Suites
## Real Redis Suites
Fast suites stay on `miniredis` by default.
When a boundary needs one real Redis smoke suite later, keep it in the same boundary package and gate it explicitly with environment-driven configuration such as `INTEGRATION_REAL_REDIS_ADDR`.
That smoke suite should complement, not replace, the deterministic `miniredis` coverage.
When one boundary explicitly needs real Redis semantics, prefer a package-local
container setup through `testcontainers-go` plus reusable helpers in
`internal/harness`, as done by `authsessionmail` and
`gatewayauthsessionmail`.
Current rule of thumb:
- use `miniredis` when the boundary does not depend on Redis persistence or
scheduling behavior
- use `testcontainers-go` only when the real Redis process materially changes
the behavior being verified
@@ -0,0 +1,110 @@
package authsessionmail_test
import (
"net/url"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestAuthsessionMailBlackBoxSendEmailCodeCreatesSuppressedDelivery(t *testing.T) {
h := newAuthsessionMailHarness(t, authsessionMailHarnessOptions{})
email := "pilot@example.com"
response := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
require.NotEmpty(t, response.ChallengeID)
list := h.eventuallyListDeliveries(t, url.Values{
"source": []string{"authsession"},
"status": []string{"suppressed"},
"recipient": []string{email},
"template_id": []string{"auth.login_code"},
})
require.Len(t, list.Items, 1)
require.Equal(t, "authsession", list.Items[0].Source)
require.Equal(t, "suppressed", list.Items[0].Status)
require.Equal(t, "auth.login_code", list.Items[0].TemplateID)
require.Equal(t, "fr-FR", list.Items[0].Locale)
require.Equal(t, []string{email}, list.Items[0].To)
detail := h.getDelivery(t, list.Items[0].DeliveryID)
require.Equal(t, "authsession", detail.Source)
require.Equal(t, "suppressed", detail.Status)
require.Equal(t, "auth.login_code", detail.TemplateID)
require.Equal(t, "fr-FR", detail.Locale)
require.False(t, detail.LocaleFallbackUsed)
require.Equal(t, []string{email}, detail.To)
require.NotEmpty(t, detail.IdempotencyKey)
attempts := h.getDeliveryAttempts(t, detail.DeliveryID)
require.Empty(t, attempts.Items)
}
func TestAuthsessionMailBlackBoxSendEmailCodeReturnsServiceUnavailableWhenMailServiceStops(t *testing.T) {
h := newAuthsessionMailHarness(t, authsessionMailHarnessOptions{})
h.stopMail(t)
response := postJSONValueWithHeaders(
t,
h.authsessionPublicURL+authSendEmailCodePath,
map[string]string{"email": "pilot@example.com"},
nil,
)
require.Equal(t, 503, response.StatusCode)
require.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, response.Body)
}
func TestAuthsessionMailBlackBoxSMTPDeliveryReachesSentStateAndSMTPPayload(t *testing.T) {
h := newAuthsessionMailHarness(t, authsessionMailHarnessOptions{mailSMTPMode: "smtp"})
email := "pilot@example.com"
response := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
require.NotEmpty(t, response.ChallengeID)
list := h.eventuallyListDeliveries(t, url.Values{
"source": []string{"authsession"},
"recipient": []string{email},
"template_id": []string{"auth.login_code"},
})
require.Len(t, list.Items, 1)
require.Equal(t, "authsession", list.Items[0].Source)
require.Equal(t, "auth.login_code", list.Items[0].TemplateID)
require.Equal(t, "fr-FR", list.Items[0].Locale)
require.Equal(t, []string{email}, list.Items[0].To)
var detail mailDeliveryDetailResponse
require.Eventually(t, func() bool {
detail = h.getDelivery(t, list.Items[0].DeliveryID)
return detail.Status == "sent"
}, 10*time.Second, 50*time.Millisecond)
require.Equal(t, "authsession", detail.Source)
require.Equal(t, "sent", detail.Status)
require.Equal(t, "auth.login_code", detail.TemplateID)
require.Equal(t, "fr-FR", detail.Locale)
require.True(t, detail.LocaleFallbackUsed)
require.Equal(t, []string{email}, detail.To)
require.NotEmpty(t, detail.IdempotencyKey)
code, ok := detail.TemplateVariables["code"].(string)
require.True(t, ok)
require.Len(t, code, 6)
var attempts mailDeliveryAttemptsResponse
require.Eventually(t, func() bool {
attempts = h.getDeliveryAttempts(t, detail.DeliveryID)
return len(attempts.Items) == 1 && attempts.Items[0].Status == "provider_accepted"
}, 10*time.Second, 50*time.Millisecond)
require.Len(t, attempts.Items, 1)
require.Equal(t, "provider_accepted", attempts.Items[0].Status)
require.NotNil(t, h.smtp)
var payload string
require.Eventually(t, func() bool {
payload = h.smtp.LatestPayload()
return payload != ""
}, 10*time.Second, 50*time.Millisecond)
require.Contains(t, payload, "Subject:")
require.Contains(t, payload, "Your login code is "+code+".")
}
+394
View File
@@ -0,0 +1,394 @@
package authsessionmail_test
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"path/filepath"
"runtime"
"testing"
"time"
"galaxy/integration/internal/harness"
"github.com/stretchr/testify/require"
)
const (
authSendEmailCodePath = "/api/v1/public/auth/send-email-code"
mailDeliveriesPath = "/api/v1/internal/deliveries"
)
type authsessionMailHarness struct {
userStub *harness.UserStub
smtp *harness.SMTPCapture
authsessionPublicURL string
mailInternalURL string
authsessionProcess *harness.Process
mailProcess *harness.Process
}
type authsessionMailHarnessOptions struct {
mailSMTPMode string
}
type httpResponse struct {
StatusCode int
Body string
Header http.Header
}
type sendEmailCodeResponse struct {
ChallengeID string `json:"challenge_id"`
}
type mailDeliveryListResponse struct {
Items []mailDeliverySummary `json:"items"`
}
type mailDeliverySummary struct {
DeliveryID string `json:"delivery_id"`
Source string `json:"source"`
TemplateID string `json:"template_id"`
Locale string `json:"locale"`
To []string `json:"to"`
Status string `json:"status"`
}
type mailDeliveryDetailResponse struct {
DeliveryID string `json:"delivery_id"`
Source string `json:"source"`
TemplateID string `json:"template_id"`
Locale string `json:"locale"`
LocaleFallbackUsed bool `json:"locale_fallback_used"`
To []string `json:"to"`
IdempotencyKey string `json:"idempotency_key"`
Status string `json:"status"`
TemplateVariables map[string]any `json:"template_variables,omitempty"`
}
type mailDeliveryAttemptsResponse struct {
Items []mailAttemptResponse `json:"items"`
}
type mailAttemptResponse struct {
Status string `json:"status"`
}
func newAuthsessionMailHarness(t *testing.T, opts authsessionMailHarnessOptions) *authsessionMailHarness {
t.Helper()
redisRuntime := harness.StartRedisContainer(t)
userStub := harness.NewUserStub(t)
mailInternalAddr := harness.FreeTCPAddress(t)
authsessionPublicAddr := harness.FreeTCPAddress(t)
authsessionInternalAddr := harness.FreeTCPAddress(t)
mailBinary := harness.BuildBinary(t, "mail", "./mail/cmd/mail")
authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession")
if opts.mailSMTPMode == "" {
opts.mailSMTPMode = "stub"
}
mailEnv := map[string]string{
"MAIL_LOG_LEVEL": "info",
"MAIL_INTERNAL_HTTP_ADDR": mailInternalAddr,
"MAIL_REDIS_ADDR": redisRuntime.Addr,
"MAIL_TEMPLATE_DIR": moduleTemplateDir(t),
"MAIL_STREAM_BLOCK_TIMEOUT": "100ms",
"MAIL_OPERATOR_REQUEST_TIMEOUT": time.Second.String(),
"MAIL_SHUTDOWN_TIMEOUT": "2s",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
}
var smtpCapture *harness.SMTPCapture
switch opts.mailSMTPMode {
case "stub":
mailEnv["MAIL_SMTP_MODE"] = "stub"
case "smtp":
smtpCapture = harness.StartSMTPCapture(t, harness.SMTPCaptureConfig{
SupportsSTARTTLS: true,
})
mailEnv["MAIL_SMTP_MODE"] = "smtp"
mailEnv["MAIL_SMTP_ADDR"] = smtpCapture.Addr()
mailEnv["MAIL_SMTP_FROM_EMAIL"] = "noreply@example.com"
mailEnv["MAIL_SMTP_FROM_NAME"] = "Galaxy Mail"
mailEnv["MAIL_SMTP_TIMEOUT"] = "2s"
mailEnv["MAIL_SMTP_INSECURE_SKIP_VERIFY"] = "true"
mailEnv["SSL_CERT_FILE"] = smtpCapture.RootCAPath()
default:
t.Fatalf("unsupported mail SMTP mode %q", opts.mailSMTPMode)
}
mailProcess := harness.StartProcess(t, "mail", mailBinary, mailEnv)
waitForMailReady(t, mailProcess, "http://"+mailInternalAddr)
authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, map[string]string{
"AUTHSESSION_LOG_LEVEL": "info",
"AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr,
"AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr,
"AUTHSESSION_REDIS_ADDR": redisRuntime.Addr,
"AUTHSESSION_USER_SERVICE_MODE": "rest",
"AUTHSESSION_USER_SERVICE_BASE_URL": userStub.BaseURL(),
"AUTHSESSION_MAIL_SERVICE_MODE": "rest",
"AUTHSESSION_MAIL_SERVICE_BASE_URL": "http://" + mailInternalAddr,
"AUTHSESSION_MAIL_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr)
return &authsessionMailHarness{
userStub: userStub,
smtp: smtpCapture,
authsessionPublicURL: "http://" + authsessionPublicAddr,
mailInternalURL: "http://" + mailInternalAddr,
authsessionProcess: authsessionProcess,
mailProcess: mailProcess,
}
}
func (h *authsessionMailHarness) stopMail(t *testing.T) {
t.Helper()
h.mailProcess.Stop(t)
}
func (h *authsessionMailHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) sendEmailCodeResponse {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.authsessionPublicURL+authSendEmailCodePath,
map[string]string{"email": email},
map[string]string{"Accept-Language": acceptLanguage},
)
require.Equal(t, http.StatusOK, response.StatusCode, response.Body)
var body sendEmailCodeResponse
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
require.NotEmpty(t, body.ChallengeID)
return body
}
func (h *authsessionMailHarness) eventuallyListDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse {
t.Helper()
var response mailDeliveryListResponse
require.Eventually(t, func() bool {
response = h.listDeliveries(t, query)
return len(response.Items) > 0
}, 10*time.Second, 50*time.Millisecond)
return response
}
func (h *authsessionMailHarness) listDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse {
t.Helper()
target := h.mailInternalURL + mailDeliveriesPath
if encoded := query.Encode(); encoded != "" {
target += "?" + encoded
}
request, err := http.NewRequest(http.MethodGet, target, nil)
require.NoError(t, err)
return doJSONRequest[mailDeliveryListResponse](t, request, http.StatusOK)
}
func (h *authsessionMailHarness) getDelivery(t *testing.T, deliveryID string) mailDeliveryDetailResponse {
t.Helper()
request, err := http.NewRequest(http.MethodGet, h.mailInternalURL+mailDeliveriesPath+"/"+url.PathEscape(deliveryID), nil)
require.NoError(t, err)
return doJSONRequest[mailDeliveryDetailResponse](t, request, http.StatusOK)
}
func (h *authsessionMailHarness) getDeliveryAttempts(t *testing.T, deliveryID string) mailDeliveryAttemptsResponse {
t.Helper()
request, err := http.NewRequest(http.MethodGet, h.mailInternalURL+mailDeliveriesPath+"/"+url.PathEscape(deliveryID)+"/attempts", nil)
require.NoError(t, err)
return doJSONRequest[mailDeliveryAttemptsResponse](t, request, http.StatusOK)
}
func postJSONValueWithHeaders(t *testing.T, targetURL string, body any, headers map[string]string) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
if value == "" {
continue
}
request.Header.Set(key, value)
}
return doRequest(t, request)
}
func doJSONRequest[T any](t *testing.T, request *http.Request, wantStatus int) T {
t.Helper()
response := doRequest(t, request)
require.Equal(t, wantStatus, response.StatusCode, response.Body)
var decoded T
require.NoError(t, json.Unmarshal([]byte(response.Body), &decoded), response.Body)
return decoded
}
func doRequest(t *testing.T, request *http.Request) httpResponse {
t.Helper()
client := &http.Client{
Timeout: 500 * time.Millisecond,
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
t.Cleanup(client.CloseIdleConnections)
response, err := client.Do(request)
require.NoError(t, err)
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
require.NoError(t, err)
return httpResponse{
StatusCode: response.StatusCode,
Body: string(payload),
Header: response.Header.Clone(),
}
}
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
}
func waitForMailReady(t *testing.T, process *harness.Process, baseURL string) {
t.Helper()
client := &http.Client{Timeout: 250 * time.Millisecond}
t.Cleanup(client.CloseIdleConnections)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
request, err := http.NewRequest(http.MethodGet, baseURL+mailDeliveriesPath, nil)
require.NoError(t, err)
response, err := client.Do(request)
if err == nil {
_, _ = io.Copy(io.Discard, response.Body)
response.Body.Close()
if response.StatusCode == http.StatusOK {
return
}
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("wait for mail readiness: timeout\n%s", process.Logs())
}
func waitForAuthsessionPublicReady(t *testing.T, process *harness.Process, baseURL string) {
t.Helper()
client := &http.Client{Timeout: 250 * time.Millisecond}
t.Cleanup(client.CloseIdleConnections)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
response, err := postJSONValueMaybe(client, baseURL+authSendEmailCodePath, map[string]string{
"email": "",
})
if err == nil && response.StatusCode == http.StatusBadRequest {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("wait for authsession public readiness: timeout\n%s", process.Logs())
}
func postJSONValueMaybe(client *http.Client, targetURL string, body any) (httpResponse, error) {
payload, err := json.Marshal(body)
if err != nil {
return httpResponse{}, err
}
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return httpResponse{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return httpResponse{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return httpResponse{}, err
}
return httpResponse{
StatusCode: response.StatusCode,
Body: string(responseBody),
Header: response.Header.Clone(),
}, nil
}
func moduleTemplateDir(t *testing.T) string {
t.Helper()
return filepath.Join(repositoryRoot(t), "mail", "templates")
}
func repositoryRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve repository root: runtime caller is unavailable")
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
@@ -64,6 +64,31 @@ func TestAuthsessionUserBlackBoxConfirmForExistingUserKeepsCreateOnlySettings(t
require.Equal(t, "Europe/Paris", account.User.TimeZone)
}
func TestAuthsessionUserBlackBoxAcceptLanguageSetsLocalizedPreferredLanguage(t *testing.T) {
t.Parallel()
h := newAuthsessionUserHarness(t)
email := "localized@example.com"
challengeID := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
deliveries := h.mailStub.RecordedDeliveries()
require.NotEmpty(t, deliveries)
require.Equal(t, "fr-FR", deliveries[len(deliveries)-1].Locale)
code := lastMailCodeFor(t, h.mailStub, email)
response := h.confirmCode(t, challengeID, code)
var confirmBody struct {
DeviceSessionID string `json:"device_session_id"`
}
requireJSONStatus(t, response, http.StatusOK, &confirmBody)
require.True(t, strings.HasPrefix(confirmBody.DeviceSessionID, "device-session-"))
lookupResponse, account := lookupUserByEmail(t, h.userServiceURL, email)
require.Equalf(t, http.StatusOK, lookupResponse.StatusCode, formatStatusError(lookupResponse))
require.Equal(t, "fr-FR", account.User.PreferredLanguage)
require.Equal(t, testTimeZone, account.User.TimeZone)
}
func TestAuthsessionUserBlackBoxBlockedEmailSendIsSuccessShapedAndConfirmIsRejectedWithoutCreatingUser(t *testing.T) {
t.Parallel()
+24 -3
View File
@@ -82,9 +82,18 @@ func newAuthsessionUserHarness(t *testing.T) *authsessionUserHarness {
func (h *authsessionUserHarness) sendChallenge(t *testing.T, email string) string {
t.Helper()
response := postJSONValue(t, h.authsessionPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": email,
})
return h.sendChallengeWithAcceptLanguage(t, email, "")
}
func (h *authsessionUserHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) string {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.authsessionPublicURL+"/api/v1/public/auth/send-email-code",
map[string]string{"email": email},
map[string]string{"Accept-Language": acceptLanguage},
)
require.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
@@ -116,12 +125,24 @@ type httpResponse struct {
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
return postJSONValueWithHeaders(t, targetURL, body, nil)
}
func postJSONValueWithHeaders(t *testing.T, targetURL string, body any, headers map[string]string) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
if value == "" {
continue
}
request.Header.Set(key, value)
}
client := &http.Client{
Timeout: 250 * time.Millisecond,
@@ -77,6 +77,26 @@ func TestGatewayAuthSessionConfirmCreatesProjectionAndAllowsSubscribeEvents(t *t
assertBootstrapEvent(t, event, h.responseSignerPublicKey, "request-bootstrap")
}
func TestGatewayAuthSessionAcceptLanguageIsForwardedToMailAndUser(t *testing.T) {
h := newGatewayAuthSessionHarness(t, gatewayAuthSessionOptions{})
clientPrivateKey := newClientPrivateKey("localized")
challengeID, code := h.sendChallengeWithAcceptLanguage(t, testEmail, "fr-FR, en;q=0.8")
deliveries := h.mailStub.RecordedDeliveries()
require.NotEmpty(t, deliveries)
require.Equal(t, "fr-FR", deliveries[len(deliveries)-1].Locale)
response := h.confirmCode(t, challengeID, code, clientPrivateKey)
require.Equal(t, http.StatusOK, response.StatusCode)
ensureCalls := h.userStub.EnsureCalls()
require.Len(t, ensureCalls, 1)
require.Equal(t, testEmail, ensureCalls[0].Email)
require.Equal(t, "fr-FR", ensureCalls[0].PreferredLanguage)
require.Equal(t, testTimeZone, ensureCalls[0].TimeZone)
}
func TestGatewayAuthSessionRepeatedConfirmReturnsSameSessionID(t *testing.T) {
h := newGatewayAuthSessionHarness(t, gatewayAuthSessionOptions{})
+24 -3
View File
@@ -196,9 +196,18 @@ func (h *gatewayAuthSessionHarness) readGatewaySessionRecord(t *testing.T, devic
func (h *gatewayAuthSessionHarness) sendChallenge(t *testing.T, email string) (string, string) {
t.Helper()
response := postJSONValue(t, h.gatewayPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": email,
})
return h.sendChallengeWithAcceptLanguage(t, email, "")
}
func (h *gatewayAuthSessionHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) (string, string) {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.gatewayPublicURL+"/api/v1/public/auth/send-email-code",
map[string]string{"email": email},
map[string]string{"Accept-Language": acceptLanguage},
)
require.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
@@ -284,12 +293,24 @@ type gatewaySessionRecord struct {
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
return postJSONValueWithHeaders(t, targetURL, body, nil)
}
func postJSONValueWithHeaders(t *testing.T, targetURL string, body any, headers map[string]string) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
if value == "" {
continue
}
request.Header.Set(key, value)
}
client := &http.Client{Timeout: 5 * time.Second}
@@ -0,0 +1,87 @@
package gatewayauthsessionmail_test
import (
"context"
"crypto/ed25519"
"net/http"
"net/url"
"testing"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
"github.com/stretchr/testify/require"
)
func TestGatewayAuthsessionMailSendAndConfirmWithRealMailService(t *testing.T) {
h := newGatewayAuthsessionMailHarness(t)
clientPrivateKey := newClientPrivateKey("real-mail")
challengeID := h.sendChallengeWithAcceptLanguage(t, testEmail, "fr-FR, en;q=0.8")
list := h.eventuallyListDeliveries(t, url.Values{
"source": []string{"authsession"},
"status": []string{"suppressed"},
"recipient": []string{testEmail},
"template_id": []string{"auth.login_code"},
})
require.Len(t, list.Items, 1)
require.Equal(t, "authsession", list.Items[0].Source)
require.Equal(t, "suppressed", list.Items[0].Status)
require.Equal(t, "auth.login_code", list.Items[0].TemplateID)
require.Equal(t, "fr-FR", list.Items[0].Locale)
require.Equal(t, []string{testEmail}, list.Items[0].To)
detail := h.getDelivery(t, list.Items[0].DeliveryID)
require.Equal(t, "authsession", detail.Source)
require.Equal(t, "suppressed", detail.Status)
require.Equal(t, "auth.login_code", detail.TemplateID)
require.Equal(t, "fr-FR", detail.Locale)
require.False(t, detail.LocaleFallbackUsed)
require.Equal(t, []string{testEmail}, detail.To)
require.NotEmpty(t, detail.IdempotencyKey)
code := templateVariableString(t, detail.TemplateVariables, "code")
confirm := h.confirmCode(t, challengeID, code, clientPrivateKey)
require.Equal(t, http.StatusOK, confirm.StatusCode, confirm.Body)
var confirmBody confirmEmailCodeResponse
require.NoError(t, decodeStrictJSONPayload([]byte(confirm.Body), &confirmBody))
require.NotEmpty(t, confirmBody.DeviceSessionID)
record := h.waitForGatewaySession(t, confirmBody.DeviceSessionID)
require.Equal(t, gatewaySessionRecord{
DeviceSessionID: confirmBody.DeviceSessionID,
UserID: "user-1",
ClientPublicKey: encodePublicKey(clientPrivateKey.Public().(ed25519.PublicKey)),
Status: "active",
}, record)
ensureCalls := h.userStub.EnsureCalls()
require.Len(t, ensureCalls, 1)
require.Equal(t, testEmail, ensureCalls[0].Email)
require.Equal(t, "fr-FR", ensureCalls[0].PreferredLanguage)
require.Equal(t, testTimeZone, ensureCalls[0].TimeZone)
conn := h.dialGateway(t)
client := gatewayv1.NewEdgeGatewayClient(conn)
stream, err := client.SubscribeEvents(context.Background(), newSubscribeEventsRequest(confirmBody.DeviceSessionID, "request-bootstrap", clientPrivateKey))
require.NoError(t, err)
event, err := stream.Recv()
require.NoError(t, err)
assertBootstrapEvent(t, event, h.responseSignerPublicKey, "request-bootstrap")
}
func TestGatewayAuthsessionMailUnavailablePassesThroughGatewaySurface(t *testing.T) {
h := newGatewayAuthsessionMailHarness(t)
h.stopMail(t)
response := postJSONValue(t, h.gatewayPublicURL+gatewaySendEmailCodePath, map[string]string{
"email": testEmail,
})
require.Equal(t, http.StatusServiceUnavailable, response.StatusCode)
require.JSONEq(t, `{"error":{"code":"service_unavailable","message":"service is unavailable"}}`, response.Body)
}
@@ -0,0 +1,546 @@
package gatewayauthsessionmail_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"path/filepath"
"runtime"
"testing"
"time"
gatewayv1 "galaxy/gateway/proto/galaxy/gateway/v1"
contractsgatewayv1 "galaxy/integration/internal/contracts/gatewayv1"
"galaxy/integration/internal/harness"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
gatewaySendEmailCodePath = "/api/v1/public/auth/send-email-code"
gatewayConfirmEmailCodePath = "/api/v1/public/auth/confirm-email-code"
gatewayMailDeliveriesPath = "/api/v1/internal/deliveries"
testEmail = "pilot@example.com"
testTimeZone = "Europe/Kaliningrad"
)
type gatewayAuthsessionMailHarness struct {
redis *redis.Client
userStub *harness.UserStub
authsessionPublicURL string
authsessionInternalURL string
gatewayPublicURL string
gatewayGRPCAddr string
mailInternalURL string
responseSignerPublicKey ed25519.PublicKey
gatewayProcess *harness.Process
authsessionProcess *harness.Process
mailProcess *harness.Process
}
type httpResponse struct {
StatusCode int
Body string
Header http.Header
}
type sendEmailCodeResponse struct {
ChallengeID string `json:"challenge_id"`
}
type confirmEmailCodeResponse struct {
DeviceSessionID string `json:"device_session_id"`
}
type gatewaySessionRecord struct {
DeviceSessionID string `json:"device_session_id"`
UserID string `json:"user_id"`
ClientPublicKey string `json:"client_public_key"`
Status string `json:"status"`
RevokedAtMS *int64 `json:"revoked_at_ms,omitempty"`
}
type mailDeliveryListResponse struct {
Items []mailDeliverySummary `json:"items"`
}
type mailDeliverySummary struct {
DeliveryID string `json:"delivery_id"`
Source string `json:"source"`
TemplateID string `json:"template_id"`
Locale string `json:"locale"`
To []string `json:"to"`
Status string `json:"status"`
}
type mailDeliveryDetailResponse struct {
DeliveryID string `json:"delivery_id"`
Source string `json:"source"`
TemplateID string `json:"template_id"`
Locale string `json:"locale"`
LocaleFallbackUsed bool `json:"locale_fallback_used"`
To []string `json:"to"`
IdempotencyKey string `json:"idempotency_key"`
Status string `json:"status"`
TemplateVariables map[string]any `json:"template_variables,omitempty"`
}
func newGatewayAuthsessionMailHarness(t *testing.T) *gatewayAuthsessionMailHarness {
t.Helper()
redisRuntime := harness.StartRedisContainer(t)
redisClient := redis.NewClient(&redis.Options{
Addr: redisRuntime.Addr,
Protocol: 2,
DisableIdentity: true,
})
t.Cleanup(func() {
require.NoError(t, redisClient.Close())
})
userStub := harness.NewUserStub(t)
responseSignerPath, responseSignerPublicKey := harness.WriteResponseSignerPEM(t, t.Name())
mailInternalAddr := harness.FreeTCPAddress(t)
authsessionPublicAddr := harness.FreeTCPAddress(t)
authsessionInternalAddr := harness.FreeTCPAddress(t)
gatewayPublicAddr := harness.FreeTCPAddress(t)
gatewayGRPCAddr := harness.FreeTCPAddress(t)
mailBinary := harness.BuildBinary(t, "mail", "./mail/cmd/mail")
authsessionBinary := harness.BuildBinary(t, "authsession", "./authsession/cmd/authsession")
gatewayBinary := harness.BuildBinary(t, "gateway", "./gateway/cmd/gateway")
mailProcess := harness.StartProcess(t, "mail", mailBinary, map[string]string{
"MAIL_LOG_LEVEL": "info",
"MAIL_INTERNAL_HTTP_ADDR": mailInternalAddr,
"MAIL_REDIS_ADDR": redisRuntime.Addr,
"MAIL_TEMPLATE_DIR": moduleTemplateDir(t),
"MAIL_SMTP_MODE": "stub",
"MAIL_STREAM_BLOCK_TIMEOUT": "100ms",
"MAIL_OPERATOR_REQUEST_TIMEOUT": time.Second.String(),
"MAIL_SHUTDOWN_TIMEOUT": "2s",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
waitForMailReady(t, mailProcess, "http://"+mailInternalAddr)
authsessionProcess := harness.StartProcess(t, "authsession", authsessionBinary, map[string]string{
"AUTHSESSION_LOG_LEVEL": "info",
"AUTHSESSION_PUBLIC_HTTP_ADDR": authsessionPublicAddr,
"AUTHSESSION_PUBLIC_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_INTERNAL_HTTP_ADDR": authsessionInternalAddr,
"AUTHSESSION_INTERNAL_HTTP_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_REDIS_ADDR": redisRuntime.Addr,
"AUTHSESSION_USER_SERVICE_MODE": "rest",
"AUTHSESSION_USER_SERVICE_BASE_URL": userStub.BaseURL(),
"AUTHSESSION_USER_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_MAIL_SERVICE_MODE": "rest",
"AUTHSESSION_MAIL_SERVICE_BASE_URL": "http://" + mailInternalAddr,
"AUTHSESSION_MAIL_SERVICE_REQUEST_TIMEOUT": time.Second.String(),
"AUTHSESSION_REDIS_GATEWAY_SESSION_CACHE_KEY_PREFIX": "gateway:session:",
"AUTHSESSION_REDIS_GATEWAY_SESSION_EVENTS_STREAM": "gateway:session_events",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
waitForAuthsessionPublicReady(t, authsessionProcess, "http://"+authsessionPublicAddr)
gatewayProcess := harness.StartProcess(t, "gateway", gatewayBinary, map[string]string{
"GATEWAY_LOG_LEVEL": "info",
"GATEWAY_PUBLIC_HTTP_ADDR": gatewayPublicAddr,
"GATEWAY_AUTHENTICATED_GRPC_ADDR": gatewayGRPCAddr,
"GATEWAY_SESSION_CACHE_REDIS_ADDR": redisRuntime.Addr,
"GATEWAY_SESSION_CACHE_REDIS_KEY_PREFIX": "gateway:session:",
"GATEWAY_SESSION_EVENTS_REDIS_STREAM": "gateway:session_events",
"GATEWAY_CLIENT_EVENTS_REDIS_STREAM": "gateway:client_events",
"GATEWAY_REPLAY_REDIS_KEY_PREFIX": "gateway:replay:",
"GATEWAY_RESPONSE_SIGNER_PRIVATE_KEY_PEM_PATH": filepath.Clean(responseSignerPath),
"GATEWAY_AUTH_SERVICE_BASE_URL": "http://" + authsessionPublicAddr,
"GATEWAY_PUBLIC_AUTH_UPSTREAM_TIMEOUT": (500 * time.Millisecond).String(),
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_REQUESTS": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_WINDOW": "1s",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_PUBLIC_AUTH_RATE_LIMIT_BURST": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_SEND_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_REQUESTS": "100",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_WINDOW": "1s",
"GATEWAY_PUBLIC_HTTP_ANTI_ABUSE_CONFIRM_EMAIL_CODE_IDENTITY_RATE_LIMIT_BURST": "100",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
harness.WaitForHTTPStatus(t, gatewayProcess, "http://"+gatewayPublicAddr+"/healthz", http.StatusOK)
harness.WaitForTCP(t, gatewayProcess, gatewayGRPCAddr)
return &gatewayAuthsessionMailHarness{
redis: redisClient,
userStub: userStub,
authsessionPublicURL: "http://" + authsessionPublicAddr,
authsessionInternalURL: "http://" + authsessionInternalAddr,
gatewayPublicURL: "http://" + gatewayPublicAddr,
gatewayGRPCAddr: gatewayGRPCAddr,
mailInternalURL: "http://" + mailInternalAddr,
responseSignerPublicKey: responseSignerPublicKey,
gatewayProcess: gatewayProcess,
authsessionProcess: authsessionProcess,
mailProcess: mailProcess,
}
}
func (h *gatewayAuthsessionMailHarness) stopMail(t *testing.T) {
t.Helper()
h.mailProcess.Stop(t)
}
func (h *gatewayAuthsessionMailHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) string {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.gatewayPublicURL+gatewaySendEmailCodePath,
map[string]string{"email": email},
map[string]string{"Accept-Language": acceptLanguage},
)
require.Equal(t, http.StatusOK, response.StatusCode, response.Body)
var body sendEmailCodeResponse
require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &body))
require.NotEmpty(t, body.ChallengeID)
return body.ChallengeID
}
func (h *gatewayAuthsessionMailHarness) confirmCode(t *testing.T, challengeID string, code string, clientPrivateKey ed25519.PrivateKey) httpResponse {
t.Helper()
return postJSONValue(t, h.gatewayPublicURL+gatewayConfirmEmailCodePath, map[string]string{
"challenge_id": challengeID,
"code": code,
"client_public_key": encodePublicKey(clientPrivateKey.Public().(ed25519.PublicKey)),
"time_zone": testTimeZone,
})
}
func (h *gatewayAuthsessionMailHarness) eventuallyListDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse {
t.Helper()
var response mailDeliveryListResponse
require.Eventually(t, func() bool {
response = h.listDeliveries(t, query)
return len(response.Items) > 0
}, 10*time.Second, 50*time.Millisecond)
return response
}
func (h *gatewayAuthsessionMailHarness) listDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse {
t.Helper()
target := h.mailInternalURL + gatewayMailDeliveriesPath
if encoded := query.Encode(); encoded != "" {
target += "?" + encoded
}
request, err := http.NewRequest(http.MethodGet, target, nil)
require.NoError(t, err)
return doJSONRequest[mailDeliveryListResponse](t, request, http.StatusOK)
}
func (h *gatewayAuthsessionMailHarness) getDelivery(t *testing.T, deliveryID string) mailDeliveryDetailResponse {
t.Helper()
request, err := http.NewRequest(http.MethodGet, h.mailInternalURL+gatewayMailDeliveriesPath+"/"+url.PathEscape(deliveryID), nil)
require.NoError(t, err)
return doJSONRequest[mailDeliveryDetailResponse](t, request, http.StatusOK)
}
func (h *gatewayAuthsessionMailHarness) waitForGatewaySession(t *testing.T, deviceSessionID string) gatewaySessionRecord {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
payload, err := h.redis.Get(context.Background(), "gateway:session:"+deviceSessionID).Bytes()
if err == nil {
var record gatewaySessionRecord
require.NoError(t, decodeStrictJSONPayload(payload, &record))
return record
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("gateway session projection for %s was not published in time", deviceSessionID)
return gatewaySessionRecord{}
}
func (h *gatewayAuthsessionMailHarness) dialGateway(t *testing.T) *grpc.ClientConn {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
conn, err := grpc.DialContext(
ctx,
h.gatewayGRPCAddr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, conn.Close())
})
return conn
}
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
return postJSONValueWithHeaders(t, targetURL, body, nil)
}
func postJSONValueWithHeaders(t *testing.T, targetURL string, body any, headers map[string]string) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
if value == "" {
continue
}
request.Header.Set(key, value)
}
return doRequest(t, request)
}
func doJSONRequest[T any](t *testing.T, request *http.Request, wantStatus int) T {
t.Helper()
response := doRequest(t, request)
require.Equal(t, wantStatus, response.StatusCode, response.Body)
var decoded T
require.NoError(t, json.Unmarshal([]byte(response.Body), &decoded), response.Body)
return decoded
}
func doRequest(t *testing.T, request *http.Request) httpResponse {
t.Helper()
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DisableKeepAlives: true,
},
}
t.Cleanup(client.CloseIdleConnections)
response, err := client.Do(request)
require.NoError(t, err)
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
require.NoError(t, err)
return httpResponse{
StatusCode: response.StatusCode,
Body: string(payload),
Header: response.Header.Clone(),
}
}
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
}
func templateVariableString(t *testing.T, variables map[string]any, field string) string {
t.Helper()
value, ok := variables[field]
require.True(t, ok, "template variable %q is missing", field)
text, ok := value.(string)
require.True(t, ok, "template variable %q must be a string", field)
require.NotEmpty(t, text)
return text
}
func newClientPrivateKey(label string) ed25519.PrivateKey {
seed := sha256.Sum256([]byte("galaxy-integration-gateway-authsessionmail-client-" + label))
return ed25519.NewKeyFromSeed(seed[:])
}
func encodePublicKey(publicKey ed25519.PublicKey) string {
return base64.StdEncoding.EncodeToString(publicKey)
}
func newSubscribeEventsRequest(deviceSessionID string, requestID string, clientPrivateKey ed25519.PrivateKey) *gatewayv1.SubscribeEventsRequest {
payloadHash := contractsgatewayv1.ComputePayloadHash(nil)
request := &gatewayv1.SubscribeEventsRequest{
ProtocolVersion: contractsgatewayv1.ProtocolVersionV1,
DeviceSessionId: deviceSessionID,
MessageType: contractsgatewayv1.SubscribeMessageType,
TimestampMs: time.Now().UnixMilli(),
RequestId: requestID,
PayloadHash: payloadHash,
TraceId: "trace-" + requestID,
}
request.Signature = contractsgatewayv1.SignRequest(clientPrivateKey, contractsgatewayv1.RequestSigningFields{
ProtocolVersion: request.GetProtocolVersion(),
DeviceSessionID: request.GetDeviceSessionId(),
MessageType: request.GetMessageType(),
TimestampMS: request.GetTimestampMs(),
RequestID: request.GetRequestId(),
PayloadHash: request.GetPayloadHash(),
})
return request
}
func assertBootstrapEvent(t *testing.T, event *gatewayv1.GatewayEvent, responseSignerPublicKey ed25519.PublicKey, wantRequestID string) {
t.Helper()
require.Equal(t, contractsgatewayv1.ServerTimeEventType, event.GetEventType())
require.Equal(t, wantRequestID, event.GetEventId())
require.Equal(t, wantRequestID, event.GetRequestId())
require.NoError(t, contractsgatewayv1.VerifyPayloadHash(event.GetPayloadBytes(), event.GetPayloadHash()))
require.NoError(t, contractsgatewayv1.VerifyEventSignature(responseSignerPublicKey, event.GetSignature(), contractsgatewayv1.EventSigningFields{
EventType: event.GetEventType(),
EventID: event.GetEventId(),
TimestampMS: event.GetTimestampMs(),
RequestID: event.GetRequestId(),
TraceID: event.GetTraceId(),
PayloadHash: event.GetPayloadHash(),
}))
}
func waitForMailReady(t *testing.T, process *harness.Process, baseURL string) {
t.Helper()
client := &http.Client{Timeout: 250 * time.Millisecond}
t.Cleanup(client.CloseIdleConnections)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
request, err := http.NewRequest(http.MethodGet, baseURL+gatewayMailDeliveriesPath, nil)
require.NoError(t, err)
response, err := client.Do(request)
if err == nil {
_, _ = io.Copy(io.Discard, response.Body)
response.Body.Close()
if response.StatusCode == http.StatusOK {
return
}
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("wait for mail readiness: timeout\n%s", process.Logs())
}
func waitForAuthsessionPublicReady(t *testing.T, process *harness.Process, baseURL string) {
t.Helper()
client := &http.Client{Timeout: 250 * time.Millisecond}
t.Cleanup(client.CloseIdleConnections)
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
response, err := postJSONValueMaybe(client, baseURL+gatewaySendEmailCodePath, map[string]string{
"email": "",
})
if err == nil && response.StatusCode == http.StatusBadRequest {
return
}
time.Sleep(25 * time.Millisecond)
}
t.Fatalf("wait for authsession public readiness: timeout\n%s", process.Logs())
}
func postJSONValueMaybe(client *http.Client, targetURL string, body any) (httpResponse, error) {
payload, err := json.Marshal(body)
if err != nil {
return httpResponse{}, err
}
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
if err != nil {
return httpResponse{}, err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.Do(request)
if err != nil {
return httpResponse{}, err
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return httpResponse{}, err
}
return httpResponse{
StatusCode: response.StatusCode,
Body: string(responseBody),
Header: response.Header.Clone(),
}, nil
}
func moduleTemplateDir(t *testing.T) string {
t.Helper()
return filepath.Join(repositoryRoot(t), "mail", "templates")
}
func repositoryRoot(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve repository root: runtime caller is unavailable")
}
return filepath.Clean(filepath.Join(filepath.Dir(file), "..", ".."))
}
@@ -61,6 +61,30 @@ func TestGatewayAuthsessionUserExistingAccountKeepsCreateOnlySettings(t *testing
require.Equal(t, "Europe/Paris", accountResponse.Account.TimeZone)
}
func TestGatewayAuthsessionUserAcceptLanguageSetsLocalizedPreferredLanguage(t *testing.T) {
h := newGatewayAuthsessionUserHarness(t)
const email = "localized@example.com"
challengeID := h.sendChallengeWithAcceptLanguage(t, email, "fr-FR, en;q=0.8")
deliveries := h.mailStub.RecordedDeliveries()
require.NotEmpty(t, deliveries)
require.Equal(t, "fr-FR", deliveries[len(deliveries)-1].Locale)
code := lastMailCodeFor(t, h.mailStub, email)
clientPrivateKey := newClientPrivateKey("localized-account")
confirmResponse := h.confirmCode(t, challengeID, code, clientPrivateKey)
var confirmBody struct {
DeviceSessionID string `json:"device_session_id"`
}
requireJSONStatus(t, confirmResponse, http.StatusOK, &confirmBody)
accountResponse := h.executeGetMyAccount(t, confirmBody.DeviceSessionID, "request-localized-account", clientPrivateKey)
require.Equal(t, "fr-FR", accountResponse.Account.PreferredLanguage)
require.Equal(t, gatewayAuthsessionUserTestTimeZone, accountResponse.Account.TimeZone)
}
func TestGatewayAuthsessionUserBlockedEmailAndUserBehavior(t *testing.T) {
h := newGatewayAuthsessionUserHarness(t)
@@ -148,9 +148,18 @@ func newGatewayAuthsessionUserHarness(t *testing.T) *gatewayAuthsessionUserHarne
func (h *gatewayAuthsessionUserHarness) sendChallenge(t *testing.T, email string) string {
t.Helper()
response := postJSONValue(t, h.gatewayPublicURL+"/api/v1/public/auth/send-email-code", map[string]string{
"email": email,
})
return h.sendChallengeWithAcceptLanguage(t, email, "")
}
func (h *gatewayAuthsessionUserHarness) sendChallengeWithAcceptLanguage(t *testing.T, email string, acceptLanguage string) string {
t.Helper()
response := postJSONValueWithHeaders(
t,
h.gatewayPublicURL+"/api/v1/public/auth/send-email-code",
map[string]string{"email": email},
map[string]string{"Accept-Language": acceptLanguage},
)
require.Equal(t, http.StatusOK, response.StatusCode)
var body struct {
@@ -299,12 +308,24 @@ type userLookupResponse struct {
func postJSONValue(t *testing.T, targetURL string, body any) httpResponse {
t.Helper()
return postJSONValueWithHeaders(t, targetURL, body, nil)
}
func postJSONValueWithHeaders(t *testing.T, targetURL string, body any, headers map[string]string) httpResponse {
t.Helper()
payload, err := json.Marshal(body)
require.NoError(t, err)
request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload))
require.NoError(t, err)
request.Header.Set("Content-Type", "application/json")
for key, value := range headers {
if value == "" {
continue
}
request.Header.Set(key, value)
}
client := &http.Client{Timeout: 5 * time.Second}
response, err := client.Do(request)
+47 -5
View File
@@ -6,26 +6,68 @@ require (
github.com/alicebob/miniredis/v2 v2.37.0
github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.42.0
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0
google.golang.org/grpc v1.80.0
)
require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mdelapenya/tlscert v0.2.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/moby/api v1.54.1 // indirect
github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+114 -1
View File
@@ -1,57 +1,170 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY=
github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30=
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0 h1:id/6LH8ZeDrtAUVSuNvZUAJ1kVpb82y1pr9yweAWsRg=
github.com/testcontainers/testcontainers-go/modules/redis v0.42.0/go.mod h1:uF0jI8FITagQpBNOgweGBmPf6rP4K0SeL1XFPbsZSSY=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
+9 -4
View File
@@ -22,6 +22,9 @@ type LoginCodeDelivery struct {
// Code stores the cleartext login code requested by authsession.
Code string
// Locale stores the canonical BCP 47 language tag selected by authsession.
Locale string
}
// MailBehavior overrides one external mail-stub response.
@@ -100,8 +103,9 @@ func (s *MailStub) handle(writer http.ResponseWriter, request *http.Request) {
}
var payload struct {
Email string `json:"email"`
Code string `json:"code"`
Email string `json:"email"`
Code string `json:"code"`
Locale string `json:"locale"`
}
if err := decodeStrictJSONRequest(request, &payload); err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest)
@@ -110,8 +114,9 @@ func (s *MailStub) handle(writer http.ResponseWriter, request *http.Request) {
s.mu.Lock()
s.deliveries = append(s.deliveries, LoginCodeDelivery{
Email: payload.Email,
Code: payload.Code,
Email: payload.Email,
Code: payload.Code,
Locale: payload.Locale,
})
behavior := s.behavior
s.mu.Unlock()
@@ -0,0 +1,47 @@
package harness
import (
"context"
"testing"
testcontainers "github.com/testcontainers/testcontainers-go"
rediscontainer "github.com/testcontainers/testcontainers-go/modules/redis"
)
const defaultRedisContainerImage = "redis:7"
// RedisRuntime stores one started real Redis container together with the
// externally reachable endpoint used by black-box suites.
type RedisRuntime struct {
Container *rediscontainer.RedisContainer
Addr string
}
// StartRedisContainer starts one isolated real Redis container and registers
// automatic cleanup for the suite.
func StartRedisContainer(t testing.TB) *RedisRuntime {
t.Helper()
ctx := context.Background()
container, err := rediscontainer.Run(ctx, defaultRedisContainerImage)
if err != nil {
t.Fatalf("start redis container: %v", err)
}
t.Cleanup(func() {
if err := testcontainers.TerminateContainer(container); err != nil {
t.Errorf("terminate redis container: %v", err)
}
})
addr, err := container.Endpoint(ctx, "")
if err != nil {
t.Fatalf("resolve redis container endpoint: %v", err)
}
return &RedisRuntime{
Container: container,
Addr: addr,
}
}
@@ -0,0 +1,377 @@
package harness
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// SMTPCaptureConfig configures one local SMTP capture server.
type SMTPCaptureConfig struct {
// SupportsSTARTTLS controls whether the server advertises and accepts the
// STARTTLS upgrade command.
SupportsSTARTTLS bool
// FinalDataReply stores the final SMTP status line returned after the
// message body has been received. Empty value keeps the default accepted
// reply.
FinalDataReply string
}
// SMTPCapture stores one running local SMTP capture server together with the
// generated trust anchor used by external processes.
type SMTPCapture struct {
addr string
rootCAPath string
listener net.Listener
tlsConfig *tls.Config
connsMu sync.Mutex
conns map[net.Conn]struct{}
payloadsMu sync.Mutex
payloads []string
acceptWG sync.WaitGroup
connWG sync.WaitGroup
}
// StartSMTPCapture starts one local SMTP server suitable for black-box tests
// that need to observe captured message payloads.
func StartSMTPCapture(t testing.TB, cfg SMTPCaptureConfig) *SMTPCapture {
t.Helper()
if cfg.FinalDataReply == "" {
cfg.FinalDataReply = "250 2.0.0 accepted"
}
serverCertificate, rootCAPEM := newSMTPCertificates(t)
rootCAPath := filepath.Join(t.TempDir(), "smtp-root-ca.pem")
if err := os.WriteFile(rootCAPath, rootCAPEM, 0o600); err != nil {
t.Fatalf("write SMTP root CA: %v", err)
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("start SMTP capture listener: %v", err)
}
capture := &SMTPCapture{
addr: listener.Addr().String(),
rootCAPath: rootCAPath,
listener: listener,
tlsConfig: &tls.Config{
Certificates: []tls.Certificate{serverCertificate},
MinVersion: tls.VersionTLS12,
},
conns: make(map[net.Conn]struct{}),
}
capture.acceptWG.Add(1)
go func() {
defer capture.acceptWG.Done()
for {
conn, err := listener.Accept()
if err != nil {
return
}
capture.trackConn(conn)
capture.connWG.Add(1)
go func() {
defer capture.connWG.Done()
defer capture.untrackConn(conn)
defer func() {
_ = conn.Close()
}()
capture.serveConnection(conn, cfg)
}()
}
}()
t.Cleanup(func() {
_ = capture.listener.Close()
capture.closeConnections()
capture.acceptWG.Wait()
capture.connWG.Wait()
})
return capture
}
// Addr returns the externally reachable TCP address of the capture server.
func (capture *SMTPCapture) Addr() string {
if capture == nil {
return ""
}
return capture.addr
}
// RootCAPath returns the PEM path that should be trusted by clients talking to
// the capture server over STARTTLS.
func (capture *SMTPCapture) RootCAPath() string {
if capture == nil {
return ""
}
return capture.rootCAPath
}
// LatestPayload returns the most recently captured SMTP DATA payload.
func (capture *SMTPCapture) LatestPayload() string {
if capture == nil {
return ""
}
capture.payloadsMu.Lock()
defer capture.payloadsMu.Unlock()
if len(capture.payloads) == 0 {
return ""
}
return capture.payloads[len(capture.payloads)-1]
}
func (capture *SMTPCapture) trackConn(conn net.Conn) {
capture.connsMu.Lock()
defer capture.connsMu.Unlock()
capture.conns[conn] = struct{}{}
}
func (capture *SMTPCapture) untrackConn(conn net.Conn) {
capture.connsMu.Lock()
defer capture.connsMu.Unlock()
delete(capture.conns, conn)
}
func (capture *SMTPCapture) closeConnections() {
capture.connsMu.Lock()
defer capture.connsMu.Unlock()
for conn := range capture.conns {
_ = conn.Close()
}
}
func (capture *SMTPCapture) appendPayload(payload string) {
capture.payloadsMu.Lock()
defer capture.payloadsMu.Unlock()
capture.payloads = append(capture.payloads, payload)
}
func (capture *SMTPCapture) serveConnection(conn net.Conn, cfg SMTPCaptureConfig) {
reader := newSMTPLineReader(conn)
writer := newSMTPLineWriter(conn)
writer.writeLine("220 localhost ESMTP")
tlsActive := false
for {
line, err := reader.readLine()
if err != nil {
return
}
command := strings.ToUpper(line)
switch {
case strings.HasPrefix(command, "EHLO "), strings.HasPrefix(command, "HELO "):
if cfg.SupportsSTARTTLS && !tlsActive {
writer.writeLines(
"250-localhost",
"250-8BITMIME",
"250-STARTTLS",
"250 SMTPUTF8",
)
continue
}
writer.writeLines(
"250-localhost",
"250-8BITMIME",
"250 SMTPUTF8",
)
case command == "STARTTLS":
if !cfg.SupportsSTARTTLS {
writer.writeLine("454 4.7.0 TLS not available")
continue
}
writer.writeLine("220 Ready to start TLS")
tlsConn := tls.Server(conn, capture.tlsConfig)
if err := tlsConn.Handshake(); err != nil {
return
}
capture.trackConn(tlsConn)
capture.untrackConn(conn)
conn = tlsConn
reader = newSMTPLineReader(conn)
writer = newSMTPLineWriter(conn)
tlsActive = true
case strings.HasPrefix(command, "MAIL FROM:"):
writer.writeLine("250 2.1.0 Ok")
case strings.HasPrefix(command, "RCPT TO:"):
writer.writeLine("250 2.1.5 Ok")
case command == "DATA":
writer.writeLine("354 End data with <CR><LF>.<CR><LF>")
var payload strings.Builder
for {
dataLine, err := reader.readRawLine()
if err != nil {
return
}
if dataLine == ".\r\n" {
break
}
payload.WriteString(dataLine)
}
capture.appendPayload(payload.String())
writer.writeLine(cfg.FinalDataReply)
case command == "RSET":
writer.writeLine("250 2.0.0 Ok")
case command == "QUIT":
writer.writeLine("221 2.0.0 Bye")
return
default:
writer.writeLine("250 2.0.0 Ok")
}
}
}
type smtpLineReader struct {
conn net.Conn
}
func newSMTPLineReader(conn net.Conn) *smtpLineReader {
return &smtpLineReader{conn: conn}
}
func (reader *smtpLineReader) readLine() (string, error) {
line, err := reader.readRawLine()
if err != nil {
return "", err
}
return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil
}
func (reader *smtpLineReader) readRawLine() (string, error) {
var buffer bytes.Buffer
tmp := make([]byte, 1)
for {
if _, err := reader.conn.Read(tmp); err != nil {
return "", err
}
buffer.WriteByte(tmp[0])
if tmp[0] == '\n' {
return buffer.String(), nil
}
}
}
type smtpLineWriter struct {
conn net.Conn
}
func newSMTPLineWriter(conn net.Conn) *smtpLineWriter {
return &smtpLineWriter{conn: conn}
}
func (writer *smtpLineWriter) writeLine(line string) {
_, _ = io.WriteString(writer.conn, line+"\r\n")
}
func (writer *smtpLineWriter) writeLines(lines ...string) {
for _, line := range lines {
writer.writeLine(line)
}
}
func newSMTPCertificates(t testing.TB) (tls.Certificate, []byte) {
t.Helper()
rootKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate SMTP root key: %v", err)
}
now := time.Now()
rootTemplate := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: "galaxy-integration-smtp-root",
},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
IsCA: true,
BasicConstraintsValid: true,
}
rootDER, err := x509.CreateCertificate(rand.Reader, &rootTemplate, &rootTemplate, &rootKey.PublicKey, rootKey)
if err != nil {
t.Fatalf("create SMTP root certificate: %v", err)
}
rootPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER})
serverKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("generate SMTP server key: %v", err)
}
serverTemplate := x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{
CommonName: "127.0.0.1",
},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(24 * time.Hour),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
}
rootCert, err := x509.ParseCertificate(rootDER)
if err != nil {
t.Fatalf("parse SMTP root certificate: %v", err)
}
serverDER, err := x509.CreateCertificate(rand.Reader, &serverTemplate, rootCert, &serverKey.PublicKey, rootKey)
if err != nil {
t.Fatalf("create SMTP server certificate: %v", err)
}
serverPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: serverDER})
serverKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(serverKey),
})
certificate, err := tls.X509KeyPair(append(serverPEM, rootPEM...), serverKeyPEM)
if err != nil {
t.Fatalf("load SMTP server key pair: %v", err)
}
return certificate, rootPEM
}