mox/webaccount/account_test.go
Mechiel Lukkien 960a51242d
add aliases/lists: when sending to an alias, the message gets delivered to all members
the members must currently all be addresses of local accounts.

a message sent to an alias is accepted if at least one of the members accepts
it. if no members accepts it (e.g. due to bad reputation of sender), the
message is rejected.

if a message is submitted to both an alias addresses and to recipients that are
members of the alias in an smtp transaction, the message will be delivered to
such members only once.  the same applies if the address in the message
from-header is the address of a member: that member won't receive the message
(they sent it). this prevents duplicate messages.

aliases have three configuration options:
- PostPublic: whether anyone can send through the alias, or only members.
  members-only lists can be useful inside organizations for internal
  communication. public lists can be useful for support addresses.
- ListMembers: whether members can see the addresses of other members. this can
  be seen in the account web interface. in the future, we could export this in
  other ways, so clients can expand the list.
- AllowMsgFrom: whether messages can be sent through the alias with the alias
  address used in the message from-header. the webmail knows it can use that
  address, and will use it as from-address when replying to a message sent to
  that address.

ideas for the future:
- allow external addresses as members. still with some restrictions, such as
  requiring a valid dkim-signature so delivery has a chance to succeed. will
  also need configuration of an admin that can receive any bounces.
- allow specifying specific members who can sent through the list (instead of
  all members).

for github issue #57 by hmfaysal.
also relevant for #99 by naturalethic.
thanks to damir & marin from sartura for discussing requirements/features.
2024-04-24 19:15:30 +02:00

472 lines
16 KiB
Go

