package handlers import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/auth" "github.com/orchard9/rdev/internal/testutil" ) // TestKeysHandler requires a database connection. // Tests are skipped if the database is not available. func setupKeysHandler(t *testing.T) (*KeysHandler, chi.Router, *auth.Service) { db := testutil.TestDB(t) t.Cleanup(func() { testutil.CleanupTestKeys(t, db) }) authService := auth.NewService(db, "test-admin-key") handler := NewKeysHandler(authService) router := chi.NewRouter() // For tests, we'll mount without the auth middleware // since we're testing the handler logic, not auth router.Route("/keys", func(r chi.Router) { r.Get("/", handler.List) r.Post("/", handler.Create) r.Get("/{id}", handler.Get) r.Delete("/{id}", handler.Revoke) }) return handler, router, authService } func TestKeysHandler_List(t *testing.T) { _, router, authService := setupKeysHandler(t) // Create some test keys for i := 0; i < 3; i++ { _, err := authService.Create(context.Background(), auth.CreateKeyRequest{ Name: "test-handler-list-" + string(rune('a'+i)), Scopes: []auth.Scope{auth.ScopeProjectsRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Failed to create test key: %v", err) } } req := httptest.NewRequest("GET", "/keys", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("Status = %d, want 200. Body: %s", rec.Code, rec.Body.String()) } var resp map[string]any if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("Failed to decode response: %v", err) } data, ok := resp["data"].([]any) if !ok { t.Fatal("Response data is not an array") } // Should have at least 3 keys if len(data) < 3 { t.Errorf("Expected at least 3 keys, got %d", len(data)) } } func TestKeysHandler_Create(t *testing.T) { _, router, _ := setupKeysHandler(t) tests := []struct { name string body CreateKeyRequest wantStatus int wantErr string }{ { name: "valid key", body: CreateKeyRequest{ Name: "test-create-key", Scopes: []string{"projects:read"}, ExpiresIn: "30d", }, wantStatus: http.StatusCreated, }, { name: "missing name", body: CreateKeyRequest{ Scopes: []string{"projects:read"}, }, wantStatus: http.StatusBadRequest, wantErr: "name: is required", }, { name: "missing scopes", body: CreateKeyRequest{ Name: "test-no-scopes", }, wantStatus: http.StatusBadRequest, wantErr: "scopes: is required", }, { name: "invalid scope", body: CreateKeyRequest{ Name: "test-invalid-scope", Scopes: []string{"invalid:scope"}, }, wantStatus: http.StatusBadRequest, wantErr: "invalid scope", }, { name: "invalid expiration", body: CreateKeyRequest{ Name: "test-invalid-exp", Scopes: []string{"projects:read"}, ExpiresIn: "invalid", }, wantStatus: http.StatusBadRequest, wantErr: "expiration", }, { name: "with project restrictions", body: CreateKeyRequest{ Name: "test-with-projects", Scopes: []string{"projects:read"}, ProjectIDs: []string{"proj-a", "proj-b"}, ExpiresIn: "90d", }, wantStatus: http.StatusCreated, }, { name: "never expires", body: CreateKeyRequest{ Name: "test-never-expires", Scopes: []string{"admin"}, ExpiresIn: "never", }, wantStatus: http.StatusCreated, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { body, _ := json.Marshal(tt.body) req := httptest.NewRequest("POST", "/keys", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != tt.wantStatus { t.Errorf("Status = %d, want %d. Body: %s", rec.Code, tt.wantStatus, rec.Body.String()) } if tt.wantErr != "" { if !strings.Contains(rec.Body.String(), tt.wantErr) { t.Errorf("Body = %q, want to contain %q", rec.Body.String(), tt.wantErr) } } // For successful creates, verify the response structure if tt.wantStatus == http.StatusCreated { var resp map[string]any json.NewDecoder(bytes.NewReader(rec.Body.Bytes())).Decode(&resp) data, _ := resp["data"].(map[string]any) if data["secret"] == nil || data["secret"] == "" { t.Error("Response should include secret") } if data["key"] == nil { t.Error("Response should include key object") } } }) } } func TestKeysHandler_Get(t *testing.T) { _, router, authService := setupKeysHandler(t) // Create a key to get result, err := authService.Create(context.Background(), auth.CreateKeyRequest{ Name: "test-handler-get", Scopes: []auth.Scope{auth.ScopeProjectsRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Failed to create test key: %v", err) } t.Run("existing key", func(t *testing.T) { req := httptest.NewRequest("GET", "/keys/"+result.Key.ID, nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("Status = %d, want 200. Body: %s", rec.Code, rec.Body.String()) } var resp map[string]any json.NewDecoder(rec.Body).Decode(&resp) data, _ := resp["data"].(map[string]any) if data["name"] != "test-handler-get" { t.Errorf("Name = %v, want test-handler-get", data["name"]) } }) t.Run("non-existent key", func(t *testing.T) { req := httptest.NewRequest("GET", "/keys/00000000-0000-0000-0000-000000000000", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("Status = %d, want 404", rec.Code) } }) } func TestKeysHandler_Revoke(t *testing.T) { _, router, authService := setupKeysHandler(t) // Create a key to revoke result, err := authService.Create(context.Background(), auth.CreateKeyRequest{ Name: "test-handler-revoke", Scopes: []auth.Scope{auth.ScopeProjectsRead}, ExpiresIn: 24 * time.Hour, CreatedBy: "test", }) if err != nil { t.Fatalf("Failed to create test key: %v", err) } t.Run("revoke existing key", func(t *testing.T) { req := httptest.NewRequest("DELETE", "/keys/"+result.Key.ID, nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Errorf("Status = %d, want 200. Body: %s", rec.Code, rec.Body.String()) } var resp map[string]any json.NewDecoder(rec.Body).Decode(&resp) data, _ := resp["data"].(map[string]any) if data["status"] != "revoked" { t.Errorf("Status = %v, want revoked", data["status"]) } // Verify the key is actually revoked _, err := authService.Validate(context.Background(), result.Secret) if err != auth.ErrKeyRevoked { t.Errorf("Key should be revoked, got err = %v", err) } }) t.Run("revoke non-existent key", func(t *testing.T) { req := httptest.NewRequest("DELETE", "/keys/00000000-0000-0000-0000-000000000000", nil) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusNotFound { t.Errorf("Status = %d, want 404", rec.Code) } }) } func TestKeysHandler_InvalidJSON(t *testing.T) { _, router, _ := setupKeysHandler(t) req := httptest.NewRequest("POST", "/keys", strings.NewReader("invalid json{")) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusBadRequest { t.Errorf("Status = %d, want 400", rec.Code) } if !strings.Contains(rec.Body.String(), "Invalid JSON") { t.Errorf("Body = %q, want to contain 'Invalid JSON'", rec.Body.String()) } } func TestApiKeyToResponse(t *testing.T) { now := time.Now() future := now.Add(24 * time.Hour) key := &auth.APIKey{ ID: "test-id", Name: "test-name", KeyPrefix: "rdev_sk_abc", Scopes: []auth.Scope{auth.ScopeProjectsRead, auth.ScopeProjectsExecute}, ProjectIDs: []string{"proj-a"}, CreatedAt: now, ExpiresAt: &future, LastUsedAt: &now, CreatedBy: "test-user", } resp := apiKeyToResponse(key) if resp.ID != "test-id" { t.Errorf("ID = %q, want test-id", resp.ID) } if resp.Name != "test-name" { t.Errorf("Name = %q, want test-name", resp.Name) } if len(resp.Scopes) != 2 { t.Errorf("Scopes length = %d, want 2", len(resp.Scopes)) } if len(resp.ProjectIDs) != 1 { t.Errorf("ProjectIDs length = %d, want 1", len(resp.ProjectIDs)) } if resp.ExpiresAt == nil { t.Error("ExpiresAt should not be nil") } if resp.LastUsedAt == nil { t.Error("LastUsedAt should not be nil") } if !resp.Active { t.Error("Active should be true") } }