feat: mail service
This commit is contained in:
+34
-4
@@ -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+".")
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user