252 lines
11 KiB
Go
252 lines
11 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
crypt "github.com/andrew/go-computer/internal/crypto"
|
|
"github.com/andrew/go-computer/internal/signals"
|
|
"github.com/emersion/go-imap/backend/memory"
|
|
imapserver "github.com/emersion/go-imap/server"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func TestExternalIntegrations(t *testing.T) {
|
|
srv, d := testServerDB(t)
|
|
jar, _ := cookiejar.New(nil)
|
|
a := apiClient{t, &http.Client{Jar: jar}, srv.URL}
|
|
credentials := map[string]any{"email": "integrations@computer.test", "password": "test-password"}
|
|
a.request("POST", "/api/signup", credentials, 201)
|
|
a.request("POST", "/api/auth/login", credentials, 200)
|
|
// CardDAV fixture (reviews CR-07/CR-08): the served multistatus is
|
|
// mutable so one test can walk the reconciliation policy — full snapshot,
|
|
// partially-withheld response, remote deletion, empty response.
|
|
var davMu sync.Mutex
|
|
var davBody string
|
|
setBody := func(body string) { davMu.Lock(); davBody = body; davMu.Unlock() }
|
|
getBody := func() string { davMu.Lock(); defer davMu.Unlock(); return davBody }
|
|
dav := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "REPORT" || r.Header.Get("Depth") != "1" {
|
|
t.Error("invalid CardDAV request")
|
|
}
|
|
u, p, _ := r.BasicAuth()
|
|
if u != "nextcloud-user" || p != "app-password" {
|
|
t.Error("CardDAV authentication")
|
|
}
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
w.WriteHeader(207)
|
|
io.WriteString(w, getBody())
|
|
}))
|
|
defer dav.Close()
|
|
card1 := `<d:response><d:href>/card.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD
|
|
UID:contact-1
|
|
FN:Contact Name
|
|
EMAIL:last@computer.test
|
|
EMAIL;TYPE=PREF:preferred@computer.test
|
|
TEL:1234
|
|
END:VCARD</card:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`
|
|
card2 := `<d:response><d:href>/card2.vcf</d:href><d:propstat><d:prop><card:address-data>BEGIN:VCARD
|
|
UID:contact-2
|
|
FN:Second Contact
|
|
EMAIL:second@computer.test
|
|
END:VCARD</card:address-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`
|
|
card2Withheld := `<d:response><d:href>/card2.vcf</d:href><d:propstat><d:prop/><d:status>HTTP/1.1 404 Not Found</d:status></d:propstat></d:response>`
|
|
wrap := func(responses ...string) string {
|
|
return `<d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">` + strings.Join(responses, "") + `</d:multistatus>`
|
|
}
|
|
contactsOf := func() []map[string]any {
|
|
t.Helper()
|
|
res, e := a.c.Get(srv.URL + "/api/contacts")
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
defer res.Body.Close()
|
|
var contacts []map[string]any
|
|
json.NewDecoder(res.Body).Decode(&contacts)
|
|
return contacts
|
|
}
|
|
req := map[string]any{"nextcloudUrl": dav.URL, "username": "nextcloud-user", "password": "app-password"}
|
|
|
|
// Full snapshot: both cards import; the PREF occurrence wins the repeated
|
|
// EMAIL property (review CR-08 — deterministic selection, no data loss).
|
|
setBody(wrap(card1, card2))
|
|
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(2) || v["removed"] != float64(0) {
|
|
t.Fatal("CardDAV sync", v)
|
|
}
|
|
contacts := contactsOf()
|
|
if len(contacts) != 2 {
|
|
t.Fatal("CardDAV import count", contacts)
|
|
}
|
|
for _, c := range contacts {
|
|
if c["uid"] == "contact-1" && c["email"] != "preferred@computer.test" {
|
|
t.Fatal("repeated EMAIL did not resolve to the PREF value:", c)
|
|
}
|
|
}
|
|
// Idempotent re-sync: nothing removed, nothing duplicated.
|
|
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(2) || v["removed"] != float64(0) {
|
|
t.Fatal("CardDAV re-sync", v)
|
|
}
|
|
if len(contactsOf()) != 2 {
|
|
t.Fatal("CardDAV re-sync duplicated contacts")
|
|
}
|
|
|
|
// Partially-withheld response (card2's data 404s): must NOT be read as a
|
|
// remote deletion (review CR-07 — never delete from a partial snapshot).
|
|
setBody(wrap(card1, card2Withheld))
|
|
if v := a.request("POST", "/api/contacts/sync", req, 200); v["removed"] != float64(0) {
|
|
t.Fatal("partial response triggered reconciliation:", v)
|
|
}
|
|
if len(contactsOf()) != 2 {
|
|
t.Fatal("partial response deleted a contact")
|
|
}
|
|
|
|
// Complete snapshot without card2: the remote deletion is mirrored.
|
|
setBody(wrap(card1))
|
|
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(1) || v["removed"] != float64(1) {
|
|
t.Fatal("CardDAV deletion reconciliation", v)
|
|
}
|
|
contacts = contactsOf()
|
|
if len(contacts) != 1 || contacts[0]["uid"] != "contact-1" {
|
|
t.Fatal("wrong contact survived reconciliation:", contacts)
|
|
}
|
|
|
|
// Empty response: the guard preserves everything (a server fault that
|
|
// answers 207 with zero cards must not wipe the address book).
|
|
setBody(wrap())
|
|
if v := a.request("POST", "/api/contacts/sync", req, 200); v["synced"] != float64(0) || v["removed"] != float64(0) {
|
|
t.Fatal("empty response reconciliation", v)
|
|
}
|
|
if len(contactsOf()) != 1 {
|
|
t.Fatal("empty response deleted contacts")
|
|
}
|
|
listener, e := net.Listen("tcp", "127.0.0.1:0")
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
mail := imapserver.New(memory.New())
|
|
mail.AllowInsecureAuth = true
|
|
go mail.Serve(listener)
|
|
defer mail.Close()
|
|
host, port, _ := net.SplitHostPort(listener.Addr().String())
|
|
pn, _ := strconv.Atoi(port)
|
|
mailbox := map[string]any{"host": host, "port": pn, "tls": false, "username": "username", "password": "password", "folder": "INBOX"}
|
|
a.request("POST", "/api/imap/test", mailbox, 200)
|
|
a.request("POST", "/api/settings/imap", mailbox, 200)
|
|
id := a.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/test", "imapQuery": "little"}, 201)["id"].(string)
|
|
a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "contact@example.org", "subject": "little"}}, 201)
|
|
v := a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
|
|
if v["created"] != float64(1) {
|
|
t.Fatal("IMAP signal creation", v)
|
|
}
|
|
// Review SYNC-02: the pass persisted a UID checkpoint for the source.
|
|
var lastUID int64
|
|
if e := d.QueryRow(`SELECT "lastUid" FROM "SignalCheckpoint"`).Scan(&lastUID); e != nil {
|
|
t.Fatal("checkpoint not persisted:", e)
|
|
}
|
|
if lastUID < 1 {
|
|
t.Fatal("checkpoint did not advance:", lastUID)
|
|
}
|
|
v = a.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
|
|
if v["created"] != float64(0) {
|
|
t.Fatal("signal deduplication", v)
|
|
}
|
|
v = a.request("GET", "/api/bookmarks/"+id+"/signals", nil, 200)
|
|
if len(v["signals"].([]any)) != 1 {
|
|
t.Fatal("IMAP envelope fetch")
|
|
}
|
|
putBookmark(a, id, map[string]any{"imapWatchEnabled": true}, 200)
|
|
v = a.request("GET", "/api/imap/check", nil, 200)
|
|
if len(v["matches"].([]any)) != 1 {
|
|
t.Fatal("legacy mailbox watch")
|
|
}
|
|
}
|
|
|
|
// CardDAV transport (review recheck): Basic-auth credentials must never
|
|
// travel plaintext. Without the netguard fixture escape hatch, an http
|
|
// Nextcloud URL is rejected at the API boundary — no dial, no credentials.
|
|
func TestCardDAVRequiresTLSOutsideFixtures(t *testing.T) {
|
|
srv := testServer(t)
|
|
a := newAPIUser(t, srv, "carddav-tls@computer.test")
|
|
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "")
|
|
a.request("POST", "/api/contacts/sync", map[string]any{
|
|
"nextcloudUrl": "http://nextcloud.example.test", "username": "u", "password": "p",
|
|
}, 400)
|
|
// https passes the transport gate (the dial itself fails: no such host
|
|
// through netguard — the point is the 400 above never happens for TLS).
|
|
resp := a.request("POST", "/api/contacts/sync", map[string]any{
|
|
"nextcloudUrl": "https://nextcloud.invalid", "username": "u", "password": "p",
|
|
}, 400)
|
|
if resp == nil {
|
|
t.Fatal("expected a connect-level rejection, not a transport-policy one")
|
|
}
|
|
}
|
|
|
|
// Review CR-21: credential writes must never restore a stale password. The
|
|
// lazy ENCRYPTION_KEY migration re-encrypts with a compare-and-swap against
|
|
// the exact ciphertext it read, and a blank/masked settings save no longer
|
|
// rewrites the password column at all.
|
|
func TestImapCredentialWritesNeverRestoreStalePasswords(t *testing.T) {
|
|
srv, d := testServerDB(t)
|
|
alice := newAPIUser(t, srv, "imap-stale@computer.test")
|
|
t.Setenv("ENCRYPTION_KEY", "dedicated-encryption-key")
|
|
uid := alice.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
|
|
|
|
// Seed a legacy row encrypted under the AUTH secret (pre-ENCRYPTION_KEY),
|
|
// exactly the shape the transparent migration expects.
|
|
legacy, e := crypt.Encrypt("legacy-password", "integration-test-secret")
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if _, e := d.Exec(`INSERT INTO "ImapConfig"(id,host,port,tls,username,password,folder,"userId") VALUES('imap-cfg-1','legacy.example.test',993,true,'user',$1,'INBOX',$2)`, legacy, uid); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
|
|
// Loading migrates: the password decrypts and the row is re-encrypted
|
|
// under the dedicated key.
|
|
m, e := signals.LoadMailbox(context.Background(), d, uid)
|
|
if e != nil || m.Password != "legacy-password" {
|
|
t.Fatalf("legacy load: %v %+v", e, m)
|
|
}
|
|
var stored string
|
|
if e := d.QueryRow(`SELECT password FROM "ImapConfig" WHERE "userId"=$1`, uid).Scan(&stored); e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if p, e := crypt.Decrypt(stored, "dedicated-encryption-key"); e != nil || p != "legacy-password" {
|
|
t.Fatalf("row was not migrated under ENCRYPTION_KEY: %v", e)
|
|
}
|
|
|
|
// The CAS guard: an update conditioned on a stale ciphertext touches no
|
|
// rows, so a concurrent replacement can never be rolled back by migration.
|
|
res, e := d.Exec(`UPDATE "ImapConfig" SET password=$1 WHERE "userId"=$2 AND password=$3`, "stale-rewrite", uid, legacy)
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if n, _ := res.RowsAffected(); n != 0 {
|
|
t.Fatal("compare-and-swap against a stale ciphertext updated the row")
|
|
}
|
|
|
|
// A concurrent replacement through the API survives...
|
|
alice.request("POST", "/api/settings/imap", map[string]any{"host": "new.example.test", "port": 993, "tls": true, "username": "user", "password": "replacement-password", "folder": "INBOX"}, 200)
|
|
// ...and a later masked save (connection settings only) must keep it.
|
|
alice.request("POST", "/api/settings/imap", map[string]any{"host": "masked.example.test", "port": 993, "tls": true, "username": "user", "password": "••••••••", "folder": "INBOX"}, 200)
|
|
m, e = signals.LoadMailbox(context.Background(), d, uid)
|
|
if e != nil {
|
|
t.Fatal(e)
|
|
}
|
|
if m.Password != "replacement-password" || m.Host != "masked.example.test" {
|
|
t.Fatalf("masked save clobbered the stored credentials: %+v", m)
|
|
}
|
|
|
|
// A masked save with no stored configuration is a 400, never an empty
|
|
// or resurrected password.
|
|
bob := newAPIUser(t, srv, "imap-nocfg@computer.test")
|
|
bob.request("POST", "/api/settings/imap", map[string]any{"host": "h.example.test", "port": 993, "tls": true, "username": "user", "password": "••••••••", "folder": "INBOX"}, 400)
|
|
}
|