From 6a4dff3f5152f9798d97e79cdb9df034a42cfc5c Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Mon, 1 Jun 2026 16:54:26 -0700 Subject: [PATCH] Fix patch and tests --- zooid/api.go | 14 ++++-- zooid/api_test.go | 103 ++++++++++++++++++++++++++++--------------- zooid/config_test.go | 61 ++++++++++++++----------- 3 files changed, 113 insertions(+), 65 deletions(-) diff --git a/zooid/api.go b/zooid/api.go index 56d49ea..566224b 100644 --- a/zooid/api.go +++ b/zooid/api.go @@ -2,8 +2,10 @@ package zooid import ( "encoding/json" + "errors" "fmt" "io" + "io/fs" "net/http" "os" "sort" @@ -163,7 +165,7 @@ func (api *APIHandler) putRelay(w http.ResponseWriter, r *http.Request) { name := ConfigNameFromId(id) path := ConfigPathFromName(name) if _, err := os.Stat(path); err != nil { - writeError(w, http.StatusConflict, "relay not found") + writeError(w, http.StatusNotFound, "relay not found") return } @@ -193,7 +195,7 @@ func (api *APIHandler) patchRelay(w http.ResponseWriter, r *http.Request) { name := ConfigNameFromId(id) path := ConfigPathFromName(name) if _, err := os.Stat(path); err != nil { - writeError(w, http.StatusConflict, "relay not found") + writeError(w, http.StatusNotFound, "relay not found") return } @@ -248,6 +250,10 @@ func (api *APIHandler) applyPatch(config *Config, patch map[string]interface{}) return err } + // Preserve unexported fields, which don't survive the JSON round-trip + patched.path = config.path + patched.secret = config.secret + // Copy patched values to original config *config = patched return nil @@ -284,7 +290,7 @@ func (api *APIHandler) deleteRelay(w http.ResponseWriter, r *http.Request) { name := ConfigNameFromId(id) path := ConfigPathFromName(name) if _, err := os.Stat(path); err != nil { - writeError(w, http.StatusConflict, "relay not found") + writeError(w, http.StatusNotFound, "relay not found") return } @@ -303,7 +309,7 @@ func (api *APIHandler) listRelayMembers(w http.ResponseWriter, r *http.Request) name := ConfigNameFromId(id) members, err := api.resolveRelayMembers(name) if err != nil { - if os.IsNotExist(err) { + if errors.Is(err, fs.ErrNotExist) { writeError(w, http.StatusNotFound, "relay not found") } else { writeError(w, http.StatusInternalServerError, fmt.Sprintf("failed to load relay members: %v", err)) diff --git a/zooid/api_test.go b/zooid/api_test.go index 0b8aab6..5455d39 100644 --- a/zooid/api_test.go +++ b/zooid/api_test.go @@ -16,16 +16,14 @@ import ( ) func TestAPIHandler_Authentication(t *testing.T) { - // Create a temporary config directory - configDir := t.TempDir() + useTestConfigDir(t) // Create a test keypair for authentication secretKey := nostr.Generate() pubkey := secretKey.Public() // Create API handler with whitelist containing our test pubkey - whitelist := pubkey.Hex() - api := NewAPIHandler(whitelist, configDir) + api := newTestAPIHandler(t, pubkey.Hex()) t.Run("missing authorization header", func(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/relay/test", strings.NewReader("{}")) @@ -173,12 +171,11 @@ func TestAPIHandler_Authentication(t *testing.T) { } func TestAPIHandler_CreateRelay(t *testing.T) { - configDir := t.TempDir() + configDir := useTestConfigDir(t) secretKey := nostr.Generate() pubkey := secretKey.Public() - whitelist := pubkey.Hex() - api := NewAPIHandler(whitelist, configDir) + api := newTestAPIHandler(t, pubkey.Hex()) validConfig := map[string]interface{}{ "host": "relay.example.com", @@ -226,6 +223,9 @@ func TestAPIHandler_CreateRelay(t *testing.T) { "host": "other.example.com", "schema": "testrelay", // Same schema as existing "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "pubkey": pubkey.Hex(), + }, } body, _ := json.Marshal(config) req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/other", secretKey, body) @@ -243,6 +243,9 @@ func TestAPIHandler_CreateRelay(t *testing.T) { "host": "relay.example.com", // Same host as existing "schema": "otherschema", "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "pubkey": pubkey.Hex(), + }, } body, _ := json.Marshal(config) req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/other2", secretKey, body) @@ -301,12 +304,11 @@ func TestAPIHandler_CreateRelay(t *testing.T) { } func TestAPIHandler_UpdateRelay(t *testing.T) { - configDir := t.TempDir() + useTestConfigDir(t) secretKey := nostr.Generate() pubkey := secretKey.Public() - whitelist := pubkey.Hex() - api := NewAPIHandler(whitelist, configDir) + api := newTestAPIHandler(t, pubkey.Hex()) // Create initial relay initialConfig := map[string]interface{}{ @@ -371,6 +373,9 @@ func TestAPIHandler_UpdateRelay(t *testing.T) { "host": "other.example.com", "schema": "otherrelay", "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "pubkey": pubkey.Hex(), + }, } body, _ := json.Marshal(otherConfig) req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/otherrelay", secretKey, body) @@ -385,6 +390,9 @@ func TestAPIHandler_UpdateRelay(t *testing.T) { "host": "relay.example.com", "schema": "otherrelay", // Duplicate "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "pubkey": pubkey.Hex(), + }, } body, _ = json.Marshal(updateConfig) req = createAuthenticatedRequest(http.MethodPut, "http://api.example.com/relay/testrelay", secretKey, body) @@ -399,12 +407,11 @@ func TestAPIHandler_UpdateRelay(t *testing.T) { } func TestAPIHandler_PatchRelay(t *testing.T) { - configDir := t.TempDir() + useTestConfigDir(t) secretKey := nostr.Generate() pubkey := secretKey.Public() - whitelist := pubkey.Hex() - api := NewAPIHandler(whitelist, configDir) + api := newTestAPIHandler(t, pubkey.Hex()) // Create initial relay with full config initialConfig := map[string]interface{}{ @@ -494,6 +501,9 @@ func TestAPIHandler_PatchRelay(t *testing.T) { "host": "other.example.com", "schema": "anotherrelay", "secret": secretKey.Hex(), + "info": map[string]interface{}{ + "pubkey": pubkey.Hex(), + }, } body, _ := json.Marshal(otherConfig) req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/relay/anotherrelay", secretKey, body) @@ -550,12 +560,11 @@ func TestAPIHandler_PatchRelay(t *testing.T) { } func TestAPIHandler_DeleteRelay(t *testing.T) { - configDir := t.TempDir() + configDir := useTestConfigDir(t) secretKey := nostr.Generate() pubkey := secretKey.Public() - whitelist := pubkey.Hex() - api := NewAPIHandler(whitelist, configDir) + api := newTestAPIHandler(t, pubkey.Hex()) // Create a relay to delete config := map[string]interface{}{ @@ -605,12 +614,11 @@ func TestAPIHandler_DeleteRelay(t *testing.T) { } func TestAPIHandler_ListRelayMembers(t *testing.T) { - configDir := t.TempDir() + useTestConfigDir(t) secretKey := nostr.Generate() pubkey := secretKey.Public() - whitelist := pubkey.Hex() - api := NewAPIHandler(whitelist, configDir) + api := newTestAPIHandler(t, pubkey.Hex()) t.Run("list members from loaded relay instance", func(t *testing.T) { member1 := nostr.Generate().Public() @@ -681,11 +689,13 @@ func TestAPIHandler_ListRelayMembers(t *testing.T) { config := &Config{ Host: "members.example.com", - Schema: "members_" + RandomString(8), + Schema: "members_" + strings.ToLower(RandomString(8)), Secret: relaySecret.Hex(), } + config.Info.Pubkey = nostr.Generate().Public().Hex() + config.path = ConfigPathFromName(ConfigNameFromId("fallback")) - if err := api.saveConfig(api.configPath("fallback"), config); err != nil { + if err := config.Save(); err != nil { t.Fatalf("failed to save config: %v", err) } @@ -779,12 +789,11 @@ func TestAPIHandler_ListRelayMembers(t *testing.T) { } func TestAPIHandler_MethodNotAllowed(t *testing.T) { - configDir := t.TempDir() + useTestConfigDir(t) secretKey := nostr.Generate() pubkey := secretKey.Public() - whitelist := pubkey.Hex() - api := NewAPIHandler(whitelist, configDir) + api := newTestAPIHandler(t, pubkey.Hex()) t.Run("GET method not allowed", func(t *testing.T) { req := createAuthenticatedRequest(http.MethodGet, "http://api.example.com/relay/test", secretKey, nil) @@ -799,12 +808,11 @@ func TestAPIHandler_MethodNotAllowed(t *testing.T) { } func TestAPIHandler_InvalidPath(t *testing.T) { - configDir := t.TempDir() + useTestConfigDir(t) secretKey := nostr.Generate() pubkey := secretKey.Public() - whitelist := pubkey.Hex() - api := NewAPIHandler(whitelist, configDir) + api := newTestAPIHandler(t, pubkey.Hex()) t.Run("invalid path returns not found", func(t *testing.T) { req := createAuthenticatedRequest(http.MethodPost, "http://api.example.com/invalid/path", secretKey, []byte("{}")) @@ -830,12 +838,11 @@ func TestAPIHandler_InvalidPath(t *testing.T) { } func TestAPIHandler_ConfigValidation(t *testing.T) { - configDir := t.TempDir() + configDir := useTestConfigDir(t) secretKey := nostr.Generate() pubkey := secretKey.Public() - whitelist := pubkey.Hex() - api := NewAPIHandler(whitelist, configDir) + api := newTestAPIHandler(t, pubkey.Hex()) t.Run("invalid info.pubkey", func(t *testing.T) { config := map[string]interface{}{ @@ -976,9 +983,33 @@ func createAuthenticatedRequest(method, url string, secretKey nostr.SecretKey, b return req } +// setTestEnv overrides a value in the package-level env map. Env memoizes +// os.Environ via sync.Once, so once the test binary has started, os.Setenv is +// ignored — mutating the cached map directly is the only way to change config +// for an individual test. Safe because tests in this package run sequentially. +func setTestEnv(key, value string) { + _ = Env("DATA") // ensure the env map has been initialized + env[key] = value +} + +// useTestConfigDir points Env("CONFIG") at a fresh temp dir for this test. +func useTestConfigDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + setTestEnv("CONFIG", dir) + return dir +} + +// newTestAPIHandler builds a handler whose whitelist contains the given pubkeys. +func newTestAPIHandler(t *testing.T, whitelist ...string) *APIHandler { + t.Helper() + setTestEnv("API_WHITELIST", strings.Join(whitelist, ",")) + return NewAPIHandler() +} + func TestNewAPIHandler(t *testing.T) { t.Run("empty whitelist", func(t *testing.T) { - api := NewAPIHandler("", "/tmp") + api := newTestAPIHandler(t) if len(api.whitelist) != 0 { t.Error("expected empty whitelist") } @@ -986,7 +1017,7 @@ func TestNewAPIHandler(t *testing.T) { t.Run("single pubkey", func(t *testing.T) { pubkey := nostr.Generate().Public().Hex() - api := NewAPIHandler(pubkey, "/tmp") + api := newTestAPIHandler(t, pubkey) if len(api.whitelist) != 1 { t.Error("expected 1 entry in whitelist") } @@ -998,8 +1029,8 @@ func TestNewAPIHandler(t *testing.T) { t.Run("multiple pubkeys", func(t *testing.T) { pubkey1 := nostr.Generate().Public().Hex() pubkey2 := nostr.Generate().Public().Hex() - whitelist := fmt.Sprintf("%s, %s", pubkey1, pubkey2) - api := NewAPIHandler(whitelist, "/tmp") + setTestEnv("API_WHITELIST", fmt.Sprintf("%s, %s", pubkey1, pubkey2)) + api := NewAPIHandler() if len(api.whitelist) != 2 { t.Error("expected 2 entries in whitelist") } @@ -1010,8 +1041,8 @@ func TestNewAPIHandler(t *testing.T) { t.Run("whitespace trimming", func(t *testing.T) { pubkey := nostr.Generate().Public().Hex() - whitelist := " " + pubkey + " " - api := NewAPIHandler(whitelist, "/tmp") + setTestEnv("API_WHITELIST", " "+pubkey+" ") + api := NewAPIHandler() if len(api.whitelist) != 1 { t.Error("expected 1 entry in whitelist after trimming") } diff --git a/zooid/config_test.go b/zooid/config_test.go index 8fa004a..9b54329 100644 --- a/zooid/config_test.go +++ b/zooid/config_test.go @@ -157,53 +157,64 @@ func TestConfig_MemberRole(t *testing.T) { } } +// validBlossomTestConfig returns a config that passes Validate except for any +// Blossom settings the caller overrides, so blossom validation can be exercised +// in isolation. +func validBlossomTestConfig() *Config { + sk := nostr.Generate() + c := &Config{ + Host: "r.example.com", + Schema: "myrelay", + Secret: sk.Hex(), + } + c.Info.Pubkey = sk.Public().Hex() + return c +} + func TestValidateBlossomFileStorage(t *testing.T) { - t.Run("blossom disabled skips validation", func(t *testing.T) { - c := &Config{} - c.Blossom.Enabled = false - c.Blossom.Backend = "s3" - normalizeBlossomConfig(c) - if err := validateBlossomFileStorage(c); err != nil { + t.Run("empty adapter defaults to local", func(t *testing.T) { + c := validBlossomTestConfig() + c.Blossom.Enabled = true + if err := c.Validate(); err != nil { t.Fatalf("expected nil, got %v", err) } + if c.Blossom.Adapter != "local" { + t.Errorf("expected adapter normalized to local, got %q", c.Blossom.Adapter) + } }) t.Run("local storage needs no s3 fields", func(t *testing.T) { - c := &Config{} + c := validBlossomTestConfig() c.Blossom.Enabled = true - c.Blossom.Backend = "local" - normalizeBlossomConfig(c) - if err := validateBlossomFileStorage(c); err != nil { + c.Blossom.Adapter = "local" + if err := c.Validate(); err != nil { t.Fatalf("expected nil, got %v", err) } }) t.Run("s3 requires bucket region keys and secret", func(t *testing.T) { - c := &Config{} + c := validBlossomTestConfig() c.Blossom.Enabled = true - c.Blossom.Backend = "s3" + c.Blossom.Adapter = "s3" c.Blossom.S3.Region = "us-east-1" - normalizeBlossomConfig(c) - if err := validateBlossomFileStorage(c); err == nil { + if err := c.Validate(); err == nil { t.Fatal("expected error for missing bucket and credentials") } c.Blossom.S3.Bucket = "b" c.Blossom.S3.AccessKey = "k" c.Blossom.S3.SecretKey = "s" - normalizeBlossomConfig(c) - if err := validateBlossomFileStorage(c); err != nil { + if err := c.Validate(); err != nil { t.Fatalf("expected nil with all s3 fields set, got %v", err) } }) - t.Run("invalid backend value", func(t *testing.T) { - c := &Config{} + t.Run("invalid adapter value", func(t *testing.T) { + c := validBlossomTestConfig() c.Blossom.Enabled = true - c.Blossom.Backend = "nfs" - normalizeBlossomConfig(c) - if err := validateBlossomFileStorage(c); err == nil { - t.Fatal("expected error for unknown backend") + c.Blossom.Adapter = "nfs" + if err := c.Validate(); err == nil { + t.Fatal("expected error for unknown adapter") } }) } @@ -223,7 +234,7 @@ pubkey = "` + sk.Public().Hex() + `" [blossom] enabled = true -backend = "s3" +adapter = "s3" [blossom.s3] region = "auto" @@ -243,7 +254,7 @@ endpoint = "http://127.0.0.1:9000" if cfg.Blossom.S3.SecretKey != "topsecret" { t.Errorf("expected s3 secret_key retained in struct, got %q", cfg.Blossom.S3.SecretKey) } - if cfg.Blossom.Backend != "s3" { - t.Errorf("backend: got %q", cfg.Blossom.Backend) + if cfg.Blossom.Adapter != "s3" { + t.Errorf("adapter: got %q", cfg.Blossom.Adapter) } }