Initial commit
This commit is contained in:
commit
3264e54723
20 changed files with 2269 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-happydeliver
|
||||
checker-happydeliver.so
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-happydeliver .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-happydeliver /checker-happydeliver
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["/checker-happydeliver", "-healthcheck"]
|
||||
ENTRYPOINT ["/checker-happydeliver"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CHECKER_NAME := checker-happydeliver
|
||||
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
|
||||
CHECKER_VERSION ?= custom-build
|
||||
|
||||
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||
|
||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||
|
||||
.PHONY: all plugin docker test clean
|
||||
|
||||
all: $(CHECKER_NAME)
|
||||
|
||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||
|
||||
plugin: $(CHECKER_NAME).so
|
||||
|
||||
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||
|
||||
docker:
|
||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||
|
||||
test:
|
||||
go test -tags standalone ./...
|
||||
|
||||
clean:
|
||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||
26
NOTICE
Normal file
26
NOTICE
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
checker-happydeliver
|
||||
Copyright (c) 2026 The happyDomain Authors
|
||||
|
||||
This product is licensed under the MIT License (see LICENSE).
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed as part of the checker-sdk-go
|
||||
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||
under the Apache License, Version 2.0:
|
||||
|
||||
checker-sdk-go
|
||||
Copyright 2020-2026 The happyDomain Authors
|
||||
|
||||
This product includes software developed as part of the happyDomain
|
||||
project (https://happydomain.org).
|
||||
|
||||
Portions of this code were originally written for the happyDomain
|
||||
server (licensed under AGPL-3.0 and a commercial license) and are
|
||||
made available there under the Apache License, Version 2.0 to enable
|
||||
a permissively licensed ecosystem of checker plugins.
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
64
README.md
Normal file
64
README.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# checker-happydeliver
|
||||
|
||||
happyDomain checker that drives a [happyDeliver](https://git.nemunai.re/happyDomain/happyDeliver)
|
||||
instance to assess outbound email deliverability for a domain.
|
||||
|
||||
The checker:
|
||||
|
||||
1. Allocates a fresh recipient address on a happyDeliver instance
|
||||
(`POST /api/test`).
|
||||
2. Sends a real test message **from the tested domain** to that address,
|
||||
using SMTP credentials supplied by the user.
|
||||
3. Polls happyDeliver until the message is analysed.
|
||||
4. Stores happyDeliver's report verbatim as the observation, exposes
|
||||
per-section scores as metrics, and emits one rule per section
|
||||
(DNS, authentication, spam, blacklist, header, content, plus overall)
|
||||
that fires CRIT when the score drops under a user-configured minimum.
|
||||
|
||||
## Options
|
||||
|
||||
### Admin
|
||||
|
||||
| Id | Type | Description |
|
||||
|----------------------|--------|-------------|
|
||||
| `happydeliver_url` | string | Default base URL of the happyDeliver API. |
|
||||
| `happydeliver_token` | secret | Default bearer token for the happyDeliver API. |
|
||||
|
||||
### User / per-domain
|
||||
|
||||
| Id | Type | Default | Description |
|
||||
|----------------------|--------|-------------|-------------|
|
||||
| `happydeliver_url` | string | (admin) | Override the happyDeliver URL. |
|
||||
| `happydeliver_token` | secret | (admin) | Override the happyDeliver token. |
|
||||
| `smtp_host` | string | (none) | Submission server. **Required.** |
|
||||
| `smtp_port` | number | `587` | Submission port. |
|
||||
| `smtp_username` | string | (none) | SMTP username (omit for anonymous submission). |
|
||||
| `smtp_password` | secret | (none) | SMTP password. |
|
||||
| `smtp_tls` | choice | `starttls` | `starttls`, `tls`, or `none`. |
|
||||
| `from_address` | string | (none) | From address. **Required.** |
|
||||
| `subject_override` | string | (default) | Override the test subject. |
|
||||
| `body_text_override` | text | (default) | Override the plain-text body. |
|
||||
| `body_html_override` | text | (default) | Override the HTML body. |
|
||||
| `wait_timeout` | number | `900` | Seconds to wait for analysis. |
|
||||
| `poll_interval` | number | `5` | Seconds between polls (clamped to [2, 60]). |
|
||||
| `min_score_<section>`| number | per-section | Minimum acceptable score for each section. |
|
||||
|
||||
### Per-rule minimum scores
|
||||
|
||||
Defaults: `overall=70`, `dns=70`, `authentication=80`, `spam=70`,
|
||||
`blacklist=90`, `header=70`, `content=60`. Each can be customised through
|
||||
the rule options shown in the happyDomain UI.
|
||||
|
||||
## Metrics
|
||||
|
||||
One `happydeliver_score` metric per section, labelled with `section=<name>`
|
||||
(values: `overall`, `dns`, `authentication`, `spam`, `blacklist`, `header`,
|
||||
`content`).
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make # standalone binary (HTTP server on :8080)
|
||||
make plugin # plugin.so loadable by happyDomain
|
||||
make docker # container image
|
||||
```
|
||||
390
checker/collect.go
Normal file
390
checker/collect.go
Normal file
|
|
@ -0,0 +1,390 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"net/smtp"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultSubject = "happyDomain deliverability test"
|
||||
defaultBodyText = "This is an automated deliverability test sent by happyDomain via happyDeliver. You can ignore this message.\r\n"
|
||||
defaultBodyHTML = `<!doctype html><html><body><p>This is an automated deliverability test sent by <strong>happyDomain</strong> via <em>happyDeliver</em>.</p><p>You can ignore this message.</p></body></html>`
|
||||
)
|
||||
|
||||
func (p *happyDeliverProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
cfg, err := loadConfig(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data := &HappyDeliverData{
|
||||
Phase: "allocate",
|
||||
Endpoint: cfg.HappyDeliverURL,
|
||||
StartedAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
|
||||
test, err := allocateTest(ctx, client, cfg)
|
||||
if err != nil {
|
||||
data.Error = err.Error()
|
||||
return data, nil
|
||||
}
|
||||
data.TestID = test.ID
|
||||
data.RecipientEmail = test.Email
|
||||
|
||||
data.Phase = "send"
|
||||
if err := sendTestEmail(ctx, cfg, test.Email); err != nil {
|
||||
data.Error = fmt.Sprintf("send: %v", err)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
data.Phase = "wait"
|
||||
if err := waitForAnalysis(ctx, client, cfg, test.ID); err != nil {
|
||||
if errors.Is(err, errTimeout) {
|
||||
data.Phase = "timeout"
|
||||
data.Error = "happyDeliver did not analyse the message before the timeout"
|
||||
return data, nil
|
||||
}
|
||||
data.Error = fmt.Sprintf("wait: %v", err)
|
||||
return data, nil
|
||||
}
|
||||
|
||||
data.Phase = "fetch"
|
||||
raw, err := fetchReport(ctx, client, cfg, test.ID)
|
||||
if err != nil {
|
||||
data.Error = fmt.Sprintf("fetch: %v", err)
|
||||
return data, nil
|
||||
}
|
||||
data.Report = raw
|
||||
data.AnalysedAt = time.Now().UTC()
|
||||
data.LatencySeconds = data.AnalysedAt.Sub(data.StartedAt).Seconds()
|
||||
|
||||
scores, grades, err := extractScores(raw)
|
||||
if err != nil {
|
||||
data.Phase = "parse"
|
||||
data.Error = fmt.Sprintf("parse: %v", err)
|
||||
return data, nil
|
||||
}
|
||||
data.Scores = scores
|
||||
data.Grades = grades
|
||||
data.Phase = "ok"
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type runConfig struct {
|
||||
HappyDeliverURL string
|
||||
HappyDeliverToken string
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPTLS string
|
||||
FromAddress string
|
||||
FromHeader string
|
||||
Subject string
|
||||
BodyText string
|
||||
BodyHTML string
|
||||
WaitTimeout time.Duration
|
||||
PollInterval time.Duration
|
||||
}
|
||||
|
||||
func loadConfig(opts sdk.CheckerOptions) (*runConfig, error) {
|
||||
cfg := &runConfig{
|
||||
HappyDeliverURL: strings.TrimSpace(stringOpt(opts, "happydeliver_url")),
|
||||
HappyDeliverToken: stringOpt(opts, "happydeliver_token"),
|
||||
SMTPHost: stringOpt(opts, "smtp_host"),
|
||||
SMTPPort: sdk.GetIntOption(opts, "smtp_port", 587),
|
||||
SMTPUsername: stringOpt(opts, "smtp_username"),
|
||||
SMTPPassword: stringOpt(opts, "smtp_password"),
|
||||
SMTPTLS: strings.ToLower(stringOpt(opts, "smtp_tls")),
|
||||
FromAddress: stringOpt(opts, "from_address"),
|
||||
Subject: stringOpt(opts, "subject_override"),
|
||||
BodyText: stringOpt(opts, "body_text_override"),
|
||||
BodyHTML: stringOpt(opts, "body_html_override"),
|
||||
}
|
||||
if cfg.SMTPTLS == "" {
|
||||
cfg.SMTPTLS = "starttls"
|
||||
}
|
||||
if cfg.Subject == "" {
|
||||
cfg.Subject = defaultSubject
|
||||
}
|
||||
if cfg.BodyText == "" {
|
||||
cfg.BodyText = defaultBodyText
|
||||
}
|
||||
if cfg.BodyHTML == "" {
|
||||
cfg.BodyHTML = defaultBodyHTML
|
||||
}
|
||||
cfg.WaitTimeout = time.Duration(sdk.GetIntOption(opts, "wait_timeout", 900)) * time.Second
|
||||
poll := sdk.GetIntOption(opts, "poll_interval", 5)
|
||||
if poll < 2 {
|
||||
poll = 2
|
||||
}
|
||||
if poll > 60 {
|
||||
poll = 60
|
||||
}
|
||||
cfg.PollInterval = time.Duration(poll) * time.Second
|
||||
|
||||
if cfg.HappyDeliverURL == "" {
|
||||
return nil, fmt.Errorf("happydeliver_url is required")
|
||||
}
|
||||
u, err := url.ParseRequestURI(cfg.HappyDeliverURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid happydeliver_url: %w", err)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return nil, fmt.Errorf("happydeliver_url must use http or https, got %q", u.Scheme)
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, fmt.Errorf("happydeliver_url is missing a host")
|
||||
}
|
||||
if cfg.SMTPHost == "" {
|
||||
return nil, fmt.Errorf("smtp_host is required")
|
||||
}
|
||||
if cfg.FromAddress == "" {
|
||||
return nil, fmt.Errorf("from_address is required")
|
||||
}
|
||||
parsedFrom, err := mail.ParseAddress(cfg.FromAddress)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid from_address: %w", err)
|
||||
}
|
||||
cfg.FromAddress = parsedFrom.Address
|
||||
cfg.FromHeader = parsedFrom.String()
|
||||
switch cfg.SMTPTLS {
|
||||
case "none", "starttls", "tls":
|
||||
default:
|
||||
return nil, fmt.Errorf("smtp_tls must be one of none, starttls, tls")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func stringOpt(opts sdk.CheckerOptions, key string) string {
|
||||
v, _ := sdk.GetOption[string](opts, key)
|
||||
return v
|
||||
}
|
||||
|
||||
type testResponse struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
func allocateTest(ctx context.Context, client *http.Client, cfg *runConfig) (*testResponse, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, joinURL(cfg.HappyDeliverURL, "/api/test"), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addAuth(req, cfg)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("POST /api/test returned %s", resp.Status)
|
||||
}
|
||||
var tr testResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tr.ID == "" || tr.Email == "" {
|
||||
return nil, fmt.Errorf("happyDeliver returned empty test allocation")
|
||||
}
|
||||
return &tr, nil
|
||||
}
|
||||
|
||||
var errTimeout = fmt.Errorf("timeout waiting for analysis")
|
||||
|
||||
func waitForAnalysis(ctx context.Context, client *http.Client, cfg *runConfig, id string) error {
|
||||
deadline := time.Now().Add(cfg.WaitTimeout)
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
return errTimeout
|
||||
}
|
||||
status, err := getTestStatus(ctx, client, cfg, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if status == "analyzed" {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(cfg.PollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getTestStatus(ctx context.Context, client *http.Client, cfg *runConfig, id string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(cfg.HappyDeliverURL, "/api/test/"+id), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
addAuth(req, cfg)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return "", fmt.Errorf("GET /api/test returned %s", resp.Status)
|
||||
}
|
||||
var t struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&t); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return t.Status, nil
|
||||
}
|
||||
|
||||
func fetchReport(ctx context.Context, client *http.Client, cfg *runConfig, id string) (json.RawMessage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, joinURL(cfg.HappyDeliverURL, "/api/report/"+id), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
addAuth(req, cfg)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, fmt.Errorf("GET /api/report returned %s", resp.Status)
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func addAuth(req *http.Request, cfg *runConfig) {
|
||||
if cfg.HappyDeliverToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.HappyDeliverToken)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
|
||||
func joinURL(base, path string) string {
|
||||
return strings.TrimRight(base, "/") + path
|
||||
}
|
||||
|
||||
func sendTestEmail(ctx context.Context, cfg *runConfig, recipient string) error {
|
||||
addr := net.JoinHostPort(cfg.SMTPHost, strconv.Itoa(cfg.SMTPPort))
|
||||
msg := buildMessage(cfg, recipient)
|
||||
|
||||
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
||||
deadline, ok := ctx.Deadline()
|
||||
if !ok {
|
||||
deadline = time.Now().Add(2 * time.Minute)
|
||||
}
|
||||
|
||||
var conn net.Conn
|
||||
var err error
|
||||
if cfg.SMTPTLS == "tls" {
|
||||
conn, err = tls.DialWithDialer(dialer, "tcp", addr, &tls.Config{ServerName: cfg.SMTPHost})
|
||||
} else {
|
||||
conn, err = dialer.DialContext(ctx, "tcp", addr)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
_ = conn.SetDeadline(deadline)
|
||||
|
||||
c, err := smtp.NewClient(conn, cfg.SMTPHost)
|
||||
if err != nil {
|
||||
conn.Close()
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
if cfg.SMTPTLS == "starttls" {
|
||||
if ok, _ := c.Extension("STARTTLS"); !ok {
|
||||
return fmt.Errorf("server does not advertise STARTTLS")
|
||||
}
|
||||
if err := c.StartTLS(&tls.Config{ServerName: cfg.SMTPHost}); err != nil {
|
||||
return fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.SMTPUsername != "" {
|
||||
auth := smtp.PlainAuth("", cfg.SMTPUsername, cfg.SMTPPassword, cfg.SMTPHost)
|
||||
if err := c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("auth: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := c.Mail(cfg.FromAddress); err != nil {
|
||||
return fmt.Errorf("MAIL FROM: %w", err)
|
||||
}
|
||||
if err := c.Rcpt(recipient); err != nil {
|
||||
return fmt.Errorf("RCPT TO: %w", err)
|
||||
}
|
||||
wc, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA: %w", err)
|
||||
}
|
||||
if _, err := wc.Write(msg); err != nil {
|
||||
wc.Close()
|
||||
return fmt.Errorf("write body: %w", err)
|
||||
}
|
||||
if err := wc.Close(); err != nil {
|
||||
return fmt.Errorf("close DATA: %w", err)
|
||||
}
|
||||
return c.Quit()
|
||||
}
|
||||
|
||||
func buildMessage(cfg *runConfig, recipient string) []byte {
|
||||
boundary := "happydeliver-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
||||
var buf bytes.Buffer
|
||||
fmt.Fprintf(&buf, "From: %s\r\n", cfg.FromHeader)
|
||||
fmt.Fprintf(&buf, "To: %s\r\n", recipient)
|
||||
fmt.Fprintf(&buf, "Subject: %s\r\n", mime.QEncoding.Encode("UTF-8", cfg.Subject))
|
||||
fmt.Fprintf(&buf, "Date: %s\r\n", time.Now().UTC().Format(time.RFC1123Z))
|
||||
fmt.Fprintf(&buf, "Message-ID: <%d.happydeliver@%s>\r\n", time.Now().UnixNano(), hostFromAddress(cfg.FromAddress))
|
||||
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\n")
|
||||
fmt.Fprintf(&buf, "Content-Type: multipart/alternative; boundary=%q\r\n\r\n", boundary)
|
||||
|
||||
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
||||
fmt.Fprintf(&buf, "Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
fmt.Fprintf(&buf, "Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||||
buf.WriteString(cfg.BodyText)
|
||||
if !strings.HasSuffix(cfg.BodyText, "\r\n") {
|
||||
buf.WriteString("\r\n")
|
||||
}
|
||||
|
||||
fmt.Fprintf(&buf, "--%s\r\n", boundary)
|
||||
fmt.Fprintf(&buf, "Content-Type: text/html; charset=UTF-8\r\n")
|
||||
fmt.Fprintf(&buf, "Content-Transfer-Encoding: 8bit\r\n\r\n")
|
||||
buf.WriteString(cfg.BodyHTML)
|
||||
buf.WriteString("\r\n")
|
||||
fmt.Fprintf(&buf, "--%s--\r\n", boundary)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func hostFromAddress(addr string) string {
|
||||
if i := strings.LastIndex(addr, "@"); i >= 0 {
|
||||
return addr[i+1:]
|
||||
}
|
||||
return "localhost"
|
||||
}
|
||||
691
checker/collect_test.go
Normal file
691
checker/collect_test.go
Normal file
|
|
@ -0,0 +1,691 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func baseValidOptions() sdk.CheckerOptions {
|
||||
return sdk.CheckerOptions{
|
||||
"happydeliver_url": "https://deliver.example.org",
|
||||
"happydeliver_token": "tok",
|
||||
"smtp_host": "smtp.example.org",
|
||||
"smtp_port": float64(587),
|
||||
"smtp_tls": "starttls",
|
||||
"from_address": "test@example.org",
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigDefaults(t *testing.T) {
|
||||
cfg, err := loadConfig(baseValidOptions())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.SMTPPort != 587 {
|
||||
t.Errorf("SMTPPort = %d, want 587", cfg.SMTPPort)
|
||||
}
|
||||
if cfg.SMTPTLS != "starttls" {
|
||||
t.Errorf("SMTPTLS = %q, want starttls", cfg.SMTPTLS)
|
||||
}
|
||||
if cfg.Subject != defaultSubject {
|
||||
t.Errorf("Subject = %q, want default", cfg.Subject)
|
||||
}
|
||||
if cfg.BodyText != defaultBodyText {
|
||||
t.Errorf("BodyText not defaulted")
|
||||
}
|
||||
if cfg.BodyHTML != defaultBodyHTML {
|
||||
t.Errorf("BodyHTML not defaulted")
|
||||
}
|
||||
if cfg.WaitTimeout != 900*time.Second {
|
||||
t.Errorf("WaitTimeout = %v, want 900s", cfg.WaitTimeout)
|
||||
}
|
||||
if cfg.PollInterval != 5*time.Second {
|
||||
t.Errorf("PollInterval = %v, want 5s", cfg.PollInterval)
|
||||
}
|
||||
if cfg.FromAddress != "test@example.org" {
|
||||
t.Errorf("FromAddress = %q", cfg.FromAddress)
|
||||
}
|
||||
if cfg.FromHeader != "<test@example.org>" {
|
||||
t.Errorf("FromHeader = %q", cfg.FromHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigPollClamping(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
in, want int
|
||||
}{
|
||||
{0, 2}, {1, 2}, {2, 2}, {30, 30}, {60, 60}, {120, 60},
|
||||
} {
|
||||
opts := baseValidOptions()
|
||||
opts["poll_interval"] = float64(tc.in)
|
||||
cfg, err := loadConfig(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("in=%d: %v", tc.in, err)
|
||||
}
|
||||
if cfg.PollInterval != time.Duration(tc.want)*time.Second {
|
||||
t.Errorf("in=%d: got %v, want %ds", tc.in, cfg.PollInterval, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigValidationErrors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(sdk.CheckerOptions)
|
||||
want string
|
||||
}{
|
||||
{"missing url", func(o sdk.CheckerOptions) { delete(o, "happydeliver_url") }, "happydeliver_url is required"},
|
||||
{"bad url", func(o sdk.CheckerOptions) { o["happydeliver_url"] = "not a url" }, "invalid happydeliver_url"},
|
||||
{"non-http scheme", func(o sdk.CheckerOptions) { o["happydeliver_url"] = "ftp://x.test" }, "must use http or https"},
|
||||
{"missing host", func(o sdk.CheckerOptions) { o["happydeliver_url"] = "https://" }, "missing a host"},
|
||||
{"missing smtp_host", func(o sdk.CheckerOptions) { delete(o, "smtp_host") }, "smtp_host is required"},
|
||||
{"missing from", func(o sdk.CheckerOptions) { delete(o, "from_address") }, "from_address is required"},
|
||||
{"bad from", func(o sdk.CheckerOptions) { o["from_address"] = "not-an-address" }, "invalid from_address"},
|
||||
{"bad tls mode", func(o sdk.CheckerOptions) { o["smtp_tls"] = "weird" }, "smtp_tls must be one of"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := baseValidOptions()
|
||||
tc.mutate(opts)
|
||||
_, err := loadConfig(opts)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tc.want)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("err = %v, want substring %q", err, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigAcceptsDisplayNameFrom(t *testing.T) {
|
||||
opts := baseValidOptions()
|
||||
opts["from_address"] = "Alice <alice@example.org>"
|
||||
cfg, err := loadConfig(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.FromAddress != "alice@example.org" {
|
||||
t.Errorf("FromAddress should be the bare address, got %q", cfg.FromAddress)
|
||||
}
|
||||
if !strings.Contains(cfg.FromHeader, "<alice@example.org>") {
|
||||
t.Errorf("FromHeader should keep display form, got %q", cfg.FromHeader)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigTrimsURL(t *testing.T) {
|
||||
opts := baseValidOptions()
|
||||
opts["happydeliver_url"] = " https://deliver.example.org "
|
||||
cfg, err := loadConfig(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.HappyDeliverURL != "https://deliver.example.org" {
|
||||
t.Errorf("URL not trimmed: %q", cfg.HappyDeliverURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"https://x": "https://x/api",
|
||||
"https://x/": "https://x/api",
|
||||
"https://x///": "https://x/api",
|
||||
"https://x/foo": "https://x/foo/api",
|
||||
}
|
||||
for base, want := range cases {
|
||||
if got := joinURL(base, "/api"); got != want {
|
||||
t.Errorf("joinURL(%q) = %q, want %q", base, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostFromAddress(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"user@example.org": "example.org",
|
||||
"a@b@example.org": "example.org",
|
||||
"no-at-sign": "localhost",
|
||||
"": "localhost",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := hostFromAddress(in); got != want {
|
||||
t.Errorf("hostFromAddress(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringOpt(t *testing.T) {
|
||||
opts := sdk.CheckerOptions{"k": "v", "n": float64(1)}
|
||||
if stringOpt(opts, "k") != "v" {
|
||||
t.Error("expected v")
|
||||
}
|
||||
if stringOpt(opts, "missing") != "" {
|
||||
t.Error("missing key should give empty string")
|
||||
}
|
||||
if stringOpt(opts, "n") != "" {
|
||||
t.Error("non-string value should give empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageStructure(t *testing.T) {
|
||||
cfg := &runConfig{
|
||||
FromAddress: "alice@example.org",
|
||||
FromHeader: "Alice <alice@example.org>",
|
||||
Subject: "Hello: accents",
|
||||
BodyText: "plain body",
|
||||
BodyHTML: "<p>html body</p>",
|
||||
}
|
||||
raw := buildMessage(cfg, "rcpt@deliver.test")
|
||||
|
||||
msg, err := mail.ReadMessage(strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatalf("not a parseable RFC 5322 message: %v\n--\n%s", err, raw)
|
||||
}
|
||||
if got := msg.Header.Get("From"); !strings.Contains(got, "alice@example.org") {
|
||||
t.Errorf("From header = %q", got)
|
||||
}
|
||||
if got := msg.Header.Get("To"); got != "rcpt@deliver.test" {
|
||||
t.Errorf("To header = %q", got)
|
||||
}
|
||||
|
||||
// Subject: Q-encoded UTF-8.
|
||||
dec := new(mime.WordDecoder)
|
||||
subj, err := dec.DecodeHeader(msg.Header.Get("Subject"))
|
||||
if err != nil {
|
||||
t.Fatalf("subject decode: %v", err)
|
||||
}
|
||||
if subj != "Hello: accents" {
|
||||
t.Errorf("decoded subject = %q", subj)
|
||||
}
|
||||
|
||||
if msg.Header.Get("MIME-Version") != "1.0" {
|
||||
t.Errorf("missing MIME-Version")
|
||||
}
|
||||
if mid := msg.Header.Get("Message-ID"); !strings.Contains(mid, "@example.org>") {
|
||||
t.Errorf("Message-ID = %q", mid)
|
||||
}
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("content-type: %v", err)
|
||||
}
|
||||
if mediaType != "multipart/alternative" {
|
||||
t.Errorf("media type = %q", mediaType)
|
||||
}
|
||||
if params["boundary"] == "" {
|
||||
t.Fatal("missing boundary")
|
||||
}
|
||||
|
||||
mr := multipart.NewReader(msg.Body, params["boundary"])
|
||||
var seenText, seenHTML bool
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("next part: %v", err)
|
||||
}
|
||||
if cte := p.Header.Get("Content-Transfer-Encoding"); cte != "8bit" {
|
||||
t.Errorf("part CTE = %q, want 8bit", cte)
|
||||
}
|
||||
body, _ := io.ReadAll(p)
|
||||
ct := p.Header.Get("Content-Type")
|
||||
switch {
|
||||
case strings.HasPrefix(ct, "text/plain"):
|
||||
seenText = true
|
||||
if !strings.Contains(string(body), "plain body") {
|
||||
t.Errorf("plain part body = %q", body)
|
||||
}
|
||||
case strings.HasPrefix(ct, "text/html"):
|
||||
seenHTML = true
|
||||
if !strings.Contains(string(body), "html body") {
|
||||
t.Errorf("html part body = %q", body)
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected part Content-Type %q", ct)
|
||||
}
|
||||
}
|
||||
if !seenText || !seenHTML {
|
||||
t.Errorf("missing parts: text=%v html=%v", seenText, seenHTML)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessageBodyTextNormalisation(t *testing.T) {
|
||||
cfg := &runConfig{
|
||||
FromAddress: "a@b.test", FromHeader: "<a@b.test>",
|
||||
Subject: "s", BodyText: "no newline", BodyHTML: "<p>x</p>",
|
||||
}
|
||||
raw := string(buildMessage(cfg, "r@x.test"))
|
||||
// The plain body must be CRLF-terminated before the next boundary line.
|
||||
if !strings.Contains(raw, "no newline\r\n--") {
|
||||
t.Errorf("plain body was not CRLF-normalised before boundary:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HTTP client tests ---------------------------------------------------
|
||||
|
||||
func TestAllocateTestSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/api/test" {
|
||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer hunter2" {
|
||||
t.Errorf("auth header = %q", got)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"id":"abc","email":"abc@deliver.test"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := &runConfig{HappyDeliverURL: srv.URL, HappyDeliverToken: "hunter2"}
|
||||
tr, err := allocateTest(context.Background(), srv.Client(), cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tr.ID != "abc" || tr.Email != "abc@deliver.test" {
|
||||
t.Errorf("got %+v", tr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateTestNon2xx(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := allocateTest(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL})
|
||||
if err == nil || !strings.Contains(err.Error(), "503") {
|
||||
t.Fatalf("expected 503 error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateTestEmptyAllocation(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"id":"","email":""}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
_, err := allocateTest(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL})
|
||||
if err == nil || !strings.Contains(err.Error(), "empty test allocation") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateTestNoToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("Authorization"); got != "" {
|
||||
t.Errorf("Authorization should be empty, got %q", got)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"id":"x","email":"x@y"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
if _, err := allocateTest(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTestStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/test/abc" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"status":"analyzed"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
st, err := getTestStatus(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL}, "abc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st != "analyzed" {
|
||||
t.Errorf("status = %q", st)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchReport(t *testing.T) {
|
||||
body := `{"score":42}`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/report/xyz" {
|
||||
t.Errorf("path = %q", r.URL.Path)
|
||||
}
|
||||
_, _ = io.WriteString(w, body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
raw, err := fetchReport(context.Background(), srv.Client(), &runConfig{HappyDeliverURL: srv.URL}, "xyz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != body {
|
||||
t.Errorf("body = %q", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAnalysisPolls(t *testing.T) {
|
||||
var hits int
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits++
|
||||
if hits < 3 {
|
||||
_, _ = io.WriteString(w, `{"status":"pending"}`)
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, `{"status":"analyzed"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
cfg := &runConfig{HappyDeliverURL: srv.URL, WaitTimeout: 5 * time.Second, PollInterval: 10 * time.Millisecond}
|
||||
if err := waitForAnalysis(context.Background(), srv.Client(), cfg, "x"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if hits < 3 {
|
||||
t.Errorf("expected at least 3 hits, got %d", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAnalysisTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"status":"pending"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
cfg := &runConfig{HappyDeliverURL: srv.URL, WaitTimeout: 25 * time.Millisecond, PollInterval: 10 * time.Millisecond}
|
||||
err := waitForAnalysis(context.Background(), srv.Client(), cfg, "x")
|
||||
if !errors.Is(err, errTimeout) {
|
||||
t.Fatalf("expected errTimeout, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForAnalysisContextCancelled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"status":"pending"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cfg := &runConfig{HappyDeliverURL: srv.URL, WaitTimeout: time.Hour, PollInterval: 10 * time.Millisecond}
|
||||
go func() {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
err := waitForAnalysis(ctx, srv.Client(), cfg, "x")
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("expected context.Canceled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- End-to-end Collect with httptest + in-process SMTP ------------------
|
||||
|
||||
func TestCollectHappyPath(t *testing.T) {
|
||||
smtpAddr, smtpReq := startFakeSMTP(t)
|
||||
|
||||
const reportJSON = `{
|
||||
"score": 88, "grade": "B",
|
||||
"summary": {
|
||||
"dns_score": 90, "dns_grade": "A",
|
||||
"authentication_score": 85, "authentication_grade": "B",
|
||||
"spam_score": 80, "spam_grade": "B",
|
||||
"blacklist_score": 100, "blacklist_grade": "A",
|
||||
"header_score": 75, "header_grade": "C",
|
||||
"content_score": 70, "content_grade": "C"
|
||||
}
|
||||
}`
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"id":"id1","email":"id1@deliver.test"}`)
|
||||
})
|
||||
mux.HandleFunc("/api/test/id1", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"status":"analyzed"}`)
|
||||
})
|
||||
mux.HandleFunc("/api/report/id1", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, reportJSON)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
host, port, _ := net.SplitHostPort(smtpAddr)
|
||||
p, _ := strconv.Atoi(port)
|
||||
|
||||
provider := &happyDeliverProvider{}
|
||||
out, err := provider.Collect(context.Background(), sdk.CheckerOptions{
|
||||
"happydeliver_url": srv.URL,
|
||||
"smtp_host": host,
|
||||
"smtp_port": float64(p),
|
||||
"smtp_tls": "none",
|
||||
"from_address": "alice@example.org",
|
||||
"poll_interval": float64(2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect returned err: %v", err)
|
||||
}
|
||||
data, ok := out.(*HappyDeliverData)
|
||||
if !ok {
|
||||
t.Fatalf("Collect returned %T, want *HappyDeliverData", out)
|
||||
}
|
||||
if data.Phase != "ok" {
|
||||
t.Errorf("Phase = %q (Error=%q), want ok", data.Phase, data.Error)
|
||||
}
|
||||
if data.Scores[SectionOverall] != 88 {
|
||||
t.Errorf("overall score = %d", data.Scores[SectionOverall])
|
||||
}
|
||||
if data.RecipientEmail != "id1@deliver.test" {
|
||||
t.Errorf("RecipientEmail = %q", data.RecipientEmail)
|
||||
}
|
||||
if data.LatencySeconds < 0 {
|
||||
t.Errorf("LatencySeconds should be non-negative, got %v", data.LatencySeconds)
|
||||
}
|
||||
|
||||
// Verify the SMTP server received a sane envelope.
|
||||
select {
|
||||
case got := <-smtpReq:
|
||||
if got.from != "alice@example.org" {
|
||||
t.Errorf("MAIL FROM = %q", got.from)
|
||||
}
|
||||
if got.to != "id1@deliver.test" {
|
||||
t.Errorf("RCPT TO = %q", got.to)
|
||||
}
|
||||
if !strings.Contains(got.data, "Subject:") {
|
||||
t.Errorf("data missing headers: %s", got.data)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("SMTP server never received a message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectAllocateFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
provider := &happyDeliverProvider{}
|
||||
out, err := provider.Collect(context.Background(), sdk.CheckerOptions{
|
||||
"happydeliver_url": srv.URL,
|
||||
"smtp_host": "irrelevant",
|
||||
"smtp_tls": "none",
|
||||
"from_address": "a@b.test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Collect should swallow error, got %v", err)
|
||||
}
|
||||
d := out.(*HappyDeliverData)
|
||||
if d.Phase != "allocate" {
|
||||
t.Errorf("phase = %q, want allocate", d.Phase)
|
||||
}
|
||||
if d.Error == "" {
|
||||
t.Error("expected an error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectParseFailure(t *testing.T) {
|
||||
smtpAddr, _ := startFakeSMTP(t)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/test", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"id":"i","email":"i@deliver.test"}`)
|
||||
})
|
||||
mux.HandleFunc("/api/test/i", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `{"status":"analyzed"}`)
|
||||
})
|
||||
mux.HandleFunc("/api/report/i", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, `not json`)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
host, port, _ := net.SplitHostPort(smtpAddr)
|
||||
p, _ := strconv.Atoi(port)
|
||||
|
||||
provider := &happyDeliverProvider{}
|
||||
out, err := provider.Collect(context.Background(), sdk.CheckerOptions{
|
||||
"happydeliver_url": srv.URL,
|
||||
"smtp_host": host,
|
||||
"smtp_port": float64(p),
|
||||
"smtp_tls": "none",
|
||||
"from_address": "alice@example.org",
|
||||
"poll_interval": float64(2),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := out.(*HappyDeliverData)
|
||||
if d.Phase != "parse" {
|
||||
t.Errorf("phase = %q, want parse (Error=%q)", d.Phase, d.Error)
|
||||
}
|
||||
if !strings.HasPrefix(d.Error, "parse:") {
|
||||
t.Errorf("error = %q", d.Error)
|
||||
}
|
||||
}
|
||||
|
||||
// --- minimal in-process SMTP server -------------------------------------
|
||||
|
||||
type smtpReceived struct{ from, to, data string }
|
||||
|
||||
func startFakeSMTP(t *testing.T) (string, <-chan smtpReceived) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
out := make(chan smtpReceived, 4)
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go handleSMTPConn(conn, out)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), out
|
||||
}
|
||||
|
||||
func handleSMTPConn(conn net.Conn, out chan<- smtpReceived) {
|
||||
defer conn.Close()
|
||||
br := bufio.NewReader(conn)
|
||||
write := func(s string) { _, _ = io.WriteString(conn, s) }
|
||||
|
||||
write("220 fake.test ESMTP\r\n")
|
||||
var rec smtpReceived
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
switch {
|
||||
case strings.HasPrefix(strings.ToUpper(line), "EHLO"), strings.HasPrefix(strings.ToUpper(line), "HELO"):
|
||||
write("250-fake.test\r\n250 OK\r\n")
|
||||
case strings.HasPrefix(strings.ToUpper(line), "MAIL FROM:"):
|
||||
rec.from = extractAngleAddr(line)
|
||||
write("250 OK\r\n")
|
||||
case strings.HasPrefix(strings.ToUpper(line), "RCPT TO:"):
|
||||
rec.to = extractAngleAddr(line)
|
||||
write("250 OK\r\n")
|
||||
case strings.ToUpper(line) == "DATA":
|
||||
write("354 send data\r\n")
|
||||
var b strings.Builder
|
||||
for {
|
||||
dl, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if dl == ".\r\n" || strings.TrimRight(dl, "\r\n") == "." {
|
||||
break
|
||||
}
|
||||
b.WriteString(dl)
|
||||
}
|
||||
rec.data = b.String()
|
||||
write("250 OK\r\n")
|
||||
out <- rec
|
||||
rec = smtpReceived{}
|
||||
case strings.ToUpper(line) == "QUIT":
|
||||
write("221 bye\r\n")
|
||||
return
|
||||
case strings.ToUpper(line) == "RSET":
|
||||
write("250 OK\r\n")
|
||||
case strings.ToUpper(line) == "NOOP":
|
||||
write("250 OK\r\n")
|
||||
default:
|
||||
write("250 OK\r\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractAngleAddr(line string) string {
|
||||
i := strings.Index(line, "<")
|
||||
j := strings.Index(line, ">")
|
||||
if i >= 0 && j > i {
|
||||
return line[i+1 : j]
|
||||
}
|
||||
if k := strings.Index(line, ":"); k > 0 {
|
||||
return strings.TrimSpace(line[k+1:])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- ensure URLs we build actually parse ---------------------------------
|
||||
|
||||
func TestJoinURLProducesParseableURL(t *testing.T) {
|
||||
u, err := url.Parse(joinURL("https://x.test/", "/api/foo"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u.Path != "/api/foo" {
|
||||
t.Errorf("path = %q", u.Path)
|
||||
}
|
||||
}
|
||||
|
||||
// sanity: errTimeout text must remain stable for log scrapers.
|
||||
func TestErrTimeoutMessage(t *testing.T) {
|
||||
if errTimeout.Error() != "timeout waiting for analysis" {
|
||||
t.Errorf("errTimeout text changed: %q", errTimeout.Error())
|
||||
}
|
||||
if !errors.Is(fmt.Errorf("wrap: %w", errTimeout), errTimeout) {
|
||||
t.Error("errTimeout not unwrappable")
|
||||
}
|
||||
// Make sure JSON marshalling of HappyDeliverData round-trips.
|
||||
d := HappyDeliverData{Phase: "ok", Scores: map[string]int{SectionOverall: 1}}
|
||||
raw, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back HappyDeliverData
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.Scores[SectionOverall] != 1 {
|
||||
t.Errorf("round-trip lost scores: %+v", back)
|
||||
}
|
||||
}
|
||||
157
checker/definition.go
Normal file
157
checker/definition.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "built-in"
|
||||
|
||||
func Definition() *sdk.CheckerDefinition {
|
||||
def := &sdk.CheckerDefinition{
|
||||
ID: "happydeliver",
|
||||
Name: "Outbound deliverability (via happyDeliver)",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"svcs.MXs"},
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyHappyDeliver},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
AdminOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "happydeliver_url",
|
||||
Type: "string",
|
||||
Label: "happyDeliver instance URL",
|
||||
Description: "Default base URL of the happyDeliver API. Users may override per-domain.",
|
||||
Placeholder: "https://deliver.example.org",
|
||||
},
|
||||
{
|
||||
Id: "happydeliver_token",
|
||||
Type: "string",
|
||||
Label: "happyDeliver API token",
|
||||
Description: "Default bearer token for the happyDeliver API.",
|
||||
Secret: true,
|
||||
},
|
||||
},
|
||||
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "happydeliver_url",
|
||||
Type: "string",
|
||||
Label: "happyDeliver instance URL (override)",
|
||||
Description: "Override the operator-provided happyDeliver URL.",
|
||||
},
|
||||
{
|
||||
Id: "happydeliver_token",
|
||||
Type: "string",
|
||||
Label: "happyDeliver API token (override)",
|
||||
Description: "Override the operator-provided happyDeliver token.",
|
||||
Secret: true,
|
||||
},
|
||||
{
|
||||
Id: "smtp_host",
|
||||
Type: "string",
|
||||
Label: "Sending SMTP host",
|
||||
Description: "Hostname or IP of the submission server used to send the test email.",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "smtp_port",
|
||||
Type: "number",
|
||||
Label: "Sending SMTP port",
|
||||
Description: "Submission port (typically 587 for STARTTLS, 465 for implicit TLS, 25 for plain).",
|
||||
Default: float64(587),
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "smtp_username",
|
||||
Type: "string",
|
||||
Label: "SMTP username",
|
||||
Description: "Username used to authenticate against the submission server.",
|
||||
},
|
||||
{
|
||||
Id: "smtp_password",
|
||||
Type: "string",
|
||||
Label: "SMTP password",
|
||||
Description: "Password used to authenticate against the submission server.",
|
||||
Secret: true,
|
||||
},
|
||||
{
|
||||
Id: "smtp_tls",
|
||||
Type: "string",
|
||||
Label: "TLS mode",
|
||||
Description: "How to negotiate TLS with the submission server.",
|
||||
Choices: []string{"starttls", "tls", "none"},
|
||||
Default: "starttls",
|
||||
},
|
||||
{
|
||||
Id: "from_address",
|
||||
Type: "string",
|
||||
Label: "From address",
|
||||
Description: "Address used in the From header of the test email. Must be in the tested domain.",
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "subject_override",
|
||||
Type: "string",
|
||||
Label: "Subject (optional)",
|
||||
Description: "Override the default test subject.",
|
||||
},
|
||||
{
|
||||
Id: "body_text_override",
|
||||
Type: "string",
|
||||
Label: "Plain-text body (optional)",
|
||||
Textarea: true,
|
||||
Description: "Override the default plain-text body.",
|
||||
},
|
||||
{
|
||||
Id: "body_html_override",
|
||||
Type: "string",
|
||||
Label: "HTML body (optional)",
|
||||
Textarea: true,
|
||||
Description: "Override the default HTML body.",
|
||||
},
|
||||
{
|
||||
Id: "wait_timeout",
|
||||
Type: "number",
|
||||
Label: "Wait timeout (s)",
|
||||
Description: "Seconds to wait for happyDeliver to receive and analyse the message.",
|
||||
Default: float64(900),
|
||||
},
|
||||
{
|
||||
Id: "poll_interval",
|
||||
Type: "number",
|
||||
Label: "Poll interval (s)",
|
||||
Description: "Seconds between status polls. Clamped to [2, 60].",
|
||||
Default: float64(5),
|
||||
},
|
||||
},
|
||||
DomainOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Label: "Domain name",
|
||||
AutoFill: sdk.AutoFillDomainName,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: []sdk.CheckRule{
|
||||
NewSectionRule(SectionOverall, "Overall", 70),
|
||||
NewSectionRule(SectionDNS, "DNS", 70),
|
||||
NewSectionRule(SectionAuthentication, "Authentication (SPF/DKIM/DMARC)", 80),
|
||||
NewSectionRule(SectionSpam, "Spam filters", 70),
|
||||
NewSectionRule(SectionBlacklist, "Blacklists", 90),
|
||||
NewSectionRule(SectionHeader, "Headers", 70),
|
||||
NewSectionRule(SectionContent, "Content", 60),
|
||||
NewLifecycleRule(),
|
||||
},
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 1 * time.Hour,
|
||||
Max: 30 * 24 * time.Hour,
|
||||
Default: 7 * 24 * time.Hour,
|
||||
},
|
||||
HasMetrics: true,
|
||||
}
|
||||
return def
|
||||
}
|
||||
174
checker/interactive.go
Normal file
174
checker/interactive.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
//go:build standalone
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func (p *happyDeliverProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "domain_name",
|
||||
Type: "string",
|
||||
Label: "Domain name",
|
||||
Placeholder: "example.com",
|
||||
Required: true,
|
||||
Description: "Domain whose outbound deliverability will be tested.",
|
||||
},
|
||||
{
|
||||
Id: "happydeliver_url",
|
||||
Type: "string",
|
||||
Label: "happyDeliver instance URL",
|
||||
Placeholder: "https://deliver.example.org",
|
||||
Required: true,
|
||||
Description: "Base URL of the happyDeliver API used to allocate the test address and fetch the report.",
|
||||
},
|
||||
{
|
||||
Id: "happydeliver_token",
|
||||
Type: "string",
|
||||
Label: "happyDeliver API token",
|
||||
Description: "Bearer token for the happyDeliver API (optional if the instance is open).",
|
||||
},
|
||||
{
|
||||
Id: "smtp_host",
|
||||
Type: "string",
|
||||
Label: "Sending SMTP host",
|
||||
Required: true,
|
||||
Description: "Hostname or IP of the submission server used to send the test email.",
|
||||
},
|
||||
{
|
||||
Id: "smtp_port",
|
||||
Type: "string",
|
||||
Label: "Sending SMTP port",
|
||||
Placeholder: "587",
|
||||
Description: "Submission port (587 for STARTTLS, 465 for implicit TLS, 25 for plain). Defaults to 587.",
|
||||
},
|
||||
{
|
||||
Id: "smtp_tls",
|
||||
Type: "string",
|
||||
Label: "TLS mode",
|
||||
Placeholder: "starttls",
|
||||
Description: "How to negotiate TLS with the submission server: starttls, tls, or none. Defaults to starttls.",
|
||||
},
|
||||
{
|
||||
Id: "smtp_username",
|
||||
Type: "string",
|
||||
Label: "SMTP username",
|
||||
Description: "Username used to authenticate against the submission server (optional).",
|
||||
},
|
||||
{
|
||||
Id: "smtp_password",
|
||||
Type: "string",
|
||||
Label: "SMTP password",
|
||||
Description: "Password used to authenticate against the submission server (optional).",
|
||||
},
|
||||
{
|
||||
Id: "from_address",
|
||||
Type: "string",
|
||||
Label: "From address",
|
||||
Placeholder: "postmaster@example.com",
|
||||
Required: true,
|
||||
Description: "Address used in the From header of the test email. Must belong to the tested domain.",
|
||||
},
|
||||
{
|
||||
Id: "subject_override",
|
||||
Type: "string",
|
||||
Label: "Subject (optional)",
|
||||
Description: "Override the default test subject.",
|
||||
},
|
||||
{
|
||||
Id: "body_text_override",
|
||||
Type: "string",
|
||||
Label: "Plain-text body (optional)",
|
||||
Description: "Override the default plain-text body.",
|
||||
},
|
||||
{
|
||||
Id: "body_html_override",
|
||||
Type: "string",
|
||||
Label: "HTML body (optional)",
|
||||
Description: "Override the default HTML body.",
|
||||
},
|
||||
{
|
||||
Id: "wait_timeout",
|
||||
Type: "string",
|
||||
Label: "Wait timeout (s)",
|
||||
Placeholder: "900",
|
||||
Description: "Seconds to wait for happyDeliver to receive and analyse the message. Defaults to 900.",
|
||||
},
|
||||
{
|
||||
Id: "poll_interval",
|
||||
Type: "string",
|
||||
Label: "Poll interval (s)",
|
||||
Placeholder: "5",
|
||||
Description: "Seconds between status polls. Clamped to [2, 60]. Defaults to 5.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *happyDeliverProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSuffix(strings.TrimSpace(r.FormValue("domain_name")), ".")
|
||||
if domain == "" {
|
||||
return nil, errors.New("domain_name is required")
|
||||
}
|
||||
|
||||
url := strings.TrimSpace(r.FormValue("happydeliver_url"))
|
||||
if url == "" {
|
||||
return nil, errors.New("happydeliver_url is required")
|
||||
}
|
||||
|
||||
smtpHost := strings.TrimSpace(r.FormValue("smtp_host"))
|
||||
if smtpHost == "" {
|
||||
return nil, errors.New("smtp_host is required")
|
||||
}
|
||||
|
||||
from := strings.TrimSpace(r.FormValue("from_address"))
|
||||
if from == "" {
|
||||
return nil, errors.New("from_address is required")
|
||||
}
|
||||
|
||||
opts := sdk.CheckerOptions{
|
||||
"domain_name": domain,
|
||||
"happydeliver_url": url,
|
||||
"happydeliver_token": strings.TrimSpace(r.FormValue("happydeliver_token")),
|
||||
"smtp_host": smtpHost,
|
||||
"smtp_username": strings.TrimSpace(r.FormValue("smtp_username")),
|
||||
"smtp_password": r.FormValue("smtp_password"),
|
||||
"from_address": from,
|
||||
"subject_override": r.FormValue("subject_override"),
|
||||
"body_text_override": r.FormValue("body_text_override"),
|
||||
"body_html_override": r.FormValue("body_html_override"),
|
||||
}
|
||||
|
||||
if v := strings.TrimSpace(r.FormValue("smtp_tls")); v != "" {
|
||||
opts["smtp_tls"] = strings.ToLower(v)
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("smtp_port")); v != "" {
|
||||
port, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil, errors.New("smtp_port must be a number")
|
||||
}
|
||||
opts["smtp_port"] = float64(port)
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("wait_timeout")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil, errors.New("wait_timeout must be a number")
|
||||
}
|
||||
opts["wait_timeout"] = float64(n)
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue("poll_interval")); v != "" {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return nil, errors.New("poll_interval must be a number")
|
||||
}
|
||||
opts["poll_interval"] = float64(n)
|
||||
}
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
47
checker/provider.go
Normal file
47
checker/provider.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &happyDeliverProvider{}
|
||||
}
|
||||
|
||||
type happyDeliverProvider struct{}
|
||||
|
||||
func (p *happyDeliverProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyHappyDeliver
|
||||
}
|
||||
|
||||
func (p *happyDeliverProvider) Definition() *sdk.CheckerDefinition {
|
||||
return Definition()
|
||||
}
|
||||
|
||||
func (p *happyDeliverProvider) ExtractMetrics(ctx sdk.ReportContext, collectedAt time.Time) ([]sdk.CheckMetric, error) {
|
||||
var data HappyDeliverData
|
||||
if err := json.Unmarshal(ctx.Data(), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data.Scores) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
metrics := make([]sdk.CheckMetric, 0, len(AllSections))
|
||||
for _, section := range AllSections {
|
||||
score, ok := data.Scores[section]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
metrics = append(metrics, sdk.CheckMetric{
|
||||
Name: "happydeliver_score",
|
||||
Value: float64(score),
|
||||
Unit: "points",
|
||||
Labels: map[string]string{"section": section},
|
||||
Timestamp: collectedAt,
|
||||
})
|
||||
}
|
||||
return metrics, nil
|
||||
}
|
||||
138
checker/provider_test.go
Normal file
138
checker/provider_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type fakeReportCtx struct {
|
||||
data json.RawMessage
|
||||
}
|
||||
|
||||
func (f fakeReportCtx) Data() json.RawMessage { return f.data }
|
||||
func (fakeReportCtx) Related(sdk.ObservationKey) []sdk.RelatedObservation { return nil }
|
||||
func (fakeReportCtx) States() []sdk.CheckState { return nil }
|
||||
|
||||
func TestProviderKey(t *testing.T) {
|
||||
p := Provider()
|
||||
if p.Key() != ObservationKeyHappyDeliver {
|
||||
t.Errorf("Key = %q", p.Key())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderImplementsMetricsReporter(t *testing.T) {
|
||||
if _, ok := Provider().(sdk.CheckerMetricsReporter); !ok {
|
||||
t.Fatal("provider should implement CheckerMetricsReporter")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMetrics(t *testing.T) {
|
||||
d := HappyDeliverData{
|
||||
Phase: "ok",
|
||||
Scores: map[string]int{
|
||||
SectionOverall: 88, SectionDNS: 90, SectionAuthentication: 70,
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(d)
|
||||
now := time.Now()
|
||||
metrics, err := Provider().(sdk.CheckerMetricsReporter).ExtractMetrics(fakeReportCtx{data: raw}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metrics) != 3 {
|
||||
t.Fatalf("expected 3 metrics, got %d", len(metrics))
|
||||
}
|
||||
// Order must match AllSections (overall first).
|
||||
if metrics[0].Labels["section"] != SectionOverall || metrics[0].Value != 88 {
|
||||
t.Errorf("metrics[0] = %+v", metrics[0])
|
||||
}
|
||||
for _, m := range metrics {
|
||||
if m.Name != "happydeliver_score" {
|
||||
t.Errorf("name = %q", m.Name)
|
||||
}
|
||||
if m.Unit != "points" {
|
||||
t.Errorf("unit = %q", m.Unit)
|
||||
}
|
||||
if !m.Timestamp.Equal(now) {
|
||||
t.Errorf("ts = %v", m.Timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMetricsNoScores(t *testing.T) {
|
||||
d := HappyDeliverData{Phase: "send", Error: "x"}
|
||||
raw, _ := json.Marshal(d)
|
||||
metrics, err := Provider().(sdk.CheckerMetricsReporter).ExtractMetrics(fakeReportCtx{data: raw}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if metrics != nil {
|
||||
t.Errorf("expected nil metrics, got %v", metrics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMetricsBadPayload(t *testing.T) {
|
||||
_, err := Provider().(sdk.CheckerMetricsReporter).ExtractMetrics(fakeReportCtx{data: []byte("garbage")}, time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("expected error on bad JSON")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinitionShape(t *testing.T) {
|
||||
def := Definition()
|
||||
if def.ID != "happydeliver" {
|
||||
t.Errorf("ID = %q", def.ID)
|
||||
}
|
||||
if !def.Availability.ApplyToDomain {
|
||||
t.Error("should apply to domain")
|
||||
}
|
||||
if !def.HasMetrics {
|
||||
t.Error("HasMetrics should be true")
|
||||
}
|
||||
// Section rules + lifecycle rule.
|
||||
if got, want := len(def.Rules), len(AllSections)+1; got != want {
|
||||
t.Errorf("rule count = %d, want %d", got, want)
|
||||
}
|
||||
// Each section must have a corresponding rule named happydeliver.score.<section>.
|
||||
have := map[string]bool{}
|
||||
for _, r := range def.Rules {
|
||||
have[r.Name()] = true
|
||||
}
|
||||
for _, s := range AllSections {
|
||||
if !have["happydeliver.score."+s] {
|
||||
t.Errorf("missing rule for section %q", s)
|
||||
}
|
||||
}
|
||||
if !have["happydeliver.lifecycle"] {
|
||||
t.Error("missing lifecycle rule")
|
||||
}
|
||||
if def.Interval == nil || def.Interval.Default != 7*24*time.Hour {
|
||||
t.Errorf("interval = %+v", def.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefinitionBuildRulesInfo(t *testing.T) {
|
||||
def := Definition()
|
||||
def.BuildRulesInfo()
|
||||
if len(def.RulesInfo) != len(def.Rules) {
|
||||
t.Fatalf("RulesInfo len = %d, want %d", len(def.RulesInfo), len(def.Rules))
|
||||
}
|
||||
// Section rules expose Options(); the lifecycle rule does not.
|
||||
var withOpts, withoutOpts int
|
||||
for _, info := range def.RulesInfo {
|
||||
if info.Options != nil {
|
||||
withOpts++
|
||||
} else {
|
||||
withoutOpts++
|
||||
}
|
||||
}
|
||||
if withOpts != len(AllSections) {
|
||||
t.Errorf("rules-with-options = %d, want %d", withOpts, len(AllSections))
|
||||
}
|
||||
if withoutOpts != 1 {
|
||||
t.Errorf("rules-without-options = %d, want 1 (lifecycle)", withoutOpts)
|
||||
}
|
||||
}
|
||||
145
checker/rule.go
Normal file
145
checker/rule.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func NewSectionRule(section, label string, defaultMin float64) sdk.CheckRule {
|
||||
return §ionRule{section: section, label: label, defaultMin: defaultMin}
|
||||
}
|
||||
|
||||
type sectionRule struct {
|
||||
section string
|
||||
label string
|
||||
defaultMin float64
|
||||
}
|
||||
|
||||
func (r *sectionRule) Name() string { return "happydeliver.score." + r.section }
|
||||
|
||||
func (r *sectionRule) Description() string {
|
||||
return fmt.Sprintf("Verify happyDeliver's %s score is above the configured minimum.", r.label)
|
||||
}
|
||||
|
||||
func (r *sectionRule) optionID() string { return "min_score_" + r.section }
|
||||
|
||||
func (r *sectionRule) Options() sdk.CheckerOptionsDocumentation {
|
||||
return sdk.CheckerOptionsDocumentation{
|
||||
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: r.optionID(),
|
||||
Type: "number",
|
||||
Label: fmt.Sprintf("Minimum %s score", r.label),
|
||||
Description: fmt.Sprintf("Minimum acceptable score (0-100) for the %s section.", r.label),
|
||||
Default: r.defaultMin,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sectionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
var data HappyDeliverData
|
||||
if err := obs.Get(ctx, ObservationKeyHappyDeliver, &data); err != nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("Failed to read happyDeliver observation: %v", err),
|
||||
Code: "happydeliver.observation.error",
|
||||
}}
|
||||
}
|
||||
if data.Phase != "ok" || data.Scores == nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Message: "No happyDeliver report available yet",
|
||||
Code: "happydeliver.score.unavailable",
|
||||
Subject: r.section,
|
||||
}}
|
||||
}
|
||||
score, ok := data.Scores[r.section]
|
||||
if !ok {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Message: fmt.Sprintf("happyDeliver report has no %s score", r.label),
|
||||
Code: "happydeliver.score.missing",
|
||||
Subject: r.section,
|
||||
}}
|
||||
}
|
||||
threshold := sdk.GetFloatOption(opts, r.optionID(), r.defaultMin)
|
||||
status := sdk.StatusOK
|
||||
if float64(score) < threshold {
|
||||
status = sdk.StatusCrit
|
||||
}
|
||||
return []sdk.CheckState{{
|
||||
Status: status,
|
||||
Message: fmt.Sprintf("%s score: %d (min %.0f, grade %s)", r.label, score, threshold, data.Grades[r.section]),
|
||||
Code: "happydeliver.score." + r.section,
|
||||
Subject: r.section,
|
||||
RuleName: r.Name(),
|
||||
Meta: map[string]any{
|
||||
"score": score,
|
||||
"grade": data.Grades[r.section],
|
||||
"min": threshold,
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
// Surfaces lifecycle failures in the UI when no per-section scores exist yet.
|
||||
func NewLifecycleRule() sdk.CheckRule { return &lifecycleRule{} }
|
||||
|
||||
type lifecycleRule struct{}
|
||||
|
||||
func (r *lifecycleRule) Name() string { return "happydeliver.lifecycle" }
|
||||
|
||||
func (r *lifecycleRule) Description() string {
|
||||
return "Reports happyDeliver lifecycle errors (allocation, send, timeout, fetch)."
|
||||
}
|
||||
|
||||
func (r *lifecycleRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
var data HappyDeliverData
|
||||
if err := obs.Get(ctx, ObservationKeyHappyDeliver, &data); err != nil {
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("Failed to read happyDeliver observation: %v", err),
|
||||
Code: "happydeliver.observation.error",
|
||||
}}
|
||||
}
|
||||
switch data.Phase {
|
||||
case "ok":
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Message: fmt.Sprintf("Message analysed in %.1fs", data.LatencySeconds),
|
||||
Code: "happydeliver.lifecycle.ok",
|
||||
}}
|
||||
case "allocate":
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Message: "Failed to allocate a happyDeliver test address: " + data.Error,
|
||||
Code: "happydeliver.api.unavailable",
|
||||
}}
|
||||
case "send":
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Message: "Failed to send the test email: " + data.Error,
|
||||
Code: "happydeliver.send.failed",
|
||||
}}
|
||||
case "timeout":
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Message: "happyDeliver did not analyse the message before the timeout",
|
||||
Code: "happydeliver.no_message_received",
|
||||
}}
|
||||
case "wait", "fetch", "parse":
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusCrit,
|
||||
Message: "happyDeliver lifecycle error: " + data.Error,
|
||||
Code: "happydeliver." + data.Phase + ".failed",
|
||||
}}
|
||||
default:
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Message: "happyDeliver run in unknown phase: " + data.Phase,
|
||||
Code: "happydeliver.lifecycle.unknown",
|
||||
}}
|
||||
}
|
||||
}
|
||||
159
checker/rule_test.go
Normal file
159
checker/rule_test.go
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
type fakeObs struct {
|
||||
data HappyDeliverData
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeObs) Get(_ context.Context, _ sdk.ObservationKey, dest any) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
raw, err := json.Marshal(f.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
func (fakeObs) GetRelated(context.Context, sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func okData() HappyDeliverData {
|
||||
return HappyDeliverData{
|
||||
Phase: "ok",
|
||||
Scores: map[string]int{
|
||||
SectionOverall: 90, SectionDNS: 80, SectionAuthentication: 60,
|
||||
},
|
||||
Grades: map[string]string{
|
||||
SectionOverall: "A", SectionDNS: "B", SectionAuthentication: "D",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionRuleAboveThreshold(t *testing.T) {
|
||||
r := NewSectionRule(SectionOverall, "Overall", 70)
|
||||
states := r.Evaluate(context.Background(), fakeObs{data: okData()}, sdk.CheckerOptions{})
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusOK {
|
||||
t.Fatalf("got %+v", states)
|
||||
}
|
||||
if !strings.Contains(states[0].Message, "score: 90") {
|
||||
t.Errorf("message = %q", states[0].Message)
|
||||
}
|
||||
if states[0].Meta["score"] != 90 {
|
||||
t.Errorf("meta score = %v", states[0].Meta["score"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionRuleBelowThresholdCRIT(t *testing.T) {
|
||||
r := NewSectionRule(SectionAuthentication, "Authentication", 80)
|
||||
states := r.Evaluate(context.Background(), fakeObs{data: okData()}, sdk.CheckerOptions{})
|
||||
if states[0].Status != sdk.StatusCrit {
|
||||
t.Errorf("status = %v, want CRIT", states[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionRuleOptionOverridesDefault(t *testing.T) {
|
||||
r := NewSectionRule(SectionDNS, "DNS", 70)
|
||||
// Score is 80, default threshold is 70 (OK), but we raise it to 95 -> CRIT.
|
||||
states := r.Evaluate(context.Background(), fakeObs{data: okData()},
|
||||
sdk.CheckerOptions{"min_score_dns": float64(95)})
|
||||
if states[0].Status != sdk.StatusCrit {
|
||||
t.Errorf("status = %v, want CRIT", states[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionRuleNoReportYet(t *testing.T) {
|
||||
r := NewSectionRule(SectionOverall, "Overall", 70)
|
||||
states := r.Evaluate(context.Background(), fakeObs{data: HappyDeliverData{Phase: "send"}}, sdk.CheckerOptions{})
|
||||
if states[0].Status != sdk.StatusInfo || states[0].Code != "happydeliver.score.unavailable" {
|
||||
t.Errorf("got %+v", states[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionRuleScoreMissing(t *testing.T) {
|
||||
r := NewSectionRule(SectionContent, "Content", 70)
|
||||
states := r.Evaluate(context.Background(), fakeObs{data: okData()}, sdk.CheckerOptions{})
|
||||
if states[0].Status != sdk.StatusInfo || states[0].Code != "happydeliver.score.missing" {
|
||||
t.Errorf("got %+v", states[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionRuleObservationError(t *testing.T) {
|
||||
r := NewSectionRule(SectionOverall, "Overall", 70)
|
||||
states := r.Evaluate(context.Background(), fakeObs{err: errors.New("boom")}, sdk.CheckerOptions{})
|
||||
if states[0].Status != sdk.StatusError {
|
||||
t.Errorf("status = %v", states[0].Status)
|
||||
}
|
||||
if !strings.Contains(states[0].Message, "boom") {
|
||||
t.Errorf("message = %q", states[0].Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionRuleNameAndOptionsDoc(t *testing.T) {
|
||||
r := NewSectionRule(SectionDNS, "DNS", 70).(*sectionRule)
|
||||
if r.Name() != "happydeliver.score.dns" {
|
||||
t.Errorf("Name = %q", r.Name())
|
||||
}
|
||||
rwo, ok := any(r).(sdk.CheckRuleWithOptions)
|
||||
if !ok {
|
||||
t.Fatal("sectionRule should implement CheckRuleWithOptions")
|
||||
}
|
||||
doc := rwo.Options()
|
||||
if len(doc.UserOpts) != 1 || doc.UserOpts[0].Id != "min_score_dns" {
|
||||
t.Errorf("doc = %+v", doc)
|
||||
}
|
||||
if doc.UserOpts[0].Default != 70.0 {
|
||||
t.Errorf("default = %v", doc.UserOpts[0].Default)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleRulePhases(t *testing.T) {
|
||||
cases := []struct {
|
||||
phase string
|
||||
errMsg string
|
||||
latency float64
|
||||
wantSt sdk.Status
|
||||
wantCode string
|
||||
}{
|
||||
{"ok", "", 1.5, sdk.StatusOK, "happydeliver.lifecycle.ok"},
|
||||
{"allocate", "down", 0, sdk.StatusCrit, "happydeliver.api.unavailable"},
|
||||
{"send", "auth fail", 0, sdk.StatusCrit, "happydeliver.send.failed"},
|
||||
{"timeout", "", 0, sdk.StatusWarn, "happydeliver.no_message_received"},
|
||||
{"wait", "x", 0, sdk.StatusCrit, "happydeliver.wait.failed"},
|
||||
{"fetch", "x", 0, sdk.StatusCrit, "happydeliver.fetch.failed"},
|
||||
{"parse", "bad", 0, sdk.StatusCrit, "happydeliver.parse.failed"},
|
||||
{"weird", "", 0, sdk.StatusInfo, "happydeliver.lifecycle.unknown"},
|
||||
}
|
||||
r := NewLifecycleRule()
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.phase, func(t *testing.T) {
|
||||
d := HappyDeliverData{Phase: tc.phase, Error: tc.errMsg, LatencySeconds: tc.latency}
|
||||
states := r.Evaluate(context.Background(), fakeObs{data: d}, sdk.CheckerOptions{})
|
||||
if states[0].Status != tc.wantSt {
|
||||
t.Errorf("status = %v, want %v", states[0].Status, tc.wantSt)
|
||||
}
|
||||
if states[0].Code != tc.wantCode {
|
||||
t.Errorf("code = %q, want %q", states[0].Code, tc.wantCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLifecycleRuleObservationError(t *testing.T) {
|
||||
states := NewLifecycleRule().Evaluate(context.Background(), fakeObs{err: errors.New("io")}, sdk.CheckerOptions{})
|
||||
if states[0].Status != sdk.StatusError {
|
||||
t.Errorf("got %+v", states[0])
|
||||
}
|
||||
}
|
||||
90
checker/types.go
Normal file
90
checker/types.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ObservationKeyHappyDeliver = "happydeliver"
|
||||
|
||||
const (
|
||||
SectionOverall = "overall"
|
||||
SectionDNS = "dns"
|
||||
SectionAuthentication = "authentication"
|
||||
SectionSpam = "spam"
|
||||
SectionBlacklist = "blacklist"
|
||||
SectionHeader = "header"
|
||||
SectionContent = "content"
|
||||
)
|
||||
|
||||
// Overall is first so metric and rule iteration is deterministic.
|
||||
var AllSections = []string{
|
||||
SectionOverall,
|
||||
SectionDNS,
|
||||
SectionAuthentication,
|
||||
SectionSpam,
|
||||
SectionBlacklist,
|
||||
SectionHeader,
|
||||
SectionContent,
|
||||
}
|
||||
|
||||
// Report is stored verbatim so new upstream sections appear without an SDK change.
|
||||
type HappyDeliverData struct {
|
||||
Phase string `json:"phase"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
TestID string `json:"test_id,omitempty"`
|
||||
RecipientEmail string `json:"recipient_email,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
AnalysedAt time.Time `json:"analysed_at,omitempty"`
|
||||
LatencySeconds float64 `json:"latency_seconds,omitempty"`
|
||||
Report json.RawMessage `json:"report,omitempty"`
|
||||
Scores map[string]int `json:"scores,omitempty"`
|
||||
Grades map[string]string `json:"grades,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Minimal subset: avoids mirroring the full schema so new upstream fields don't break us.
|
||||
type reportEnvelope struct {
|
||||
Score int `json:"score"`
|
||||
Grade string `json:"grade"`
|
||||
Summary struct {
|
||||
DNSScore int `json:"dns_score"`
|
||||
DNSGrade string `json:"dns_grade"`
|
||||
AuthenticationScore int `json:"authentication_score"`
|
||||
AuthenticationGrade string `json:"authentication_grade"`
|
||||
SpamScore int `json:"spam_score"`
|
||||
SpamGrade string `json:"spam_grade"`
|
||||
BlacklistScore int `json:"blacklist_score"`
|
||||
BlacklistGrade string `json:"blacklist_grade"`
|
||||
HeaderScore int `json:"header_score"`
|
||||
HeaderGrade string `json:"header_grade"`
|
||||
ContentScore int `json:"content_score"`
|
||||
ContentGrade string `json:"content_grade"`
|
||||
} `json:"summary"`
|
||||
}
|
||||
|
||||
func extractScores(raw json.RawMessage) (map[string]int, map[string]string, error) {
|
||||
var env reportEnvelope
|
||||
if err := json.Unmarshal(raw, &env); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
scores := map[string]int{
|
||||
SectionOverall: env.Score,
|
||||
SectionDNS: env.Summary.DNSScore,
|
||||
SectionAuthentication: env.Summary.AuthenticationScore,
|
||||
SectionSpam: env.Summary.SpamScore,
|
||||
SectionBlacklist: env.Summary.BlacklistScore,
|
||||
SectionHeader: env.Summary.HeaderScore,
|
||||
SectionContent: env.Summary.ContentScore,
|
||||
}
|
||||
grades := map[string]string{
|
||||
SectionOverall: env.Grade,
|
||||
SectionDNS: env.Summary.DNSGrade,
|
||||
SectionAuthentication: env.Summary.AuthenticationGrade,
|
||||
SectionSpam: env.Summary.SpamGrade,
|
||||
SectionBlacklist: env.Summary.BlacklistGrade,
|
||||
SectionHeader: env.Summary.HeaderGrade,
|
||||
SectionContent: env.Summary.ContentGrade,
|
||||
}
|
||||
return scores, grades, nil
|
||||
}
|
||||
76
checker/types_test.go
Normal file
76
checker/types_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractScores(t *testing.T) {
|
||||
raw := json.RawMessage(`{
|
||||
"score": 82, "grade": "B",
|
||||
"summary": {
|
||||
"dns_score": 90, "dns_grade": "A",
|
||||
"authentication_score": 75, "authentication_grade": "C",
|
||||
"spam_score": 88, "spam_grade": "B",
|
||||
"blacklist_score": 100, "blacklist_grade": "A",
|
||||
"header_score": 70, "header_grade": "C",
|
||||
"content_score": 65, "content_grade": "D"
|
||||
}
|
||||
}`)
|
||||
|
||||
scores, grades, err := extractScores(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := map[string]int{
|
||||
SectionOverall: 82, SectionDNS: 90, SectionAuthentication: 75,
|
||||
SectionSpam: 88, SectionBlacklist: 100, SectionHeader: 70, SectionContent: 65,
|
||||
}
|
||||
for k, v := range want {
|
||||
if scores[k] != v {
|
||||
t.Errorf("scores[%s] = %d, want %d", k, scores[k], v)
|
||||
}
|
||||
}
|
||||
wantGrades := map[string]string{
|
||||
SectionOverall: "B", SectionDNS: "A", SectionAuthentication: "C",
|
||||
SectionSpam: "B", SectionBlacklist: "A", SectionHeader: "C", SectionContent: "D",
|
||||
}
|
||||
for k, v := range wantGrades {
|
||||
if grades[k] != v {
|
||||
t.Errorf("grades[%s] = %q, want %q", k, grades[k], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractScoresMissingFieldsDefaultToZero(t *testing.T) {
|
||||
scores, grades, err := extractScores(json.RawMessage(`{"score": 50, "grade": "F"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if scores[SectionOverall] != 50 || grades[SectionOverall] != "F" {
|
||||
t.Errorf("overall not extracted: %v / %v", scores[SectionOverall], grades[SectionOverall])
|
||||
}
|
||||
if scores[SectionDNS] != 0 || grades[SectionDNS] != "" {
|
||||
t.Errorf("missing fields should default to zero values")
|
||||
}
|
||||
for _, s := range AllSections {
|
||||
if _, ok := scores[s]; !ok {
|
||||
t.Errorf("section %q missing from scores map", s)
|
||||
}
|
||||
if _, ok := grades[s]; !ok {
|
||||
t.Errorf("section %q missing from grades map", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractScoresInvalidJSON(t *testing.T) {
|
||||
if _, _, err := extractScores(json.RawMessage(`{not json`)); err == nil {
|
||||
t.Fatal("expected error for invalid JSON, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllSectionsOverallFirst(t *testing.T) {
|
||||
if len(AllSections) == 0 || AllSections[0] != SectionOverall {
|
||||
t.Fatalf("AllSections must start with overall, got %v", AllSections)
|
||||
}
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module git.happydns.org/checker-happydeliver
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.happydns.org/checker-sdk-go v1.5.0
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
24
main.go
Normal file
24
main.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
hd "git.happydns.org/checker-happydeliver/checker"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
hd.Version = Version
|
||||
|
||||
srv := server.New(hd.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
13
plugin/plugin.go
Normal file
13
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
hd "git.happydns.org/checker-happydeliver/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
hd.Version = Version
|
||||
return hd.Definition(), hd.Provider(), nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue