From 4541224a67c4a023d199e43e9587659811c41a9d Mon Sep 17 00:00:00 2001 From: rajsingh1301 Date: Thu, 4 Jun 2026 10:29:11 +0530 Subject: [PATCH 1/5] feat: add deterministic mock AI provider for local/demo use - Implement MockProvider that returns deterministic mock summary - Wire mock provider in NewProvider and handle readyz health checks - Update CacheMiddleware to handle mock provider cache keys - Add unit and integration tests covering the mock provider - Update README and .env.example with mock provider documentation --- .env.example | 2 +- gateway/README.md | 4 +- gateway/cache.go | 2 + gateway/internal/ai/mock.go | 16 +++++ gateway/internal/ai/provider.go | 2 + gateway/internal/ai/provider_test.go | 25 ++++++++ gateway/main.go | 3 + gateway/main_test.go | 95 ++++++++++++++++++++++++++++ 8 files changed, 146 insertions(+), 3 deletions(-) create mode 100644 gateway/internal/ai/mock.go diff --git a/.env.example b/.env.example index d14731a..95cc5c7 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ PORT=3000 NODE_ENV=development # AI Service Configuration -# AI provider selection: "openrouter" (default) or "ollama" +# AI provider selection: "openrouter" (default), "ollama", or "mock" (local/demo only) AI_PROVIDER=openrouter # OpenRouter Configuration (when AI_PROVIDER=openrouter) diff --git a/gateway/README.md b/gateway/README.md index 47ad390..38c569b 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -88,7 +88,7 @@ Common optional variables: | Variable | Default | Notes | | --- | --- | --- | | `PORT` | `3000` | Gateway listen port. | -| `AI_PROVIDER` | `openrouter` | Supported values: `openrouter`, `ollama`. | +| `AI_PROVIDER` | `openrouter` | Supported values: `openrouter`, `ollama`, `mock` (local/demo only). | | `OPENROUTER_MODEL` | `z-ai/glm-4.5-air:free` in code/docs unless overridden | OpenRouter model. | | `OPENROUTER_URL` | `https://openrouter.ai/api/v1/chat/completions` provider default | Used by tests and custom OpenRouter-compatible endpoints. | | `OLLAMA_URL` | `http://localhost:11434` | Used when `AI_PROVIDER=ollama`. | @@ -129,7 +129,7 @@ cd gateway RECEIPT_STORE=memory CACHE_ENABLED=false go run . ``` -The verifier must be reachable at `VERIFIER_URL` for signed requests. OpenRouter startup requires `OPENROUTER_API_KEY` unless `AI_PROVIDER=ollama`. +The verifier must be reachable at `VERIFIER_URL` for signed requests. OpenRouter startup requires `OPENROUTER_API_KEY` unless `AI_PROVIDER` is set to `ollama` or `mock`. ## Testing diff --git a/gateway/cache.go b/gateway/cache.go index 031d172..8bafb54 100644 --- a/gateway/cache.go +++ b/gateway/cache.go @@ -108,6 +108,8 @@ func CacheMiddleware() gin.HandlerFunc { if model == "" { model = "llama2" } + } else if os.Getenv("AI_PROVIDER") == "mock" { + model = "mock" } else if model == "" { model = "z-ai/glm-4.5-air:free" } diff --git a/gateway/internal/ai/mock.go b/gateway/internal/ai/mock.go new file mode 100644 index 0000000..ee22259 --- /dev/null +++ b/gateway/internal/ai/mock.go @@ -0,0 +1,16 @@ +package ai + +import "context" + +// MockProvider implements the Provider interface for local/demo testing +type MockProvider struct{} + +// NewMockProvider creates a new MockProvider instance +func NewMockProvider() *MockProvider { + return &MockProvider{} +} + +// Generate returns a deterministic mock summary response +func (p *MockProvider) Generate(ctx context.Context, text string) (string, error) { + return "This is a deterministic mock summary of the input text for local/demo testing.", nil +} diff --git a/gateway/internal/ai/provider.go b/gateway/internal/ai/provider.go index ec02797..c2f24a1 100644 --- a/gateway/internal/ai/provider.go +++ b/gateway/internal/ai/provider.go @@ -25,6 +25,8 @@ func NewProvider() (Provider, error) { return NewOpenRouterProvider(), nil case "ollama": return NewOllamaProvider(), nil + case "mock": + return NewMockProvider(), nil default: return nil, fmt.Errorf("unsupported AI provider: %s", providerType) } diff --git a/gateway/internal/ai/provider_test.go b/gateway/internal/ai/provider_test.go index 26147d0..f52d16f 100644 --- a/gateway/internal/ai/provider_test.go +++ b/gateway/internal/ai/provider_test.go @@ -1,6 +1,7 @@ package ai import ( + "context" "testing" ) @@ -29,6 +30,12 @@ func TestNewProvider(t *testing.T) { wantType: "*ai.OllamaProvider", wantErr: false, }, + { + name: "mock provider", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + }, { name: "unsupported provider", providerType: "invalid", @@ -70,6 +77,10 @@ func TestNewProvider(t *testing.T) { if _, ok := provider.(*OllamaProvider); !ok { t.Errorf("NewProvider() returned %T, want *OllamaProvider", provider) } + case "*ai.MockProvider": + if _, ok := provider.(*MockProvider); !ok { + t.Errorf("NewProvider() returned %T, want *MockProvider", provider) + } } }) } @@ -135,3 +146,17 @@ func TestNewOllamaProvider_Defaults(t *testing.T) { t.Errorf("expected default model 'llama2', got '%s'", provider.model) } } + +func TestMockProvider(t *testing.T) { + provider := NewMockProvider() + ctx := context.Background() + resp, err := provider.Generate(ctx, "hello world") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + expected := "This is a deterministic mock summary of the input text for local/demo testing." + if resp != expected { + t.Errorf("expected '%s', got '%s'", expected, resp) + } +} diff --git a/gateway/main.go b/gateway/main.go index 41be12d..a55077a 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -1040,6 +1040,9 @@ func handleReadyz(c *gin.Context) { case "ollama": aiStatus = checkOllamaHealth() checks["ollama"] = aiStatus + case "mock": + aiStatus = "ok" + checks["mock"] = aiStatus } checks["ai_provider"] = gin.H{ "provider": providerType, diff --git a/gateway/main_test.go b/gateway/main_test.go index 56933d7..760dd62 100644 --- a/gateway/main_test.go +++ b/gateway/main_test.go @@ -3,12 +3,14 @@ package main import ( "bytes" "encoding/json" + "gateway/internal/ai" "net/http" "net/http/httptest" "os" "strconv" "strings" "testing" + "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -616,3 +618,96 @@ func TestHandleReadyz_RedisUnreachable(t *testing.T) { checks := response["checks"].(map[string]interface{}) require.Equal(t, "unreachable", checks["redis"]) } + +func TestHandleReadyz_MockProvider(t *testing.T) { + gin.SetMode(gin.TestMode) + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("RECEIPT_STORE", "memory") + t.Setenv("CACHE_ENABLED", "false") + + origVerifier := checkVerifierHealth + defer func() { + checkVerifierHealth = origVerifier + }() + + checkVerifierHealth = func() string { return "ok" } + + r := gin.Default() + r.GET("/readyz", handleReadyz) + + req, _ := http.NewRequest(http.MethodGet, "/readyz", nil) + w := httptest.NewRecorder() + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + require.Equal(t, true, response["ready"]) + + checks := response["checks"].(map[string]interface{}) + require.Equal(t, "ok", checks["verifier"]) + require.Equal(t, "ok", checks["mock"]) + + aiProviderCheck := checks["ai_provider"].(map[string]interface{}) + require.Equal(t, "mock", aiProviderCheck["provider"]) + require.Equal(t, "ok", aiProviderCheck["status"]) +} + +func TestHandleSummarize_MockProvider(t *testing.T) { + // Set up a verifier that returns valid immediately + verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{"is_valid":true, "recovered_address":"0xabc","error":""}`)) + })) + defer verifier.Close() + + // Environment + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("VERIFIER_URL", verifier.URL) + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("RECEIPT_STORE", "memory") + t.Setenv("CACHE_ENABLED", "false") + + // Initialize AI provider for this test + var err error + aiProvider, err = ai.NewProvider() + require.NoError(t, err) + + // Initialize receipt store for this test + err = initReceiptStore() + require.NoError(t, err) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.POST("/api/ai/summarize", handleSummarize) + + // Build a valid request with signature/nonce + reqBody := strings.NewReader(`{"text":"hello world text to summarize"}`) + req, _ := http.NewRequest("POST", "/api/ai/summarize", reqBody) + req.Header.Set("X-402-Signature", "sig") + req.Header.Set("X-402-Nonce", "nonce") + req.Header.Set("X-402-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + // Verify deterministic response + expected := "This is a deterministic mock summary of the input text for local/demo testing." + require.Equal(t, expected, response["result"]) + + // Check that X-402-Receipt header is set + receiptHeader := w.Header().Get("X-402-Receipt") + require.NotEmpty(t, receiptHeader) +} + From 454bf7d4793a4db7c1e110e159961da2a16e090a Mon Sep 17 00:00:00 2001 From: rajsingh1301 Date: Thu, 4 Jun 2026 12:00:37 +0530 Subject: [PATCH 2/5] test: isolate globals mutation inside TestHandleSummarize_MockProvider --- gateway/main_test.go | 142 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 125 insertions(+), 17 deletions(-) diff --git a/gateway/main_test.go b/gateway/main_test.go index 760dd62..198500a 100644 --- a/gateway/main_test.go +++ b/gateway/main_test.go @@ -9,8 +9,8 @@ import ( "os" "strconv" "strings" + "sync" "testing" - "time" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" @@ -658,9 +658,62 @@ func TestHandleReadyz_MockProvider(t *testing.T) { } func TestHandleSummarize_MockProvider(t *testing.T) { - // Set up a verifier that returns valid immediately + // Expected test values + expectedRecipient := "0x2cAF48b4BA1C58721a85dFADa5aC01C2DFa62219" + expectedAmount := "0.001" + expectedToken := "USDC" + expectedChainID := 84532 + expectedSignature := "test-signature" + + var expectedNonce string + var expectedTimestamp uint64 + + // Set up a verifier that asserts the parsed context parameters match exactly verifier := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(200) + var req VerifyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"failed to decode verify request"}`)) + return + } + + if req.Signature != expectedSignature { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"signature mismatch"}`)) + return + } + if req.Context.Nonce != expectedNonce { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"nonce mismatch"}`)) + return + } + if req.Context.Timestamp != expectedTimestamp { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"timestamp mismatch"}`)) + return + } + if req.Context.Recipient != expectedRecipient { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"recipient mismatch"}`)) + return + } + if req.Context.Amount != expectedAmount { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"amount mismatch"}`)) + return + } + if req.Context.Token != expectedToken { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"token mismatch"}`)) + return + } + if req.Context.ChainID != expectedChainID { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"is_valid":false,"error":"chain_id mismatch"}`)) + return + } + + w.WriteHeader(http.StatusOK) w.Write([]byte(`{"is_valid":true, "recovered_address":"0xabc","error":""}`)) })) defer verifier.Close() @@ -672,6 +725,32 @@ func TestHandleSummarize_MockProvider(t *testing.T) { t.Setenv("RECEIPT_STORE", "memory") t.Setenv("CACHE_ENABLED", "false") + serverPrivateKeyTestMu.Lock() + receiptGlobalsTestMu.Lock() + + // Save package-level globals + origAIProvider := aiProvider + origReceiptStore := getActiveReceiptStore() + origKey := serverPrivateKey + origKeyErr := serverPrivateKeyErr + origOnce := serverPrivateKeyOnce + + defer func() { + aiProvider = origAIProvider + setActiveReceiptStore(origReceiptStore) + serverPrivateKey = origKey + serverPrivateKeyErr = origKeyErr + serverPrivateKeyOnce = origOnce + + receiptGlobalsTestMu.Unlock() + serverPrivateKeyTestMu.Unlock() + }() + + // Reset private key once/error states for this test + serverPrivateKey = nil + serverPrivateKeyErr = nil + serverPrivateKeyOnce = sync.Once{} + // Initialize AI provider for this test var err error aiProvider, err = ai.NewProvider() @@ -685,29 +764,58 @@ func TestHandleSummarize_MockProvider(t *testing.T) { r := gin.New() r.POST("/api/ai/summarize", handleSummarize) - // Build a valid request with signature/nonce - reqBody := strings.NewReader(`{"text":"hello world text to summarize"}`) - req, _ := http.NewRequest("POST", "/api/ai/summarize", reqBody) - req.Header.Set("X-402-Signature", "sig") - req.Header.Set("X-402-Nonce", "nonce") - req.Header.Set("X-402-Timestamp", strconv.FormatInt(time.Now().Unix(), 10)) - req.Header.Set("Content-Type", "application/json") + // Step 1: Send unsigned request to trigger 402 challenge + reqBody1 := strings.NewReader(`{"text":"hello world text to summarize"}`) + req1, _ := http.NewRequest("POST", "/api/ai/summarize", reqBody1) + req1.Header.Set("Content-Type", "application/json") + w1 := httptest.NewRecorder() + r.ServeHTTP(w1, req1) - w := httptest.NewRecorder() - r.ServeHTTP(w, req) + require.Equal(t, http.StatusPaymentRequired, w1.Code) - require.Equal(t, http.StatusOK, w.Code) + var challengeResp map[string]interface{} + err = json.Unmarshal(w1.Body.Bytes(), &challengeResp) + require.NoError(t, err) - var response map[string]interface{} - err = json.Unmarshal(w.Body.Bytes(), &response) + paymentCtx, ok := challengeResp["paymentContext"].(map[string]interface{}) + require.True(t, ok) + + expectedNonce, ok = paymentCtx["nonce"].(string) + require.True(t, ok) + + timestampFloat, ok := paymentCtx["timestamp"].(float64) + require.True(t, ok) + expectedTimestamp = uint64(timestampFloat) + + // Verify returned payment context fields match expectations + require.Equal(t, expectedRecipient, paymentCtx["recipient"]) + require.Equal(t, expectedAmount, paymentCtx["amount"]) + require.Equal(t, expectedToken, paymentCtx["token"]) + require.Equal(t, float64(expectedChainID), paymentCtx["chainId"]) + + // Step 2: Send signed retry using context values + reqBody2 := strings.NewReader(`{"text":"hello world text to summarize"}`) + req2, _ := http.NewRequest("POST", "/api/ai/summarize", reqBody2) + req2.Header.Set("X-402-Signature", expectedSignature) + req2.Header.Set("X-402-Nonce", expectedNonce) + req2.Header.Set("X-402-Timestamp", strconv.FormatUint(expectedTimestamp, 10)) + req2.Header.Set("Content-Type", "application/json") + + w2 := httptest.NewRecorder() + r.ServeHTTP(w2, req2) + + require.Equal(t, http.StatusOK, w2.Code) + + var response2 map[string]interface{} + err = json.Unmarshal(w2.Body.Bytes(), &response2) require.NoError(t, err) // Verify deterministic response expected := "This is a deterministic mock summary of the input text for local/demo testing." - require.Equal(t, expected, response["result"]) + require.Equal(t, expected, response2["result"]) // Check that X-402-Receipt header is set - receiptHeader := w.Header().Get("X-402-Receipt") + receiptHeader := w2.Header().Get("X-402-Receipt") require.NotEmpty(t, receiptHeader) } From 0f58ba120f2b63624e2bf289e915b83a0226323b Mon Sep 17 00:00:00 2001 From: rajsingh1301 Date: Thu, 4 Jun 2026 21:44:10 +0530 Subject: [PATCH 3/5] feat: pass AI_PROVIDER in Compose, fix test isolation (go vet), and update READMEs --- README.md | 8 +++++--- docker-compose.yml | 3 +++ gateway/README.md | 2 +- gateway/internal/ai/mock.go | 14 ++++++++------ gateway/main_test.go | 3 +-- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f530c5b..9b292ee 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ flowchart TB Gateway["gateway/ Go Gin API :3000"] Verifier["verifier/ Rust Axum :3002"] Redis["Redis 7 receipt store by default\noptional response cache"] - AI["AI provider: OpenRouter or Ollama"] + AI["AI provider: OpenRouter, Ollama, or Mock"] Wallet["EVM wallet on configured chain\nBase Sepolia default"] Client --> Web @@ -107,7 +107,7 @@ flowchart TB Summarize["POST /api/ai/summarize"] PaymentContext["Create PaymentContext\nrecipient, token, amount, chainId, nonce, timestamp"] VerifyClient["Verifier HTTP client\nVERIFIER_URL"] - AIClient["AI provider client\nOpenRouter or Ollama"] + AIClient["AI provider client\nOpenRouter, Ollama, or Mock"] ReceiptSigner["Receipt signer\nserver wallet key"] ReceiptLookup["GET /api/receipts/{id}"] Health["/healthz and /readyz"] @@ -132,6 +132,7 @@ flowchart TB subgraph Providers["AI providers"] OpenRouter["OpenRouter chat completions"] Ollama["Local Ollama generate API"] + Mock["Mock offline provider"] end Browser --> Gin @@ -148,6 +149,7 @@ flowchart TB Summarize -->|verified cache miss| AIClient AIClient --> OpenRouter AIClient --> Ollama + AIClient --> Mock Summarize --> ReceiptSigner --> Receipts ReceiptSigner --> MemoryStore Cache -->|cached response receipt| ReceiptSigner @@ -316,7 +318,7 @@ Core local variables live in [.env.example](.env.example). Production placeholde | Variable | Service | Notes | | --- | --- | --- | -| `AI_PROVIDER` | Gateway | `openrouter` by default, `ollama` for local Ollama experiments. | +| `AI_PROVIDER` | Gateway | `openrouter` by default, `ollama` for local Ollama experiments, or `mock` for offline local/demo use. | | `OPENROUTER_API_KEY` | Gateway | Required when using OpenRouter. Never commit a real key. | | `OPENROUTER_MODEL` | Gateway | OpenRouter model name. Demo docs use `z-ai/glm-4.5-air:free` unless overridden. | | `OLLAMA_URL`, `OLLAMA_MODEL` | Gateway | Used only when `AI_PROVIDER=ollama`. | diff --git a/docker-compose.yml b/docker-compose.yml index dddd77c..59fe6d3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,9 @@ services: - METRICS_ENABLED=${METRICS_ENABLED:-true} - METRICS_PATH=${METRICS_PATH:-/metrics} # Pass through from .env file + - AI_PROVIDER + - OLLAMA_URL + - OLLAMA_MODEL - OPENROUTER_API_KEY - OPENROUTER_MODEL - SERVER_WALLET_PRIVATE_KEY diff --git a/gateway/README.md b/gateway/README.md index 38c569b..7ed0b01 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -70,7 +70,7 @@ The verifier route `POST /verify` is not a gateway route. It belongs to the inte | `ratelimit.go` | Token bucket implementation. | | `metrics.go` | Prometheus metric definitions and metrics endpoint config helpers. | | `middleware.go` | Request timeout, correlation ID, and request metrics middleware. | -| `internal/ai/` | OpenRouter and Ollama provider implementations. | +| `internal/ai/` | OpenRouter, Ollama, and Mock provider implementations. | | `openapi.yaml` | Public gateway API contract. | ## Configuration diff --git a/gateway/internal/ai/mock.go b/gateway/internal/ai/mock.go index ee22259..896b382 100644 --- a/gateway/internal/ai/mock.go +++ b/gateway/internal/ai/mock.go @@ -2,15 +2,17 @@ package ai import "context" -// MockProvider implements the Provider interface for local/demo testing -type MockProvider struct{} +type MockProvider struct { + Name string +} -// NewMockProvider creates a new MockProvider instance func NewMockProvider() *MockProvider { - return &MockProvider{} + return &MockProvider{Name: "Mock"} } -// Generate returns a deterministic mock summary response -func (p *MockProvider) Generate(ctx context.Context, text string) (string, error) { +func (m *MockProvider) Generate(ctx context.Context, text string) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } return "This is a deterministic mock summary of the input text for local/demo testing.", nil } diff --git a/gateway/main_test.go b/gateway/main_test.go index 198500a..5840118 100644 --- a/gateway/main_test.go +++ b/gateway/main_test.go @@ -733,14 +733,13 @@ func TestHandleSummarize_MockProvider(t *testing.T) { origReceiptStore := getActiveReceiptStore() origKey := serverPrivateKey origKeyErr := serverPrivateKeyErr - origOnce := serverPrivateKeyOnce defer func() { aiProvider = origAIProvider setActiveReceiptStore(origReceiptStore) serverPrivateKey = origKey serverPrivateKeyErr = origKeyErr - serverPrivateKeyOnce = origOnce + serverPrivateKeyOnce = sync.Once{} receiptGlobalsTestMu.Unlock() serverPrivateKeyTestMu.Unlock() From 77f42b53cd6e91a6e4de5b217dd3f6e5dc684f96 Mon Sep 17 00:00:00 2001 From: rajsingh1301 Date: Fri, 5 Jun 2026 11:22:22 +0530 Subject: [PATCH 4/5] feat: add mock provider safety gate and docker-safe ollama configuration --- docker-compose.yml | 6 ++- gateway/config_test.go | 61 ++++++++++++++++++++++++++++ gateway/internal/ai/ollama.go | 7 ++++ gateway/internal/ai/provider.go | 14 +++++++ gateway/internal/ai/provider_test.go | 43 +++++++++++++++++++- gateway/main.go | 21 ++++++++++ gateway/main_test.go | 2 + 7 files changed, 152 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 59fe6d3..c185ca5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,10 +17,12 @@ services: - METRICS_PATH=${METRICS_PATH:-/metrics} # Pass through from .env file - AI_PROVIDER - - OLLAMA_URL + - OLLAMA_URL=${OLLAMA_URL:-http://host.docker.internal:11434} - OLLAMA_MODEL - OPENROUTER_API_KEY - OPENROUTER_MODEL + - OPENROUTER_URL + - RUNNING_IN_DOCKER=true - SERVER_WALLET_PRIVATE_KEY - RECIPIENT_ADDRESS - PAYMENT_AMOUNT @@ -40,6 +42,8 @@ services: start_period: 10s networks: - microai-net + extra_hosts: + - "host.docker.internal:host-gateway" verifier: build: ./verifier diff --git a/gateway/config_test.go b/gateway/config_test.go index 0023a35..1312e72 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -474,3 +474,64 @@ func TestGetReceiptTTL(t *testing.T) { func stringPtr(value string) *string { return &value } + +func TestValidateConfig_MockProvider(t *testing.T) { + t.Run("allowed in dev environment", func(t *testing.T) { + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "development") + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("RECEIPT_STORE", "memory") + + err := validateConfig() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("allowed with ALLOW_MOCK_PROVIDER", func(t *testing.T) { + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("RECEIPT_STORE", "memory") + + err := validateConfig() + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + + t.Run("rejected in production", func(t *testing.T) { + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "production") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("RECEIPT_STORE", "memory") + + err := validateConfig() + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "disabled in production") { + t.Fatalf("expected error to mention disabled in production, got %v", err) + } + }) + + t.Run("rejected outside allowed env", func(t *testing.T) { + t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "") + t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") + t.Setenv("RECEIPT_STORE", "memory") + + err := validateConfig() + if err == nil { + t.Fatal("expected error, got nil") + } + }) +} diff --git a/gateway/internal/ai/ollama.go b/gateway/internal/ai/ollama.go index 57847cb..e1eb9b1 100644 --- a/gateway/internal/ai/ollama.go +++ b/gateway/internal/ai/ollama.go @@ -24,6 +24,13 @@ func NewOllamaProvider() *OllamaProvider { url = "http://localhost:11434" } + if os.Getenv("RUNNING_IN_DOCKER") == "true" { + if url == "http://localhost:11434" || url == "http://localhost:11434/" || + url == "http://127.0.0.1:11434" || url == "http://127.0.0.1:11434/" { + url = "http://host.docker.internal:11434" + } + } + model := os.Getenv("OLLAMA_MODEL") if model == "" { model = "llama2" diff --git a/gateway/internal/ai/provider.go b/gateway/internal/ai/provider.go index c2f24a1..a7bd688 100644 --- a/gateway/internal/ai/provider.go +++ b/gateway/internal/ai/provider.go @@ -26,6 +26,20 @@ func NewProvider() (Provider, error) { case "ollama": return NewOllamaProvider(), nil case "mock": + nodeEnv := os.Getenv("NODE_ENV") + appEnv := os.Getenv("APP_ENV") + allowMock := os.Getenv("ALLOW_MOCK_PROVIDER") == "true" + + if nodeEnv == "production" || appEnv == "production" { + return nil, fmt.Errorf("mock AI provider is disabled in production environments") + } + + isAllowedEnv := nodeEnv == "development" || nodeEnv == "local" || nodeEnv == "demo" || nodeEnv == "test" || nodeEnv == "dev" || + appEnv == "development" || appEnv == "local" || appEnv == "demo" || appEnv == "test" || appEnv == "dev" + + if !isAllowedEnv && !allowMock { + return nil, fmt.Errorf("mock AI provider is disabled. Enable it by setting ALLOW_MOCK_PROVIDER=true, or set NODE_ENV/APP_ENV to development/local/demo/test") + } return NewMockProvider(), nil default: return nil, fmt.Errorf("unsupported AI provider: %s", providerType) diff --git a/gateway/internal/ai/provider_test.go b/gateway/internal/ai/provider_test.go index f52d16f..a5a04dd 100644 --- a/gateway/internal/ai/provider_test.go +++ b/gateway/internal/ai/provider_test.go @@ -11,6 +11,7 @@ func TestNewProvider(t *testing.T) { providerType string wantType string wantErr bool + setupEnv func(t *testing.T) }{ { name: "default to openrouter", @@ -31,10 +32,43 @@ func TestNewProvider(t *testing.T) { wantErr: false, }, { - name: "mock provider", + name: "mock provider allowed via NODE_ENV=test", providerType: "mock", wantType: "*ai.MockProvider", wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("NODE_ENV", "test") + }, + }, + { + name: "mock provider allowed via ALLOW_MOCK_PROVIDER=true", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + }, + }, + { + name: "mock provider rejected in production", + providerType: "mock", + wantType: "", + wantErr: true, + setupEnv: func(t *testing.T) { + t.Setenv("NODE_ENV", "production") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + }, + }, + { + name: "mock provider rejected outside allowed environments", + providerType: "mock", + wantType: "", + wantErr: true, + setupEnv: func(t *testing.T) { + t.Setenv("NODE_ENV", "") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "") + }, }, { name: "unsupported provider", @@ -46,7 +80,14 @@ func TestNewProvider(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Setenv("NODE_ENV", "") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "") + t.Setenv("AI_PROVIDER", tt.providerType) + if tt.setupEnv != nil { + tt.setupEnv(t) + } provider, err := NewProvider() diff --git a/gateway/main.go b/gateway/main.go index a55077a..151cb89 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -74,6 +74,21 @@ func validateConfig() error { providerType := os.Getenv("AI_PROVIDER") if providerType == "" || providerType == "openrouter" { required = append(required, "OPENROUTER_API_KEY") + } else if providerType == "mock" { + nodeEnv := os.Getenv("NODE_ENV") + appEnv := os.Getenv("APP_ENV") + allowMock := os.Getenv("ALLOW_MOCK_PROVIDER") == "true" + + if nodeEnv == "production" || appEnv == "production" { + return fmt.Errorf("mock AI provider is disabled in production environments") + } + + isAllowedEnv := nodeEnv == "development" || nodeEnv == "local" || nodeEnv == "demo" || nodeEnv == "test" || nodeEnv == "dev" || + appEnv == "development" || appEnv == "local" || appEnv == "demo" || appEnv == "test" || appEnv == "dev" + + if !isAllowedEnv && !allowMock { + return fmt.Errorf("mock AI provider is disabled. Enable it by setting ALLOW_MOCK_PROVIDER=true, or set NODE_ENV/APP_ENV to development/local/demo/test") + } } if err := validateReceiptStoreMode(); err != nil { @@ -1130,6 +1145,12 @@ var checkOllamaHealth = func() string { if ollamaURL == "" { ollamaURL = "http://localhost:11434" } + if os.Getenv("RUNNING_IN_DOCKER") == "true" { + if ollamaURL == "http://localhost:11434" || ollamaURL == "http://localhost:11434/" || + ollamaURL == "http://127.0.0.1:11434" || ollamaURL == "http://127.0.0.1:11434/" { + ollamaURL = "http://host.docker.internal:11434" + } + } healthURL := strings.TrimSuffix(ollamaURL, "/") + "/api/tags" ctx, cancel := context.WithTimeout(context.Background(), getHealthCheckTimeout()) diff --git a/gateway/main_test.go b/gateway/main_test.go index 5840118..5dde0b1 100644 --- a/gateway/main_test.go +++ b/gateway/main_test.go @@ -622,6 +622,7 @@ func TestHandleReadyz_RedisUnreachable(t *testing.T) { func TestHandleReadyz_MockProvider(t *testing.T) { gin.SetMode(gin.TestMode) t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "test") t.Setenv("RECEIPT_STORE", "memory") t.Setenv("CACHE_ENABLED", "false") @@ -720,6 +721,7 @@ func TestHandleSummarize_MockProvider(t *testing.T) { // Environment t.Setenv("AI_PROVIDER", "mock") + t.Setenv("NODE_ENV", "test") t.Setenv("VERIFIER_URL", verifier.URL) t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("RECEIPT_STORE", "memory") From f03518a872ef4f336a2664d960dccdbf70e61868 Mon Sep 17 00:00:00 2001 From: rajsingh1301 Date: Fri, 5 Jun 2026 12:01:05 +0530 Subject: [PATCH 5/5] feat: normalize mock AI safety gates and isolate config test environments --- gateway/config_test.go | 12 +++++++ gateway/internal/ai/provider.go | 41 ++++++++++++++++-------- gateway/internal/ai/provider_test.go | 47 ++++++++++++++++++++++++++++ gateway/main.go | 15 ++------- 4 files changed, 89 insertions(+), 26 deletions(-) diff --git a/gateway/config_test.go b/gateway/config_test.go index 1312e72..fcbbf87 100644 --- a/gateway/config_test.go +++ b/gateway/config_test.go @@ -479,6 +479,10 @@ func TestValidateConfig_MockProvider(t *testing.T) { t.Run("allowed in dev environment", func(t *testing.T) { t.Setenv("AI_PROVIDER", "mock") t.Setenv("NODE_ENV", "development") + t.Setenv("APP_ENV", "") + t.Setenv("ALLOW_MOCK_PROVIDER", "") + t.Setenv("CACHE_ENABLED", "false") + t.Setenv("RECIPIENT_ADDRESS", "") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") t.Setenv("RECEIPT_STORE", "memory") @@ -492,7 +496,10 @@ func TestValidateConfig_MockProvider(t *testing.T) { t.Run("allowed with ALLOW_MOCK_PROVIDER", func(t *testing.T) { t.Setenv("AI_PROVIDER", "mock") t.Setenv("NODE_ENV", "") + t.Setenv("APP_ENV", "") t.Setenv("ALLOW_MOCK_PROVIDER", "true") + t.Setenv("CACHE_ENABLED", "false") + t.Setenv("RECIPIENT_ADDRESS", "") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") t.Setenv("RECEIPT_STORE", "memory") @@ -506,7 +513,10 @@ func TestValidateConfig_MockProvider(t *testing.T) { t.Run("rejected in production", func(t *testing.T) { t.Setenv("AI_PROVIDER", "mock") t.Setenv("NODE_ENV", "production") + t.Setenv("APP_ENV", "") t.Setenv("ALLOW_MOCK_PROVIDER", "true") + t.Setenv("CACHE_ENABLED", "false") + t.Setenv("RECIPIENT_ADDRESS", "") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") t.Setenv("RECEIPT_STORE", "memory") @@ -525,6 +535,8 @@ func TestValidateConfig_MockProvider(t *testing.T) { t.Setenv("NODE_ENV", "") t.Setenv("APP_ENV", "") t.Setenv("ALLOW_MOCK_PROVIDER", "") + t.Setenv("CACHE_ENABLED", "false") + t.Setenv("RECIPIENT_ADDRESS", "") t.Setenv("SERVER_WALLET_PRIVATE_KEY", "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") t.Setenv("VERIFIER_URL", "http://127.0.0.1:3002") t.Setenv("RECEIPT_STORE", "memory") diff --git a/gateway/internal/ai/provider.go b/gateway/internal/ai/provider.go index a7bd688..e0c74e0 100644 --- a/gateway/internal/ai/provider.go +++ b/gateway/internal/ai/provider.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "strings" ) // Provider defines the interface for AI service providers @@ -26,22 +27,36 @@ func NewProvider() (Provider, error) { case "ollama": return NewOllamaProvider(), nil case "mock": - nodeEnv := os.Getenv("NODE_ENV") - appEnv := os.Getenv("APP_ENV") - allowMock := os.Getenv("ALLOW_MOCK_PROVIDER") == "true" - - if nodeEnv == "production" || appEnv == "production" { - return nil, fmt.Errorf("mock AI provider is disabled in production environments") - } - - isAllowedEnv := nodeEnv == "development" || nodeEnv == "local" || nodeEnv == "demo" || nodeEnv == "test" || nodeEnv == "dev" || - appEnv == "development" || appEnv == "local" || appEnv == "demo" || appEnv == "test" || appEnv == "dev" - - if !isAllowedEnv && !allowMock { - return nil, fmt.Errorf("mock AI provider is disabled. Enable it by setting ALLOW_MOCK_PROVIDER=true, or set NODE_ENV/APP_ENV to development/local/demo/test") + if err := IsMockAllowed(os.Getenv("NODE_ENV"), os.Getenv("APP_ENV"), os.Getenv("ALLOW_MOCK_PROVIDER")); err != nil { + return nil, err } return NewMockProvider(), nil default: return nil, fmt.Errorf("unsupported AI provider: %s", providerType) } } + +// IsMockAllowed evaluates NODE_ENV, APP_ENV, and ALLOW_MOCK_PROVIDER to see if the mock provider can be run. +// It normalizes checks by lowercasing and trimming spaces, evaluating production prefixes, +// and treating truthy values for ALLOW_MOCK_PROVIDER. +func IsMockAllowed(nodeEnv, appEnv, allowMock string) error { + nodeEnv = strings.ToLower(strings.TrimSpace(nodeEnv)) + appEnv = strings.ToLower(strings.TrimSpace(appEnv)) + allowMock = strings.ToLower(strings.TrimSpace(allowMock)) + + isProd := strings.HasPrefix(nodeEnv, "prod") || strings.HasPrefix(appEnv, "prod") + if isProd { + return fmt.Errorf("mock AI provider is disabled in production environments") + } + + isAllowedEnv := nodeEnv == "development" || nodeEnv == "local" || nodeEnv == "demo" || nodeEnv == "test" || nodeEnv == "dev" || + appEnv == "development" || appEnv == "local" || appEnv == "demo" || appEnv == "test" || appEnv == "dev" + + isExplicitlyAllowed := allowMock == "true" || allowMock == "1" || allowMock == "yes" + + if !isAllowedEnv && !isExplicitlyAllowed { + return fmt.Errorf("mock AI provider is disabled. Enable it by setting ALLOW_MOCK_PROVIDER=true, or set NODE_ENV/APP_ENV to development/local/demo/test") + } + + return nil +} diff --git a/gateway/internal/ai/provider_test.go b/gateway/internal/ai/provider_test.go index a5a04dd..ebfdec1 100644 --- a/gateway/internal/ai/provider_test.go +++ b/gateway/internal/ai/provider_test.go @@ -49,6 +49,33 @@ func TestNewProvider(t *testing.T) { t.Setenv("ALLOW_MOCK_PROVIDER", "true") }, }, + { + name: "mock provider allowed via ALLOW_MOCK_PROVIDER=1", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("ALLOW_MOCK_PROVIDER", "1") + }, + }, + { + name: "mock provider allowed via ALLOW_MOCK_PROVIDER=yes", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("ALLOW_MOCK_PROVIDER", "yes") + }, + }, + { + name: "mock provider allowed via ALLOW_MOCK_PROVIDER=TRUE (case insensitive)", + providerType: "mock", + wantType: "*ai.MockProvider", + wantErr: false, + setupEnv: func(t *testing.T) { + t.Setenv("ALLOW_MOCK_PROVIDER", "TRUE") + }, + }, { name: "mock provider rejected in production", providerType: "mock", @@ -59,6 +86,26 @@ func TestNewProvider(t *testing.T) { t.Setenv("ALLOW_MOCK_PROVIDER", "true") }, }, + { + name: "mock provider rejected in production prefix (prod-east)", + providerType: "mock", + wantType: "", + wantErr: true, + setupEnv: func(t *testing.T) { + t.Setenv("NODE_ENV", "prod-east") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + }, + }, + { + name: "mock provider rejected in production prefix (PROD) (case insensitive)", + providerType: "mock", + wantType: "", + wantErr: true, + setupEnv: func(t *testing.T) { + t.Setenv("APP_ENV", "PROD") + t.Setenv("ALLOW_MOCK_PROVIDER", "true") + }, + }, { name: "mock provider rejected outside allowed environments", providerType: "mock", diff --git a/gateway/main.go b/gateway/main.go index 151cb89..eb46de0 100644 --- a/gateway/main.go +++ b/gateway/main.go @@ -75,19 +75,8 @@ func validateConfig() error { if providerType == "" || providerType == "openrouter" { required = append(required, "OPENROUTER_API_KEY") } else if providerType == "mock" { - nodeEnv := os.Getenv("NODE_ENV") - appEnv := os.Getenv("APP_ENV") - allowMock := os.Getenv("ALLOW_MOCK_PROVIDER") == "true" - - if nodeEnv == "production" || appEnv == "production" { - return fmt.Errorf("mock AI provider is disabled in production environments") - } - - isAllowedEnv := nodeEnv == "development" || nodeEnv == "local" || nodeEnv == "demo" || nodeEnv == "test" || nodeEnv == "dev" || - appEnv == "development" || appEnv == "local" || appEnv == "demo" || appEnv == "test" || appEnv == "dev" - - if !isAllowedEnv && !allowMock { - return fmt.Errorf("mock AI provider is disabled. Enable it by setting ALLOW_MOCK_PROVIDER=true, or set NODE_ENV/APP_ENV to development/local/demo/test") + if err := ai.IsMockAllowed(os.Getenv("NODE_ENV"), os.Getenv("APP_ENV"), os.Getenv("ALLOW_MOCK_PROVIDER")); err != nil { + return err } }