From bfd7a881ee3d0e18a92b05907b3011bc5d7df350 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Theodor=20Angerg=C3=A5rd?= Date: Mon, 7 Sep 2026 20:26:05 +0200 Subject: [PATCH] test: add unit coverage and Testify CI baseline --- .github/workflows/test.yml | 20 ++++++++++ README.md | 9 ++++- cmd/config.go | 29 ++++++++------ cmd/config_test.go | 58 ++++++++++++++++++++++++++++ go.mod | 3 ++ google_mail/google_service.go | 15 +++++--- google_mail/message_test.go | 72 +++++++++++++++++++++++++++++++++++ web/mail_test.go | 67 ++++++++++++++++++++++++++++++++ 8 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 cmd/config_test.go create mode 100644 google_mail/message_test.go create mode 100644 web/mail_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7ff65d6 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,20 @@ +name: Test + +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - run: go test -race ./... diff --git a/README.md b/README.md index 8b88062..9e99246 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,13 @@ You can either set this project up manually or with a simple docker compose setu Please referer to the software design document before starting development: `DESIGN.md` See issues for suggested features. +### Tests + +Run the unit tests and race detector with `go test -race ./...`. Tests use +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. + ### Manual Make sure you have golang installed and you `$GOPATH` setup. 1. Follow the steps in [Setup](#setup) and enable debug mode. @@ -124,4 +131,4 @@ services: ``` -Other services would then be able to reach this service on `http://gotify:8080/...` with `123abc` as the preshared key \ No newline at end of file +Other services would then be able to reach this service on `http://gotify:8080/...` with `123abc` as the preshared key diff --git a/cmd/config.go b/cmd/config.go index 54d74bb..c73d5bc 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -5,21 +5,26 @@ import ( ) func loadConfig() error { - viper.SetDefault("port", "8080") - viper.SetDefault("debug-mode", false) - viper.SetDefault("google-mail.keyfile", "gapps.json") - viper.SetDefault("mock-mode", false) - viper.SetDefault("max-mail-size", 20e6) + return readConfig(viper.GetViper(), "/etc/gotify/", "$HOME/.gotify/", ".") +} + +// readConfig accepts explicit locations so configuration can be tested in isolation. +func readConfig(config *viper.Viper, paths ...string) error { + config.SetDefault("port", "8080") + config.SetDefault("debug-mode", false) + config.SetDefault("google-mail.keyfile", "gapps.json") + config.SetDefault("mock-mode", false) + config.SetDefault("max-mail-size", 20e6) - viper.SetEnvPrefix("gotify") - viper.AutomaticEnv() + config.SetEnvPrefix("gotify") + config.AutomaticEnv() - viper.SetConfigName("config") // name of config file (without extension) - viper.AddConfigPath("/etc/gotify/") // path to look for the config file in - viper.AddConfigPath("$HOME/.gotify/") // call multiple times to add many search paths - viper.AddConfigPath(".") // optionally look for config in the working directory + config.SetConfigName("config") + for _, path := range paths { + config.AddConfigPath(path) + } - err := viper.ReadInConfig() // Find and read the config file + err := config.ReadInConfig() // Find and read the config file return err } diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..f27a94c --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "os" + "path/filepath" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfigDefaults(t *testing.T) { + clearConfigEnvironment(t) + config := viper.New() + err := readConfig(config, t.TempDir()) + var missing viper.ConfigFileNotFoundError + require.ErrorAs(t, err, &missing) + assert.Equal(t, "8080", config.GetString("port")) + assert.Equal(t, int64(20e6), config.GetInt64("max-mail-size")) + assert.Equal(t, "gapps.json", config.GetString("google-mail.keyfile")) + assert.False(t, config.GetBool("debug-mode")) + assert.False(t, config.GetBool("mock-mode")) +} + +func TestConfigPrecedence(t *testing.T) { + clearConfigEnvironment(t) + first, second := t.TempDir(), t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(first, "config.toml"), []byte("port = '9000'\npre-shared-key = 'file-key'\nmax-mail-size = 4096\ndebug-mode = true\n[google-mail]\nadmin-mail = 'file@example.invalid'\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(second, "config.toml"), []byte("port = '9001'\n"), 0600)) + config := viper.New() + require.NoError(t, readConfig(config, first, second)) + assert.Equal(t, "9000", config.GetString("port")) + assert.Equal(t, "file-key", config.GetString("pre-shared-key")) + assert.True(t, config.GetBool("debug-mode")) + assert.Equal(t, int64(4096), config.GetInt64("max-mail-size")) + assert.Equal(t, "file@example.invalid", config.GetString("google-mail.admin-mail")) + t.Setenv("GOTIFY_PORT", "9100") + t.Setenv("GOTIFY_PRE-SHARED-KEY", "environment-key") + t.Setenv("GOTIFY_GOOGLE-MAIL.ADMIN-MAIL", "environment@example.invalid") + assert.Equal(t, "9100", config.GetString("port")) + assert.Equal(t, "environment-key", config.GetString("pre-shared-key")) + assert.Equal(t, "environment@example.invalid", config.GetString("google-mail.admin-mail")) +} + +func TestInvalidConfig(t *testing.T) { + clearConfigEnvironment(t) + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "config.toml"), []byte("port = ["), 0600)) + assert.Error(t, readConfig(viper.New(), dir)) +} + +func clearConfigEnvironment(t *testing.T) { + t.Helper() + for _, key := range []string{"PORT", "PRE-SHARED-KEY", "MAX-MAIL-SIZE", "DEBUG-MODE", "MOCK-MODE", "GOOGLE-MAIL.KEYFILE", "GOOGLE-MAIL.ADMIN-MAIL"} { + t.Setenv("GOTIFY_"+key, "") + } +} diff --git a/go.mod b/go.mod index 72c1777..271d00f 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ toolchain go1.23.7 require ( github.com/gocraft/web v0.0.0-20190207150652-9707327fb69b github.com/spf13/viper v1.20.0 + github.com/stretchr/testify v1.10.0 golang.org/x/net v0.37.0 golang.org/x/oauth2 v0.28.0 google.golang.org/api v0.227.0 @@ -16,6 +17,7 @@ require ( cloud.google.com/go/auth v0.15.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect cloud.google.com/go/compute/metadata v0.6.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.8.0 // indirect github.com/go-logr/logr v1.4.2 // indirect @@ -26,6 +28,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.14.1 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect diff --git a/google_mail/google_service.go b/google_mail/google_service.go index a05fb39..59fa564 100644 --- a/google_mail/google_service.go +++ b/google_mail/google_service.go @@ -61,6 +61,14 @@ func NewGoogleMailServiceCreator(keyPath string, adminMail string, debug bool) ( func (g *googleService) SendMail(mail gotify.Mail) (gotify.Mail, error) { mail.From = g.adminMail + msg := &gmail.Message{ + Raw: base64.RawURLEncoding.EncodeToString([]byte(buildRawMessage(mail))), + } + _, err := g.mailService.Users.Messages.Send(mail.From, msg).Do() + return mail, err +} + +func buildRawMessage(mail gotify.Mail) string { var msgRaw string subject := "=?UTF-8?B?" + base64.StdEncoding.EncodeToString([]byte(mail.Subject)) + "?=" @@ -92,12 +100,7 @@ func (g *googleService) SendMail(mail gotify.Mail) (gotify.Mail, error) { mail.Body + "\r\n" } - msg := &gmail.Message{ - Raw: base64.RawURLEncoding.EncodeToString([]byte(msgRaw)), - } - _, err := g.mailService.Users.Messages.Send(mail.From, msg).Do() - - return mail, err + return msgRaw } func (g *googleService) Destroy() error { diff --git a/google_mail/message_test.go b/google_mail/message_test.go new file mode 100644 index 0000000..07601f6 --- /dev/null +++ b/google_mail/message_test.go @@ -0,0 +1,72 @@ +package google_mail + +import ( + "encoding/base64" + "io" + "mime" + "mime/multipart" + "net/mail" + "strings" + "testing" + + "github.com/cthit/gotify" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildRawMessage(t *testing.T) { + for _, attachments := range []bool{false, true} { + name := "plain" + if attachments { + name = "attachments" + } + t.Run(name, func(t *testing.T) { + input := gotify.Mail{From: "sender@example.invalid", To: "recipient@example.invalid", Subject: "Hallå 世界 ✓", Body: "A UTF-8 body: åäö\nSecond line"} + contents := []string{"hello\nworld", "\x00\x01\xff\xfe"} + if attachments { + input.Attachments = []gotify.Attachment{ + {Name: "greeting.txt", ContentType: "text/plain", Data: base64.StdEncoding.EncodeToString([]byte(contents[0]))}, + {Name: "binary.dat", ContentType: "application/octet-stream", Data: base64.StdEncoding.EncodeToString([]byte(contents[1]))}, + } + } + message, err := mail.ReadMessage(strings.NewReader(buildRawMessage(input))) + require.NoError(t, err) + assert.Equal(t, input.From, message.Header.Get("From")) + assert.Equal(t, input.To, message.Header.Get("To")) + subject, err := new(mime.WordDecoder).DecodeHeader(message.Header.Get("Subject")) + require.NoError(t, err) + assert.Equal(t, input.Subject, subject) + if !attachments { + body, err := io.ReadAll(message.Body) + require.NoError(t, err) + assert.Equal(t, input.Body+"\r\n", string(body)) + return + } + kind, params, err := mime.ParseMediaType(message.Header.Get("Content-Type")) + require.NoError(t, err) + assert.Equal(t, "multipart/mixed", kind) + require.NotEmpty(t, params["boundary"]) + parts := multipart.NewReader(message.Body, params["boundary"]) + part, err := parts.NextPart() + require.NoError(t, err) + assert.Equal(t, "text/plain; charset=UTF-8", part.Header.Get("Content-Type")) + body, err := io.ReadAll(part) + require.NoError(t, err) + assert.Equal(t, input.Body, string(body)) + for i, expected := range input.Attachments { + part, err := parts.NextPart() + require.NoError(t, err) + assert.Equal(t, expected.Name, part.FileName()) + kind, _, err := mime.ParseMediaType(part.Header.Get("Content-Type")) + require.NoError(t, err) + assert.Equal(t, expected.ContentType, kind) + assert.Equal(t, "base64", part.Header.Get("Content-Transfer-Encoding")) + data, err := io.ReadAll(base64.NewDecoder(base64.StdEncoding, part)) + require.NoError(t, err) + assert.Equal(t, []byte(contents[i]), data) + } + _, err = parts.NextPart() + assert.ErrorIs(t, err, io.EOF) + }) + } +} diff --git a/web/mail_test.go b/web/mail_test.go new file mode 100644 index 0000000..8fd5602 --- /dev/null +++ b/web/mail_test.go @@ -0,0 +1,67 @@ +package web + +import ( + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/cthit/gotify" + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeMailService struct { + sent []gotify.Mail + err error + destroyed int +} + +func (f *fakeMailService) SendMail(mail gotify.Mail) (gotify.Mail, error) { + f.sent = append(f.sent, mail) + mail.From = "sender@example.invalid" + return mail, f.err +} + +func (f *fakeMailService) Destroy() error { f.destroyed++; return nil } + +func TestMailHandler(t *testing.T) { + viper.Set("max-mail-size", 256) + t.Cleanup(viper.Reset) + for _, tc := range []struct { + name, authorization, body string + serviceError error + status, sends int + }{ + {name: "missing key", body: `{}`, status: 401}, + {name: "wrong key", authorization: "pre-shared: wrong", body: `{}`, status: 401}, + {name: "wrong scheme", authorization: "Bearer secret", body: `{}`, status: 401}, + {name: "invalid JSON", authorization: "pre-shared: secret", body: `{`, status: 400}, + {name: "wrong field type", authorization: "pre-shared: secret", body: `{"to":42}`, status: 400}, + {name: "trailing JSON", authorization: "pre-shared: secret", body: `{} {}`, status: 400}, + {name: "too large", authorization: "pre-shared: secret", body: strings.Repeat("x", 257), status: 413}, + {name: "upstream failure", authorization: "pre-shared: secret", body: `{}`, serviceError: errors.New("send failed"), status: 500, sends: 1}, + {name: "success", authorization: "pre-shared: secret", body: `{"to":"recipient@example.invalid","subject":"Hello","body":"World"}`, status: 200, sends: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + service := &fakeMailService{err: tc.serviceError} + handler := Router("secret", func() gotify.MailService { return service }, false) + req := httptest.NewRequest(http.MethodPost, "/mail", strings.NewReader(tc.body)) + req.Header.Set("Authorization", tc.authorization) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + assert.Equal(t, tc.status, recorder.Code) + assert.Len(t, service.sent, tc.sends) + assert.Equal(t, 1, service.destroyed) + if tc.status == http.StatusOK { + require.Len(t, service.sent, 1) + assert.Equal(t, "recipient@example.invalid", service.sent[0].To) + assert.JSONEq(t, `{"to":"recipient@example.invalid","from":"sender@example.invalid","subject":"Hello","body":"World","attachments":null}`, recorder.Body.String()) + } else { + assert.Empty(t, recorder.Body.String()) + } + }) + } +}