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