package httpserver import ( "context" "net/http" "net/http/httptest" "path/filepath" "runtime" "strconv" "testing" "time" "github.com/go-chi/chi/v5" "update_server/internal/apikeys" "update_server/internal/db" ) func TestAPIKeyMiddlewareEnforcesAuthPermissionAndProjectScope(t *testing.T) { t.Parallel() h, service, store := newAPIKeyMiddlewareTestHandler(t) ctx := context.Background() allowedProject, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: "Desktop App", Slug: "desktop-app"}) if err != nil { t.Fatalf("create allowed project: %v", err) } blockedProject, err := store.Projects.Create(ctx, db.CreateProjectParams{Name: "Mobile App", Slug: "mobile-app"}) if err != nil { t.Fatalf("create blocked project: %v", err) } validKey, err := service.Create(ctx, apikeys.CreateParams{ Name: "Download Clients", ScopeMode: db.ScopeModeProjectAllowList, CanDownload: true, ProjectIDs: []int64{allowedProject.ID}, }) if err != nil { t.Fatalf("create valid api key: %v", err) } noPermissionKey, err := service.Create(ctx, apikeys.CreateParams{ Name: "No Permission", ScopeMode: db.ScopeModeAllProjects, CanUpload: true, ProjectIDs: nil, }) if err != nil { t.Fatalf("create no-permission api key: %v", err) } expiredAt := time.Now().UTC().Add(-time.Hour) expiredKey, err := service.Create(ctx, apikeys.CreateParams{ Name: "Expired Key", ScopeMode: db.ScopeModeAllProjects, CanDownload: true, ExpiresAt: &expiredAt, }) if err != nil { t.Fatalf("create expired api key: %v", err) } router := chi.NewRouter() router.Route("/api", func(r chi.Router) { r.Group(func(r chi.Router) { r.Use(h.requireAPIKey) r.Use(h.requireAPIKeyPermission(apikeys.PermissionDownload)) r.With(h.requireAPIKeyProjectAccess("projectID")).Get("/projects/{projectID}", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true}) }) }) }) recorder := performMiddlewareRequest(router, "/api/projects/1", "") if recorder.Code != http.StatusUnauthorized { t.Fatalf("expected missing auth to return 401, got %d", recorder.Code) } if header := recorder.Header().Get("WWW-Authenticate"); header == "" { t.Fatal("expected missing auth to include WWW-Authenticate header") } recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+noPermissionKey.RawKey) if recorder.Code != http.StatusForbidden { t.Fatalf("expected missing permission to return 403, got %d", recorder.Code) } recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, blockedProject.ID), "Bearer "+validKey.RawKey) if recorder.Code != http.StatusForbidden { t.Fatalf("expected blocked project to return 403, got %d", recorder.Code) } recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+expiredKey.RawKey) if recorder.Code != http.StatusUnauthorized { t.Fatalf("expected expired key to return 401, got %d", recorder.Code) } recorder = performMiddlewareRequest(router, "/api/projects/"+itoa(t, allowedProject.ID), "Bearer "+validKey.RawKey) if recorder.Code != http.StatusOK { t.Fatalf("expected allowed project to return 200, got %d with body %s", recorder.Code, recorder.Body.String()) } } func newAPIKeyMiddlewareTestHandler(t *testing.T) (*handler, *apikeys.Service, *db.Store) { t.Helper() ctx := context.Background() sqlitePath := filepath.Join(t.TempDir(), "http-api-keys.sqlite") database, err := db.Open(ctx, sqlitePath) if err != nil { t.Fatalf("open sqlite: %v", err) } if err := db.Migrate(ctx, database, middlewareProjectPath(t, "migrations")); err != nil { _ = database.Close() t.Fatalf("migrate sqlite: %v", err) } store := db.NewStore(database) t.Cleanup(func() { _ = store.Close() }) service := apikeys.NewService(store) return &handler{ store: store, apiKeys: service, }, service, store } func middlewareProjectPath(t *testing.T, parts ...string) string { t.Helper() _, filename, _, ok := runtime.Caller(0) if !ok { t.Fatal("resolve caller path") } root := filepath.Join(filepath.Dir(filename), "..", "..") items := append([]string{root}, parts...) return filepath.Join(items...) } func performMiddlewareRequest(router http.Handler, target, authorization string) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodGet, target, nil) req.RemoteAddr = "127.0.0.1:12345" if authorization != "" { req.Header.Set("Authorization", authorization) } recorder := httptest.NewRecorder() router.ServeHTTP(recorder, req) return recorder } func itoa(t *testing.T, value int64) string { t.Helper() return strconv.FormatInt(value, 10) }