10 cryptorand "crypto/rand"
31 "github.com/mjl-/bstore"
32 "github.com/mjl-/sherpa"
34 "github.com/mjl-/mox/config"
35 "github.com/mjl-/mox/dns"
36 "github.com/mjl-/mox/junk"
37 "github.com/mjl-/mox/mlog"
38 "github.com/mjl-/mox/mox-"
39 "github.com/mjl-/mox/queue"
40 "github.com/mjl-/mox/store"
41 "github.com/mjl-/mox/webauth"
42 "github.com/mjl-/mox/webhook"
45var ctxbg = context.Background()
49 webauth.BadAuthDelay = 0
52func tcheck(t *testing.T, err error, msg string) {
55 t.Fatalf("%s: %s", msg, err)
59func readBody(r io.Reader) string {
60 buf, err := io.ReadAll(r)
62 return fmt.Sprintf("read error: %s", err)
64 return fmt.Sprintf("data: %q", buf)
67func tneedErrorCode(t *testing.T, code string, fn func()) {
74 t.Fatalf("expected sherpa user error, saw success")
76 if err, ok := x.(*sherpa.Error); !ok {
78 t.Fatalf("expected sherpa error, saw %#v", x)
79 } else if err.Code != code {
81 t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
88func tcompare(t *testing.T, got, expect any) {
90 if !reflect.DeepEqual(got, expect) {
91 t.Fatalf("got:\n%#v\nexpected:\n%#v", got, expect)
95func TestAccount(t *testing.T) {
96 os.RemoveAll("../testdata/httpaccount/data")
97 mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf")
98 mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
99 mox.MustLoadConfig(true, false)
100 log := mlog.New("webaccount", nil)
101 acc, err := store.OpenAccount(log, "mjl☺")
102 tcheck(t, err, "open account")
103 err = acc.SetPassword(log, "test1234")
104 tcheck(t, err, "set password")
107 tcheck(t, err, "closing account")
110 defer store.Switchboard()()
112 api := Account{cookiePath: "/account/"}
113 apiHandler, err := makeSherpaHandler(api.cookiePath, false)
114 tcheck(t, err, "sherpa handler")
116 // Record HTTP response to get session cookie for login.
117 respRec := httptest.NewRecorder()
118 reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
119 ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
121 // Missing login token.
122 tneedErrorCode(t, "user:error", func() { api.Login(ctx, "", "mjl☺@mox.example", "test1234") })
124 // Login with loginToken.
125 loginCookie := &http.Cookie{Name: "webaccountlogin"}
126 loginCookie.Value = api.LoginPrep(ctx)
127 reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
129 csrfToken := api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "test1234")
130 var sessionCookie *http.Cookie
131 for _, c := range respRec.Result().Cookies() {
132 if c.Name == "webaccountsession" {
137 if sessionCookie == nil {
138 t.Fatalf("missing session cookie")
141 // Valid loginToken, but bad credentials.
142 loginCookie.Value = api.LoginPrep(ctx)
143 reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
144 tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "badauth") })
145 tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@mox.example", "badauth") })
146 tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@baddomain.example", "badauth") })
148 type httpHeaders [][2]string
149 ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
151 cookieOK := &http.Cookie{Name: "webaccountsession", Value: sessionCookie.Value}
152 cookieBad := &http.Cookie{Name: "webaccountsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
153 hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
154 hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
155 hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
156 hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
158 testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
161 req := httptest.NewRequest(method, path, nil)
162 for _, kv := range headers {
163 req.Header.Add(kv[0], kv[1])
165 rr := httptest.NewRecorder()
166 rr.Body = &bytes.Buffer{}
167 handle(apiHandler, false, rr, req)
168 if rr.Code != expStatusCode {
169 t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
173 for _, h := range expHeaders {
174 if resp.Header.Get(h[0]) != h[1] {
175 t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1])
183 testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
185 testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
188 userAuthError := func(resp *http.Response, expCode string) {
191 var response struct {
192 Error *sherpa.Error `json:"error"`
194 err := json.NewDecoder(resp.Body).Decode(&response)
195 tcheck(t, err, "parsing response as json")
196 if response.Error == nil {
197 t.Fatalf("expected sherpa error with code %s, no error", expCode)
199 if response.Error.Code != expCode {
200 t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
203 badAuth := func(resp *http.Response) {
205 userAuthError(resp, "user:badAuth")
207 noAuth := func(resp *http.Response) {
209 userAuthError(resp, "user:noAuth")
212 testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
213 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
214 testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
215 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
216 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
217 testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
218 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
219 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
220 testHTTPAuthAPI("GET", "/api/Types", http.StatusMethodNotAllowed, nil, nil)
221 testHTTPAuthAPI("POST", "/api/Types", http.StatusOK, httpHeaders{ctJSON}, nil)
223 testHTTP("POST", "/import", httpHeaders{}, http.StatusForbidden, nil, nil)
224 testHTTP("POST", "/import", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
225 testHTTP("GET", "/export", httpHeaders{}, http.StatusForbidden, nil, nil)
226 testHTTP("GET", "/export", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
227 testHTTP("GET", "/export", httpHeaders{hdrSessionOK}, http.StatusForbidden, nil, nil)
229 // SetPassword needs the token.
230 sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
231 reqInfo = requestInfo{"mjl☺@mox.example", "mjl☺", sessionToken, respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
232 ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
234 api.SetPassword(ctx, "test1234")
236 err = queue.Init() // For DB.
237 tcheck(t, err, "queue init")
238 defer queue.Shutdown()
240 account, _, _, _ := api.Account(ctx)
242 // Check we don't see the alias member list.
243 tcompare(t, len(account.Aliases), 1)
244 tcompare(t, account.Aliases[0], config.AddressAlias{
245 SubscriptionAddress: "mjl☺@mox.example",
247 LocalpartStr: "support",
248 Domain: dns.Domain{ASCII: "mox.example"},
253 api.DestinationSave(ctx, "mjl☺@mox.example", account.Destinations["mjl☺@mox.example"], account.Destinations["mjl☺@mox.example"]) // todo: save modified value and compare it afterwards
255 api.AccountSaveFullName(ctx, account.FullName+" changed") // todo: check if value was changed
256 api.AccountSaveFullName(ctx, account.FullName)
260 importers.Stop <- struct{}{}
263 // Import mbox/maildir tgz/zip.
264 testImport := func(filename string, expect int) {
267 var reqBody bytes.Buffer
268 mpw := multipart.NewWriter(&reqBody)
269 part, err := mpw.CreateFormFile("file", path.Base(filename))
270 tcheck(t, err, "creating form file")
271 buf, err := os.ReadFile(filename)
272 tcheck(t, err, "reading file")
273 _, err = part.Write(buf)
274 tcheck(t, err, "write part")
276 tcheck(t, err, "close multipart writer")
278 r := httptest.NewRequest("POST", "/import", &reqBody)
279 r.Header.Add("Content-Type", mpw.FormDataContentType())
280 r.Header.Add("x-mox-csrf", string(csrfToken))
281 r.Header.Add("Cookie", cookieOK.String())
282 w := httptest.NewRecorder()
283 handle(apiHandler, false, w, r)
284 if w.Code != http.StatusOK {
285 t.Fatalf("import, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
288 if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
289 t.Fatalf("parsing import response: %v", err)
292 l := importListener{m.Token, make(chan importEvent, 100), make(chan bool)}
293 importers.Register <- &l
295 t.Fatalf("register failed")
298 importers.Unregister <- &l
307 switch x := e.Event.(type) {
311 t.Fatalf("unexpected problem: %q", x.Message)
316 t.Fatalf("unexpected aborted import")
318 panic(fmt.Sprintf("missing case for Event %#v", e))
322 t.Fatalf("imported %d messages, expected %d", count, expect)
325 testImport(filepath.FromSlash("../testdata/importtest.mbox.zip"), 2)
326 testImport(filepath.FromSlash("../testdata/importtest.maildir.tgz"), 2)
328 // Check there are messages, with the right flags.
329 acc.DB.Read(ctxbg, func(tx *bstore.Tx) error {
330 _, err = bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "other").FilterIn("Keywords", "test").Get()
331 tcheck(t, err, `fetching message with keywords "other" and "test"`)
333 mb, err := acc.MailboxFind(tx, "importtest")
334 tcheck(t, err, "looking up mailbox importtest")
336 t.Fatalf("missing mailbox importtest")
338 sort.Strings(mb.Keywords)
339 if strings.Join(mb.Keywords, " ") != "other test" {
340 t.Fatalf(`expected mailbox keywords "other" and "test", got %v`, mb.Keywords)
343 n, err := bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "custom").Count()
344 tcheck(t, err, `fetching message with keyword "custom"`)
346 t.Fatalf(`got %d messages with keyword "custom", expected 2`, n)
349 mb, err = acc.MailboxFind(tx, "maildir")
350 tcheck(t, err, "looking up mailbox maildir")
352 t.Fatalf("missing mailbox maildir")
354 if strings.Join(mb.Keywords, " ") != "custom" {
355 t.Fatalf(`expected mailbox keywords "custom", got %v`, mb.Keywords)
361 testExport := func(format, archive string, expectFiles int) {
364 fields := url.Values{
365 "csrf": []string{string(csrfToken)},
366 "format": []string{format},
367 "archive": []string{archive},
368 "mailbox": []string{""},
369 "recursive": []string{"on"},
371 r := httptest.NewRequest("POST", "/export", strings.NewReader(fields.Encode()))
372 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
373 r.Header.Add("Cookie", cookieOK.String())
374 w := httptest.NewRecorder()
375 handle(apiHandler, false, w, r)
376 if w.Code != http.StatusOK {
377 t.Fatalf("export, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
380 if archive == "zip" {
381 buf := w.Body.Bytes()
382 zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
383 tcheck(t, err, "reading zip")
384 for _, f := range zr.File {
385 if !strings.HasSuffix(f.Name, "/") {
390 var src io.Reader = w.Body
391 if archive == "tgz" {
392 gzr, err := gzip.NewReader(src)
393 tcheck(t, err, "gzip reader")
396 tr := tar.NewReader(src)
402 tcheck(t, err, "next file in tar")
403 if !strings.HasSuffix(h.Name, "/") {
406 _, err = io.Copy(io.Discard, tr)
407 tcheck(t, err, "reading from tar")
410 if count != expectFiles {
411 t.Fatalf("export, has %d files, expected %d", count, expectFiles)
415 testExport("maildir", "tgz", 6) // 2 mailboxes, each with 2 messages and a dovecot-keyword file
416 testExport("maildir", "zip", 6)
417 testExport("mbox", "tar", 2+6) // 2 imported plus 6 default mailboxes (Inbox, Draft, etc)
418 testExport("mbox", "zip", 2+6)
420 sl := api.SuppressionList(ctx)
421 tcompare(t, len(sl), 0)
423 api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing")
424 tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing") }) // Duplicate.
425 tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "bogus", true, "testing") }) // Bad address.
427 sl = api.SuppressionList(ctx)
428 tcompare(t, len(sl), 1)
430 api.SuppressionRemove(ctx, "mjl@mox.example")
431 tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "mjl@mox.example") }) // Absent.
432 tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "bogus") }) // Not an address.
435 hookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
436 fmt.Fprintln(w, "ok")
439 defer hookServer.Close()
441 api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{"delivered"})
442 api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{})
443 tneedErrorCode(t, "user:error", func() {
444 api.OutgoingWebhookSave(ctx, "http://localhost:1234/outgoing", "Basic base64", []string{"bogus"})
446 tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookSave(ctx, "invalid", "Basic base64", nil) })
447 api.OutgoingWebhookSave(ctx, "", "", nil) // Restore.
449 code, response, errmsg := api.OutgoingWebhookTest(ctx, hookServer.URL, "", webhook.Outgoing{})
450 tcompare(t, code, 200)
451 tcompare(t, response, "ok\n")
452 tcompare(t, errmsg, "")
453 tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookTest(ctx, "bogus", "", webhook.Outgoing{}) })
455 api.IncomingWebhookSave(ctx, "http://localhost:1234", "Basic base64")
456 tneedErrorCode(t, "user:error", func() { api.IncomingWebhookSave(ctx, "invalid", "Basic base64") })
457 api.IncomingWebhookSave(ctx, "", "") // Restore.
459 code, response, errmsg = api.IncomingWebhookTest(ctx, hookServer.URL, "", webhook.Incoming{})
460 tcompare(t, code, 200)
461 tcompare(t, response, "ok\n")
462 tcompare(t, errmsg, "")
463 tneedErrorCode(t, "user:error", func() { api.IncomingWebhookTest(ctx, "bogus", "", webhook.Incoming{}) })
465 api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example"})
466 api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example", "mjl☺+fromid@mox.example"})
467 api.FromIDLoginAddressesSave(ctx, []string{})
468 tneedErrorCode(t, "user:error", func() { api.FromIDLoginAddressesSave(ctx, []string{"bogus@other.example"}) })
470 api.KeepRetiredPeriodsSave(ctx, time.Minute, time.Minute)
471 api.KeepRetiredPeriodsSave(ctx, 0, 0) // Restore.
473 api.AutomaticJunkFlagsSave(ctx, true, "^(junk|spam)", "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)", "")
474 api.AutomaticJunkFlagsSave(ctx, false, "", "", "")
476 api.JunkFilterSave(ctx, nil)
477 jf := config.JunkFilter{
486 api.JunkFilterSave(ctx, &jf)
488 api.RejectsSave(ctx, "Rejects", true)
489 api.RejectsSave(ctx, "Rejects", false)
490 api.RejectsSave(ctx, "", false) // Restore.
492 // Make cert for TLSPublicKey.
493 certBuf := fakeCert(t)
495 err = pem.Encode(&b, &pem.Block{Type: "CERTIFICATE", Bytes: certBuf})
496 tcheck(t, err, "encoding certificate as pem")
497 certPEM := b.String()
499 err = store.Init(ctx)
500 tcheck(t, err, "store init")
503 tcheck(t, err, "store close")
506 tpkl, err := api.TLSPublicKeys(ctx)
507 tcheck(t, err, "list tls public keys")
508 tcompare(t, len(tpkl), 0)
510 tpk, err := api.TLSPublicKeyAdd(ctx, "mjl☺@mox.example", "", false, certPEM)
511 tcheck(t, err, "add tls public key")
512 // Key already exists.
513 tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyAdd(ctx, "mjl☺@mox.example", "", false, certPEM) })
515 tpkl, err = api.TLSPublicKeys(ctx)
516 tcheck(t, err, "list tls public keys")
517 tcompare(t, tpkl, []store.TLSPublicKey{tpk})
519 tpk.NoIMAPPreauth = true
520 err = api.TLSPublicKeyUpdate(ctx, tpk)
521 tcheck(t, err, "tls public key update")
523 badtpk.Fingerprint = "bogus"
524 tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyUpdate(ctx, badtpk) })
526 tpkl, err = api.TLSPublicKeys(ctx)
527 tcheck(t, err, "list tls public keys")
528 tcompare(t, len(tpkl), 1)
529 tcompare(t, tpkl[0].NoIMAPPreauth, true)
531 err = api.TLSPublicKeyRemove(ctx, tpk.Fingerprint)
532 tcheck(t, err, "tls public key remove")
533 tneedErrorCode(t, "user:error", func() { api.TLSPublicKeyRemove(ctx, tpk.Fingerprint) })
535 tpkl, err = api.TLSPublicKeys(ctx)
536 tcheck(t, err, "list tls public keys")
537 tcompare(t, len(tpkl), 0)
540 tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
543func fakeCert(t *testing.T) []byte {
545 seed := make([]byte, ed25519.SeedSize)
546 privKey := ed25519.NewKeyFromSeed(seed) // Fake key, don't use this for real!
547 template := &x509.Certificate{
548 SerialNumber: big.NewInt(1), // Required field...
550 localCertBuf, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
551 tcheck(t, err, "making certificate")