Files
server-scripts/haven-notify/e2e_test.go
Jose Henrique b1cdf15cc8
Some checks failed
Check scripts syntax / check-scripts-syntax (push) Successful in 3s
Test automated-nfs-backup / test-automated-nfs-backup (push) Successful in 4s
Haven Notify Build and Deploy / Build Haven Notify Image (pull_request) Has been cancelled
Haven Notify Build and Deploy / Build Haven Notify Image (PR) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Deploy Haven Notify (internal) (pull_request) Has been cancelled
Haven Notify Build and Deploy / Test Haven Notify (pull_request) Has been cancelled
adding test containers
2026-07-16 09:14:51 -03:00

323 lines
9.9 KiB
Go

//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\" <tag>","critical":true}`,
want: discordPayload{
Embeds: []discordEmbed{{
Title: "❌ Error",
Description: "**backup** encountered an error!",
Color: 15158332,
Fields: []discordEmbedField{
{Name: "📄 Message", Value: "line 1\n\"quoted\" <tag>", 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("&#34;")) {
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)
}