improved edge cases for versioning system and implemented tests

This commit is contained in:
AndReicscs
2026-06-15 15:57:28 +00:00
parent d8b6758610
commit 982a1d8944
6 changed files with 243 additions and 33 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ func (h *ComposeHandler) GetNodeCompose(w http.ResponseWriter, r *http.Request)
}
hostFallback := "https://" + r.Host
yamlData, err := h.service.GetNodeCompose(token, hostFallback)
yamlData, err := h.service.GetNodeCompose(token, hostFallback, HubAPIVersion)
if err != nil {
if err.Error() == "unauthorized" {
RespondError(w, "Unauthorized", http.StatusUnauthorized)
+6 -3
View File
@@ -2,10 +2,10 @@ package api
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"strconv"
"sync"
"golang.org/x/time/rate"
@@ -108,8 +108,11 @@ func AgentAuthMiddleware(auth NodeAuthenticator, rateLimiter *RateLimiter) func(
// Version handshake
wizardMinHubAPIStr := r.Header.Get("X-Wizard-Min-Hub-Api")
if wizardMinHubAPIStr != "" {
var wizardMinHubAPI int
fmt.Sscanf(wizardMinHubAPIStr, "%d", &wizardMinHubAPI)
wizardMinHubAPI, err := strconv.Atoi(strings.TrimSpace(wizardMinHubAPIStr))
if err != nil {
http.Error(w, "Invalid X-Wizard-Min-Hub-Api format", http.StatusBadRequest)
return
}
if HubAPIVersion < wizardMinHubAPI {
http.Error(w, "This Wizard requires Hub v"+wizardMinHubAPIStr+" or later. Please update your Hub.", http.StatusUpgradeRequired)
return
+11
View File
@@ -121,6 +121,17 @@ func TestAgentAuthMiddleware(t *testing.T) {
freshHandler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
})
t.Run("Wizard Version Mismatch", func(t *testing.T) {
req := httptest.NewRequest("POST", "/", nil)
req.Header.Set("X-Api-Key", "agent-key")
// Simulate a Wizard requesting a highly futuristic Hub API version
req.Header.Set("X-Wizard-Min-Hub-Api", "99")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
// Should return HTTP 426 Upgrade Required
assert.Equal(t, http.StatusUpgradeRequired, rec.Code)
})
}
func TestDualAuthMiddleware(t *testing.T) {
+1 -1
View File
@@ -107,7 +107,7 @@ func (h *SensorHandler) ToggleSilence(w http.ResponseWriter, r *http.Request) {
// GetManifests fetches the sensor manifest JSON.
func (h *SensorHandler) GetManifests(w http.ResponseWriter, r *http.Request) {
body, err := h.composeService.FetchManifestBytes()
body, err := h.composeService.FetchManifestBytes(HubAPIVersion)
if err != nil {
RespondError(w, "Failed to reach manifest registry", http.StatusBadGateway)
return
+72 -28
View File
@@ -6,6 +6,7 @@ import (
"log"
"net/http"
"strings"
"strconv"
"sync"
@@ -23,10 +24,22 @@ type Store interface {
SetNodeDesiredRevision(nodeID, rev string) error
}
type RegistryIndex struct {
Sensors []struct {
ID string `json:"id"`
Latest string `json:"latest"`
Versions []struct {
V string `json:"v"`
MinHubAPI string `json:"min_hub_api"`
} `json:"versions"`
} `json:"sensors"`
}
type Service struct {
store Store
cache map[string]models.SensorManifest
mu sync.RWMutex
store Store
cache map[string]models.SensorManifest
indexCache *RegistryIndex
mu sync.RWMutex
}
func NewService(store Store) *Service {
@@ -54,45 +67,67 @@ type PreviewRequest struct {
// --- MANIFEST FETCHING ---
func (s *Service) FetchManifestBytes() ([]byte, error) {
manifests, err := s.fetchStrictCatalogManifests()
func (s *Service) FetchManifestBytes(currentHubAPI int) ([]byte, error) {
manifests, err := s.fetchStrictCatalogManifests(currentHubAPI)
if err != nil {
return nil, err
}
return json.Marshal(manifests)
}
func (s *Service) fetchStrictCatalogManifests() ([]models.SensorManifest, error) {
func (s *Service) fetchStrictCatalogManifests(currentHubAPI int) ([]models.SensorManifest, error) {
registryURL, err := s.store.GetConfigValue("registry_url")
if err != nil || registryURL == "" {
registryURL = "https://raw.githubusercontent.com/andreicscs/HoneyWire/registry-pages"
registryURL = "https://raw.githubusercontent.com/andreicscs/HoneyWire/registry-pages" // TODO implement the default in the store level. and check that url actually works...
}
indexURL := strings.TrimRight(registryURL, "/") + "/index.json"
var idx RegistryIndex
resp, err := http.Get(indexURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("registry returned status %d", resp.StatusCode)
}
var idx struct {
Sensors []struct {
ID string `json:"id"`
Latest string `json:"latest"`
} `json:"sensors"`
}
if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
return nil, err
if err != nil || resp.StatusCode != http.StatusOK {
log.Printf("[WARNING] Registry fetch failed (err: %v), falling back to index cache.", err)
s.mu.RLock()
cachedIdx := s.indexCache
s.mu.RUnlock()
if cachedIdx == nil {
return nil, fmt.Errorf("registry unreachable and no local index cache available")
}
idx = *cachedIdx
} else {
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&idx); err != nil {
return nil, err
}
// Update index cache
s.mu.Lock()
s.indexCache = &idx
s.mu.Unlock()
}
var result []models.SensorManifest
for _, sensor := range idx.Sensors {
cacheKey := sensor.ID + "-v" + sensor.Latest
targetVersion := ""
// Find the highest version where min_hub_api <= currentHubAPI
// Assuming versions are ordered [lowest -> highest] by the build script
for i := len(sensor.Versions) - 1; i >= 0; i-- {
if reqAPI, err := strconv.Atoi(strings.TrimSpace(sensor.Versions[i].MinHubAPI)); err == nil {
if currentHubAPI >= reqAPI {
targetVersion = sensor.Versions[i].V
break
}
}
}
if targetVersion == "" {
log.Printf("[WARNING] No compatible version found for sensor %s", sensor.ID)
continue
}
cacheKey := sensor.ID + "-v" + targetVersion
s.mu.RLock()
cached, ok := s.cache[cacheKey]
s.mu.RUnlock()
@@ -103,7 +138,7 @@ func (s *Service) fetchStrictCatalogManifests() ([]models.SensorManifest, error)
}
sensorName := strings.TrimPrefix(sensor.ID, "hw-sensor-")
manifestURL := fmt.Sprintf("%s/%s-v%s.json", strings.TrimRight(registryURL, "/"), sensorName, sensor.Latest)
manifestURL := fmt.Sprintf("%s/%s-v%s.json", strings.TrimRight(registryURL, "/"), sensorName, targetVersion)
mResp, fetchErr := http.Get(manifestURL)
if fetchErr != nil {
@@ -135,7 +170,7 @@ func (s *Service) fetchStrictCatalogManifests() ([]models.SensorManifest, error)
// --- GENERATION LOGIC ---
func (s *Service) GetNodeCompose(token, hostFallback string) ([]byte, error) {
func (s *Service) GetNodeCompose(token, hostFallback string, currentHubAPI int) ([]byte, error) {
nodeID, err := s.store.GetNodeByKey(token)
if err != nil || nodeID == "" {
return nil, fmt.Errorf("unauthorized")
@@ -165,7 +200,7 @@ func (s *Service) GetNodeCompose(token, hostFallback string) ([]byte, error) {
effectiveRevision = node.GenerateRevisionHash(nodeDetails.InstalledSensors)
}
manifests, fetchErr := s.fetchStrictCatalogManifests()
manifests, fetchErr := s.fetchStrictCatalogManifests(currentHubAPI)
if fetchErr != nil {
log.Printf("[ERROR] fetchStrictCatalogManifests failed: %v", fetchErr)
return nil, fmt.Errorf("failed_to_fetch")
@@ -189,6 +224,15 @@ func (s *Service) GetNodeCompose(token, hostFallback string) ([]byte, error) {
return nil, fmt.Errorf("invalid_manifest: %w", valErr)
}
if manifest.MinHubAPI != "" {
if minAPI, err := strconv.Atoi(strings.TrimSpace(manifest.MinHubAPI)); err == nil {
if currentHubAPI < minAPI {
log.Printf("[ERROR] Sensor %s requires Hub API %d, but Hub is running API %d", sensor.ID, minAPI, currentHubAPI)
return nil, fmt.Errorf("incompatible_sensor: %s requires Hub API %d", sensor.ID, minAPI)
}
}
}
userVars := make(map[string]string)
for k, v := range sensor.EnvVars {
if str, ok := v.(string); ok {
@@ -0,0 +1,152 @@
package composesvc_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/honeywire/hub/internal/models"
composesvc "github.com/honeywire/hub/internal/services/compose"
)
type MockStore struct {
RegistryURL string
}
func (m *MockStore) GetConfigValue(key string) (string, error) {
if key == "registry_url" {
return m.RegistryURL, nil
}
if key == "hub_endpoint" {
return "http://localhost:8080", nil
}
return "", nil
}
func (m *MockStore) GetNodeByKey(token string) (string, error) {
return "node-1", nil
}
func (m *MockStore) GetNodeDetails(nodeID string) (*models.Node, error) {
return &models.Node{
ID: "node-1",
InstalledSensors: []models.NodeSensor{
{ID: "hw-sensor-test"},
},
}, nil
}
func (m *MockStore) SetNodeDesiredRevision(nodeID, rev string) error {
return nil
}
func TestComposeSmartVersionSelection(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/index.json" {
json.NewEncoder(w).Encode(map[string]interface{}{
"sensors": []map[string]interface{}{
{
"id": "hw-sensor-test",
"latest": "2.0.0",
"versions": []map[string]string{
{"v": "1.0.0", "min_hub_api": "1"},
{"v": "1.5.0", "min_hub_api": " 2 "}, // Injecting malicious whitespace
{"v": "2.0.0", "min_hub_api": " 3"}, // Injecting malicious whitespace
},
},
},
})
return
}
// Mock responses for the requested versions
version := r.URL.Path[len("/test-v") : len(r.URL.Path)-5] // Extract version from path
json.NewEncoder(w).Encode(map[string]interface{}{
"id": "hw-sensor-test",
"version": version,
"schema_version": "1.0",
"min_hub_api": "1", // Mock doesn't need to match perfectly, just needs to parse
"deployment": map[string]interface{}{
"image_repository": "test",
"image_tag": version,
},
})
}))
defer ts.Close()
store := &MockStore{RegistryURL: ts.URL}
svc := composesvc.NewService(store)
t.Run("Perfect Match Resolution (Hub API 2)", func(t *testing.T) {
// Hub API 2 should select v1.5.0, ignoring v2.0.0
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 2)
if err != nil {
t.Fatalf("Expected success, got error: %v", err)
}
if !contains(yamlData, "image: test:1.5.0") {
t.Errorf("Expected to deploy v1.5.0, got yaml:\n%s", string(yamlData))
}
})
t.Run("Legacy Backward Compat (Hub API 4)", func(t *testing.T) {
// Hub API 4 should select v2.0.0 because 4 >= 3
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 4)
if err != nil {
t.Fatalf("Expected success, got error: %v", err)
}
if !contains(yamlData, "image: test:2.0.0") {
t.Errorf("Expected to deploy v2.0.0, got yaml:\n%s", string(yamlData))
}
})
t.Run("No Compatible Version Found", func(t *testing.T) {
// Hub API 0 is too old for everything (minimum is 1)
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 0)
if err != nil {
t.Fatalf("Expected success (generates empty compose, logs warning), got err: %v", err)
}
if contains(yamlData, "image: test:") {
t.Errorf("Expected NO sensor to be deployed, but found one in yaml:\n%s", string(yamlData))
}
})
t.Run("Whitespace Robust Parsing", func(t *testing.T) {
// Even if min_hub_api has spaces like " 3 ", it should parse cleanly and fail on Hub API 2
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 2)
if err != nil {
t.Fatalf("Expected success, got error: %v", err)
}
// Because min_hub_api=" 3 " for v2.0.0 parses successfully, Hub API 2 will correctly reject it and fallback to v1.5.0
if !contains(yamlData, "image: test:1.5.0") {
t.Errorf("Expected fallback to v1.5.0, got yaml:\n%s", string(yamlData))
}
})
t.Run("Network Cache Fallback on 502", func(t *testing.T) {
// First, do a successful fetch to populate the cache
_, _ = svc.GetNodeCompose("dummy", "http://localhost", 2)
// Now break the registry URL so the next fetch fails completely
store.RegistryURL = "http://localhost:1" // guaranteed connection refused
// Attempt to fetch again. The network will fail, but the cache should save the day!
yamlData, err := svc.GetNodeCompose("dummy", "http://localhost", 2)
if err != nil {
t.Fatalf("Expected cache fallback success, got error: %v", err)
}
if !contains(yamlData, "image: test:1.5.0") {
t.Errorf("Expected cached fallback to v1.5.0, got yaml:\n%s", string(yamlData))
}
})
}
func contains(b []byte, s string) bool {
return strings.Contains(string(b), s)
}