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
244 lines
8.8 KiB
Go
244 lines
8.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
func TestDiscordDeliveryRetries(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
statuses []int
|
|
headers []http.Header
|
|
transportErr error
|
|
wantCalls int
|
|
wantSleeps []time.Duration
|
|
wantError error
|
|
}{
|
|
{name: "success", statuses: []int{204}, wantCalls: 1},
|
|
{name: "all 2xx accepted", statuses: []int{299}, wantCalls: 1},
|
|
{name: "permanent 4xx", statuses: []int{400}, wantCalls: 1, wantError: errWebhookDelivery},
|
|
{name: "5xx recovers", statuses: []int{500, 204}, wantCalls: 2, wantSleeps: []time.Duration{250 * time.Millisecond}},
|
|
{name: "5xx exhausts", statuses: []int{500, 502, 503}, wantCalls: 3, wantSleeps: []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, wantError: errWebhookDelivery},
|
|
{name: "429 honors capped retry after", statuses: []int{429, 204}, headers: []http.Header{{"Retry-After": []string{"10"}}, nil}, wantCalls: 2, wantSleeps: []time.Duration{2 * time.Second}},
|
|
{name: "429 uses reset after", statuses: []int{429, 204}, headers: []http.Header{{"X-Ratelimit-Reset-After": []string{"0.125"}}, nil}, wantCalls: 2, wantSleeps: []time.Duration{125 * time.Millisecond}},
|
|
{name: "transport errors exhaust", transportErr: errors.New("connection reset"), wantCalls: 3, wantSleeps: []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, wantError: errWebhookDelivery},
|
|
{name: "timeouts exhaust", transportErr: context.DeadlineExceeded, wantCalls: 3, wantSleeps: []time.Duration{250 * time.Millisecond, 500 * time.Millisecond}, wantError: errWebhookTimeout},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
var mu sync.Mutex
|
|
calls := 0
|
|
var sleeps []time.Duration
|
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
calls++
|
|
if request.Header.Get("Content-Type") != "application/json" {
|
|
t.Errorf("Content-Type = %q", request.Header.Get("Content-Type"))
|
|
}
|
|
if test.transportErr != nil {
|
|
return nil, test.transportErr
|
|
}
|
|
index := min(calls-1, len(test.statuses)-1)
|
|
var headers http.Header
|
|
if index < len(test.headers) {
|
|
headers = test.headers[index]
|
|
}
|
|
return response(test.statuses[index], headers), nil
|
|
})
|
|
client, err := newDiscordClient("https://discord.invalid/webhook", &http.Client{Transport: transport}, func(_ context.Context, delay time.Duration) error {
|
|
sleeps = append(sleeps, delay)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
err = client.send(context.Background(), discordPayload{Content: "hello"})
|
|
if !errors.Is(err, test.wantError) {
|
|
t.Fatalf("send() error = %v, want %v", err, test.wantError)
|
|
}
|
|
if calls != test.wantCalls {
|
|
t.Fatalf("calls = %d, want %d", calls, test.wantCalls)
|
|
}
|
|
if len(sleeps) != len(test.wantSleeps) {
|
|
t.Fatalf("sleeps = %v, want %v", sleeps, test.wantSleeps)
|
|
}
|
|
for index := range sleeps {
|
|
if sleeps[index] != test.wantSleeps[index] {
|
|
t.Fatalf("sleeps = %v, want %v", sleeps, test.wantSleeps)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDiscordDeliveryPayloadAndCanceledSleep(t *testing.T) {
|
|
var sent discordPayload
|
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
if err := json.NewDecoder(request.Body).Decode(&sent); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return response(204, nil), nil
|
|
})
|
|
client, err := newDiscordClient("http://discord.invalid/webhook", &http.Client{Transport: transport}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := client.send(context.Background(), discordPayload{Content: strings.Repeat("界", 2100)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if utf8.RuneCountInString(sent.Content) != 2000 || !strings.HasSuffix(sent.Content, truncationMarker) {
|
|
t.Fatalf("content length/suffix = %d/%q", utf8.RuneCountInString(sent.Content), []rune(sent.Content)[1980:])
|
|
}
|
|
if sent.AllowedMentions.Parse == nil || len(sent.AllowedMentions.Parse) != 0 {
|
|
t.Fatalf("allowed mentions = %#v", sent.AllowedMentions)
|
|
}
|
|
|
|
client, err = newDiscordClient("http://discord.invalid/webhook", &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return response(500, nil), nil
|
|
})}, func(context.Context, time.Duration) error { return context.DeadlineExceeded })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := client.send(context.Background(), discordPayload{Content: "x"}); !errors.Is(err, errWebhookTimeout) {
|
|
t.Fatalf("send() error = %v, want timeout", err)
|
|
}
|
|
}
|
|
|
|
func TestDiscordDeliveryRecoversFromTransportError(t *testing.T) {
|
|
var calls int
|
|
var sleeps []time.Duration
|
|
transport := roundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
calls++
|
|
if calls == 1 {
|
|
return nil, errors.New("temporary connection failure")
|
|
}
|
|
return response(http.StatusNoContent, nil), nil
|
|
})
|
|
client, err := newDiscordClient("https://discord.invalid/webhook", &http.Client{Transport: transport}, func(_ context.Context, delay time.Duration) error {
|
|
sleeps = append(sleeps, delay)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := client.send(context.Background(), discordPayload{Content: "hello"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls != 2 || len(sleeps) != 1 || sleeps[0] != 250*time.Millisecond {
|
|
t.Fatalf("calls/sleeps = %d/%v", calls, sleeps)
|
|
}
|
|
}
|
|
|
|
func TestDiscordDeliveryHonorsCallerCancellation(t *testing.T) {
|
|
var calls, sleeps int
|
|
transport := roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
calls++
|
|
return nil, request.Context().Err()
|
|
})
|
|
client, err := newDiscordClient("https://discord.invalid/webhook", &http.Client{Transport: transport}, func(context.Context, time.Duration) error {
|
|
sleeps++
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := client.send(ctx, discordPayload{Content: "hello"}); !errors.Is(err, errWebhookTimeout) {
|
|
t.Fatalf("send() error = %v, want timeout", err)
|
|
}
|
|
if calls > 1 || sleeps != 0 {
|
|
t.Fatalf("canceled delivery made %d calls and %d sleeps", calls, sleeps)
|
|
}
|
|
}
|
|
|
|
func TestRetryDelayParsing(t *testing.T) {
|
|
if got, ok := parseRetryDelay("1.5"); !ok || got != 1500*time.Millisecond {
|
|
t.Fatalf("parseRetryDelay() = %v, %v", got, ok)
|
|
}
|
|
if _, ok := parseRetryDelay("garbage"); ok {
|
|
t.Fatal("invalid retry delay accepted")
|
|
}
|
|
httpDate := time.Now().Add(time.Second).UTC().Format(http.TimeFormat)
|
|
if got, ok := parseRetryDelay(httpDate); !ok || got < 0 || got > time.Second {
|
|
t.Fatalf("HTTP-date retry delay = %v, %v", got, ok)
|
|
}
|
|
if got := fallbackRetryDelay(1); got != 500*time.Millisecond {
|
|
t.Fatalf("fallbackRetryDelay(1) = %v", got)
|
|
}
|
|
if retryableStatus(499) || !retryableStatus(500) || !retryableStatus(429) {
|
|
t.Fatal("retryableStatus returned unexpected result")
|
|
}
|
|
|
|
canceled, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if err := sleepContext(canceled, time.Hour); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("sleepContext() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeDiscordPayloadLimits(t *testing.T) {
|
|
fields := make([]discordEmbedField, 30)
|
|
for index := range fields {
|
|
fields[index] = discordEmbedField{
|
|
Name: strings.Repeat("n", 300),
|
|
Value: strings.Repeat("界", 1200),
|
|
}
|
|
}
|
|
payload := discordPayload{
|
|
Content: strings.Repeat("c", 2100),
|
|
Embeds: []discordEmbed{{
|
|
Title: strings.Repeat("t", 300),
|
|
Description: strings.Repeat("d", 5000),
|
|
Fields: fields,
|
|
Footer: &discordEmbedFooter{Text: strings.Repeat("f", 2200)},
|
|
}},
|
|
}
|
|
normalizeDiscordPayload(&payload)
|
|
if utf8.RuneCountInString(payload.Content) != 2000 || utf8.RuneCountInString(payload.Embeds[0].Title) > 256 || utf8.RuneCountInString(payload.Embeds[0].Description) > 4096 {
|
|
t.Fatal("individual limits were not applied")
|
|
}
|
|
if len(payload.Embeds[0].Fields) > 25 || discordEmbedCharacters(payload.Embeds) > 6000 {
|
|
t.Fatalf("embed limits = fields %d, chars %d", len(payload.Embeds[0].Fields), discordEmbedCharacters(payload.Embeds))
|
|
}
|
|
for _, field := range payload.Embeds[0].Fields {
|
|
if utf8.RuneCountInString(field.Name) > 256 || utf8.RuneCountInString(field.Value) > 1024 {
|
|
t.Fatal("field limit was not applied")
|
|
}
|
|
}
|
|
|
|
if got := truncateRunes("abcdef", 4); got != "… [t" {
|
|
t.Fatalf("small truncation = %q", got)
|
|
}
|
|
if got := truncateRunes("abc", 3); got != "abc" {
|
|
t.Fatalf("unneeded truncation = %q", got)
|
|
}
|
|
if got := truncateRunes("abc", 0); got != "" {
|
|
t.Fatalf("zero truncation = %q", got)
|
|
}
|
|
}
|
|
|
|
func TestDiscordClientRejectsInvalidURLs(t *testing.T) {
|
|
for _, value := range []string{"", "relative/path", "ftp://example.com/hook", "https:///hook"} {
|
|
if _, err := newDiscordClient(value, nil, nil); err == nil {
|
|
t.Fatalf("newDiscordClient(%q) succeeded", value)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestIsTimeout(t *testing.T) {
|
|
if !isTimeout(context.DeadlineExceeded) || isTimeout(errors.New("ordinary")) {
|
|
t.Fatal("isTimeout returned unexpected result")
|
|
}
|
|
}
|