Files
brain/internal/http/integration_test.go
T
Andrew S Erwin b9721ff13f
release gate / check (push) Failing after 8m35s
initial commit on Federted servers
2026-10-19 16:23:55 -05:00

221 lines
9.4 KiB
Go

package http
import (
"bytes"
"encoding/json"
"github.com/andrew/go-computer/internal/auth"
"github.com/andrew/go-computer/internal/config"
"github.com/andrew/go-computer/internal/db"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/url"
"os"
"testing"
)
// Integration tests create and drop only their own random PostgreSQL schema.
// They never truncate application tables or delete pre-existing accounts.
func testServer(t *testing.T) *httptest.Server {
t.Helper()
srv, _ := testServerDB(t)
return srv
}
func testServerDB(t *testing.T) (*httptest.Server, *db.DB) {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set TEST_DATABASE_URL to run PostgreSQL integration tests")
}
// The limiter stores are process-global and login has no loopback
// exemption (review SEC-1); give every test a clean slate.
auth.ResetRateLimits()
t.Cleanup(auth.ResetRateLimits)
t.Setenv("AUTH_SECRET", "integration-test-secret")
t.Setenv("NEXTAUTH_SECRET", "integration-test-secret")
t.Setenv("ADMIN_EMAILS", "")
// CardDAV/IMAP fakes listen on loopback; netguard blocks private ranges by
// default (production compose never sets this).
t.Setenv("INTEGRATIONS_ALLOW_PRIVATE", "1")
// The IMAP fixture is a plaintext loopback server; production refuses
// non-TLS mailboxes (review NET-02) and this flag is the test-only escape
// hatch (see signals.AllowInsecure).
t.Setenv("INTEGRATIONS_ALLOW_INSECURE_IMAP", "1")
admin, e := db.New(&config.Config{DatabaseURL: dsn})
if e != nil {
t.Fatal(e)
}
schema := "test_" + db.NewID()
if _, e = admin.Exec(`CREATE SCHEMA ` + db.Quote(schema)); e != nil {
t.Fatal(e)
}
u, e := url.Parse(dsn)
if e != nil {
t.Fatal(e)
}
q := u.Query()
q.Set("search_path", schema)
u.RawQuery = q.Encode()
d, e := db.New(&config.Config{DatabaseURL: u.String()})
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { d.Close(); admin.Exec(`DROP SCHEMA ` + db.Quote(schema) + ` CASCADE`); admin.Close() })
raw, e := os.ReadFile("../../db/schema.sql")
if e != nil {
t.Fatal(e)
}
if _, e = d.Exec(string(raw)); e != nil {
t.Fatal(e)
}
srv := httptest.NewServer(NewServer(config.Load(), d).Handler)
t.Cleanup(srv.Close)
return srv, d
}
type apiClient struct {
t *testing.T
c *http.Client
base string
}
func (a apiClient) request(method, path string, body any, status int) map[string]any {
a.t.Helper()
var b io.Reader
if body != nil {
raw, _ := json.Marshal(body)
b = bytes.NewReader(raw)
}
req, _ := http.NewRequest(method, a.base+path, b)
req.Header.Set("Content-Type", "application/json")
res, e := a.c.Do(req)
if e != nil {
a.t.Fatal(e)
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
if res.StatusCode != status {
a.t.Fatalf("%s %s: wanted %d, got %d: %s", method, path, status, res.StatusCode, raw)
}
var v map[string]any
json.Unmarshal(raw, &v)
return v
}
// meID resolves the authenticated client's user id. Signup responses no
// longer carry it (review CR-03: identical bodies for every accepted
// registration, so no unauthenticated existence oracle); identity comes from
// the session after login.
func meID(c apiClient) string {
c.t.Helper()
return c.request("GET", "/api/me", nil, 200)["user"].(map[string]any)["id"].(string)
}
// putBookmark sends PUT /api/bookmarks/{id} with the bookmark's CURRENT
// version attached (review CR-04: the concurrency contract requires it).
func putBookmark(c apiClient, id string, body map[string]any, status int) map[string]any {
c.t.Helper()
cur := c.request("GET", "/api/bookmarks/"+id, nil, 200)
v, _ := cur["version"].(float64)
body["version"] = int(v)
return c.request("PUT", "/api/bookmarks/"+id, body, status)
}
func TestApplicationFlows(t *testing.T) {
srv := testServer(t)
newClient := func() apiClient { jar, _ := cookiejar.New(nil); return apiClient{t, &http.Client{Jar: jar}, srv.URL} }
alice, bob, anonymous := newClient(), newClient(), newClient()
post := func(email string) map[string]any {
return map[string]any{"email": email, "password": "a-long-test-password"}
}
alice.request("POST", "/api/signup", post("alice@computer.test"), 201)
bob.request("POST", "/api/signup", post("bob@computer.test"), 201)
// Review SEC-4/CR-03: a duplicate signup returns the SAME 201 {"ok":true}
// body as a fresh creation (no 409, no distinguishing field).
dup := alice.request("POST", "/api/signup", post("alice@computer.test"), 201)
if len(dup) != 1 || dup["ok"] != true {
t.Fatalf("duplicate signup body differs from a fresh creation: %v", dup)
}
alice.request("POST", "/api/auth/login", post("alice@computer.test"), 200)
bob.request("POST", "/api/auth/login", post("bob@computer.test"), 200)
au := meID(alice)
bu := meID(bob)
if alice.request("GET", "/api/me", nil, 200)["isAdmin"] != true {
t.Fatal("first signup was not admin")
}
anonymous.request("GET", "/api/bookmarks", nil, 401)
bob.request("GET", "/api/admin/users", nil, 403)
tag := alice.request("POST", "/api/tags", map[string]any{"name": "Research"}, 201)["id"].(string)
b := alice.request("POST", "/api/bookmarks", map[string]any{"url": "https://127.0.0.1/link", "title": "Original", "notes": "# Notes", "tagIds": []string{tag}, "dueDate": "2026-10-07", "alertAt": "2000-01-01"}, 201)
id := b["id"].(string)
bob.request("GET", "/api/bookmarks/"+id, nil, 404)
bob.request("PUT", "/api/bookmarks/"+id, map[string]any{"notes": "attack"}, 404)
putBookmark(alice, id, map[string]any{"dueDate": nil, "isPublic": false}, 200)
if v := alice.request("GET", "/api/bookmarks/"+id, nil, 200); v["title"] != "Original" || v["dueDate"] != nil {
t.Fatal("partial/null update broken")
}
putBookmark(alice, id, map[string]any{"tagIds": []string{"missing"}}, 400)
v := alice.request("GET", "/api/bookmarks/"+id, nil, 200)
if len(v["tags"].([]any)) != 1 {
t.Fatal("failed update lost tags")
}
alice.request("POST", "/api/bookmarks/"+id+"/share", map[string]any{"userIds": []string{bu}, "isPublic": true}, 200)
bob.request("GET", "/api/bookmarks/"+id, nil, 200)
anonymous.request("GET", "/api/public/bookmarks/"+id, nil, 200)
bob.request("DELETE", "/api/bookmarks/"+id, nil, 404)
alice.request("PUT", "/api/tags/"+tag, map[string]any{"shareUserIds": []string{bu}}, 200)
bob.request("GET", "/api/bookmarks?tagId="+tag, nil, 200)
page := bob.request("POST", "/api/pages", map[string]any{"title": "Workspace", "content": "Markdown text"}, 201)["id"].(string)
bob.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": id}, 200)
bob.request("PATCH", "/api/pages/"+page, map[string]any{"addBookmarkId": id}, 200)
v = bob.request("GET", "/api/pages/"+page, nil, 200)
if len(v["bookmarks"].([]any)) != 1 {
t.Fatal("duplicate page link")
}
bob.request("GET", "/api/pages/"+page+"/markdown", nil, 200)
alice.request("GET", "/api/kanban/columns", nil, 200)
col := alice.request("POST", "/api/kanban/columns", map[string]any{"label": "Custom"}, 201)["id"].(string)
putBookmark(alice, id, map[string]any{"kanbanColumnId": col}, 200)
v = alice.request("DELETE", "/api/kanban/columns/"+col, nil, 200)
if v["moved"] != float64(1) {
t.Fatal("deleted column did not move card")
}
alice.request("GET", "/api/bookmarks/"+id, nil, 200)
alice.request("PUT", "/api/kanban/columns", map[string]any{"order": []string{"not-mine"}}, 400)
alice.request("GET", "/api/alerts/check", nil, 200)
alice.request("DELETE", "/api/alerts/"+id, nil, 200)
v = alice.request("GET", "/api/bookmarks/"+id, nil, 200)
if v["alertSent"] != true {
t.Fatal("alert dismissal")
}
putBookmark(alice, id, map[string]any{"alertAt": "2000-01-02"}, 200)
source := alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"type": "email", "config": map[string]any{"from": "sender@computer.test"}}, 201)["id"].(string)
alice.request("POST", "/api/bookmarks/"+id+"/signals", map[string]any{"action": "check"}, 200)
alice.request("PATCH", "/api/signal-sources/"+source, map[string]any{"enabled": false}, 200)
bob.request("DELETE", "/api/signal-sources/"+source, nil, 404)
alice.request("POST", "/api/settings/imap", map[string]any{"host": "imap.invalid", "username": "alice", "password": "encrypted-password"}, 200)
v = alice.request("GET", "/api/settings/imap", nil, 200)
if v["password"] != "••••••••" {
t.Fatal("mailbox password exposed")
}
alice.request("POST", "/api/settings/imap", map[string]any{"host": "imap.invalid", "username": "alice", "password": ""}, 200)
alice.request("POST", "/api/shared-dates", map[string]any{"date": "2026-10-07", "recipientIds": []string{bu}}, 201)
bob.request("GET", "/api/shared-dates", nil, 200)
alice.request("POST", "/api/admin/view-as", map[string]any{"userId": bu}, 200)
v = alice.request("GET", "/api/me", nil, 200)
if v["effectiveUser"].(map[string]any)["id"] != bu || v["user"].(map[string]any)["id"] != au {
t.Fatal("view-as identity")
}
alice.request("GET", "/api/admin/users", nil, 200)
alice.request("GET", "/api/pages/"+page, nil, 200)
alice.request("POST", "/api/admin/view-as", map[string]any{"userId": nil}, 200)
alice.request("PATCH", "/api/admin/users", map[string]any{"userId": au, "role": "USER"}, 400)
alice.request("GET", "/api/auth/oidc/status", nil, 200)
alice.request("GET", "/api/auth/callback/authelia", nil, 501)
bob.request("DELETE", "/api/pages/"+page, nil, 200)
alice.request("DELETE", "/api/bookmarks/"+id, nil, 200)
anonymous.request("GET", "/api/public/bookmarks/"+id, nil, 404)
alice.request("POST", "/api/auth/signout", map[string]any{}, 200)
alice.request("GET", "/api/me", nil, 401)
}