Files
mailgoserver/internal/webui/carddav_test.go
T

252 lines
8.8 KiB
Go

package webui
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"mailgoserver/internal/db"
)
const carddavTestAppPassword = "carddav-test-app-password-1!"
// createTestMailboxWithAppPassword creates a mailbox plus an active app password for
// it — CardDAV/CalDAV auth (DAVBasicAuth) goes through db.VerifyMailboxAppPassword, the
// same credential IMAP/SMTP use, never the portal session password. Also opts both the
// domain and the mailbox into both protocols (both master-off by default — see
// schema.go's caldav_enabled/carddav_enabled comments) so every existing DAV test
// keeps exercising the actual PUT/GET/PROPFIND behavior rather than the separate
// enablement gate, which has its own dedicated tests.
func createTestMailboxWithAppPassword(t *testing.T, app *App, email string, domainID int64) int64 {
t.Helper()
mailboxID := createTestMailboxWithPassword(t, app, email, domainID, "portal-password-unused-1!")
hash, err := db.HashPassword(carddavTestAppPassword)
if err != nil {
t.Fatal(err)
}
if _, err := app.DB.CreateAppPassword(mailboxID, "carddav test", hash, nil); err != nil {
t.Fatal(err)
}
if err := app.DB.SetDomainCalDAVEnabled(domainID, true); err != nil {
t.Fatal(err)
}
if err := app.DB.SetDomainCardDAVEnabled(domainID, true); err != nil {
t.Fatal(err)
}
if err := app.DB.SetMailboxDAVEnabled(mailboxID, true, true); err != nil {
t.Fatal(err)
}
return mailboxID
}
func TestCardDAVBasicAuthRequiresAppPassword(t *testing.T) {
app := newTestApp(t)
domains, _ := app.DB.ListDomains()
createTestMailboxWithAppPassword(t, app, "auther@example.com", domains[0].ID)
srv := httptest.NewServer(app.Mux())
defer srv.Close()
// No credentials at all.
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/carddav/auther@example.com/addressbooks/default/", nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("no auth: status=%d, want 401", resp.StatusCode)
}
if resp.Header.Get("WWW-Authenticate") == "" {
t.Error("expected WWW-Authenticate header on 401 so clients know to prompt")
}
// Wrong password — must be logged and counted toward abuse-blacklist scoping, same
// as a failed SMTP/IMAP auth attempt.
req, _ = http.NewRequest(http.MethodGet, srv.URL+"/carddav/auther@example.com/addressbooks/default/", nil)
req.SetBasicAuth("auther@example.com", "wrong-password")
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("wrong password: status=%d, want 401", resp.StatusCode)
}
logs, err := app.DB.ListRecentAuthLogs(50)
if err != nil {
t.Fatal(err)
}
found := false
for _, l := range logs {
if l.AuthType == "dav_login" && l.Identifier == "auther@example.com" && !l.Success {
found = true
}
}
if !found {
t.Error("expected a failed dav_login auth attempt logged")
}
// Correct credentials succeed — PROPFIND (not a bare GET, which WebDAV only
// defines for an individual resource, not a collection) on the address book
// collection itself, matching what a real client does to discover it.
req, _ = http.NewRequest("PROPFIND", srv.URL+"/carddav/auther@example.com/addressbooks/default/", nil)
req.Header.Set("Depth", "0")
req.SetBasicAuth("auther@example.com", carddavTestAppPassword)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusMultiStatus {
t.Fatalf("correct auth PROPFIND: status=%d body=%s, want 207", resp.StatusCode, body)
}
}
func TestCardDAVPutGetDeleteRoundTrip(t *testing.T) {
app := newTestApp(t)
domains, _ := app.DB.ListDomains()
createTestMailboxWithAppPassword(t, app, "carduser@example.com", domains[0].ID)
srv := httptest.NewServer(app.Mux())
defer srv.Close()
objURL := srv.URL + "/carddav/carduser@example.com/addressbooks/default/test-uid-1.vcf"
vcardBody := "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Jane Doe\r\nEMAIL:jane@example.com\r\nTEL:555-1234\r\nORG:Acme\r\nEND:VCARD\r\n"
put := func() *http.Response {
req, _ := http.NewRequest(http.MethodPut, objURL, strings.NewReader(vcardBody))
req.Header.Set("Content-Type", "text/vcard")
req.SetBasicAuth("carduser@example.com", carddavTestAppPassword)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
resp := put()
resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("PUT: status=%d, want 201", resp.StatusCode)
}
get := func() *http.Response {
req, _ := http.NewRequest(http.MethodGet, objURL, nil)
req.SetBasicAuth("carduser@example.com", carddavTestAppPassword)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
resp = get()
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET: status=%d body=%s", resp.StatusCode, body)
}
if !strings.Contains(string(body), "Jane Doe") || !strings.Contains(string(body), "jane@example.com") {
t.Fatalf("GET body missing expected fields: %s", body)
}
// The webmail Contacts page reads the same underlying row — a PUT over CardDAV
// must show up there too.
contacts, err := app.DB.ListContacts(mustMailboxID(t, app, "carduser@example.com"))
if err != nil || len(contacts) != 1 || contacts[0].Email != "jane@example.com" || contacts[0].Org != "Acme" {
t.Fatalf("expected 1 contact from CardDAV PUT visible via ListContacts, got %+v (err=%v)", contacts, err)
}
del := func() *http.Response {
req, _ := http.NewRequest(http.MethodDelete, objURL, nil)
req.SetBasicAuth("carduser@example.com", carddavTestAppPassword)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
resp = del()
resp.Body.Close()
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
t.Fatalf("DELETE: status=%d, want 204/200", resp.StatusCode)
}
resp = get()
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("GET after DELETE: status=%d, want 404", resp.StatusCode)
}
}
func TestCardDAVListsWebmailCreatedContact(t *testing.T) {
app := newTestApp(t)
domains, _ := app.DB.ListDomains()
mailboxID := createTestMailboxWithAppPassword(t, app, "seen@example.com", domains[0].ID)
if _, err := app.DB.CreateContact(mailboxID, "friend@example.com", "Friend Name", "555-0000"); err != nil {
t.Fatal(err)
}
contacts, err := app.DB.ListContacts(mailboxID)
if err != nil || len(contacts) != 1 {
t.Fatal(err)
}
uid := contacts[0].UID
if uid == "" {
t.Fatal("expected a generated uid on contact created via the webmail Contacts CRUD")
}
srv := httptest.NewServer(app.Mux())
defer srv.Close()
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/carddav/seen@example.com/addressbooks/default/"+uid+".vcf", nil)
req.SetBasicAuth("seen@example.com", carddavTestAppPassword)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK || !strings.Contains(string(body), "Friend Name") {
t.Fatalf("expected the webmail-created contact visible over CardDAV: status=%d body=%s", resp.StatusCode, body)
}
}
// TestCardDAVScopedToOwnMailbox confirms one mailbox's app-password credentials can
// never read another mailbox's contacts, even by guessing a path with the right uid —
// the path segment is never trusted; the authenticated mailbox from Basic Auth is.
func TestCardDAVScopedToOwnMailbox(t *testing.T) {
app := newTestApp(t)
domains, _ := app.DB.ListDomains()
victimID := createTestMailboxWithAppPassword(t, app, "victim@example.com", domains[0].ID)
createTestMailboxWithAppPassword(t, app, "attacker@example.com", domains[0].ID)
if _, err := app.DB.CreateContact(victimID, "secret@example.com", "Secret Contact", ""); err != nil {
t.Fatal(err)
}
contacts, _ := app.DB.ListContacts(victimID)
uid := contacts[0].UID
srv := httptest.NewServer(app.Mux())
defer srv.Close()
// Attacker authenticates as themselves but requests a path under the victim's
// principal — DAVBasicAuth's ctx mailbox (attacker) is what every backend method
// actually uses, so this must 404, not leak the victim's contact.
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/carddav/victim@example.com/addressbooks/default/"+uid+".vcf", nil)
req.SetBasicAuth("attacker@example.com", carddavTestAppPassword)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404 (attacker's own ctx mailbox has no such contact), got %d", resp.StatusCode)
}
}
func mustMailboxID(t *testing.T, app *App, email string) int64 {
t.Helper()
mbox, err := app.DB.GetMailboxByEmail(email)
if err != nil || mbox == nil {
t.Fatalf("mailbox %s not found: %v", email, err)
}
return mbox.ID
}