package webaccount
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path"
"path/filepath"
"reflect"
"runtime/debug"
"sort"
"strings"
"testing"
"time"
"github.com/mjl-/bstore"
"github.com/mjl-/sherpa"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/queue"
"github.com/mjl-/mox/store"
"github.com/mjl-/mox/webauth"
"github.com/mjl-/mox/webhook"
)
var ctxbg = context.Background()
func init() {
mox.LimitersInit()
webauth.BadAuthDelay = 0
}
func tcheck(t *testing.T, err error, msg string) {
t.Helper()
if err != nil {
t.Fatalf("%s: %s", msg, err)
}
}
func readBody(r io.Reader) string {
buf, err := io.ReadAll(r)
if err != nil {
return fmt.Sprintf("read error: %s", err)
}
return fmt.Sprintf("data: %q", buf)
}
func tneedErrorCode(t *testing.T, code string, fn func()) {
t.Helper()
defer func() {
t.Helper()
x := recover()
if x == nil {
debug.PrintStack()
t.Fatalf("expected sherpa user error, saw success")
}
if err, ok := x.(*sherpa.Error); !ok {
debug.PrintStack()
t.Fatalf("expected sherpa error, saw %#v", x)
} else if err.Code != code {
debug.PrintStack()
t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
}
}()
fn()
}
func tcompare(t *testing.T, got, expect any) {
t.Helper()
if !reflect.DeepEqual(got, expect) {
t.Fatalf("got:\n%#v\nexpected:\n%#v", got, expect)
}
}
func TestAccount(t *testing.T) {
os.RemoveAll("../testdata/httpaccount/data")
mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf")
mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
mox.MustLoadConfig(true, false)
log := mlog.New("webaccount", nil)
acc, err := store.OpenAccount(log, "mjl☺")
tcheck(t, err, "open account")
err = acc.SetPassword(log, "test1234")
tcheck(t, err, "set password")
defer func() {
err = acc.Close()
tcheck(t, err, "closing account")
acc.CheckClosed()
}()
defer store.Switchboard()()
api := Account{cookiePath: "/account/"}
apiHandler, err := makeSherpaHandler(api.cookiePath, false)
tcheck(t, err, "sherpa handler")
// Record HTTP response to get session cookie for login.
respRec := httptest.NewRecorder()
reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
// Missing login token.
tneedErrorCode(t, "user:error", func() { api.Login(ctx, "", "mjl☺@mox.example", "test1234") })
// Login with loginToken.
loginCookie := &http.Cookie{Name: "webaccountlogin"}
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
csrfToken := api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "test1234")
var sessionCookie *http.Cookie
for _, c := range respRec.Result().Cookies() {
if c.Name == "webaccountsession" {
sessionCookie = c
break
}
}
if sessionCookie == nil {
t.Fatalf("missing session cookie")
}
// Valid loginToken, but bad credentials.
loginCookie.Value = api.LoginPrep(ctx)
reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "badauth") })
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@mox.example", "badauth") })
tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@baddomain.example", "badauth") })
type httpHeaders [][2]string
ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
cookieOK := &http.Cookie{Name: "webaccountsession", Value: sessionCookie.Value}
cookieBad := &http.Cookie{Name: "webaccountsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
req := httptest.NewRequest(method, path, nil)
for _, kv := range headers {
req.Header.Add(kv[0], kv[1])
}
rr := httptest.NewRecorder()
rr.Body = &bytes.Buffer{}
handle(apiHandler, false, rr, req)
if rr.Code != expStatusCode {
t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
}
resp := rr.Result()
for _, h := range expHeaders {
if resp.Header.Get(h[0]) != h[1] {
t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1])
}
}
if check != nil {
check(resp)
}
}
testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
t.Helper()
testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
}
userAuthError := func(resp *http.Response, expCode string) {
t.Helper()
var response struct {
Error *sherpa.Error `json:"error"`
}
err := json.NewDecoder(resp.Body).Decode(&response)
tcheck(t, err, "parsing response as json")
if response.Error == nil {
t.Fatalf("expected sherpa error with code %s, no error", expCode)
}
if response.Error.Code != expCode {
t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
}
}
badAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:badAuth")
}
noAuth := func(resp *http.Response) {
t.Helper()
userAuthError(resp, "user:noAuth")
}
testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
testHTTPAuthAPI("GET", "/api/Types", http.StatusMethodNotAllowed, nil, nil)
testHTTPAuthAPI("POST", "/api/Types", http.StatusOK, httpHeaders{ctJSON}, nil)
testHTTP("POST", "/import", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("POST", "/import", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export", httpHeaders{}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
testHTTP("GET", "/export", httpHeaders{hdrSessionOK}, http.StatusForbidden, nil, nil)
// SetPassword needs the token.
sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
reqInfo = requestInfo{"mjl☺@mox.example", "mjl☺", sessionToken, respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
api.SetPassword(ctx, "test1234")
err = queue.Init() // For DB.
tcheck(t, err, "queue init")
account, _, _, _ := api.Account(ctx)
// Check we don't see the alias member list.
tcompare(t, len(account.Aliases), 1)
tcompare(t, account.Aliases[0], config.AddressAlias{
SubscriptionAddress: "mjl☺@mox.example",
Alias: config.Alias{
LocalpartStr: "support",
Domain: dns.Domain{ASCII: "mox.example"},
AllowMsgFrom: true,
},
})
api.DestinationSave(ctx, "mjl☺@mox.example", account.Destinations["mjl☺@mox.example"], account.Destinations["mjl☺@mox.example"]) // todo: save modified value and compare it afterwards
api.AccountSaveFullName(ctx, account.FullName+" changed") // todo: check if value was changed
api.AccountSaveFullName(ctx, account.FullName)
go ImportManage()
// Import mbox/maildir tgz/zip.
testImport := func(filename string, expect int) {
t.Helper()
var reqBody bytes.Buffer
mpw := multipart.NewWriter(&reqBody)
part, err := mpw.CreateFormFile("file", path.Base(filename))
tcheck(t, err, "creating form file")
buf, err := os.ReadFile(filename)
tcheck(t, err, "reading file")
_, err = part.Write(buf)
tcheck(t, err, "write part")
err = mpw.Close()
tcheck(t, err, "close multipart writer")
r := httptest.NewRequest("POST", "/import", &reqBody)
r.Header.Add("Content-Type", mpw.FormDataContentType())
r.Header.Add("x-mox-csrf", string(csrfToken))
r.Header.Add("Cookie", cookieOK.String())
w := httptest.NewRecorder()
handle(apiHandler, false, w, r)
if w.Code != http.StatusOK {
t.Fatalf("import, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
}
var m ImportProgress
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("parsing import response: %v", err)
}
l := importListener{m.Token, make(chan importEvent, 100), make(chan bool)}
importers.Register <- &l
if !<-l.Register {
t.Fatalf("register failed")
}
defer func() {
importers.Unregister <- &l
}()
count := 0
loop:
for {
e := <-l.Events
if e.Event == nil {
continue
}
switch x := e.Event.(type) {
case importCount:
count += x.Count
case importProblem:
t.Fatalf("unexpected problem: %q", x.Message)
case importStep:
case importDone:
break loop
case importAborted:
t.Fatalf("unexpected aborted import")
default:
panic(fmt.Sprintf("missing case for Event %#v", e))
}
}
if count != expect {
t.Fatalf("imported %d messages, expected %d", count, expect)
}
}
testImport(filepath.FromSlash("../testdata/importtest.mbox.zip"), 2)
testImport(filepath.FromSlash("../testdata/importtest.maildir.tgz"), 2)
// Check there are messages, with the right flags.
acc.DB.Read(ctxbg, func(tx *bstore.Tx) error {
_, err = bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "other").FilterIn("Keywords", "test").Get()
tcheck(t, err, `fetching message with keywords "other" and "test"`)
mb, err := acc.MailboxFind(tx, "importtest")
tcheck(t, err, "looking up mailbox importtest")
if mb == nil {
t.Fatalf("missing mailbox importtest")
}
sort.Strings(mb.Keywords)
if strings.Join(mb.Keywords, " ") != "other test" {
t.Fatalf(`expected mailbox keywords "other" and "test", got %v`, mb.Keywords)
}
n, err := bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "custom").Count()
tcheck(t, err, `fetching message with keyword "custom"`)
if n != 2 {
t.Fatalf(`got %d messages with keyword "custom", expected 2`, n)
}
mb, err = acc.MailboxFind(tx, "maildir")
tcheck(t, err, "looking up mailbox maildir")
if mb == nil {
t.Fatalf("missing mailbox maildir")
}
if strings.Join(mb.Keywords, " ") != "custom" {
t.Fatalf(`expected mailbox keywords "custom", got %v`, mb.Keywords)
}
return nil
})
testExport := func(format, archive string, expectFiles int) {
t.Helper()
fields := url.Values{
"csrf": []string{string(csrfToken)},
"format": []string{format},
"archive": []string{archive},
"mailbox": []string{""},
"recursive": []string{"on"},
}
r := httptest.NewRequest("POST", "/export", strings.NewReader(fields.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Add("Cookie", cookieOK.String())
w := httptest.NewRecorder()
handle(apiHandler, false, w, r)
if w.Code != http.StatusOK {
t.Fatalf("export, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
}
var count int
if archive == "zip" {
buf := w.Body.Bytes()
zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
tcheck(t, err, "reading zip")
for _, f := range zr.File {
if !strings.HasSuffix(f.Name, "/") {
count++
}
}
} else {
var src io.Reader = w.Body
if archive == "tgz" {
gzr, err := gzip.NewReader(src)
tcheck(t, err, "gzip reader")
src = gzr
}
tr := tar.NewReader(src)
for {
h, err := tr.Next()
if err == io.EOF {
break
}
tcheck(t, err, "next file in tar")
if !strings.HasSuffix(h.Name, "/") {
count++
}
_, err = io.Copy(io.Discard, tr)
tcheck(t, err, "reading from tar")
}
}
if count != expectFiles {
t.Fatalf("export, has %d files, expected %d", count, expectFiles)
}
}
testExport("maildir", "tgz", 6) // 2 mailboxes, each with 2 messages and a dovecot-keyword file
testExport("maildir", "zip", 6)
testExport("mbox", "tar", 2+6) // 2 imported plus 6 default mailboxes (Inbox, Draft, etc)
testExport("mbox", "zip", 2+6)
sl := api.SuppressionList(ctx)
tcompare(t, len(sl), 0)
api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing")
tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing") }) // Duplicate.
tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "bogus", true, "testing") }) // Bad address.
sl = api.SuppressionList(ctx)
tcompare(t, len(sl), 1)
api.SuppressionRemove(ctx, "mjl@mox.example")
tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "mjl@mox.example") }) // Absent.
tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "bogus") }) // Not an address.
var hooks int
hookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
hooks++
}))
defer hookServer.Close()
api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{"delivered"})
api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{})
tneedErrorCode(t, "user:error", func() {
api.OutgoingWebhookSave(ctx, "http://localhost:1234/outgoing", "Basic base64", []string{"bogus"})
})
tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookSave(ctx, "invalid", "Basic base64", nil) })
api.OutgoingWebhookSave(ctx, "", "", nil) // Restore.
code, response, errmsg := api.OutgoingWebhookTest(ctx, hookServer.URL, "", webhook.Outgoing{})
tcompare(t, code, 200)
tcompare(t, response, "ok\n")
tcompare(t, errmsg, "")
tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookTest(ctx, "bogus", "", webhook.Outgoing{}) })
api.IncomingWebhookSave(ctx, "http://localhost:1234", "Basic base64")
tneedErrorCode(t, "user:error", func() { api.IncomingWebhookSave(ctx, "invalid", "Basic base64") })
api.IncomingWebhookSave(ctx, "", "") // Restore.
code, response, errmsg = api.IncomingWebhookTest(ctx, hookServer.URL, "", webhook.Incoming{})
tcompare(t, code, 200)
tcompare(t, response, "ok\n")
tcompare(t, errmsg, "")
tneedErrorCode(t, "user:error", func() { api.IncomingWebhookTest(ctx, "bogus", "", webhook.Incoming{}) })
api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example"})
api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example", "mjl☺+fromid@mox.example"})
api.FromIDLoginAddressesSave(ctx, []string{})
tneedErrorCode(t, "user:error", func() { api.FromIDLoginAddressesSave(ctx, []string{"bogus@other.example"}) })
api.KeepRetiredPeriodsSave(ctx, time.Minute, time.Minute)
api.KeepRetiredPeriodsSave(ctx, 0, 0) // Restore.
api.AutomaticJunkFlagsSave(ctx, true, "^(junk|spam)", "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)", "")
api.AutomaticJunkFlagsSave(ctx, false, "", "", "")
api.RejectsSave(ctx, "Rejects", true)
api.RejectsSave(ctx, "Rejects", false)
api.RejectsSave(ctx, "", false) // Restore.
api.Logout(ctx)
tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
}