Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
176 changes: 176 additions & 0 deletions google_mail/integration_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
35 changes: 35 additions & 0 deletions web/router_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
Loading