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
323 lines
9.9 KiB
Go
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(""")) {
|
|
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)
|
|
}
|