Files
goclaw/internal/http/auth_test.go
T
viettranx 843b550651 feat: runtime packages UI, pkg-helper, configurable shell deny groups (#244)
Runtime package management with security hardening:

- pkg-helper: root-privileged daemon for apk install/uninstall via Unix socket
- HTTP API: /v1/packages (list/install/uninstall/runtimes), admin role required for writes
- Shell deny groups: 15 configurable groups (per-agent overrides via context)
- Packages UI: Web page for managing system/pip/npm packages with confirmation dialogs
- Docker: privilege separation (root entrypoint → su-exec drop), init for zombie reaping
- Security: umask socket creation, persist file validation, deny pattern hardening
  (Node.js fetch/http, Python from/import, curl localhost, sensitive env vars)
- Auth: empty gateway token → admin role (dev/single-user mode)
2026-03-17 19:50:26 +07:00

316 lines
8.4 KiB
Go

package http
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/nextlevelbuilder/goclaw/internal/bus"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/store"
"github.com/google/uuid"
)
// setupTestCache initializes the package-level cache for testing.
// Returns a cleanup function to restore state.
func setupTestCache(t *testing.T, keys map[string]*store.APIKeyData) *mockAPIKeyStore {
t.Helper()
ms := newMockAPIKeyStore()
for hash, key := range keys {
ms.keys[hash] = key
}
pkgAPIKeyCache = newAPIKeyCache(ms, 5*time.Minute)
t.Cleanup(func() { pkgAPIKeyCache = nil })
return ms
}
func TestResolveAuth_GatewayToken(t *testing.T) {
setupTestCache(t, nil)
r := httptest.NewRequest("GET", "/v1/agents", nil)
r.Header.Set("Authorization", "Bearer my-gateway-token")
auth := resolveAuth(r, "my-gateway-token")
if !auth.Authenticated {
t.Fatal("expected authenticated")
}
if auth.Role != permissions.RoleAdmin {
t.Errorf("role = %v, want admin", auth.Role)
}
}
func TestResolveAuth_WrongToken(t *testing.T) {
setupTestCache(t, nil)
r := httptest.NewRequest("GET", "/v1/agents", nil)
r.Header.Set("Authorization", "Bearer wrong-token")
auth := resolveAuth(r, "correct-token")
if auth.Authenticated {
t.Fatal("expected unauthenticated for wrong token")
}
}
func TestResolveAuth_NoAuthConfigured(t *testing.T) {
setupTestCache(t, nil)
r := httptest.NewRequest("GET", "/v1/agents", nil)
auth := resolveAuth(r, "") // no gateway token configured
if !auth.Authenticated {
t.Fatal("expected authenticated when no token configured")
}
if auth.Role != permissions.RoleAdmin {
t.Errorf("role = %v, want admin (no token = dev/single-user mode)", auth.Role)
}
}
func TestResolveAuth_APIKeyReadScope(t *testing.T) {
// We need to hash the token the same way crypto.HashAPIKey does
// For testing, we'll inject directly into the cache
keyID := uuid.New()
ms := newMockAPIKeyStore()
ms.keys["test-hash"] = &store.APIKeyData{
ID: keyID,
Scopes: []string{"operator.read"},
}
pkgAPIKeyCache = newAPIKeyCache(ms, 5*time.Minute)
defer func() { pkgAPIKeyCache = nil }()
// Pre-populate cache directly for the hash
pkgAPIKeyCache.getOrFetch(nil, "test-hash")
// Now test via resolveAuthBearer with the hash lookup
r := httptest.NewRequest("GET", "/v1/agents", nil)
// Directly test with the resolved key
key, role := pkgAPIKeyCache.getOrFetch(nil, "test-hash")
if key == nil {
t.Fatal("expected key from cache")
}
_ = r
if role != permissions.RoleViewer {
t.Errorf("role = %v, want viewer for read scope", role)
}
}
func TestResolveAuth_APIKeyAdminScope(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["admin-hash"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.admin"},
}
pkgAPIKeyCache = newAPIKeyCache(ms, 5*time.Minute)
defer func() { pkgAPIKeyCache = nil }()
key, role := pkgAPIKeyCache.getOrFetch(nil, "admin-hash")
if key == nil {
t.Fatal("expected key from cache")
}
if role != permissions.RoleAdmin {
t.Errorf("role = %v, want admin", role)
}
}
func TestResolveAuth_APIKeyWriteScope(t *testing.T) {
ms := newMockAPIKeyStore()
ms.keys["write-hash"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.write"},
}
pkgAPIKeyCache = newAPIKeyCache(ms, 5*time.Minute)
defer func() { pkgAPIKeyCache = nil }()
key, role := pkgAPIKeyCache.getOrFetch(nil, "write-hash")
if key == nil {
t.Fatal("expected key from cache")
}
if role != permissions.RoleOperator {
t.Errorf("role = %v, want operator for write scope", role)
}
}
func TestHttpMinRole(t *testing.T) {
tests := []struct {
method string
want permissions.Role
}{
{http.MethodGet, permissions.RoleViewer},
{http.MethodHead, permissions.RoleViewer},
{http.MethodOptions, permissions.RoleViewer},
{http.MethodPost, permissions.RoleOperator},
{http.MethodPut, permissions.RoleOperator},
{http.MethodPatch, permissions.RoleOperator},
{http.MethodDelete, permissions.RoleOperator},
}
for _, tt := range tests {
got := httpMinRole(tt.method)
if got != tt.want {
t.Errorf("httpMinRole(%s) = %v, want %v", tt.method, got, tt.want)
}
}
}
func TestRequireAuth_Unauthorized(t *testing.T) {
setupTestCache(t, nil)
handler := requireAuth("secret", "", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/v1/agents", nil)
w := httptest.NewRecorder()
handler(w, r)
if w.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", w.Code)
}
}
func TestRequireAuth_GatewayTokenPasses(t *testing.T) {
setupTestCache(t, nil)
handler := requireAuth("secret", "", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/v1/agents", nil)
r.Header.Set("Authorization", "Bearer secret")
w := httptest.NewRecorder()
handler(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
}
func TestRequireAuth_InjectLocaleAndUserID(t *testing.T) {
setupTestCache(t, nil)
var gotLocale, gotUserID string
handler := requireAuth("secret", "", func(w http.ResponseWriter, r *http.Request) {
gotLocale = store.LocaleFromContext(r.Context())
gotUserID = store.UserIDFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/v1/agents", nil)
r.Header.Set("Authorization", "Bearer secret")
r.Header.Set("Accept-Language", "vi")
r.Header.Set("X-GoClaw-User-Id", "user123")
w := httptest.NewRecorder()
handler(w, r)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if gotLocale != "vi" {
t.Errorf("locale = %q, want 'vi'", gotLocale)
}
if gotUserID != "user123" {
t.Errorf("userID = %q, want 'user123'", gotUserID)
}
}
func TestRequireAuth_AdminRoleEnforced(t *testing.T) {
// No auth configured → admin role (dev/single-user mode) → admin endpoint accessible
setupTestCache(t, nil)
handler := requireAuth("", permissions.RoleAdmin, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("POST", "/v1/api-keys", nil)
w := httptest.NewRecorder()
handler(w, r)
// No token configured → admin role, admin endpoint → 200
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (no token = admin in dev mode)", w.Code)
}
}
func TestRequireAuth_AutoDetectRole_GET(t *testing.T) {
// No auth configured → operator role. GET needs viewer → passes.
setupTestCache(t, nil)
handler := requireAuth("", "", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
r := httptest.NewRequest("GET", "/v1/agents", nil)
w := httptest.NewRecorder()
handler(w, r)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200 (operator can access viewer endpoint)", w.Code)
}
}
func TestInitAPIKeyCache_PubsubInvalidation(t *testing.T) {
mb := bus.New()
ms := newMockAPIKeyStore()
ms.keys["pubsub-hash"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.read"},
}
// Save original and restore after test
origCache := pkgAPIKeyCache
defer func() { pkgAPIKeyCache = origCache }()
InitAPIKeyCache(ms, mb)
// Populate cache
key, _ := pkgAPIKeyCache.getOrFetch(nil, "pubsub-hash")
if key == nil {
t.Fatal("expected key after initial fetch")
}
if ms.getCalls() != 1 {
t.Fatalf("calls = %d, want 1", ms.getCalls())
}
// Broadcast cache invalidation
mb.Broadcast(bus.Event{
Name: "cache.invalidate",
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindAPIKeys, Key: "any"},
})
// Cache should be cleared, next fetch should hit store
pkgAPIKeyCache.getOrFetch(nil, "pubsub-hash")
if ms.getCalls() != 2 {
t.Errorf("calls after invalidation = %d, want 2", ms.getCalls())
}
}
func TestInitAPIKeyCache_IgnoresOtherKinds(t *testing.T) {
mb := bus.New()
ms := newMockAPIKeyStore()
ms.keys["other-hash"] = &store.APIKeyData{
ID: uuid.New(),
Scopes: []string{"operator.read"},
}
origCache := pkgAPIKeyCache
defer func() { pkgAPIKeyCache = origCache }()
InitAPIKeyCache(ms, mb)
// Populate cache
pkgAPIKeyCache.getOrFetch(nil, "other-hash")
// Broadcast a different kind
mb.Broadcast(bus.Event{
Name: "cache.invalidate",
Payload: bus.CacheInvalidatePayload{Kind: bus.CacheKindAgent, Key: "any"},
})
// Cache should NOT be cleared
pkgAPIKeyCache.getOrFetch(nil, "other-hash")
if ms.getCalls() != 1 {
t.Errorf("calls = %d, want 1 (non-api_keys kind should not invalidate)", ms.getCalls())
}
}