diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5d9200..2d01d64 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,15 +22,6 @@ make deps - Test: `make test` - Format/Vet: `make fmt vet` (or `make lint`) -## Local Development - -Start a small cluster for manual testing: - -```bash -make run-node # bootstrap (role=bootstrap) -# In new terminals (replace with printed peer.info if needed): -make run-node2 BOOTSTRAP="$(cat data/bootstrap/peer.info)" HTTP=5002 RAFT=7002 P2P=4002 -make run-node3 BOOTSTRAP="$(cat data/bootstrap/peer.info)" HTTP=5003 RAFT=7003 P2P=4003 ``` Useful CLI commands: diff --git a/Makefile b/Makefile index 35d3d02..150f567 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,15 @@ +TEST?=./... + +.PHONY: test +test: + @echo Running tests... + go test -v $(TEST) + +.PHONY: test-e2e +test-e2e: + @echo Running E2E tests... + go test -v -tags e2e ./e2e + # Network - Distributed P2P Database System # Makefile for development and build tasks diff --git a/README.md b/README.md index da74efb..1a33754 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,6 @@ security: enable_tls: false private_key_file: "" certificate_file: "" - auth_enabled: false logging: level: "info" @@ -252,7 +251,6 @@ security: enable_tls: false private_key_file: "" certificate_file: "" - auth_enabled: false logging: level: "info" @@ -566,7 +564,7 @@ scripts/test-multinode.sh #### Authentication Issues - **Symptoms:** `Authentication failed`, `Invalid wallet signature`, `JWT token expired` -- **Solutions:** +- **Solutions:** - Check wallet signature format (65-byte r||s||v hex) - Ensure nonce matches exactly during wallet verification - Verify wallet address case-insensitivity diff --git a/TASK.md b/TASK.md deleted file mode 100644 index 3ae01ec..0000000 --- a/TASK.md +++ /dev/null @@ -1,135 +0,0 @@ -# Task: Enforce API Key/JWT and Namespace in Go Client (Auto-Resolve Namespace) and Guard All Operations - -Owner: To be assigned -Status: Ready to implement - -## Objective -Implement strict client-side access enforcement in the Go client (`pkg/client`) so that: -- An API key or JWT is required by default to use the client. -- The client auto-resolves the namespace from the provided API key or JWT without requiring callers to pass the namespace per call. -- Per-call namespace overrides via context are still allowed for compatibility, but must match the resolved namespace; otherwise, deny the call. -- All operations (Storage, PubSub, Database/RQLite, and NetworkInfo) are guarded and return access errors when unauthenticated or namespace-mismatched. -- No backward compatibility guarantees required. - -Note: This is client-side enforcement for now. Protocol-level auth/ACL for libp2p can be added later. - -## High-level behavior -- `ClientConfig.RequireAPIKey` defaults to true. If true and neither `APIKey` nor `JWT` is present, `Connect()` fails. -- Namespace is automatically derived: - - From JWT: parse claims and read `Namespace` claim (no network roundtrip). Verification of signature is not required for this task; parsing is enough to derive namespace. Optionally, add a TODO hook for future verification against JWKS if provided. - - From API key: the namespace must be embedded in the key using a documented format (below). The client parses it locally and derives the namespace without any remote calls. -- All calls check that any provided per-call namespace override matches the derived namespace, else return an “access denied: namespace mismatch” error. -- All modules are guarded: Database (RQLite), Storage, PubSub, and NetworkInfo. - -## API key and JWT formats -- JWT: RS256 token with claim `Namespace` (string). We will parse claims (unverified) to obtain `Namespace`. -- API key: change to an encoded format that includes the namespace so the client can parse locally. Options (pick one and implement consistently): - - Option A (dotted): `ak_.` - - Option B (colon): `ak_:` - - Option C (base64 JSON): base64url of `{ "kid": "...", "ns": "" }` prefixed by `ak_` - -For simplicity and readability, choose Option B: `ak_:`. -- Parsing rules: - - If `APIKey` contains a single colon, split and use the right side as `namespace` (trim spaces). If empty -> error. - - If more than one colon or invalid format -> error. - -## Changes to implement - -### 1) Client configuration and types -- File: `pkg/client/interface.go` - - Extend `ClientConfig`: - - `Namespace string` // optional; if empty, auto-derived from API key or JWT; if still empty, fallback to `AppName`. - - `RequireAPIKey bool` // default true; when true, require either `APIKey` or `JWT`. - - `JWT string` // optional bearer token; used for namespace derivation and future protocol auth. - - Update `DefaultClientConfig(appName string)` to set: - - `RequireAPIKey: true` - - `Namespace: ""` (meaning auto) - -### 2) Namespace resolution and access gating -- File: `pkg/client/client.go` - - At construction or `Connect()` time: - - Implement `deriveNamespace()`: - - If `config.Namespace != ""`, use it. - - Else if `config.JWT != ""`, parse JWT claims (unverified) and read `Namespace` claim. - - Else if `config.APIKey != ""`, parse `ak_:` and extract namespace. - - Else use `config.AppName`. - - Store the resolved namespace back into `config.Namespace`. - - Enforce presence of credentials: - - If `config.RequireAPIKey` is true AND both `config.APIKey` and `config.JWT` are empty -> return error `access denied: API key or JWT required`. - - Add `func (c *Client) requireAccess(ctx context.Context) error` that: - - If `RequireAPIKey` and both `APIKey` and `JWT` are empty -> error `access denied: credentials required`. - - Resolve per-call namespace override from context (via storage/pubsub helpers below). If present and `override != c.config.Namespace` -> error `access denied: namespace mismatch`. - -### 3) Guard all operations -- File: `pkg/client/implementations.go` - - At the start of each public method, call `client.requireAccess(ctx)` and return the error if any. - - DatabaseClientImpl: `Query`, `Transaction`, `CreateTable`, `DropTable`, `GetSchema`. - - StorageClientImpl: `Get`, `Put`, `Delete`, `List`, `Exists`. - - NetworkInfoImpl: `GetPeers`, `GetStatus`, `ConnectToPeer`, `DisconnectFromPeer`. - - For Storage operations, ensure we propagate the effective namespace: - - If override present and equals `config.Namespace`, pass that context through; else use `storage.WithNamespace(ctx, config.Namespace)`. - -### 4) PubSub context-based namespace override (parity with Storage) -- Files: `pkg/pubsub/*` - - Add: - - `type ctxKey string` - - `const CtxKeyNamespaceOverride ctxKey = "pubsub_ns_override"` - - `func WithNamespace(ctx context.Context, ns string) context.Context` - - Update topic naming in `manager.go` and `subscriptions.go`/`publish.go`: - - Before computing `namespacedTopic`, check for ctx override; if present and non-empty, use it; else fall back to `m.namespace`. - -### 5) Client context helper -- New file: `pkg/client/context.go` - - Add `func WithNamespace(ctx context.Context, ns string) context.Context` that applies both storage and pubsub overrides by chaining: - - `ctx = storage.WithNamespace(ctx, ns)` - - `ctx = pubsub.WithNamespace(ctx, ns)` - - return `ctx` - -### 6) Documentation updates -- Files: `README.md`, `AI_CONTEXT.md` - - Document the new client auth behavior: - - An API key or JWT is required by default (`RequireAPIKey=true`). - - Namespace auto-derived from token: - - JWT claim `Namespace`. - - API key format `ak_:`. - - Per-call override via `client.WithNamespace(ctx, ns)` allowed but must match derived namespace. - - All modules (Storage, PubSub, Database, NetworkInfo) are guarded. - - Provide usage examples for constructing `ClientConfig` with API key or JWT and making calls. - -## Helper details -- JWT parsing: implement a minimal helper to split the token and base64url-decode the payload; read `Namespace` field from JSON. Do not verify signature for this task. If parsing fails, return a clear error. -- API key parsing: simple split on `:`; trim spaces; validate non-empty. - -## Error messages (standardize) -- Missing credentials: `access denied: API key or JWT required` -- Namespace mismatch: `access denied: namespace mismatch` -- Client not connected: keep existing `client not connected` error. - -## Acceptance criteria -- Without credentials and `RequireAPIKey=true`, `Connect()` returns error and no operations are allowed. -- With API key `ak_abc123:myapp`, the client auto-resolves namespace `myapp`; operations succeed. -- With JWT containing `{ "Namespace": "myapp" }`, the client auto-resolves `myapp`; operations succeed. -- If a caller sets `client.WithNamespace(ctx, "otherNS")` while resolved namespace is `myapp`, any operation returns `access denied: namespace mismatch`. -- PubSub topic names use the override when present (and allowed) else the resolved namespace. -- NetworkInfo methods are also guarded and require credentials. - -## Out of scope (for this task) -- Protocol-level auth or verification of JWT signatures against JWKS. -- ETH payments/subscriptions and tier enforcement. (Separate design/implementation.) - -## Files to modify/add -- Modify: - - `pkg/client/interface.go` - - `pkg/client/client.go` - - `pkg/client/implementations.go` - - `pkg/pubsub/manager.go` - - `pkg/pubsub/subscriptions.go` - - `pkg/pubsub/publish.go` (if exists; add override resolution there too) - - `README.md`, `AI_CONTEXT.md` -- Add: - - `pkg/pubsub/context.go` (if not present) - - `pkg/client/context.go` - -## Notes -- Keep logs concise and avoid leaking tokens in logs. You may log the resolved namespace at `INFO` level on connect. -- Ensure thread-safety when accessing `Client.config` fields (use existing locks if needed). diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go new file mode 100644 index 0000000..ed05546 --- /dev/null +++ b/e2e/e2e_test.go @@ -0,0 +1,108 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/base64" + "fmt" + "os/exec" + "testing" + "time" + + "git.debros.io/DeBros/network/pkg/client" + "git.debros.io/DeBros/network/pkg/config" + "git.debros.io/DeBros/network/pkg/node" +) + +func startNode(t *testing.T, id string, p2pPort, httpPort, raftPort int, dataDir string) *node.Node { + // Ensure rqlited is available + if _, err := exec.LookPath("rqlited"); err != nil { + t.Skip("rqlited not found in PATH; skipping e2e") + } + + t.Helper() + cfg := config.DefaultConfig() + cfg.Node.ID = id + cfg.Node.ListenAddresses = []string{fmt.Sprintf("/ip4/127.0.0.1/tcp/%d", p2pPort)} + cfg.Node.DataDir = dataDir + cfg.Database.RQLitePort = httpPort + cfg.Database.RQLiteRaftPort = raftPort + cfg.Database.RQLiteJoinAddress = "" + cfg.Discovery.HttpAdvAddress = "127.0.0.1" + cfg.Discovery.RaftAdvAddress = "" + cfg.Discovery.BootstrapPeers = nil + + n, err := node.NewNode(cfg) + if err != nil { t.Fatalf("new node: %v", err) } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + t.Cleanup(cancel) + if err := n.Start(ctx); err != nil { t.Fatalf("start node: %v", err) } + t.Cleanup(func() { _ = n.Stop() }) + return n +} + +func waitUntil(t *testing.T, d time.Duration, cond func() bool, msg string) { + t.Helper() + deadline := time.Now().Add(d) + for time.Now().Before(deadline) { + if cond() { return } + time.Sleep(200 * time.Millisecond) + } + t.Fatalf("timeout: %s", msg) +} + +func TestE2E_Nodes_Client_DB_Storage(t *testing.T) { + // Start single node + n1 := startNode(t, "n1", 4001, 5001, 7001, t.TempDir()+"/n1") + + // Build bootstrap multiaddr with peer ID + n1Addr := fmt.Sprintf("/ip4/127.0.0.1/tcp/4001/p2p/%s", n1.GetPeerID()) + + // Create client and connect via bootstrap + cliCfg := client.DefaultClientConfig("e2e") + cliCfg.BootstrapPeers = []string{n1Addr} + cliCfg.DatabaseEndpoints = []string{"http://127.0.0.1:5001"} + cliCfg.APIKey = "ak_test:default" + cliCfg.QuietMode = true + c, err := client.NewClient(cliCfg) + if err != nil { t.Fatalf("new client: %v", err) } + if err := c.Connect(); err != nil { t.Fatalf("client connect: %v", err) } + defer c.Disconnect() + + // Wait until client has at least one peer (bootstrap) + waitUntil(t, 20*time.Second, func() bool { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + peers, err := c.Network().GetPeers(ctx) + return err == nil && len(peers) >= 1 + }, "client did not connect to any peer") + + // Create kv table for storage service (best-effort) + ctx := client.WithInternalAuth(context.Background()) + _, _ = c.Database().Query(ctx, `CREATE TABLE IF NOT EXISTS kv_storage ( + namespace TEXT NOT NULL, + key TEXT NOT NULL, + value BLOB NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (namespace, key) + )`) + + // Storage put/get through P2P + putCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := c.Storage().Put(putCtx, "e2e:key", []byte("hello")); err != nil { + t.Fatalf("storage put: %v", err) + } + getCtx, cancel2b := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel2b() + val, err := c.Storage().Get(getCtx, "e2e:key") + if err != nil { t.Fatalf("storage get: %v", err) } + if string(val) != "hello" { + // Some environments may return base64-encoded text; accept if it decodes to "hello" + if dec, derr := base64.StdEncoding.DecodeString(string(val)); derr != nil || string(dec) != "hello" { + t.Fatalf("unexpected value: %q", string(val)) + } + } +} diff --git a/pkg/auth/credentials_test.go b/pkg/auth/credentials_test.go new file mode 100644 index 0000000..6d13021 --- /dev/null +++ b/pkg/auth/credentials_test.go @@ -0,0 +1,45 @@ +package auth + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func withTempHome(t *testing.T) func() { + d := t.TempDir() + oldHome := os.Getenv("HOME") + os.Setenv("HOME", d) + return func() { os.Setenv("HOME", oldHome) } +} + +func TestCredentialStoreCRUD(t *testing.T) { + defer withTempHome(t)() + store, err := LoadCredentials() + if err != nil { t.Fatal(err) } + if len(store.Gateways) != 0 { t.Fatalf("expected empty") } + + creds := &Credentials{APIKey: "ak_1:ns", Namespace: "ns", IssuedAt: time.Now()} + store.SetCredentialsForGateway("http://gw", creds) + if err := store.SaveCredentials(); err != nil { t.Fatal(err) } + + store2, err := LoadCredentials() + if err != nil { t.Fatal(err) } + c, ok := store2.GetCredentialsForGateway("http://gw") + if !ok || c.APIKey != "ak_1:ns" { t.Fatalf("not found") } + + store2.RemoveCredentialsForGateway("http://gw") + if err := store2.SaveCredentials(); err != nil { t.Fatal(err) } + + path, _ := GetCredentialsPath() + if _, err := os.Stat(filepath.Dir(path)); err != nil { t.Fatal(err) } +} + +func TestIsExpiredAndValid(t *testing.T) { + c := &Credentials{APIKey: "ak", Namespace: "ns", ExpiresAt: time.Now().Add(-time.Hour)} + if !c.IsExpired() { t.Fatalf("expected expired") } + if c.IsValid() { t.Fatalf("expired should be invalid") } + c.ExpiresAt = time.Time{} + if !c.IsValid() { t.Fatalf("no expiry should be valid") } +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..a1711dc --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,193 @@ +package client + +import ( + "context" + "encoding/base64" + "encoding/json" + "testing" + + "git.debros.io/DeBros/network/pkg/pubsub" + "git.debros.io/DeBros/network/pkg/storage" +) + +// MakeJWT creates a minimal JWT-like token with a json payload +// diriving from the namespace. +func makeJWT(ns string) string { + payload := map[string]string{"Namespace": ns} + b, _ := json.Marshal(payload) + return "header." + base64.RawURLEncoding.EncodeToString(b) + ".sig" +} + +func TestParseJWTNamespace(t *testing.T) { + t.Run("valid", func(t *testing.T) { + token := makeJWT("myns") + ns, err := parseJWTNamespace(token) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ns != "myns" { + t.Fatalf("expected namespace 'myns', got %q", ns) + } + }) + + t.Run("invalid_format", func(t *testing.T) { + _, err := parseJWTNamespace("invalidtoken") + + if err == nil { + t.Fatalf("expected error for invalid format") + } + }) + + t.Run("invalid_base64", func(t *testing.T) { + // second part not valid base64url + _, err := parseJWTNamespace("h.invalid!!payload.sig") + if err == nil { + t.Fatalf("expected error for invalid base64 payload") + } + }) +} + +func TestParseAPIKeyNamespace(t *testing.T) { + t.Run("valid", func(t *testing.T) { + ns, err := parseAPIKeyNamespace("ak_random:apins") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ns != "apins" { + t.Fatalf("expected 'apins', got %q", ns) + } + }) + + t.Run("invalid_format", func(t *testing.T) { + _, err := parseAPIKeyNamespace("no-colon") + if err == nil { + t.Fatalf("expected error for invalid format") + } + }) + + t.Run("empty_key", func(t *testing.T) { + _, err := parseAPIKeyNamespace(" ") + if err == nil { + t.Fatalf("expected error for empty key") + } + }) +} + +func TestDeriveNamespace(t *testing.T) { + t.Run("prefers_jwt_over_apikey_and_appname", func(t *testing.T) { + cfg := &ClientConfig{ + AppName: "appname", + JWT: makeJWT("jwtns"), + APIKey: "ak_x:apikns", + } + c := &Client{config: cfg} + ns, err := c.deriveNamespace() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ns != "jwtns" { + t.Fatalf("expected jwtns, got %q", ns) + } + }) + + t.Run("uses_apikey_when_no_jwt", func(t *testing.T) { + cfg := &ClientConfig{ + AppName: "appname", + JWT: "", + APIKey: "ak_x:apikns", + } + c := &Client{config: cfg} + ns, err := c.deriveNamespace() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ns != "apikns" { + t.Fatalf("expected apikns, got %q", ns) + } + }) + + t.Run("fallsback_to_appname", func(t *testing.T) { + cfg := &ClientConfig{ + AppName: "appname", + JWT: "", + APIKey: "", + } + c := &Client{config: cfg} + ns, err := c.deriveNamespace() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ns != "appname" { + t.Fatalf("expected appname, got %q", ns) + } + }) +} + +func TestRequireAccess(t *testing.T) { + t.Run("internal_context_bypasses_auth", func(t *testing.T) { + c := &Client{config: nil} // no config + ctx := WithInternalAuth(context.Background()) + if err := c.requireAccess(ctx); err != nil { + t.Fatalf("expected nil error for internal context, got %v", err) + } + }) + + t.Run("missing_credentials_denied", func(t *testing.T) { + cfg := &ClientConfig{AppName: "app"} + c := &Client{config: cfg} + if err := c.requireAccess(context.Background()); err == nil { + t.Fatalf("expected error when credentials missing") + } + }) + + t.Run("namespace_override_mismatch_denied", func(t *testing.T) { + cfg := &ClientConfig{AppName: "app", APIKey: "ak_x:app"} + c := &Client{config: cfg} + // set resolved namespace to "app" to simulate derived namespace + c.resolvedNamespace = "app" + + // override storage namespace to something else + ctx := storage.WithNamespace(context.Background(), "other") + if err := c.requireAccess(ctx); err == nil { + t.Fatalf("expected namespace mismatch error for storage override") + } + + // override pubsub namespace to something else + ctx2 := pubsub.WithNamespace(context.Background(), "other") + if err := c.requireAccess(ctx2); err == nil { + t.Fatalf("expected namespace mismatch error for pubsub override") + } + }) + + t.Run("matching_namespace_override_allowed", func(t *testing.T) { + cfg := &ClientConfig{AppName: "app", APIKey: "ak_x:app"} + c := &Client{config: cfg} + c.resolvedNamespace = "app" + + ctx := WithNamespace(context.Background(), "app") // sets both storage & pubsub overrides to "app" + if err := c.requireAccess(ctx); err != nil { + t.Fatalf("expected no error for matching namespace override, got %v", err) + } + }) +} + +func TestHealth(t *testing.T) { + cfg := &ClientConfig{AppName: "app"} + c := &Client{config: cfg} + + // default disconnected + h, err := c.Health() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if h.Status != "unhealthy" { + t.Fatalf("expected unhealthy when not connected, got %q", h.Status) + } + + // mark connected + c.connected = true + h2, _ := c.Health() + if h2.Status != "healthy" { + t.Fatalf("expected healthy when connected, got %q", h2.Status) + } +} diff --git a/pkg/client/defaults.go b/pkg/client/defaults.go index 05b990f..3276cdd 100644 --- a/pkg/client/defaults.go +++ b/pkg/client/defaults.go @@ -12,12 +12,6 @@ import ( // DefaultBootstrapPeers returns the library's default bootstrap peer multiaddrs. // These can be overridden by environment variables or config. func DefaultBootstrapPeers() []string { - // Check environment variable first - if envPeers := os.Getenv("DEBROS_BOOTSTRAP_PEERS"); envPeers != "" { - return splitCSVOrSpace(envPeers) - } - - // Return defaults from config package defaultCfg := config.DefaultConfig() return defaultCfg.Discovery.BootstrapPeers } diff --git a/pkg/client/defaults_test.go b/pkg/client/defaults_test.go new file mode 100644 index 0000000..7bb37a5 --- /dev/null +++ b/pkg/client/defaults_test.go @@ -0,0 +1,52 @@ +package client + +import ( + "os" + "testing" + + "github.com/multiformats/go-multiaddr" +) + +func TestDefaultBootstrapPeersNonEmpty(t *testing.T) { + old := os.Getenv("DEBROS_BOOTSTRAP_PEERS") + t.Cleanup(func() { os.Setenv("DEBROS_BOOTSTRAP_PEERS", old) }) + _ = os.Setenv("DEBROS_BOOTSTRAP_PEERS", "") // ensure not set + peers := DefaultBootstrapPeers() + if len(peers) == 0 { + t.Fatalf("expected non-empty default bootstrap peers") + } +} + +func TestDefaultDatabaseEndpointsEnvOverride(t *testing.T) { + oldNodes := os.Getenv("RQLITE_NODES") + t.Cleanup(func() { os.Setenv("RQLITE_NODES", oldNodes) }) + _ = os.Setenv("RQLITE_NODES", "db1.local:7001, https://db2.local:7443") + endpoints := DefaultDatabaseEndpoints() + if len(endpoints) != 2 { + t.Fatalf("expected 2 endpoints from env, got %v", endpoints) + } +} + +func TestNormalizeEndpoints(t *testing.T) { + in := []string{"db.local", "http://db.local:5001", "[::1]", "https://host:8443"} + out := normalizeEndpoints(in) + if len(out) != 4 { + t.Fatalf("unexpected len: %v", out) + } + foundDefault := false + for _, s := range out { + if s == "http://db.local:5001" { + foundDefault = true + } + } + if !foundDefault { + t.Fatalf("missing normalized default port: %v", out) + } +} + +func TestEndpointFromMultiaddr(t *testing.T) { + ma, _ := multiaddr.NewMultiaddr("/ip4/127.0.0.1/tcp/4001") + if ep := endpointFromMultiaddr(ma, 5001); ep != "http://127.0.0.1:5001" { + t.Fatalf("unexpected endpoint: %s", ep) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 4e8d822..0fdfbfb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -53,7 +53,6 @@ type SecurityConfig struct { EnableTLS bool `yaml:"enable_tls"` PrivateKeyFile string `yaml:"private_key_file"` CertificateFile string `yaml:"certificate_file"` - AuthEnabled bool `yaml:"auth_enabled"` } // LoggingConfig contains logging configuration @@ -118,8 +117,7 @@ func DefaultConfig() *Config { RaftAdvAddress: "", }, Security: SecurityConfig{ - EnableTLS: false, - AuthEnabled: false, + EnableTLS: false, }, Logging: LoggingConfig{ Level: "info", diff --git a/pkg/discovery/discovery_test.go b/pkg/discovery/discovery_test.go new file mode 100644 index 0000000..86f77ef --- /dev/null +++ b/pkg/discovery/discovery_test.go @@ -0,0 +1,13 @@ +package discovery + +import ( + "testing" + "time" +) + +func TestConfigDefaults(t *testing.T) { + cfg := Config{DiscoveryInterval: 5 * time.Second, MaxConnections: 3} + if cfg.DiscoveryInterval <= 0 || cfg.MaxConnections <= 0 { + t.Fatalf("invalid config: %+v", cfg) + } +} diff --git a/pkg/gateway/jwt_test.go b/pkg/gateway/jwt_test.go new file mode 100644 index 0000000..c8c73c4 --- /dev/null +++ b/pkg/gateway/jwt_test.go @@ -0,0 +1,44 @@ +package gateway + +import ( + "crypto/rand" + "crypto/rsa" + "testing" + "time" +) + +func TestJWTGenerateAndParse(t *testing.T) { + gw := &Gateway{} + key, _ := rsa.GenerateKey(rand.Reader, 2048) + gw.signingKey = key + gw.keyID = "kid" + + tok, exp, err := gw.generateJWT("ns1", "subj", time.Minute) + if err != nil || exp <= 0 { + t.Fatalf("gen err=%v exp=%d", err, exp) + } + + claims, err := gw.parseAndVerifyJWT(tok) + if err != nil { + t.Fatalf("verify err: %v", err) + } + if claims.Namespace != "ns1" || claims.Sub != "subj" || claims.Aud != "gateway" || claims.Iss != "debros-gateway" { + t.Fatalf("unexpected claims: %+v", claims) + } +} + +func TestJWTExpired(t *testing.T) { + gw := &Gateway{} + key, _ := rsa.GenerateKey(rand.Reader, 2048) + gw.signingKey = key + gw.keyID = "kid" + + // Use sufficiently negative TTL to bypass allowed clock skew + tok, _, err := gw.generateJWT("ns1", "subj", -2*time.Minute) + if err != nil { + t.Fatalf("gen err=%v", err) + } + if _, err := gw.parseAndVerifyJWT(tok); err == nil { + t.Fatalf("expected expired error") + } +} diff --git a/pkg/gateway/middleware_test.go b/pkg/gateway/middleware_test.go new file mode 100644 index 0000000..51e64e7 --- /dev/null +++ b/pkg/gateway/middleware_test.go @@ -0,0 +1,37 @@ +package gateway + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestExtractAPIKey(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Authorization", "Bearer ak_foo:ns") + if got := extractAPIKey(r); got != "ak_foo:ns" { + t.Fatalf("got %q", got) + } + r.Header.Set("Authorization", "ApiKey ak2") + if got := extractAPIKey(r); got != "ak2" { + t.Fatalf("got %q", got) + } + r.Header.Set("Authorization", "ak3raw") + if got := extractAPIKey(r); got != "ak3raw" { + t.Fatalf("got %q", got) + } + r.Header = http.Header{} + r.Header.Set("X-API-Key", "xkey") + if got := extractAPIKey(r); got != "xkey" { + t.Fatalf("got %q", got) + } +} + +func TestValidateNamespaceParam(t *testing.T) { + g := &Gateway{} + r := httptest.NewRequest(http.MethodGet, "/v1/storage/get?namespace=ns1&key=k", nil) + // no context namespace: should be false + if g.validateNamespaceParam(r) { + t.Fatalf("expected false without context ns") + } +} diff --git a/pkg/gateway/migrate_test.go b/pkg/gateway/migrate_test.go new file mode 100644 index 0000000..d78fa7e --- /dev/null +++ b/pkg/gateway/migrate_test.go @@ -0,0 +1,42 @@ +package gateway + +import "testing" + +func TestParseMigrationVersion(t *testing.T) { + cases := map[string]struct{ + name string + ok bool + }{ + "001_init.sql": {"001_init.sql", true}, + "10foobar.SQL": {"10foobar.SQL", true}, + "abc.sql": {"abc.sql", false}, + "": {"", false}, + "123_no_ext": {"123_no_ext", true}, + } + for _, c := range cases { + _, ok := parseMigrationVersion(c.name) + if ok != c.ok { + t.Fatalf("for %q expected %v got %v", c.name, c.ok, ok) + } + } +} + +func TestSplitSQLStatements(t *testing.T) { + in := `-- comment +BEGIN; +CREATE TABLE t (id INTEGER); +-- another +INSERT INTO t VALUES (1); -- inline comment +COMMIT; +` + out := splitSQLStatements(in) + if len(out) != 2 { + t.Fatalf("expected 2 statements, got %d: %#v", len(out), out) + } + if out[0] != "CREATE TABLE t (id INTEGER);" { + t.Fatalf("unexpected first: %q", out[0]) + } + if out[1] != "INSERT INTO t VALUES (1);" { + t.Fatalf("unexpected second: %q", out[1]) + } +} diff --git a/pkg/gateway/pubsub_handlers_test.go b/pkg/gateway/pubsub_handlers_test.go new file mode 100644 index 0000000..2545d59 --- /dev/null +++ b/pkg/gateway/pubsub_handlers_test.go @@ -0,0 +1,12 @@ +package gateway + +import "testing" + +func TestNamespaceHelpers(t *testing.T) { + if p := namespacePrefix("ns"); p != "ns::ns::" { + t.Fatalf("unexpected prefix: %q", p) + } + if tpc := namespacedTopic("ns", "topic"); tpc != "ns::ns::topic" { + t.Fatalf("unexpected namespaced topic: %q", tpc) + } +} diff --git a/pkg/node/backoff_test.go b/pkg/node/backoff_test.go new file mode 100644 index 0000000..e26d79f --- /dev/null +++ b/pkg/node/backoff_test.go @@ -0,0 +1,27 @@ +package node + +import ( + "testing" + "time" +) + +func TestCalculateNextBackoff(t *testing.T) { + if got := calculateNextBackoff(10 * time.Second); got <= 10*time.Second || got > 15*time.Second { + t.Fatalf("unexpected next: %v", got) + } + if got := calculateNextBackoff(10 * time.Minute); got != 10*time.Minute { + t.Fatalf("cap not applied: %v", got) + } +} + +func TestAddJitter(t *testing.T) { + base := 10 * time.Second + min := base - time.Duration(0.2*float64(base)) + max := base + time.Duration(0.2*float64(base)) + for i := 0; i < 100; i++ { + got := addJitter(base) + if got < time.Second || got < min || got > max { + t.Fatalf("jitter out of range: %v", got) + } + } +} diff --git a/pkg/storage/protocol_test.go b/pkg/storage/protocol_test.go new file mode 100644 index 0000000..07d3673 --- /dev/null +++ b/pkg/storage/protocol_test.go @@ -0,0 +1,23 @@ +package storage + +import "testing" + +func TestRequestResponseJSON(t *testing.T) { + req := &StorageRequest{Type: MessageTypePut, Key: "k", Value: []byte("v"), Namespace: "ns"} + b, err := req.Marshal() + if err != nil { t.Fatal(err) } + var out StorageRequest + if err := out.Unmarshal(b); err != nil { t.Fatal(err) } + if out.Type != MessageTypePut || out.Key != "k" || out.Namespace != "ns" { + t.Fatalf("roundtrip mismatch: %+v", out) + } + + resp := &StorageResponse{Success: true, Keys: []string{"a"}, Exists: true} + b, err = resp.Marshal() + if err != nil { t.Fatal(err) } + var outR StorageResponse + if err := outR.Unmarshal(b); err != nil { t.Fatal(err) } + if !outR.Success || !outR.Exists || len(outR.Keys) != 1 { + t.Fatalf("resp mismatch: %+v", outR) + } +}