//go:build e2e package main import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "strings" "testing" "time" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/log" "github.com/testcontainers/testcontainers-go/modules/mockserver" "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" ) const mockServerImage = "mockserver/mockserver:5.15.0@sha256:0f9ef78c94894ac3e70135d156193b25e23872575d58e2228344964273b4af6b" type mockRecordedRequest struct { Method string `json:"method"` Path string `json:"path"` Headers map[string][]string `json:"headers"` Body struct { RawBytes string `json:"rawBytes"` } `json:"body"` } func TestContainerE2E(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) defer cancel() logger := log.TestLogger(t) testNetwork, err := network.New(ctx) if err != nil { t.Fatal(err) } testcontainers.CleanupNetwork(t, testNetwork) mock, err := mockserver.Run(ctx, mockServerImage, network.WithNetwork([]string{"discord-mock"}, testNetwork), testcontainers.WithLogger(logger), ) testcontainers.CleanupContainer(t, mock) if err != nil { t.Fatalf("start MockServer: %v", err) } mockURL, err := mock.URL(ctx) if err != nil { t.Fatal(err) } buildPlatform := "linux/amd64" targetOS := "linux" targetArch := "amd64" app, err := testcontainers.Run(ctx, "", testcontainers.WithDockerfile(testcontainers.FromDockerfile{ Context: ".", Dockerfile: "Dockerfile", Repo: "haven-notify-e2e", Tag: "test", KeepImage: false, BuildArgs: map[string]*string{ "BUILDPLATFORM": &buildPlatform, "TARGETOS": &targetOS, "TARGETARCH": &targetArch, }, }), network.WithNetwork([]string{"haven-notify"}, testNetwork), testcontainers.WithEnv(map[string]string{"WEBHOOK_URL": "http://discord-mock:1080/discord"}), testcontainers.WithExposedPorts("8080/tcp"), testcontainers.WithWaitStrategy(wait.ForHTTP("/ready").WithPort("8080/tcp").WithStartupTimeout(45*time.Second)), testcontainers.WithLogger(logger), ) testcontainers.CleanupContainer(t, app) if err != nil { t.Fatalf("start Haven Notify: %v", err) } appURL, err := app.PortEndpoint(ctx, "8080/tcp", "http") if err != nil { t.Fatal(err) } inspect, err := app.Inspect(ctx) if err != nil { t.Fatal(err) } if inspect.Config.User != "haven-notify" { t.Fatalf("container user = %q", inspect.Config.User) } t.Run("health probes", func(t *testing.T) { for path, body := range map[string]string{"/ready": "Ready", "/live": "Alive"} { response, err := http.Get(appURL + path) if err != nil { t.Fatal(err) } contents, _ := io.ReadAll(response.Body) _ = response.Body.Close() if response.StatusCode != 200 || string(contents) != body { t.Fatalf("%s = (%d, %q)", path, response.StatusCode, contents) } } }) if err := configureMock(mockURL, []mockExpectation{{Status: 204}}); err != nil { t.Fatal(err) } cases := []struct { path string body string want discordPayload }{ { path: "/notify", body: `{"title":"Título \"quoted\"","message":"line 1\nC:\\backups @everyone"}`, want: discordPayload{ Content: "**Título \"quoted\"**\nline 1\nC:\\backups @everyone", AllowedMentions: discordAllowedMentions{Parse: []string{}}, }, }, { path: "/template/notify/backup", body: `{"title":"Nightly","asset":"photos","backupSizeInMB":1536,"extra":[{"name":"Host","value":"nebula"}]}`, want: discordPayload{ Embeds: []discordEmbed{{ Title: "📦 Backup - Nightly", Description: "**photos** has been backed up successfully! ✅🫡\n", Color: 3066993, Fields: []discordEmbedField{ {Name: "💾 Backup Size", Value: "1.50 GiB", Inline: true}, {Name: "Host", Value: "nebula", Inline: true}, }, Footer: &discordEmbedFooter{Text: "✨ Haven Notify ✨"}, }}, AllowedMentions: discordAllowedMentions{Parse: []string{}}, }, }, { path: "/template/notify/update", body: `{"host":"nexus","asset":"k3s","time":0}`, want: discordPayload{ Embeds: []discordEmbed{{ Title: "🔄 Update - k3s", Description: "**nexus** has successfully updated **k3s**! ✅", Color: 3447003, Fields: []discordEmbedField{ {Name: "⏱️ Time Taken", Value: "0 seconds", Inline: true}, }, Footer: &discordEmbedFooter{Text: "✨ Haven Notify ✨"}, }}, AllowedMentions: discordAllowedMentions{Parse: []string{}}, }, }, { path: "/template/notify/error", body: `{"caller":"backup","message":"line 1\n\"quoted\" ","critical":true}`, want: discordPayload{ Embeds: []discordEmbed{{ Title: "❌ Error", Description: "**backup** encountered an error!", Color: 15158332, Fields: []discordEmbedField{ {Name: "📄 Message", Value: "line 1\n\"quoted\" ", Inline: false}, }, Footer: &discordEmbedFooter{Text: "✨ Haven Notify ✨"}, }}, AllowedMentions: discordAllowedMentions{Parse: []string{}}, }, }, } for _, test := range cases { response, err := postJSON(appURL+test.path, test.body) if err != nil { t.Fatalf("POST %s: %v", test.path, err) } contents, _ := io.ReadAll(response.Body) _ = response.Body.Close() if response.StatusCode != 200 || string(contents) != "Notification sent" { t.Fatalf("POST %s = (%d, %q)", test.path, response.StatusCode, contents) } } recorded, err := retrieveMockRequests(mockURL) if err != nil { t.Fatal(err) } if len(recorded) != len(cases) { t.Fatalf("recorded requests = %d, want %d", len(recorded), len(cases)) } for index, request := range recorded { if request.Method != http.MethodPost || request.Path != "/discord" || len(request.Headers["Content-Type"]) == 0 || request.Headers["Content-Type"][0] != "application/json" { t.Fatalf("recorded request %d = %+v", index, request) } body, err := base64.StdEncoding.DecodeString(request.Body.RawBytes) if err != nil || !json.Valid(body) || bytes.Contains(body, []byte(""")) { t.Fatalf("recorded body %d = %s, %v", index, body, err) } want, err := json.Marshal(cases[index].want) if err != nil { t.Fatal(err) } if !bytes.Equal(body, want) { t.Fatalf("recorded body %d = %s, want %s", index, body, want) } } t.Run("invalid request does not call webhook", func(t *testing.T) { if err := configureMock(mockURL, []mockExpectation{{Status: 204}}); err != nil { t.Fatal(err) } response, err := postJSON(appURL+"/notify", `{"title":"missing message"}`) if err != nil { t.Fatal(err) } _ = response.Body.Close() if response.StatusCode != 400 { t.Fatalf("status = %d", response.StatusCode) } recorded, err := retrieveMockRequests(mockURL) if err != nil || len(recorded) != 0 { t.Fatalf("recorded = %d, error = %v", len(recorded), err) } }) t.Run("transient failure retries", func(t *testing.T) { if err := configureMock(mockURL, []mockExpectation{{Status: 500, Times: 1, Priority: 10}, {Status: 204}}); err != nil { t.Fatal(err) } response, err := postJSON(appURL+"/notify", `{"title":"retry","message":"once"}`) if err != nil { t.Fatal(err) } _ = response.Body.Close() if response.StatusCode != 200 { t.Fatalf("status = %d", response.StatusCode) } recorded, err := retrieveMockRequests(mockURL) if err != nil || len(recorded) != 2 { t.Fatalf("recorded = %d, error = %v", len(recorded), err) } }) bad, err := testcontainers.Run(ctx, "haven-notify-e2e:test", testcontainers.WithWaitStrategy(wait.ForExit().WithExitTimeout(15*time.Second)), testcontainers.WithLogger(logger), ) testcontainers.CleanupContainer(t, bad) if err != nil { t.Fatalf("start unconfigured container: %v", err) } state, err := bad.State(ctx) if err != nil { t.Fatal(err) } if state.Running || state.ExitCode == 0 { t.Fatalf("unconfigured container state = %+v", state) } } type mockExpectation struct { Status int Times int Priority int } func configureMock(baseURL string, expectations []mockExpectation) error { request, _ := http.NewRequest(http.MethodPut, baseURL+"/mockserver/reset", nil) response, err := http.DefaultClient.Do(request) if err != nil { return err } _ = response.Body.Close() for _, expectation := range expectations { payload := map[string]any{ "httpRequest": map[string]any{"method": "POST", "path": "/discord"}, "httpResponse": map[string]any{"statusCode": expectation.Status}, "priority": expectation.Priority, } if expectation.Times > 0 { payload["times"] = map[string]any{"remainingTimes": expectation.Times, "unlimited": false} } body, _ := json.Marshal(payload) request, _ = http.NewRequest(http.MethodPut, baseURL+"/mockserver/expectation", bytes.NewReader(body)) request.Header.Set("Content-Type", "application/json") response, err = http.DefaultClient.Do(request) if err != nil { return err } _ = response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { return fmt.Errorf("MockServer expectation returned %d", response.StatusCode) } } return nil } func retrieveMockRequests(baseURL string) ([]mockRecordedRequest, error) { request, _ := http.NewRequest(http.MethodPut, baseURL+"/mockserver/retrieve?type=REQUESTS&format=JSON", strings.NewReader(`{"path":"/discord"}`)) request.Header.Set("Content-Type", "application/json") response, err := http.DefaultClient.Do(request) if err != nil { return nil, err } defer response.Body.Close() var recorded []mockRecordedRequest err = json.NewDecoder(response.Body).Decode(&recorded) return recorded, err } func postJSON(url, body string) (*http.Response, error) { request, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) if err != nil { return nil, err } request.Header.Set("Content-Type", "application/json") return http.DefaultClient.Do(request) }