Skip to content

Commit

Permalink
Merge pull request #3 from d--j/integration
Browse files Browse the repository at this point in the history
Add Integration Tests
  • Loading branch information
d--j authored Mar 12, 2023
2 parents 9ef3191 + f693f85 commit 6a79f03
Show file tree
Hide file tree
Showing 67 changed files with 5,624 additions and 109 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,9 @@ jobs:
uses: shogo82148/actions-goveralls@v1
with:
path-to-profile: profile.cov

- name: Install Postfix
run: sudo -n -- apt-get update -q && sudo -n -- apt-get install -y postfix sasl2-bin libsasl2-2 libsasl2-modules ssl-cert cpio courier-authlib

- name: Integration Test
run: cd integration && SKIP_POSTFIX_AUTH=1 go run github.com/d--j/go-milter/integration/runner ./tests
8 changes: 8 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
GO_MILTER_DIR := $(shell go list -f '{{.Dir}}' github.com/d--j/go-milter)

integration:
docker build -q --progress=plain -t go-milter-integration "$(GO_MILTER_DIR)/integration/docker" && \
docker run --rm -w /usr/src/root/integration -v $(PWD):/usr/src/root go-milter-integration \
go run github.com/d--j/go-milter/integration/runner -filter '.*' ./tests

.PHONY: integration
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ A Go library to write mail filters.
* milter can skip e.g. body chunks when it does not need all chunks
* milter can send progress notifications when response can take some time
* milter can automatically instruct the MTA which macros it needs.
* Automatic [integration tests](integration/README.md) that test the compatibility with Postfix and Sendmail.

## Installation

Expand Down
22 changes: 19 additions & 3 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -688,9 +688,24 @@ func (s *ClientSession) DataStart() (*Action, error) {
return act, nil
}

func trimLastLineBreak(in string) string {
l := len(in)
if l > 2 && in[l-2:] == "\r\n" {
return in[:l-2]
}
if l > 1 && in[l-1:] == "\n" {
return in[:l-1]
}
if l > 1 && in[l-1:] == "\r" {
return in[:l-1]
}
return in
}

// HeaderField sends a single header field to the milter.
//
// Value should be the original field value without any unfolding applied.
// value may contain the last CR LF that ist the end marker of this header.
//
// HeaderEnd() must be called after the last field.
//
Expand Down Expand Up @@ -718,7 +733,7 @@ func (s *ClientSession) HeaderField(key, value string, macros map[MacroName]stri
Code: wire.CodeHeader,
}
msg.Data = wire.AppendCString(msg.Data, key)
msg.Data = wire.AppendCString(msg.Data, value)
msg.Data = wire.AppendCString(msg.Data, trimLastLineBreak(value))

