mirror of
https://github.com/DeBrosOfficial/network.git
synced 2025-10-06 08:39:08 +00:00
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:
commit
e15a547a10
@ -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:
|
||||
|
12
Makefile
12
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
|
||||
|
||||
|
@ -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"
|
||||
|
135
TASK.md
135
TASK.md
@ -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
108
e2e/e2e_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
45
pkg/auth/credentials_test.go
Normal file
45
pkg/auth/credentials_test.go
Normal 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
193
pkg/client/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
52
pkg/client/defaults_test.go
Normal file
52
pkg/client/defaults_test.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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",
|
||||
|
13
pkg/discovery/discovery_test.go
Normal file
13
pkg/discovery/discovery_test.go
Normal 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
44
pkg/gateway/jwt_test.go
Normal 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")
|
||||
}
|
||||
}
|
37
pkg/gateway/middleware_test.go
Normal file
37
pkg/gateway/middleware_test.go
Normal 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")
|
||||
}
|
||||
}
|
42
pkg/gateway/migrate_test.go
Normal file
42
pkg/gateway/migrate_test.go
Normal 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])
|
||||
}
|
||||
}
|
12
pkg/gateway/pubsub_handlers_test.go
Normal file
12
pkg/gateway/pubsub_handlers_test.go
Normal 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
27
pkg/node/backoff_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
23
pkg/storage/protocol_test.go
Normal file
23
pkg/storage/protocol_test.go
Normal 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)
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user