From 042e23f97b85d69515de05f1e25104e527acb1f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Theodor=20Angerg=C3=A5rd?= Date: Mon, 7 Sep 2026 20:29:10 +0200 Subject: [PATCH] test: exercise Gmail HTTP and JWT integration locally --- README.md | 7 ++ google_mail/integration_test.go | 176 ++++++++++++++++++++++++++++++++ web/router_test.go | 35 +++++++ 3 files changed, 218 insertions(+) create mode 100644 google_mail/integration_test.go create mode 100644 web/router_test.go diff --git a/README.md b/README.md index 9e99246..52b98d5 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,13 @@ Testify assertions, isolated configuration files, and a fake mail service; they do not require Google credentials or a running service. CI runs the same command for every pull request, including stacked branches. +`go test -race ./...` also runs local HTTP integration tests through the real +router and Google client. A local token endpoint checks the signed JWT and +returns a test token, while a Gmail API stub captures outgoing messages and +simulates success, quota errors, service failures, and malformed responses. +Run just these tests with `go test -race ./google_mail -run TestGmailIntegration`. +They test our API integration contract, not Google-side permissions or delivery. + ### Manual Make sure you have golang installed and you `$GOPATH` setup. 1. Follow the steps in [Setup](#setup) and enable debug mode. diff --git a/google_mail/integration_test.go b/google_mail/integration_test.go new file mode 100644 index 0000000..7c37f32 --- /dev/null +++ b/google_mail/integration_test.go @@ -0,0 +1,176 @@ +package google_mail + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "io" + "net/http" + "net/http/httptest" + "net/mail" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/cthit/gotify" + gotifyweb "github.com/cthit/gotify/web" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/api/gmail/v1" +) + +// These are HTTP integration tests: the real router, JWT token exchange and +// Google client communicate with local servers. No Google account is contacted. +func TestGmailIntegration(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + viper.Set("max-mail-size", 20e6) + t.Cleanup(viper.Reset) + for _, tc := range []struct { + name string + tokenStatus, gmailStatus int + response string + wantStatus int + }{ + {"success", 200, 200, `{"id":"sent-1","historyId":"123","labelIds":["SENT"]}`, 200}, + {"rate limited", 200, 429, `{"error":{"code":429,"message":"quota exceeded"}}`, 500}, + {"upstream unavailable", 200, 503, `{"error":{"code":503,"message":"unavailable"}}`, 500}, + {"malformed response", 200, 200, `{"id":`, 500}, + {"token rejected", 400, 200, `{"error":"invalid_grant"}`, 500}, + } { + t.Run(tc.name, func(t *testing.T) { + assertions := make(chan string, 4) + messages := make(chan gmail.Message, 4) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/token": + assert.Equal(t, http.MethodPost, r.Method) + if !assert.NoError(t, r.ParseForm()) { + w.WriteHeader(400) + return + } + assert.Equal(t, "urn:ietf:params:oauth:grant-type:jwt-bearer", r.Form.Get("grant_type")) + assertions <- r.Form.Get("assertion") + w.WriteHeader(tc.tokenStatus) + if tc.tokenStatus == 200 { + _, err := io.WriteString(w, `{"access_token":"local-token","token_type":"Bearer","expires_in":3600}`) + assert.NoError(t, err) + } else { + _, err := io.WriteString(w, tc.response) + assert.NoError(t, err) + } + case "/gmail/v1/users/sender@example.invalid/messages/send": + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "Bearer local-token", r.Header.Get("Authorization")) + var message gmail.Message + if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&message)) { + w.WriteHeader(400) + return + } + messages <- message + w.WriteHeader(tc.gmailStatus) + _, err := io.WriteString(w, tc.response) + assert.NoError(t, err) + default: + t.Errorf("unexpected upstream request: %s %s", r.Method, r.URL) + w.WriteHeader(404) + } + })) + t.Cleanup(upstream.Close) + credentials, err := json.Marshal(map[string]string{ + "type": "service_account", "client_email": "service@example.invalid", + "private_key": string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})), + "token_uri": upstream.URL + "/token", + }) + require.NoError(t, err) + keyPath := filepath.Join(t.TempDir(), "credentials.json") + require.NoError(t, os.WriteFile(keyPath, credentials, 0600)) + creator, err := NewGoogleMailServiceCreator(keyPath, "sender@example.invalid", false) + require.NoError(t, err) + // The client already exposes BasePath, so no production test switch is needed. + creator().(*googleService).mailService.BasePath = upstream.URL + "/" + app := httptest.NewServer(gotifyweb.Router("secret", creator, false)) + t.Cleanup(app.Close) + input := gotify.Mail{From: "untrusted@example.invalid", To: "recipient@example.invalid", Subject: "Integration åäö", Body: "Hello from Gotify", Attachments: []gotify.Attachment{{Name: "file.txt", ContentType: "text/plain", Data: "aGVsbG8="}}} + payload, err := json.Marshal(input) + require.NoError(t, err) + req, err := http.NewRequest(http.MethodPost, app.URL+"/mail", bytes.NewReader(payload)) + require.NoError(t, err) + req.Header.Set("Authorization", "pre-shared: secret") + req.Header.Set("Content-Type", "application/json") + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Do(req) + require.NoError(t, err) + defer response.Body.Close() + assert.Equal(t, tc.wantStatus, response.StatusCode) + select { + case assertion := <-assertions: + verifyAssertion(t, key, assertion, upstream.URL+"/token") + default: + t.Fatal("no JWT token exchange") + } + if tc.tokenStatus != 200 { + assert.Empty(t, messages, "Gmail must not be called without a token") + return + } + var sent gmail.Message + select { + case sent = <-messages: + default: + t.Fatal("no Gmail send request") + } + raw, err := base64.RawURLEncoding.DecodeString(sent.Raw) + require.NoError(t, err) + parsed, err := mail.ReadMessage(bytes.NewReader(raw)) + require.NoError(t, err) + assert.Equal(t, "sender@example.invalid", parsed.Header.Get("From")) + assert.Equal(t, input.To, parsed.Header.Get("To")) + assert.Contains(t, string(raw), "aGVsbG8=") + if tc.wantStatus == 200 { + var returned gotify.Mail + require.NoError(t, json.NewDecoder(response.Body).Decode(&returned)) + input.From = "sender@example.invalid" + assert.Equal(t, input, returned) + } else { + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + assert.Empty(t, body, "upstream details should not leak to callers") + } + }) + } +} + +func verifyAssertion(t *testing.T, key *rsa.PrivateKey, assertion, audience string) { + t.Helper() + parts := strings.Split(assertion, ".") + require.Len(t, parts, 3) + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + require.NoError(t, err) + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + require.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], signature)) + data, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + var claims struct { + Issuer string `json:"iss"` + Subject string `json:"sub"` + Audience string `json:"aud"` + Scope string `json:"scope"` + Expires int64 `json:"exp"` + } + require.NoError(t, json.Unmarshal(data, &claims)) + assert.Equal(t, "service@example.invalid", claims.Issuer) + assert.Equal(t, "sender@example.invalid", claims.Subject) + assert.Equal(t, audience, claims.Audience) + assert.Equal(t, gmail.GmailSendScope, claims.Scope) + assert.Greater(t, claims.Expires, time.Now().Unix()) +} diff --git a/web/router_test.go b/web/router_test.go new file mode 100644 index 0000000..8dbf5ed --- /dev/null +++ b/web/router_test.go @@ -0,0 +1,35 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/cthit/gotify" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRoutingContract(t *testing.T) { + for _, tc := range []struct { + method, path string + status int + }{ + {http.MethodGet, "/mail", 404}, + {http.MethodPut, "/mail", 404}, + {http.MethodPost, "/missing", 404}, + {http.MethodPost, "/mail/", 400}, + } { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + server := httptest.NewServer(Router("secret", func() gotify.MailService { return &fakeMailService{} }, false)) + t.Cleanup(server.Close) + req, err := http.NewRequest(tc.method, server.URL+tc.path, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "pre-shared: secret") + response, err := server.Client().Do(req) + require.NoError(t, err) + defer response.Body.Close() + assert.Equal(t, tc.status, response.StatusCode) + }) + } +}