Files
Nip-05-api/internal/http/server_test.go
2026-04-29 02:35:00 +00:00

358 lines
9.3 KiB
Go

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
}