if err := s.writePacket(msg); err != nil {
return nil, s.errorOut(fmt.Errorf("milter: header field: %w", err))
Expand Down Expand Up @@ -818,12 +833,11 @@ func (s *ClientSession) BodyChunk(chunk []byte) (*Action, error) {
if s.state < clientStateHeaderEndCalled || s.state > clientStateBodyChunkCalled {
return nil, s.errorOut(fmt.Errorf("milter: body: in wrong state %d", s.state))
}
s.state = clientStateBodyChunkCalled
if s.skip {
return &Action{Type: ActionContinue}, nil
}

s.state = clientStateBodyChunkCalled

if s.ProtocolOption(OptNoBody) {
return &Action{Type: ActionContinue}, nil
}
Expand Down Expand Up @@ -886,6 +900,8 @@ func (s *ClientSession) BodyReadFrom(r io.Reader) ([]ModifyAction, *Action, erro
if scanner.Err() != nil {
return nil, nil, scanner.Err()
}
} else {
s.state = clientStateBodyChunkCalled
}

return s.End()
Expand Down
155 changes: 155 additions & 0 deletions integration/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# go-milter integration tests

## How it works

The integration test runner starts a receiving SMTP server and test milter servers. It then configures different MTAs to
use the test milter servers and send all emails to the receiving SMTP server. When all this is set up and running,
the test runner send the testcases as SMTP transactions to the MTA and checks if the right filter decision at the right
time was made and whether the outgoing SMTP message is as expected.

## Testcases

A testcase is a text file that has three parts: input steps, the expected milter decision (accept, reject etc.) and
optional output data (mail from, header etc.) that gets compared with the actual output of the MTA.

### Input steps

You can omit input steps. Necessary input steps get automatically added to the testcase.

#### `HELO [hello-hostname]`

Sends a HELO/EHLO to the SMTP server

#### `STARTTLS`

Start TLS encryption of connection

#### `AUTH [[email protected]|[email protected]]`

Authenticates SMTP connection. There are only two users hard-coded [email protected] (password `password1`) and [email protected] (password `password2`).

#### `FROM <addr> args`

Sends a `MAIL FROM` SMTP command.

#### `TO <addr> args`

Sends a `RCPT TO` SMTP command.

#### `RESET`

Sends a `RSET` SMTP command.

#### `HEADER`

Sends the `DATA` SMTP command and then the header. The header to send follows the `HEADER` line. The end of
the header is marked with a single `.` in a line (like in SMTP connections)

#### `BODY`

Sends the body part of the DATA. The end of the body part is also marked with a single `.`.

### `DECISION [decision]@[step]`

Every testcase needs to have a `DECISION`. Valid `decision`s are: `ACCEPT`, `TEMPFAIL`, `REJECT`, `DISCARD-OR-QUARANTINE` and `CUSTOM`.
If you specify `CUSTOM` then the lines after the `DECISION` line get parsed as a SMTP response and the mitler should
set this SMTP response.

The `step` can be `HELO`, `FROM`, `TO`, `DATA`, `EOM` and `*`. If the step is omitted `*` is assumed.
`*` means that the decision can happen after any step.

### Output

If you specified `ACCEPT` as decision you can add `FROM`, `TO`, `HEADER` and `BODY` lines (see syntax above) after the `DECISION` line.
These values get compared with the actual result the MTA send to our receiving SMTP server.

## How to add integration tests to your go-milter based mail filter

You need docker since the test are run inside a docker container.

Add a Makefile
```makefile
GO_MILTER_DIR := $(shell go list -f '{{.Dir}}' github.com/d--j/go-milter)

integration:
docker build -q --progress=plain -t go-milter-integration "$(GO_MILTER_DIR)/integration/docker" && \
docker run --rm -w /usr/src/root/integration -v $(PWD):/usr/src/root go-milter-integration \
go run github.com/d--j/go-milter/integration/runner -filter '.*' ./tests

.PHONY: integration
```

Add an `integration` directory. Execute the following inside:
```shell
go mod init
go mod edit -require github.com/d--j/go-milter
go mod edit -require github.com/d--j/go-milter/integration
go mod edit -replace $(cd .. && go list '{{.Path}}')=..
mkdir tests
```

Tests consist of a test milter and testcases that get feed into an MTA that is configured to use the test milter.

A test milter can look something like this:

```go
package main

import (
"context"

"github.com/d--j/go-milter/integration"
"github.com/d--j/go-milter/mailfilter"
)

func main() {
integration.RequiredTags("auth-plain", "auth-no", "tls-starttls", "tls-no")
integration.Test(func(ctx context.Context, trx *mailfilter.Transaction) (mailfilter.Decision, error) {
return mailfilter.CustomErrorResponse(501, "Test"), nil
}, mailfilter.WithDecisionAt(mailfilter.DecisionAtMailFrom))
}
```

A testcase for this milter would be:
```
DECISION CUSTOM
501 Test
```

## How to handle dynamic data

If your milter is time dependent or relies on external data you can use monkey pathing to make the output of your milter
static. E.g. the following sets a constant time for `time.Now` and mocks the SPF checks of your milter to static values:

```go
package patches

import (
"net"
"strings"
"time"

"blitiri.com.ar/go/spf"
"github.com/agiledragon/gomonkey/v2"
)

var ConstantDate = time.Date(2023, time.January, 1, 12, 0, 0, 0, time.UTC)

func Apply() *gomonkey.Patches {
return gomonkey.
ApplyFuncReturn(time.Now, ConstantDate).
ApplyFunc(spf.CheckHostWithSender, func(_ net.IP, helo, sender string, _ ...spf.Option) (spf.Result, error) {
if strings.HasSuffix(sender, "@example.com") || helo == "example.com" {
return spf.Pass, nil
}
if strings.HasSuffix(sender, "@example.net") || helo == "example.net" {
return spf.Fail, nil
}
return spf.None, nil
})
}
```

The `Received` line that the MTA add contains dynamic data (date, queue id). Your test milter will see this dynamic header,
but before comparing the SMTP message with the testcase output data the test runner replaces the first
`Recieved` header with the static header `Received: placeholder`.
16 changes: 16 additions & 0 deletions integration/docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
FROM golang:1-bullseye
RUN apt-get update -q \
&& apt-get install -y sudo syslog-ng sasl2-bin libsasl2-2 libsasl2-modules ssl-cert m4 expect tcl-expect cpio \
&& mkdir /pkgs && cd /pkgs \
&& apt-get download \
postfix sendmail sendmail-base sendmail-bin sendmail-cf sensible-mda \
libsigsegv2 maildrop libicu67 libnsl2 \
courier-authlib libcourier-unicode4 liblockfile-bin liblockfile1 \
libltdl7 libwrap0 lockfile-progs \
&& dpkg --force-all -i *.deb \
&& rm -rf /pkgs /var/lib/apt/lists/*
RUN mkdir /.cache && chmod 0777 /.cache
RUN git config --global --add safe.directory /usr/src/root
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
WORKDIR /usr/src/root/integration
CMD ["go", "run", "github.com/d--j/go-milter/integration/runner", "./tests"]
15 changes: 15 additions & 0 deletions integration/docker/syslog-ng.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@version: 3.28
@include "scl.conf"

source s_local {
internal();
};

destination d_local {
file("/var/log/messages");
};

log {
source(s_local);
destination(d_local);
};
66 changes: 66 additions & 0 deletions integration/filter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Package integration has integration tests and utilities for integration tests.
package integration

import (
"flag"
"fmt"
"log"
"os"
"strings"

"github.com/d--j/go-milter/mailfilter"
"golang.org/x/tools/go/buildutil"
)

var Network = flag.String("network", "", "network")
var Address = flag.String("address", "", "address")
var Tags []string

const ExitSkip = 99

func init() {
flag.Var((*buildutil.TagsFlag)(&Tags), "tags", buildutil.TagsFlagDoc)
}

func Test(decider mailfilter.DecisionModificationFunc, opts ...mailfilter.Option) {
if !flag.Parsed() {
flag.Parse()
}
if Network == nil || *Network == "" {
log.Fatal("no network specified")
}
if Address == nil || *Address == "" {
log.Fatal("no address specified")
}
filter, err := mailfilter.New(*Network, *Address, decider, opts...)
if err != nil {
log.Fatal(err)
}
log.Printf("Started milter on %s:%s", filter.Addr().Network(), filter.Addr().String())
filter.Wait()
}

func HasTag(tag string) bool {
if !flag.Parsed() {
flag.Parse()
}
for _, t := range Tags {
if t == tag {
return true
}
}
return false
}

func Skip(reason string) {
log.Printf("skip test: %s", reason)
os.Exit(ExitSkip)
}

func RequiredTags(tags ...string) {
for _, t := range tags {
if !HasTag(t) {
Skip(fmt.Sprintf("required tags not met: %s", strings.Join(tags, ",")))
}
}
}
19 changes: 19 additions & 0 deletions integration/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module github.com/d--j/go-milter/integration

go 1.18

require (
github.com/d--j/go-milter v0.6.0
github.com/emersion/go-message v0.16.0
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.16.0
golang.org/x/tools v0.1.12
)

require (
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 // indirect
golang.org/x/net v0.7.0 // indirect
golang.org/x/text v0.7.0 // indirect
)

replace github.com/d--j/go-milter => ../
18 changes: 18 additions & 0 deletions integration/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
github.com/emersion/go-message v0.16.0 h1:uZLz8ClLv3V5fSFF/fFdW9jXjrZkXIpE1Fn8fKx7pO4=
github.com/emersion/go-message v0.16.0/go.mod h1:pDJDgf/xeUIF+eicT6B/hPX/ZbEorKkUMPOxrPVG2eQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.16.0 h1:eB9CY9527WdEZSs5sWisTmilDX7gG+Q/2IdRcmubpa8=
github.com/emersion/go-smtp v0.16.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594 h1:IbFBtwoTQyw0fIM5xv1HF+Y+3ZijDR839WMulgxCcUY=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
Loading

0 comments on commit 6a79f03

Please sign in to comment.