1package webaccount
2
3import (
4 "archive/tar"
5 "archive/zip"
6 "bytes"
7 "compress/gzip"
8 "context"
9 "encoding/json"
10 "fmt"
11 "io"
12 "mime/multipart"
13 "net/http"
14 "net/http/httptest"
15 "net/url"
16 "os"
17 "path"
18 "path/filepath"
19 "reflect"
20 "runtime/debug"
21 "sort"
22 "strings"
23 "testing"
24 "time"
25
26 "github.com/mjl-/bstore"
27 "github.com/mjl-/sherpa"
28
29 "github.com/mjl-/mox/config"
30 "github.com/mjl-/mox/dns"
31 "github.com/mjl-/mox/junk"
32 "github.com/mjl-/mox/mlog"
33 "github.com/mjl-/mox/mox-"
34 "github.com/mjl-/mox/queue"
35 "github.com/mjl-/mox/store"
36 "github.com/mjl-/mox/webauth"
37 "github.com/mjl-/mox/webhook"
38)
39
40var ctxbg = context.Background()
41
42func init() {
43 mox.LimitersInit()
44 webauth.BadAuthDelay = 0
45}
46
47func tcheck(t *testing.T, err error, msg string) {
48 t.Helper()
49 if err != nil {
50 t.Fatalf("%s: %s", msg, err)
51 }
52}
53
54func readBody(r io.Reader) string {
55 buf, err := io.ReadAll(r)
56 if err != nil {
57 return fmt.Sprintf("read error: %s", err)
58 }
59 return fmt.Sprintf("data: %q", buf)
60}
61
62func tneedErrorCode(t *testing.T, code string, fn func()) {
63 t.Helper()
64 defer func() {
65 t.Helper()
66 x := recover()
67 if x == nil {
68 debug.PrintStack()
69 t.Fatalf("expected sherpa user error, saw success")
70 }
71 if err, ok := x.(*sherpa.Error); !ok {
72 debug.PrintStack()
73 t.Fatalf("expected sherpa error, saw %#v", x)
74 } else if err.Code != code {
75 debug.PrintStack()
76 t.Fatalf("expected sherpa error code %q, saw other sherpa error %#v", code, err)
77 }
78 }()
79
80 fn()
81}
82
83func tcompare(t *testing.T, got, expect any) {
84 t.Helper()
85 if !reflect.DeepEqual(got, expect) {
86 t.Fatalf("got:\n%#v\nexpected:\n%#v", got, expect)
87 }
88}
89
90func TestAccount(t *testing.T) {
91 os.RemoveAll("../testdata/httpaccount/data")
92 mox.ConfigStaticPath = filepath.FromSlash("../testdata/httpaccount/mox.conf")
93 mox.ConfigDynamicPath = filepath.Join(filepath.Dir(mox.ConfigStaticPath), "domains.conf")
94 mox.MustLoadConfig(true, false)
95 log := mlog.New("webaccount", nil)
96 acc, err := store.OpenAccount(log, "mjl☺")
97 tcheck(t, err, "open account")
98 err = acc.SetPassword(log, "test1234")
99 tcheck(t, err, "set password")
100 defer func() {
101 err = acc.Close()
102 tcheck(t, err, "closing account")
103 acc.CheckClosed()
104 }()
105 defer store.Switchboard()()
106
107 api := Account{cookiePath: "/account/"}
108 apiHandler, err := makeSherpaHandler(api.cookiePath, false)
109 tcheck(t, err, "sherpa handler")
110
111 // Record HTTP response to get session cookie for login.
112 respRec := httptest.NewRecorder()
113 reqInfo := requestInfo{"", "", "", respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
114 ctx := context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
115
116 // Missing login token.
117 tneedErrorCode(t, "user:error", func() { api.Login(ctx, "", "mjl☺@mox.example", "test1234") })
118
119 // Login with loginToken.
120 loginCookie := &http.Cookie{Name: "webaccountlogin"}
121 loginCookie.Value = api.LoginPrep(ctx)
122 reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
123
124 csrfToken := api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "test1234")
125 var sessionCookie *http.Cookie
126 for _, c := range respRec.Result().Cookies() {
127 if c.Name == "webaccountsession" {
128 sessionCookie = c
129 break
130 }
131 }
132 if sessionCookie == nil {
133 t.Fatalf("missing session cookie")
134 }
135
136 // Valid loginToken, but bad credentials.
137 loginCookie.Value = api.LoginPrep(ctx)
138 reqInfo.Request.Header = http.Header{"Cookie": []string{loginCookie.String()}}
139 tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "mjl☺@mox.example", "badauth") })
140 tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@mox.example", "badauth") })
141 tneedErrorCode(t, "user:loginFailed", func() { api.Login(ctx, loginCookie.Value, "baduser@baddomain.example", "badauth") })
142
143 type httpHeaders [][2]string
144 ctJSON := [2]string{"Content-Type", "application/json; charset=utf-8"}
145
146 cookieOK := &http.Cookie{Name: "webaccountsession", Value: sessionCookie.Value}
147 cookieBad := &http.Cookie{Name: "webaccountsession", Value: "AAAAAAAAAAAAAAAAAAAAAA mjl"}
148 hdrSessionOK := [2]string{"Cookie", cookieOK.String()}
149 hdrSessionBad := [2]string{"Cookie", cookieBad.String()}
150 hdrCSRFOK := [2]string{"x-mox-csrf", string(csrfToken)}
151 hdrCSRFBad := [2]string{"x-mox-csrf", "AAAAAAAAAAAAAAAAAAAAAA"}
152
153 testHTTP := func(method, path string, headers httpHeaders, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
154 t.Helper()
155
156 req := httptest.NewRequest(method, path, nil)
157 for _, kv := range headers {
158 req.Header.Add(kv[0], kv[1])
159 }
160 rr := httptest.NewRecorder()
161 rr.Body = &bytes.Buffer{}
162 handle(apiHandler, false, rr, req)
163 if rr.Code != expStatusCode {
164 t.Fatalf("got status %d, expected %d (%s)", rr.Code, expStatusCode, readBody(rr.Body))
165 }
166
167 resp := rr.Result()
168 for _, h := range expHeaders {
169 if resp.Header.Get(h[0]) != h[1] {
170 t.Fatalf("for header %q got value %q, expected %q", h[0], resp.Header.Get(h[0]), h[1])
171 }
172 }
173
174 if check != nil {
175 check(resp)
176 }
177 }
178 testHTTPAuthAPI := func(method, path string, expStatusCode int, expHeaders httpHeaders, check func(resp *http.Response)) {
179 t.Helper()
180 testHTTP(method, path, httpHeaders{hdrCSRFOK, hdrSessionOK}, expStatusCode, expHeaders, check)
181 }
182
183 userAuthError := func(resp *http.Response, expCode string) {
184 t.Helper()
185
186 var response struct {
187 Error *sherpa.Error `json:"error"`
188 }
189 err := json.NewDecoder(resp.Body).Decode(&response)
190 tcheck(t, err, "parsing response as json")
191 if response.Error == nil {
192 t.Fatalf("expected sherpa error with code %s, no error", expCode)
193 }
194 if response.Error.Code != expCode {
195 t.Fatalf("got sherpa error code %q, expected %s", response.Error.Code, expCode)
196 }
197 }
198 badAuth := func(resp *http.Response) {
199 t.Helper()
200 userAuthError(resp, "user:badAuth")
201 }
202 noAuth := func(resp *http.Response) {
203 t.Helper()
204 userAuthError(resp, "user:noAuth")
205 }
206
207 testHTTP("POST", "/api/Bogus", httpHeaders{}, http.StatusOK, nil, noAuth)
208 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad}, http.StatusOK, nil, noAuth)
209 testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionBad}, http.StatusOK, nil, noAuth)
210 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionBad}, http.StatusOK, nil, badAuth)
211 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK}, http.StatusOK, nil, noAuth)
212 testHTTP("POST", "/api/Bogus", httpHeaders{hdrSessionOK}, http.StatusOK, nil, noAuth)
213 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFBad, hdrSessionOK}, http.StatusOK, nil, badAuth)
214 testHTTP("POST", "/api/Bogus", httpHeaders{hdrCSRFOK, hdrSessionBad}, http.StatusOK, nil, badAuth)
215 testHTTPAuthAPI("GET", "/api/Types", http.StatusMethodNotAllowed, nil, nil)
216 testHTTPAuthAPI("POST", "/api/Types", http.StatusOK, httpHeaders{ctJSON}, nil)
217
218 testHTTP("POST", "/import", httpHeaders{}, http.StatusForbidden, nil, nil)
219 testHTTP("POST", "/import", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
220 testHTTP("GET", "/export", httpHeaders{}, http.StatusForbidden, nil, nil)
221 testHTTP("GET", "/export", httpHeaders{hdrSessionBad}, http.StatusForbidden, nil, nil)
222 testHTTP("GET", "/export", httpHeaders{hdrSessionOK}, http.StatusForbidden, nil, nil)
223
224 // SetPassword needs the token.
225 sessionToken := store.SessionToken(strings.SplitN(sessionCookie.Value, " ", 2)[0])
226 reqInfo = requestInfo{"mjl☺@mox.example", "mjl☺", sessionToken, respRec, &http.Request{RemoteAddr: "127.0.0.1:1234"}}
227 ctx = context.WithValue(ctxbg, requestInfoCtxKey, reqInfo)
228
229 api.SetPassword(ctx, "test1234")
230
231 err = queue.Init() // For DB.
232 tcheck(t, err, "queue init")
233 defer queue.Shutdown()
234
235 account, _, _, _ := api.Account(ctx)
236
237 // Check we don't see the alias member list.
238 tcompare(t, len(account.Aliases), 1)
239 tcompare(t, account.Aliases[0], config.AddressAlias{
240 SubscriptionAddress: "mjl☺@mox.example",
241 Alias: config.Alias{
242 LocalpartStr: "support",
243 Domain: dns.Domain{ASCII: "mox.example"},
244 AllowMsgFrom: true,
245 },
246 })
247
248 api.DestinationSave(ctx, "mjl☺@mox.example", account.Destinations["mjl☺@mox.example"], account.Destinations["mjl☺@mox.example"]) // todo: save modified value and compare it afterwards
249
250 api.AccountSaveFullName(ctx, account.FullName+" changed") // todo: check if value was changed
251 api.AccountSaveFullName(ctx, account.FullName)
252
253 go ImportManage()
254 defer func() {
255 importers.Stop <- struct{}{}
256 }()
257
258 // Import mbox/maildir tgz/zip.
259 testImport := func(filename string, expect int) {
260 t.Helper()
261
262 var reqBody bytes.Buffer
263 mpw := multipart.NewWriter(&reqBody)
264 part, err := mpw.CreateFormFile("file", path.Base(filename))
265 tcheck(t, err, "creating form file")
266 buf, err := os.ReadFile(filename)
267 tcheck(t, err, "reading file")
268 _, err = part.Write(buf)
269 tcheck(t, err, "write part")
270 err = mpw.Close()
271 tcheck(t, err, "close multipart writer")
272
273 r := httptest.NewRequest("POST", "/import", &reqBody)
274 r.Header.Add("Content-Type", mpw.FormDataContentType())
275 r.Header.Add("x-mox-csrf", string(csrfToken))
276 r.Header.Add("Cookie", cookieOK.String())
277 w := httptest.NewRecorder()
278 handle(apiHandler, false, w, r)
279 if w.Code != http.StatusOK {
280 t.Fatalf("import, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
281 }
282 var m ImportProgress
283 if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
284 t.Fatalf("parsing import response: %v", err)
285 }
286
287 l := importListener{m.Token, make(chan importEvent, 100), make(chan bool)}
288 importers.Register <- &l
289 if !<-l.Register {
290 t.Fatalf("register failed")
291 }
292 defer func() {
293 importers.Unregister <- &l
294 }()
295 count := 0
296 loop:
297 for {
298 e := <-l.Events
299 if e.Event == nil {
300 continue
301 }
302 switch x := e.Event.(type) {
303 case importCount:
304 count += x.Count
305 case importProblem:
306 t.Fatalf("unexpected problem: %q", x.Message)
307 case importStep:
308 case importDone:
309 break loop
310 case importAborted:
311 t.Fatalf("unexpected aborted import")
312 default:
313 panic(fmt.Sprintf("missing case for Event %#v", e))
314 }
315 }
316 if count != expect {
317 t.Fatalf("imported %d messages, expected %d", count, expect)
318 }
319 }
320 testImport(filepath.FromSlash("../testdata/importtest.mbox.zip"), 2)
321 testImport(filepath.FromSlash("../testdata/importtest.maildir.tgz"), 2)
322
323 // Check there are messages, with the right flags.
324 acc.DB.Read(ctxbg, func(tx *bstore.Tx) error {
325 _, err = bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "other").FilterIn("Keywords", "test").Get()
326 tcheck(t, err, `fetching message with keywords "other" and "test"`)
327
328 mb, err := acc.MailboxFind(tx, "importtest")
329 tcheck(t, err, "looking up mailbox importtest")
330 if mb == nil {
331 t.Fatalf("missing mailbox importtest")
332 }
333 sort.Strings(mb.Keywords)
334 if strings.Join(mb.Keywords, " ") != "other test" {
335 t.Fatalf(`expected mailbox keywords "other" and "test", got %v`, mb.Keywords)
336 }
337
338 n, err := bstore.QueryTx[store.Message](tx).FilterEqual("Expunged", false).FilterIn("Keywords", "custom").Count()
339 tcheck(t, err, `fetching message with keyword "custom"`)
340 if n != 2 {
341 t.Fatalf(`got %d messages with keyword "custom", expected 2`, n)
342 }
343
344 mb, err = acc.MailboxFind(tx, "maildir")
345 tcheck(t, err, "looking up mailbox maildir")
346 if mb == nil {
347 t.Fatalf("missing mailbox maildir")
348 }
349 if strings.Join(mb.Keywords, " ") != "custom" {
350 t.Fatalf(`expected mailbox keywords "custom", got %v`, mb.Keywords)
351 }
352
353 return nil
354 })
355
356 testExport := func(format, archive string, expectFiles int) {
357 t.Helper()
358
359 fields := url.Values{
360 "csrf": []string{string(csrfToken)},
361 "format": []string{format},
362 "archive": []string{archive},
363 "mailbox": []string{""},
364 "recursive": []string{"on"},
365 }
366 r := httptest.NewRequest("POST", "/export", strings.NewReader(fields.Encode()))
367 r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
368 r.Header.Add("Cookie", cookieOK.String())
369 w := httptest.NewRecorder()
370 handle(apiHandler, false, w, r)
371 if w.Code != http.StatusOK {
372 t.Fatalf("export, got status code %d, expected 200: %s", w.Code, w.Body.Bytes())
373 }
374 var count int
375 if archive == "zip" {
376 buf := w.Body.Bytes()
377 zr, err := zip.NewReader(bytes.NewReader(buf), int64(len(buf)))
378 tcheck(t, err, "reading zip")
379 for _, f := range zr.File {
380 if !strings.HasSuffix(f.Name, "/") {
381 count++
382 }
383 }
384 } else {
385 var src io.Reader = w.Body
386 if archive == "tgz" {
387 gzr, err := gzip.NewReader(src)
388 tcheck(t, err, "gzip reader")
389 src = gzr
390 }
391 tr := tar.NewReader(src)
392 for {
393 h, err := tr.Next()
394 if err == io.EOF {
395 break
396 }
397 tcheck(t, err, "next file in tar")
398 if !strings.HasSuffix(h.Name, "/") {
399 count++
400 }
401 _, err = io.Copy(io.Discard, tr)
402 tcheck(t, err, "reading from tar")
403 }
404 }
405 if count != expectFiles {
406 t.Fatalf("export, has %d files, expected %d", count, expectFiles)
407 }
408 }
409
410 testExport("maildir", "tgz", 6) // 2 mailboxes, each with 2 messages and a dovecot-keyword file
411 testExport("maildir", "zip", 6)
412 testExport("mbox", "tar", 2+6) // 2 imported plus 6 default mailboxes (Inbox, Draft, etc)
413 testExport("mbox", "zip", 2+6)
414
415 sl := api.SuppressionList(ctx)
416 tcompare(t, len(sl), 0)
417
418 api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing")
419 tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "mjl@mox.example", true, "testing") }) // Duplicate.
420 tneedErrorCode(t, "user:error", func() { api.SuppressionAdd(ctx, "bogus", true, "testing") }) // Bad address.
421
422 sl = api.SuppressionList(ctx)
423 tcompare(t, len(sl), 1)
424
425 api.SuppressionRemove(ctx, "mjl@mox.example")
426 tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "mjl@mox.example") }) // Absent.
427 tneedErrorCode(t, "user:error", func() { api.SuppressionRemove(ctx, "bogus") }) // Not an address.
428
429 var hooks int
430 hookServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
431 fmt.Fprintln(w, "ok")
432 hooks++
433 }))
434 defer hookServer.Close()
435
436 api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{"delivered"})
437 api.OutgoingWebhookSave(ctx, "http://localhost:1234", "Basic base64", []string{})
438 tneedErrorCode(t, "user:error", func() {
439 api.OutgoingWebhookSave(ctx, "http://localhost:1234/outgoing", "Basic base64", []string{"bogus"})
440 })
441 tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookSave(ctx, "invalid", "Basic base64", nil) })
442 api.OutgoingWebhookSave(ctx, "", "", nil) // Restore.
443
444 code, response, errmsg := api.OutgoingWebhookTest(ctx, hookServer.URL, "", webhook.Outgoing{})
445 tcompare(t, code, 200)
446 tcompare(t, response, "ok\n")
447 tcompare(t, errmsg, "")
448 tneedErrorCode(t, "user:error", func() { api.OutgoingWebhookTest(ctx, "bogus", "", webhook.Outgoing{}) })
449
450 api.IncomingWebhookSave(ctx, "http://localhost:1234", "Basic base64")
451 tneedErrorCode(t, "user:error", func() { api.IncomingWebhookSave(ctx, "invalid", "Basic base64") })
452 api.IncomingWebhookSave(ctx, "", "") // Restore.
453
454 code, response, errmsg = api.IncomingWebhookTest(ctx, hookServer.URL, "", webhook.Incoming{})
455 tcompare(t, code, 200)
456 tcompare(t, response, "ok\n")
457 tcompare(t, errmsg, "")
458 tneedErrorCode(t, "user:error", func() { api.IncomingWebhookTest(ctx, "bogus", "", webhook.Incoming{}) })
459
460 api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example"})
461 api.FromIDLoginAddressesSave(ctx, []string{"mjl☺@mox.example", "mjl☺+fromid@mox.example"})
462 api.FromIDLoginAddressesSave(ctx, []string{})
463 tneedErrorCode(t, "user:error", func() { api.FromIDLoginAddressesSave(ctx, []string{"bogus@other.example"}) })
464
465 api.KeepRetiredPeriodsSave(ctx, time.Minute, time.Minute)
466 api.KeepRetiredPeriodsSave(ctx, 0, 0) // Restore.
467
468 api.AutomaticJunkFlagsSave(ctx, true, "^(junk|spam)", "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)", "")
469 api.AutomaticJunkFlagsSave(ctx, false, "", "", "")
470
471 api.JunkFilterSave(ctx, nil)
472 jf := config.JunkFilter{
473 Threshold: 0.95,
474 Params: junk.Params{
475 Twograms: true,
476 MaxPower: 0.1,
477 TopWords: 10,
478 IgnoreWords: 0.1,
479 },
480 }
481 api.JunkFilterSave(ctx, &jf)
482
483 api.RejectsSave(ctx, "Rejects", true)
484 api.RejectsSave(ctx, "Rejects", false)
485 api.RejectsSave(ctx, "", false) // Restore.
486
487 api.Logout(ctx)
488 tneedErrorCode(t, "server:error", func() { api.Logout(ctx) })
489}
490