This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user