Merge pull request 'Enforce API key/JWT authentication and namespace gating in client' (#18) from testing into main

Reviewed-on: #18
This commit is contained in:
anonpenguin 2025-08-23 07:29:21 +00:00
commit e15a547a10
17 changed files with 610 additions and 156 deletions

View File

@ -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:

View File

@ -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

View File

@ -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

135
TASK.md
View File

@ -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_<random>.<namespace>`
- Option B (colon): `ak_<random>:<namespace>`
- Option C (base64 JSON): base64url of `{ "kid": "...", "ns": "<namespace>" }` prefixed by `ak_`
For simplicity and readability, choose Option B: `ak_<random>:<namespace>`.
- 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_<random>:<namespace>` 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_<random>:<namespace>`.
- 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).

108
e2e/e2e_test.go Normal file
View File

@ -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))
}
}
}

View File

@ -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") }
}

193
pkg/client/client_test.go Normal file
View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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)
}
}

View File

@ -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",

View File

@ -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)
}
}

44
pkg/gateway/jwt_test.go Normal file
View File

@ -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")
}
}

View File

@ -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")
}
}

View File

@ -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])
}
}

View File

@ -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)
}
}

27
pkg/node/backoff_test.go Normal file
View File

@ -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)
}
}
}

View File

@ -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)
}
}