package http_test import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "github.com/noderunners/nip05api/internal/audit" "github.com/noderunners/nip05api/internal/config" "github.com/noderunners/nip05api/internal/db" "github.com/noderunners/nip05api/internal/dm" httpapi "github.com/noderunners/nip05api/internal/http" "github.com/noderunners/nip05api/internal/messages" "github.com/noderunners/nip05api/internal/user" "github.com/noderunners/nip05api/internal/webhook" ) const testHex = "0e8c41ebcd55a8d8db2e0a8c3a4b9c5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c" const testKey = "test-admin-key-twenty-five-chars" type fixture struct { srv *httptest.Server db *db.DB } func newFixture(t *testing.T) *fixture { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") d, err := db.Open(dbPath) if err != nil { t.Fatal(err) } if err := d.Migrate(context.Background()); err != nil { t.Fatal(err) } cfg := &config.Config{ Domain: "test.local", Port: 0, AdminAPIKey: testKey, FrontendURL: "https://test.local/nip05", Lightning: config.LightningConfig{Enabled: false}, Expiry: config.ExpiryConfig{GraceDays: 30}, ReservedUsernames: []string{"admin", "root"}, RateLimitPerMin: 0, // disabled in tests } tmpls, _ := messages.Load("/nonexistent.yaml") users := user.NewService(user.NewRepo(d), cfg.ReservedUsernames) dms := dm.NewService(dm.NewRepo(d), tmpls, false) hooks := webhook.NewService(webhook.NewRepo(d), cfg.Domain, false) srv := httptest.NewServer(httpapi.NewServer(httpapi.Deps{ Cfg: cfg, DB: d, Users: users, DMs: dms, Hooks: hooks, Audit: audit.New(d), Version: "test", }).Handler) t.Cleanup(func() { srv.Close() _ = d.Close() }) return &fixture{srv: srv, db: d} } func (f *fixture) get(t *testing.T, path string) (*http.Response, []byte) { t.Helper() resp, err := http.Get(f.srv.URL + path) if err != nil { t.Fatal(err) } defer resp.Body.Close() body := readAll(t, resp) return resp, body } func (f *fixture) admin(t *testing.T, method, path string, payload any) (*http.Response, []byte) { t.Helper() var body []byte if payload != nil { var err error body, err = json.Marshal(payload) if err != nil { t.Fatal(err) } } req, _ := http.NewRequest(method, f.srv.URL+path, bytes.NewReader(body)) req.Header.Set("X-API-Key", testKey) if payload != nil { req.Header.Set("Content-Type", "application/json") } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() return resp, readAll(t, resp) } func readAll(t *testing.T, resp *http.Response) []byte { t.Helper() buf := new(bytes.Buffer) if _, err := buf.ReadFrom(resp.Body); err != nil { t.Fatal(err) } return buf.Bytes() } func TestHealthz(t *testing.T) { f := newFixture(t) resp, body := f.get(t, "/healthz") if resp.StatusCode != 200 { t.Fatalf("status %d: %s", resp.StatusCode, body) } var got map[string]string _ = json.Unmarshal(body, &got) if got["status"] != "ok" || got["version"] != "test" { t.Errorf("body: %s", body) } } func TestPricing(t *testing.T) { f := newFixture(t) resp, body := f.get(t, "/v1/pricing") if resp.StatusCode != 200 { t.Fatalf("status %d: %s", resp.StatusCode, body) } var got map[string]any _ = json.Unmarshal(body, &got) if _, ok := got["yearly_sats"]; !ok { t.Errorf("missing yearly_sats: %s", body) } } func TestNostrJSON_EmptyAndPopulated(t *testing.T) { f := newFixture(t) resp, body := f.get(t, "/.well-known/nostr.json") if resp.StatusCode != 200 { t.Fatalf("status %d", resp.StatusCode) } var got map[string]any _ = json.Unmarshal(body, &got) if got["names"] == nil { t.Errorf("missing names key: %s", body) } f.admin(t, "POST", "/v1/admin/users", map[string]any{ "pubkey": testHex, "username": "alice", "subscription_type": "yearly", "years": 1, }) _, body = f.get(t, "/.well-known/nostr.json") var populated struct { Names map[string]string `json:"names"` } if err := json.Unmarshal(body, &populated); err != nil { t.Fatal(err) } if populated.Names["alice"] != testHex { t.Errorf("alice not in nostr.json: %s", body) } } func TestUsernameAvailability(t *testing.T) { f := newFixture(t) resp, body := f.get(t, "/v1/usernames/alice/available") if resp.StatusCode != 200 { t.Fatalf("status %d", resp.StatusCode) } var got map[string]any _ = json.Unmarshal(body, &got) if got["available"] != true { t.Errorf("expected available=true: %s", body) } // Reserved name. _, body = f.get(t, "/v1/usernames/admin/available") _ = json.Unmarshal(body, &got) if got["available"] != false { t.Errorf("admin should be reserved: %s", body) } // Invalid format. _, body = f.get(t, "/v1/usernames/-bad/available") _ = json.Unmarshal(body, &got) if got["available"] != false { t.Errorf("-bad should be invalid: %s", body) } } func TestAdminAuthGate(t *testing.T) { f := newFixture(t) resp, _ := f.get(t, "/v1/admin/users") if resp.StatusCode != 401 { t.Fatalf("expected 401, got %d", resp.StatusCode) } resp, _ = f.admin(t, "GET", "/v1/admin/users", nil) if resp.StatusCode != 200 { t.Fatalf("expected 200 with key, got %d", resp.StatusCode) } } func TestAdminLifecycle(t *testing.T) { f := newFixture(t) // Add. resp, body := f.admin(t, "POST", "/v1/admin/users", map[string]any{ "pubkey": testHex, "username": "alice", "subscription_type": "yearly", "years": 1, }) if resp.StatusCode != 201 { t.Fatalf("add status %d: %s", resp.StatusCode, body) } // Lookup hex. resp, body = f.get(t, "/v1/users/"+testHex) if resp.StatusCode != 200 { t.Fatalf("lookup status %d", resp.StatusCode) } var got map[string]any _ = json.Unmarshal(body, &got) if got["is_whitelisted"] != true || got["username"] != "alice" { t.Errorf("unexpected lookup body: %s", body) } // Lookup npub form. npub := "npub1p6xyr67d2k5d3kewp2xr5juutehh4zuup50z7wjtt3kharu6pvwqjh7065" resp, _ = f.get(t, "/v1/users/"+npub) if resp.StatusCode != 200 { t.Fatalf("npub lookup status %d", resp.StatusCode) } // Username unavailable now. _, body = f.get(t, "/v1/usernames/alice/available") _ = json.Unmarshal(body, &got) if got["available"] != false { t.Errorf("expected unavailable: %s", body) } // Extend. resp, body = f.admin(t, "POST", "/v1/admin/users/"+testHex+"/extend", map[string]any{"years": 2}) if resp.StatusCode != 200 { t.Fatalf("extend status %d: %s", resp.StatusCode, body) } // Update username. resp, body = f.admin(t, "PUT", "/v1/admin/users/"+testHex, map[string]any{"username": "alice2"}) if resp.StatusCode != 200 { t.Fatalf("update status %d: %s", resp.StatusCode, body) } // Delete. resp, _ = f.admin(t, "DELETE", "/v1/admin/users/"+testHex, nil) if resp.StatusCode != 200 { t.Fatalf("delete status %d", resp.StatusCode) } // Gone. resp, _ = f.get(t, "/v1/users/"+testHex) if resp.StatusCode != 404 { t.Fatalf("expected 404 after delete, got %d", resp.StatusCode) } // Audit log should reflect every admin action. wantActions := []string{"user.added", "user.extended", "user.username_changed", "user.deleted"} rows, qerr := f.db.Query(`SELECT action FROM audit_log`) if qerr != nil { t.Fatal(qerr) } defer rows.Close() seen := map[string]bool{} for rows.Next() { var action string if err := rows.Scan(&action); err != nil { t.Fatal(err) } seen[action] = true } for _, action := range wantActions { if !seen[action] { t.Errorf("missing audit action %q (got %v)", action, seen) } } } func TestAdminAdd_BadInputs(t *testing.T) { f := newFixture(t) resp, _ := f.admin(t, "POST", "/v1/admin/users", map[string]any{ "pubkey": "notapubkey", "username": "alice", "subscription_type": "yearly", }) if resp.StatusCode != 400 { t.Errorf("bad pubkey: expected 400, got %d", resp.StatusCode) } resp, _ = f.admin(t, "POST", "/v1/admin/users", map[string]any{ "pubkey": testHex, "username": "admin", "subscription_type": "yearly", }) if resp.StatusCode == 200 || resp.StatusCode == 201 { t.Errorf("reserved username accepted: %d", resp.StatusCode) } } func TestOpenAPI(t *testing.T) { f := newFixture(t) resp, body := f.get(t, "/openapi.json") if resp.StatusCode != 200 { t.Fatalf("status %d", resp.StatusCode) } var spec map[string]any if err := json.Unmarshal(body, &spec); err != nil { t.Fatalf("openapi not valid json: %v", err) } if spec["openapi"] == nil { t.Errorf("missing openapi field: %s", body[:min(200, len(body))]) } } func TestDocsPage(t *testing.T) { f := newFixture(t) resp, body := f.get(t, "/docs") if resp.StatusCode != 200 { t.Fatalf("status %d", resp.StatusCode) } if !bytes.Contains(body, []byte("swagger-ui")) { t.Errorf("expected swagger UI markup") } } func TestBodyLimit(t *testing.T) { f := newFixture(t) huge := bytes.Repeat([]byte("a"), 2<<20) // 2 MiB body := []byte(`{"pubkey":"` + testHex + `","username":"alice","subscription_type":"yearly","data":"` + string(huge) + `"}`) req, _ := http.NewRequest("POST", f.srv.URL+"/v1/admin/users", bytes.NewReader(body)) req.Header.Set("X-API-Key", testKey) req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() if resp.StatusCode != 413 { t.Errorf("expected 413, got %d", resp.StatusCode) } } func min(a, b int) int { if a < b { return a } return b }