From 1bd43e8de52074a787a39aa52dd05626fd9f4885 Mon Sep 17 00:00:00 2001 From: matthewpeterkort Date: Thu, 12 Feb 2026 08:59:55 -0800 Subject: [PATCH 1/6] various updates / bug fixes --- gecko/handleDir.go | 26 +++- gecko/middleware.go | 76 ++++++++++-- gecko/server.go | 4 + tests/integration/middleware_test.go | 172 +++++++++++++++++++++++++++ 4 files changed, 264 insertions(+), 14 deletions(-) diff --git a/gecko/handleDir.go b/gecko/handleDir.go index 92be113..b665a7c 100644 --- a/gecko/handleDir.go +++ b/gecko/handleDir.go @@ -4,7 +4,6 @@ import ( "fmt" "net/http" "path" - "slices" "strings" "github.com/bmeg/grip-graphql/middleware" @@ -31,7 +30,15 @@ func (server *Server) handleListProjects(ctx iris.Context) { return } server.Logger.Info("projects: %s", projs) - q := gripql.V().HasLabel("ResearchStudy").Has(gripql.Within("auth_resource_path", projs...)).As("f0").Render(map[string]any{"project": "$f0.auth_resource_path"}) + q := gripql.V(). + HasLabel("ResearchStudy"). + Has(gripql.Within("auth_resource_path", projs...)). + As("project"). + OutE("rootDir_Directory"). // Only keep projects that have a root directory + Select("project"). // Go back to project + Distinct("auth_resource_path"). + Render(map[string]any{"project": "$project.auth_resource_path"}) + res, err := server.gripqlClient.Traversal( ctx, &gripql.GraphQuery{Graph: server.gripGraphName, Query: q.Statements}, @@ -49,9 +56,7 @@ func (server *Server) handleListProjects(ctx iris.Context) { if !ok { continue } - if !slices.Contains(out, renda) { - out = append(out, renda) - } + out = append(out, renda) } jsonResponseFrom(out, 200).write(ctx) } @@ -97,11 +102,20 @@ func (server *Server) handleDirGet(ctx iris.Context) { projectId = "/programs/" + project_split[0] + "/projects/" + project_split[1] // Shouldn't have to filter on base query because rootDir_Directory edge only ever connects to the root directory + // Start traversal from the project q := gripql.V().HasLabel("ResearchStudy").Has(gripql.Eq("auth_resource_path", projectId)).OutE("rootDir_Directory").OutNull().OutNull() if dirPath != "/" { for splStr := range strings.SplitSeq(strings.Trim(dirPath, "/"), "/") { - q = q.Has(gripql.Eq("name", splStr)).OutNull() + // Traverse to child directory + // IMPORTANT: Filter by auth_resource_path at EACH step to ensure we stay within the project's ownership. + // This prevents bleeding into directories with the same name but different project ownership. + q = q.Has(gripql.Eq("name", splStr)). + Has(gripql.Eq("auth_resource_path", projectId)). + OutNull() } + } else { + // Even for root, ensure the returned node belongs to the project (extra safety) + q = q.Has(gripql.Eq("auth_resource_path", projectId)) } server.Logger.Info("Executing query: %s", q.String()) diff --git a/gecko/middleware.go b/gecko/middleware.go index 0f3dc06..89f89c3 100644 --- a/gecko/middleware.go +++ b/gecko/middleware.go @@ -1,6 +1,7 @@ package gecko import ( + "encoding/json" "fmt" "net/http" "slices" @@ -8,6 +9,7 @@ import ( "time" "github.com/bmeg/grip-graphql/middleware" + "github.com/calypr/gecko/gecko/config" "github.com/kataras/iris/v12" ) @@ -30,7 +32,7 @@ func (server *Server) GetProjectsFromToken(ctx iris.Context, jwtHandler middlewa fmt.Println("ERR: ", err) val, ok := err.(*middleware.ServerError) if !ok { - return nil, newErrorResponse(fmt.Sprintf("expecting error to be serverError type"), http.StatusNotFound, nil) + return nil, newErrorResponse("expecting error to be serverError type", http.StatusNotFound, nil) } return nil, newErrorResponse(val.Message, val.StatusCode, nil) @@ -125,11 +127,6 @@ func (server *Server) ConfigAuthMiddleware(jwtHandler middleware.JWTHandler) iri } } -func ConfigIDToProjectIDMware(ctx iris.Context) { - configID := ctx.Params().Get("configId") - ctx.Params().Set("projectId", configID) -} - func (server *Server) GeneralAuthMware(jwtHandler middleware.JWTHandler, method, service string) iris.Handler { return func(ctx iris.Context) { authorizationHeader := ctx.GetHeader("Authorization") @@ -154,7 +151,7 @@ func (server *Server) GeneralAuthMware(jwtHandler middleware.JWTHandler, method, if err != nil { val, ok := err.(*middleware.ServerError) if !ok { - errResponse := newErrorResponse(fmt.Sprintf("expecting error to be serverError type"), http.StatusNotFound, nil) + errResponse := newErrorResponse("expecting error to be serverError type", http.StatusNotFound, nil) errResponse.log.write(server.Logger) _ = errResponse.write(ctx) ctx.StopExecution() @@ -209,7 +206,7 @@ func (server *Server) BaseConfigsAuthMiddleware(jwtHandler middleware.JWTHandler if err != nil { val, ok := err.(*middleware.ServerError) if !ok { - errResponse := newErrorResponse(fmt.Sprintf("expecting error to be serverError type"), http.StatusNotFound, nil) + errResponse := newErrorResponse("expecting error to be serverError type", http.StatusNotFound, nil) errResponse.log.write(server.Logger) _ = errResponse.write(ctx) ctx.StopExecution() @@ -231,3 +228,66 @@ func (server *Server) BaseConfigsAuthMiddleware(jwtHandler middleware.JWTHandler ctx.Next() } } + +func (server *Server) AppCardAuthMiddleware(jwtHandler middleware.JWTHandler) iris.Handler { + return func(ctx iris.Context) { + method := ctx.Method() + + var permMethod string + switch method { + case "GET": + permMethod = "read" + case "POST", "DELETE": + permMethod = "create" + default: + errResp := newErrorResponse( + fmt.Sprintf("Unsupported HTTP method %s", method), + http.StatusMethodNotAllowed, nil, + ) + errResp.log.write(server.Logger) + _ = errResp.write(ctx) + return + } + + var projectId string + + if method == "GET" || method == "DELETE" { + projectId = ctx.Params().Get("projectId") + } else { // POST + body, err := ctx.GetBody() + if err != nil { + errResponse := newErrorResponse("Failed to read request body", 500, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + var card config.AppCard + if err := json.Unmarshal(body, &card); err != nil { + errResponse := newErrorResponse("Invalid JSON in body", 400, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + projectId = card.Perms + } + + if projectId == "" { + errResponse := newErrorResponse("Missing or empty projectId (from perms)", 400, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + // Set projectId for GeneralAuthMware to check permissions + ctx.Params().Set("projectId", projectId) + + authHandler := server.GeneralAuthMware(jwtHandler, permMethod, "*") + authHandler(ctx) + + if ctx.IsStopped() { + return + } + + ctx.Next() + } +} diff --git a/gecko/server.go b/gecko/server.go index 28dda1a..247cab5 100644 --- a/gecko/server.go +++ b/gecko/server.go @@ -111,6 +111,10 @@ func (server *Server) MakeRouter() *iris.Application { router.Get("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigGET) router.Put("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigPUT) router.Delete("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) + + router.Get("/config/apps_page/appcard/{projectId}", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardGET) + router.Post("/config/apps_page/appcard", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardPOST) + router.Delete("/config/apps_page/appcard/{projectId}", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardDELETE) } else { server.Logger.Warning("Skipping DB endpoints — no database configured") } diff --git a/tests/integration/middleware_test.go b/tests/integration/middleware_test.go index 7fd4cf3..22e71ae 100644 --- a/tests/integration/middleware_test.go +++ b/tests/integration/middleware_test.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "fmt" "log" "net/http" @@ -217,3 +218,174 @@ func TestBaseConfigsAuthMiddleware_InvalidJWTHandler(t *testing.T) { assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.Contains(t, rec.Body.String(), "Invalid JWT handler configuration") } + +// TestAppCardAuthMiddleware_NoAuthorization +func TestAppCardAuthMiddleware_NoAuthorization(t *testing.T) { + mockJWT := &MockJWTHandler{} + srv := setupServer() + mware := srv.AppCardAuthMiddleware(mockJWT) + + req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/HTAN_INT-BForePC", nil) + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "HTAN_INT-BForePC") // would be set by path in real route + + mware(ctx) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "Authorization token not provided") +} + +// TestAppCardAuthMiddleware_GET_Success +func TestAppCardAuthMiddleware_GET_Success(t *testing.T) { + mockJWT := &MockJWTHandler{ + AllowedResources: []string{"/programs/HTAN_INT/projects/BForePC"}, + } + srv := setupServer() + mware := srv.AppCardAuthMiddleware(mockJWT) + + req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/HTAN_INT-BForePC", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "HTAN_INT-BForePC") + + mware(ctx) + + assert.False(t, ctx.IsStopped(), "Middleware should allow request to continue") + assert.Equal(t, http.StatusOK, rec.Code) // Middleware let it through, handler (default) returns 200 +} + +// TestAppCardAuthMiddleware_GET_Denied +func TestAppCardAuthMiddleware_GET_Denied(t *testing.T) { + mockJWT := &MockJWTHandler{ + AllowedResources: []string{"/programs/other/projects/wrong"}, + } + srv := setupServer() + mware := srv.AppCardAuthMiddleware(mockJWT) + + req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/HTAN_INT-BForePC", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "HTAN_INT-BForePC") + + mware(ctx) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Contains(t, rec.Body.String(), "User is not allowed to read on resource path") +} + +// TestAppCardAuthMiddleware_POST_MissingPermsInBody +func TestAppCardAuthMiddleware_POST_MissingPermsInBody(t *testing.T) { + mockJWT := &MockJWTHandler{} + srv := setupServer() + mware := srv.AppCardAuthMiddleware(mockJWT) + + body := `{"title": "Test", "description": "desc", "icon": "/icon.svg", "href": "/link"}` // missing perms + req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard", bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer dummy") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + + mware(ctx) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "Missing or empty projectId (from perms)") +} + +// TestAppCardAuthMiddleware_POST_Success +func TestAppCardAuthMiddleware_POST_Success(t *testing.T) { + mockJWT := &MockJWTHandler{ + AllowedResources: []string{"/programs/HTAN_INT/projects/BForePC"}, + } + srv := setupServer() + mware := srv.AppCardAuthMiddleware(mockJWT) + + body := `{ + "title": "Explore BForePC", + "description": "Explore data", + "icon": "/icons/binoculars.svg", + "href": "/Explorer/HTAN_INT-BForePC", + "perms": "HTAN_INT-BForePC" + }` + req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard", bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer dummy") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + + mware(ctx) + + assert.False(t, ctx.IsStopped()) + assert.Equal(t, http.StatusOK, rec.Code) // Middleware let it through, handler (default) returns 200 +} + +// TestAppCardAuthMiddleware_POST_Denied +func TestAppCardAuthMiddleware_POST_Denied(t *testing.T) { + mockJWT := &MockJWTHandler{ + AllowedResources: []string{"/programs/other/projects/wrong"}, + } + srv := setupServer() + mware := srv.AppCardAuthMiddleware(mockJWT) + + body := `{ + "title": "Explore BForePC", + "perms": "HTAN_INT-BForePC" + }` + req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard", bytes.NewBufferString(body)) + req.Header.Set("Authorization", "Bearer dummy") + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + + mware(ctx) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.Contains(t, rec.Body.String(), "User is not allowed to create on resource path") +} + +// TestAppCardAuthMiddleware_DELETE_Success +func TestAppCardAuthMiddleware_DELETE_Success(t *testing.T) { + mockJWT := &MockJWTHandler{ + AllowedResources: []string{"/programs/HTAN_INT/projects/BForePC"}, + } + srv := setupServer() + mware := srv.AppCardAuthMiddleware(mockJWT) + + req := httptest.NewRequest(http.MethodDelete, "/config/apps_page/appcard/HTAN_INT-BForePC", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "HTAN_INT-BForePC") + + mware(ctx) + + assert.False(t, ctx.IsStopped()) +} + +// TestAppCardAuthMiddleware_UnsupportedMethod +func TestAppCardAuthMiddleware_UnsupportedMethod(t *testing.T) { + mockJWT := &MockJWTHandler{} + srv := setupServer() + mware := srv.AppCardAuthMiddleware(mockJWT) + + req := httptest.NewRequest(http.MethodPatch, "/config/apps_page/appcard/something", nil) + req.Header.Set("Authorization", "Bearer dummy") + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + + mware(ctx) + + assert.Equal(t, http.StatusMethodNotAllowed, rec.Code) + assert.Contains(t, rec.Body.String(), "Unsupported HTTP method") +} From dd3a8cec53a149c5086620bf5bcb481b14b52403 Mon Sep 17 00:00:00 2001 From: matthewpeterkort Date: Thu, 12 Feb 2026 09:15:08 -0800 Subject: [PATCH 2/6] fix build err, add tests --- gecko/handleAppCard.go | 208 ++++++++++++++++++++++++++++++++++++ gecko/handleAppCard_test.go | 132 +++++++++++++++++++++++ gecko/handleDir.go | 57 +++++----- gecko/handleDir_test.go | 55 ++++++++++ go.mod | 3 +- go.sum | 15 +-- 6 files changed, 432 insertions(+), 38 deletions(-) create mode 100644 gecko/handleAppCard.go create mode 100644 gecko/handleAppCard_test.go create mode 100644 gecko/handleDir_test.go diff --git a/gecko/handleAppCard.go b/gecko/handleAppCard.go new file mode 100644 index 0000000..9c72e96 --- /dev/null +++ b/gecko/handleAppCard.go @@ -0,0 +1,208 @@ +package gecko + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/calypr/gecko/gecko/config" + "github.com/kataras/iris/v12" +) + +// handleAppCardGET godoc +// @Summary Get a specific AppCard by projectId (perms) +// @Description Retrieves a single AppCard from the apps_page configuration by its perms value (used as projectId). +// @Tags Config +// @Produce json +// @Param projectId path string true "Project ID (AppCard perms value, e.g., HTAN_INT-BForePC)" +// @Success 200 {object} config.AppCard "The requested AppCard" +// @Failure 404 {object} ErrorResponse "AppCard not found" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /config/apps_page/appcard/{projectId} [get] +func (server *Server) handleAppCardGET(ctx iris.Context) { + configType := "apps_page" + configId := "default" // Adjust if you use dynamic configId + + projectId := ctx.Params().Get("projectId") + if projectId == "" { + errResponse := newErrorResponse("Missing projectId parameter", 400, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + var currentCfg config.AppsConfig + err := configGETGeneric(server.db, configId, configType, ¤tCfg) + if errors.Is(err, sql.ErrNoRows) { + // No config exists yet → no AppCards + msg := fmt.Sprintf("AppCard with projectId (perms) %s not found (no config exists)", projectId) + errResponse := newErrorResponse(msg, 404, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + if err != nil { + msg := fmt.Sprintf("Failed to retrieve apps_page config: %s", err) + errResponse := newErrorResponse(msg, 500, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + // Find the matching AppCard by Perms + for _, card := range currentCfg.AppCards { + if card.Perms == projectId { + jsonResponseFrom(card, http.StatusOK).write(ctx) + return + } + } + + // Not found + msg := fmt.Sprintf("AppCard with projectId (perms) %s not found", projectId) + errResponse := newErrorResponse(msg, 404, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) +} + +// handleAppCardPOST godoc +// @Summary Add or update an AppCard +// @Description Adds a new AppCard to the apps_page configuration or updates an existing one if the perms matches. Assumes a fixed configId "default" for apps_page. +// @Tags Config +// @Accept json +// @Produce json +// @Param body body config.AppCard true "AppCard details" +// @Success 200 {object} map[string]interface{} "AppCard added or updated" +// @Failure 400 {object} ErrorResponse "Invalid request body" +// @Failure 404 {object} ErrorResponse "Config not found (if required)" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /config/apps_page/appcard [post] +func (server *Server) handleAppCardPOST(ctx iris.Context) { + configType := "apps_page" + configId := "default" // Hardcoded assumption; adjust if multiple configs exist + + var currentCfg config.AppsConfig + err := configGETGeneric(server.db, configId, configType, ¤tCfg) + if errors.Is(err, sql.ErrNoRows) { + // Initialize empty if not found + currentCfg = config.AppsConfig{AppCards: []config.AppCard{}} + } else if err != nil { + msg := fmt.Sprintf("Failed to get apps_page config: %s", err) + errResponse := newErrorResponse(msg, 500, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + // Body already read in middleware; GetBody() returns cached version + body, _ := ctx.GetBody() + var newCard config.AppCard + if err := json.Unmarshal(body, &newCard); err != nil { + msg := "Invalid JSON format" + errResponse := newErrorResponse(msg, 400, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + // Update if Perms exists, else append + updated := false + for i := range currentCfg.AppCards { + if currentCfg.AppCards[i].Perms == newCard.Perms { + currentCfg.AppCards[i] = newCard + updated = true + break + } + } + if !updated { + currentCfg.AppCards = append(currentCfg.AppCards, newCard) + } + + // Save the updated config + err = configPUTGeneric(server.db, configId, configType, ¤tCfg) + if err != nil { + msg := fmt.Sprintf("Failed to update apps_page config: %s", err) + errResponse := newErrorResponse(msg, 500, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + jsonResponseFrom( + map[string]any{ + "code": 200, + "message": fmt.Sprintf("AppCard with perms %s added or updated", newCard.Perms), + }, + http.StatusOK, + ).write(ctx) +} + +// handleAppCardDELETE godoc +// @Summary Delete an AppCard +// @Description Deletes an AppCard from the apps_page configuration by projectId (perms). Assumes a fixed configId "default" for apps_page. +// @Tags Config +// @Produce json +// @Param projectId path string true "Project ID (AppCard perms value, e.g., HTAN_INT-BForePC)" +// @Success 200 {object} map[string]interface{} "AppCard deleted" +// @Failure 404 {object} ErrorResponse "AppCard or config not found" +// @Failure 500 {object} ErrorResponse "Server error" +// @Router /config/apps_page/appcard/{projectId} [delete] +func (server *Server) handleAppCardDELETE(ctx iris.Context) { + configType := "apps_page" + configId := "default" // Hardcoded assumption; adjust if multiple configs exist + projectId := ctx.Params().Get("projectId") + + var currentCfg config.AppsConfig + err := configGETGeneric(server.db, configId, configType, ¤tCfg) + if errors.Is(err, sql.ErrNoRows) { + msg := "No apps_page config found" + errResponse := newErrorResponse(msg, 404, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } else if err != nil { + msg := fmt.Sprintf("Failed to get apps_page config: %s", err) + errResponse := newErrorResponse(msg, 500, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + // Remove the matching AppCard by Perms + newCards := []config.AppCard{} + found := false + for _, card := range currentCfg.AppCards { + if card.Perms == projectId { + found = true + continue + } + newCards = append(newCards, card) + } + if !found { + msg := fmt.Sprintf("AppCard with projectId (perms) %s not found", projectId) + errResponse := newErrorResponse(msg, 404, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + currentCfg.AppCards = newCards + + // Save the updated config + err = configPUTGeneric(server.db, configId, configType, ¤tCfg) + if err != nil { + msg := fmt.Sprintf("Failed to update apps_page config: %s", err) + errResponse := newErrorResponse(msg, 500, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } + + jsonResponseFrom( + map[string]any{ + "code": 200, + "message": fmt.Sprintf("AppCard with perms %s deleted", projectId), + }, + http.StatusOK, + ).write(ctx) +} diff --git a/gecko/handleAppCard_test.go b/gecko/handleAppCard_test.go new file mode 100644 index 0000000..44991a4 --- /dev/null +++ b/gecko/handleAppCard_test.go @@ -0,0 +1,132 @@ +package gecko + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/calypr/gecko/gecko/config" + "github.com/jmoiron/sqlx" + "github.com/kataras/iris/v12" + "github.com/stretchr/testify/assert" +) + +func TestHandleAppCardGET_Success(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + // Create a server instance with the mock DB and initialized Logger + srv := &Server{ + db: sqlxDB, + Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, + } + + // Create query response for AppCards config + // The handler calls configGETGeneric which does a SELECT + rows := sqlmock.NewRows([]string{"name", "content"}). + AddRow("default", []byte(`{"appCards": [{"perms": "PROG-PROJ", "title": "Test Card"}]}`)) + + // Using regex to match the query in sql.go: SELECT name, content FROM config_schema.apps_page WHERE name=$1 + mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). + WithArgs("default"). + WillReturnRows(rows) + + req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/PROG-PROJ", nil) + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "PROG-PROJ") + + srv.handleAppCardGET(ctx) + + assert.Equal(t, http.StatusOK, rec.Code) + + var card config.AppCard + json.Unmarshal(rec.Body.Bytes(), &card) + assert.Equal(t, "PROG-PROJ", card.Perms) + assert.Equal(t, "Test Card", card.Title) + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } +} + +func TestHandleAppCardGET_NotFound(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + srv := &Server{ + db: sqlxDB, + Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, + } + + // Case 1: Config exists but card not found + rows := sqlmock.NewRows([]string{"name", "content"}). + AddRow("default", []byte(`{"appCards": [{"perms": "OTHER-PROJ"}]}`)) + + mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). + WithArgs("default"). + WillReturnRows(rows) + + req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/PROG-PROJ", nil) + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "PROG-PROJ") + + srv.handleAppCardGET(ctx) + + // Should be 404 because card is not in the list + assert.Equal(t, http.StatusNotFound, rec.Code) +} + +func TestHandleAppCardPOST_Update(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + srv := &Server{ + db: sqlxDB, + Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, + } + + // Initial state: one card + rows := sqlmock.NewRows([]string{"name", "content"}). + AddRow("default", []byte(`{"appCards": [{"perms": "PROG-PROJ", "title": "Old Title"}]}`)) + + mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). + WithArgs("default"). + WillReturnRows(rows) + + // Expect UPDATE with modified data + mock.ExpectExec("INSERT INTO config_schema.apps_page"). + WithArgs("default", sqlmock.AnyArg()). // We can't easily match JSON blob exactly without regex, AnyArg is safer for smoke test + WillReturnResult(sqlmock.NewResult(1, 1)) + + body := `{"perms": "PROG-PROJ", "title": "New Title"}` + req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard", bytes.NewBufferString(body)) + rec := httptest.NewRecorder() + app := iris.New() + ctx := app.ContextPool.Acquire(rec, req) + + srv.handleAppCardPOST(ctx) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "added or updated") +} diff --git a/gecko/handleDir.go b/gecko/handleDir.go index b665a7c..96cda73 100644 --- a/gecko/handleDir.go +++ b/gecko/handleDir.go @@ -30,14 +30,7 @@ func (server *Server) handleListProjects(ctx iris.Context) { return } server.Logger.Info("projects: %s", projs) - q := gripql.V(). - HasLabel("ResearchStudy"). - Has(gripql.Within("auth_resource_path", projs...)). - As("project"). - OutE("rootDir_Directory"). // Only keep projects that have a root directory - Select("project"). // Go back to project - Distinct("auth_resource_path"). - Render(map[string]any{"project": "$project.auth_resource_path"}) + q := buildListProjectsQuery(projs) res, err := server.gripqlClient.Traversal( ctx, @@ -101,22 +94,7 @@ func (server *Server) handleDirGet(ctx iris.Context) { } projectId = "/programs/" + project_split[0] + "/projects/" + project_split[1] - // Shouldn't have to filter on base query because rootDir_Directory edge only ever connects to the root directory - // Start traversal from the project - q := gripql.V().HasLabel("ResearchStudy").Has(gripql.Eq("auth_resource_path", projectId)).OutE("rootDir_Directory").OutNull().OutNull() - if dirPath != "/" { - for splStr := range strings.SplitSeq(strings.Trim(dirPath, "/"), "/") { - // Traverse to child directory - // IMPORTANT: Filter by auth_resource_path at EACH step to ensure we stay within the project's ownership. - // This prevents bleeding into directories with the same name but different project ownership. - q = q.Has(gripql.Eq("name", splStr)). - Has(gripql.Eq("auth_resource_path", projectId)). - OutNull() - } - } else { - // Even for root, ensure the returned node belongs to the project (extra safety) - q = q.Has(gripql.Eq("auth_resource_path", projectId)) - } + q := buildDirGetQuery(projectId, dirPath) server.Logger.Info("Executing query: %s", q.String()) @@ -155,3 +133,34 @@ func isValidPosixPath(p *string) bool { } return true } + +func buildListProjectsQuery(projs []any) *gripql.Query { + return gripql.V(). + HasLabel("ResearchStudy"). + Has(gripql.Within("auth_resource_path", projs...)). + As("project"). + OutE("rootDir_Directory"). // Only keep projects that have a root directory + Select("project"). // Go back to project + Distinct("auth_resource_path"). + Render(map[string]any{"project": "$project.auth_resource_path"}) +} + +func buildDirGetQuery(projectId, dirPath string) *gripql.Query { + // Shouldn't have to filter on base query because rootDir_Directory edge only ever connects to the root directory + // Start traversal from the project + q := gripql.V().HasLabel("ResearchStudy").Has(gripql.Eq("auth_resource_path", projectId)).OutE("rootDir_Directory").OutNull().OutNull() + if dirPath != "/" { + for splStr := range strings.SplitSeq(strings.Trim(dirPath, "/"), "/") { + // Traverse to child directory + // IMPORTANT: Filter by auth_resource_path at EACH step to ensure we stay within the project's ownership. + // This prevents bleeding into directories with the same name but different project ownership. + q = q.Has(gripql.Eq("name", splStr)). + Has(gripql.Eq("auth_resource_path", projectId)). + OutNull() + } + } else { + // Even for root, ensure the returned node belongs to the project (extra safety) + q = q.Has(gripql.Eq("auth_resource_path", projectId)) + } + return q +} diff --git a/gecko/handleDir_test.go b/gecko/handleDir_test.go new file mode 100644 index 0000000..044d3f5 --- /dev/null +++ b/gecko/handleDir_test.go @@ -0,0 +1,55 @@ +package gecko + +import ( + "encoding/json" + "testing" + + "github.com/bmeg/grip/gripql" + "github.com/stretchr/testify/assert" +) + +// Helper to convert query to string for assertion +func queryString(q *gripql.Query) string { + b, _ := json.Marshal(q.Statements) + return string(b) +} + +func TestBuildListProjectsQuery(t *testing.T) { + projs := []any{"PROG-PROJ1", "PROG-PROJ2"} + q := buildListProjectsQuery(projs) + + // Verify key components of the query + jsonStr := queryString(q) + assert.Contains(t, jsonStr, "ResearchStudy") + assert.Contains(t, jsonStr, "auth_resource_path") + assert.Contains(t, jsonStr, "rootDir_Directory") // Must have OutE + assert.Contains(t, jsonStr, "Distinct") // Must have Distinct +} + +func TestBuildDirGetQuery_Root(t *testing.T) { + projectId := "/programs/PROG/projects/PROJ" + dirPath := "/" + q := buildDirGetQuery(projectId, dirPath) + + jsonStr := queryString(q) + assert.Contains(t, jsonStr, "ResearchStudy") + // The query logic for root just filters by auth_resource_path + assert.Contains(t, jsonStr, "auth_resource_path") + assert.Contains(t, jsonStr, "rootDir_Directory") + // Verify it does NOT contain loop logic + assert.NotContains(t, jsonStr, "name") +} + +func TestBuildDirGetQuery_SubDir(t *testing.T) { + projectId := "/programs/PROG/projects/PROJ" + dirPath := "/data/foo" + q := buildDirGetQuery(projectId, dirPath) + + jsonStr := queryString(q) + // Verify traversal + assert.Contains(t, jsonStr, "data") + assert.Contains(t, jsonStr, "foo") + // Verify security check is present in loop (auth_resource_path appears multiple times) + // We can't easily count occurrences in JSON string without parsing, but Contains is a good smoke test + assert.Contains(t, jsonStr, "auth_resource_path") +} diff --git a/go.mod b/go.mod index c949b19..0509524 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/calypr/gecko go 1.24.2 require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/bmeg/grip v0.0.0-20251106174949-7f0784126fbb github.com/bmeg/grip-graphql v0.0.0-20251106183540-8b2f286248b3 github.com/google/uuid v1.6.0 @@ -64,14 +65,12 @@ require ( github.com/mailgun/raymond/v2 v2.0.48 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect - github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/schollz/closestmatch v2.1.0+incompatible // indirect github.com/sergi/go-diff v1.1.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/stretchr/objx v0.5.2 // indirect github.com/tdewolff/minify/v2 v2.20.19 // indirect github.com/tdewolff/parse/v2 v2.7.12 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect diff --git a/go.sum b/go.sum index 0dc62a5..da74376 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,8 @@ github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53 h1:sR+/8Yb4s github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno= github.com/CloudyKit/jet/v6 v6.2.0 h1:EpcZ6SR9n28BUGtNJSvlBqf90IpjeFr36Tizxhn/oME= github.com/CloudyKit/jet/v6 v6.2.0/go.mod h1:d3ypHeIRNo2+XyqnGA8s+aphtcVpjP5hPwP/Lzo7Ro4= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Joker/hpp v1.0.0 h1:65+iuJYdRXv/XyN62C1uEmmOx3432rNG/rKlX6V7Kkc= github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY= github.com/Joker/jade v1.1.3 h1:Qbeh12Vq6BxURXT1qZBRHsDxeURB8ztcL6f3EXSGeHk= @@ -27,12 +29,8 @@ github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer5 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= -github.com/bmeg/grip v0.0.0-20251015152131-81c3f20ad301 h1:8Wxnterbl6QrjrzOFHMYx5opl5y2jbSHh634VNVivqU= -github.com/bmeg/grip v0.0.0-20251015152131-81c3f20ad301/go.mod h1:YhsmNY+ksx9ohglQWKI+WazwS28uLKPO6ulWS6QLY30= github.com/bmeg/grip v0.0.0-20251106174949-7f0784126fbb h1:GYQ0Tfj36h8m+6dZolHDQJyVnjjqT3pgBZlFGHT+HOE= github.com/bmeg/grip v0.0.0-20251106174949-7f0784126fbb/go.mod h1:BxpaUuXbymKkEPvSDslziCzU17akkBo1ubu9nAFsI1A= -github.com/bmeg/grip-graphql v0.0.0-20250924224746-dc7f74b4040f h1:GQxRuotthPMMnds1EpYWlveOShUWuCEKhO3tc1KLYPI= -github.com/bmeg/grip-graphql v0.0.0-20250924224746-dc7f74b4040f/go.mod h1:mbCMMkG3xa2/mhs1pbKWoCb95STPri/bqCJtTn2oIKw= github.com/bmeg/grip-graphql v0.0.0-20251106183540-8b2f286248b3 h1:rWKYGUcCdStTQxCjKZU1e3/0PioP12WQPchsRZFSe5M= github.com/bmeg/grip-graphql v0.0.0-20251106183540-8b2f286248b3/go.mod h1:YcZY4w597zXAzi5iA9A48KIRGpnSSCb66ZFyN88LRKA= github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= @@ -143,8 +141,7 @@ github.com/kataras/tunnel v0.0.4 h1:sCAqWuJV7nPzGrlb0os3j49lk2JhILT0rID38NHNLpA= github.com/kataras/tunnel v0.0.4/go.mod h1:9FkU4LaeifdMWqZu7o20ojmW4B7hdhv2CMLwfnHGpYw= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -183,8 +180,6 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= -github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= -github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -211,8 +206,6 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -221,8 +214,6 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= From 66f5f1a7d34cec5adbd9fad6299781df91f683dc Mon Sep 17 00:00:00 2001 From: matthewpeterkort Date: Thu, 19 Feb 2026 13:52:11 -0800 Subject: [PATCH 3/6] path fetching error --- gecko/handleConfig.go | 28 ++++++++++++++++++++++++---- gecko/server.go | 5 +++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/gecko/handleConfig.go b/gecko/handleConfig.go index c8779e9..b386ea4 100644 --- a/gecko/handleConfig.go +++ b/gecko/handleConfig.go @@ -17,15 +17,21 @@ import ( // @Tags Config // @Accept json // @Produce json -// @Param configType path string true "Configuration Type (table name)" +// @Param configType path string false "Configuration Type (table name)" // @Success 200 {array} string "List of config IDs" // @Failure 404 {object} ErrorResponse "No configs found for this type" // @Failure 500 {object} ErrorResponse "Server error" // @Router /config/list [get] +// @Router /config/{configType}/list [get] func (server *Server) handleConfigListGET(ctx iris.Context) { - configList, err := configListByType(server.db, "explorer") - if configList == nil && err == nil { - errResponse := newErrorResponse(fmt.Sprintf("No configs found for type: %s", "explorer"), 404, nil) + configType := ctx.Params().Get("configType") + if configType == "" { + configType = ctx.URLParamDefault("type", "explorer") + } + + configList, err := configListByType(server.db, configType) + if (configList == nil || len(configList) == 0) && err == nil { + errResponse := newErrorResponse(fmt.Sprintf("No configs found for type: %s", configType), 404, nil) errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return @@ -39,6 +45,12 @@ func (server *Server) handleConfigListGET(ctx iris.Context) { jsonResponseFrom(configList, http.StatusOK).write(ctx) } +// handleConfigTypesGET returns a list of all supported configuration types. +func (server *Server) handleConfigTypesGET(ctx iris.Context) { + types := []string{"explorer", "nav", "file_summary", "apps_page"} + jsonResponseFrom(types, http.StatusOK).write(ctx) +} + // handleConfigGET godoc // @Summary Get a specific configuration // @Description Retrieve configuration by configType and configId @@ -116,6 +128,10 @@ func (server *Server) handleConfigDELETE(ctx iris.Context) { configType := ctx.Params().Get("configType") configId := ctx.Params().Get("configId") + if configType == "" { + configType = "explorer" + } + // Pass configType to the generic DELETE function deleted, err := configDELETEGeneric(server.db, configId, configType) if deleted == false && err == nil { @@ -159,6 +175,10 @@ func (server *Server) handleConfigPUT(ctx iris.Context) { configId := ctx.Params().Get("configId") configType := ctx.Params().Get("configType") + if configType == "" { + configType = "explorer" + } + var cfg config.Configurable // Use the interface type switch configType { diff --git a/gecko/server.go b/gecko/server.go index 247cab5..f3a310d 100644 --- a/gecko/server.go +++ b/gecko/server.go @@ -107,9 +107,14 @@ func (server *Server) MakeRouter() *iris.Application { // project id must be in the form [program-project] if not permissions checking will not work and you won't be able to view the project if server.db != nil { + router.Get("/config/types", server.handleConfigTypesGET) router.Get("/config/list", server.handleConfigListGET) + router.Get("/config/{configType}/list", server.handleConfigListGET) + router.Get("/config/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigGET) router.Get("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigGET) + router.Put("/config/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigPUT) router.Put("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigPUT) + router.Delete("/config/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) router.Delete("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) router.Get("/config/apps_page/appcard/{projectId}", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardGET) From 44c32e7ed603e9038f4aa1a50506467d256f1a99 Mon Sep 17 00:00:00 2001 From: matthewpeterkort Date: Thu, 19 Feb 2026 14:12:45 -0800 Subject: [PATCH 4/6] fix gecko resolution logic --- gecko/handleConfig.go | 53 ++++++++++++++++++++++++++++--------------- gecko/middleware.go | 7 +++--- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/gecko/handleConfig.go b/gecko/handleConfig.go index b386ea4..a9c99c3 100644 --- a/gecko/handleConfig.go +++ b/gecko/handleConfig.go @@ -11,6 +11,38 @@ import ( "github.com/kataras/iris/v12" ) +func isKnownType(t string) bool { + switch t { + case "explorer", "nav", "file_summary", "apps_page": + return true + } + return false +} + +func (server *Server) resolveConfigParams(ctx iris.Context) (string, string) { + configType := ctx.Params().Get("configType") + configId := ctx.Params().Get("configId") + + // If we are on a shorthand route like /config/{configId} + if configType == "" && configId != "" { + if isKnownType(configId) { + // e.g. /config/apps_page -> type: apps_page, id: default + return configId, "default" + } + // e.g. /config/my-project -> type: explorer, id: my-project + return "explorer", configId + } + + if configType == "" { + configType = "explorer" + } + if configId == "" { + configId = "default" + } + + return configType, configId +} + // handleConfigListGET godoc // @Summary List all configuration IDs for a specific type // @Description Retrieve a list of all available configuration IDs for the given type (table). @@ -63,12 +95,7 @@ func (server *Server) handleConfigTypesGET(ctx iris.Context) { // @Failure 500 {object} ErrorResponse "Server error" // @Router /config/{configType}/{configId} [get] func (server *Server) handleConfigGET(ctx iris.Context) { - configType := ctx.Params().Get("configType") - configId := ctx.Params().Get("configId") - - if configType == "" { - configType = "explorer" - } + configType, configId := server.resolveConfigParams(ctx) var cfg config.Configurable // Use the interface type @@ -125,12 +152,7 @@ func (server *Server) handleConfigGET(ctx iris.Context) { // @Failure 500 {object} ErrorResponse "Server error" // @Router /config/{configType}/{configId} [delete] func (server *Server) handleConfigDELETE(ctx iris.Context) { - configType := ctx.Params().Get("configType") - configId := ctx.Params().Get("configId") - - if configType == "" { - configType = "explorer" - } + configType, configId := server.resolveConfigParams(ctx) // Pass configType to the generic DELETE function deleted, err := configDELETEGeneric(server.db, configId, configType) @@ -172,12 +194,7 @@ func (server *Server) handleConfigDELETE(ctx iris.Context) { // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /config/{configType}/{configId} [put] func (server *Server) handleConfigPUT(ctx iris.Context) { - configId := ctx.Params().Get("configId") - configType := ctx.Params().Get("configType") - - if configType == "" { - configType = "explorer" - } + configType, configId := server.resolveConfigParams(ctx) var cfg config.Configurable // Use the interface type diff --git a/gecko/middleware.go b/gecko/middleware.go index 89f89c3..1372a88 100644 --- a/gecko/middleware.go +++ b/gecko/middleware.go @@ -71,7 +71,8 @@ func convertAnyToStringSlice(anySlice []any) ([]string, *ErrorResponse) { func (server *Server) ConfigAuthMiddleware(jwtHandler middleware.JWTHandler) iris.Handler { return func(ctx iris.Context) { method := ctx.Method() - configType := ctx.Params().Get("configType") + configType, configID := server.resolveConfigParams(ctx) + if configType == "explorer" { var permMethod string switch method { @@ -89,7 +90,6 @@ func (server *Server) ConfigAuthMiddleware(jwtHandler middleware.JWTHandler) iri return } - configID := ctx.Params().Get("configId") ctx.Params().Set("projectId", configID) explorerAuthHandler := server.GeneralAuthMware(jwtHandler, permMethod, "*") @@ -100,13 +100,14 @@ func (server *Server) ConfigAuthMiddleware(jwtHandler middleware.JWTHandler) iri ctx.Next() } else { - // Non-explorer path + // Non-explorer path (nav, apps_page, etc.) if method == "GET" { ctx.Next() return } if method == "PUT" || method == "DELETE" { + // Base config edit requires broader permission baseAuthHandler := server.BaseConfigsAuthMiddleware(jwtHandler, "*", "*", "/programs") baseAuthHandler(ctx) From c6b4a3280cae69bd419a76f6b914d32fdf7d7c24 Mon Sep 17 00:00:00 2001 From: matthewpeterkort Date: Thu, 19 Feb 2026 14:32:40 -0800 Subject: [PATCH 5/6] patch gecko --- gecko/handleAppCard.go | 6 +-- gecko/handleAppCard_test.go | 91 ++++++++++++------------------------- gecko/handleConfig.go | 31 ++++++------- gecko/middleware.go | 10 ++-- gecko/server.go | 1 - 5 files changed, 52 insertions(+), 87 deletions(-) diff --git a/gecko/handleAppCard.go b/gecko/handleAppCard.go index 9c72e96..ab4a9af 100644 --- a/gecko/handleAppCard.go +++ b/gecko/handleAppCard.go @@ -23,7 +23,7 @@ import ( // @Router /config/apps_page/appcard/{projectId} [get] func (server *Server) handleAppCardGET(ctx iris.Context) { configType := "apps_page" - configId := "default" // Adjust if you use dynamic configId + configId := "1" // Matches the ID used in helm chart bootstrap projectId := ctx.Params().Get("projectId") if projectId == "" { @@ -80,7 +80,7 @@ func (server *Server) handleAppCardGET(ctx iris.Context) { // @Router /config/apps_page/appcard [post] func (server *Server) handleAppCardPOST(ctx iris.Context) { configType := "apps_page" - configId := "default" // Hardcoded assumption; adjust if multiple configs exist + configId := "1" // Matches the ID used in helm chart bootstrap var currentCfg config.AppsConfig err := configGETGeneric(server.db, configId, configType, ¤tCfg) @@ -150,7 +150,7 @@ func (server *Server) handleAppCardPOST(ctx iris.Context) { // @Router /config/apps_page/appcard/{projectId} [delete] func (server *Server) handleAppCardDELETE(ctx iris.Context) { configType := "apps_page" - configId := "default" // Hardcoded assumption; adjust if multiple configs exist + configId := "1" // Matches the ID used in helm chart bootstrap projectId := ctx.Params().Get("projectId") var currentCfg config.AppsConfig diff --git a/gecko/handleAppCard_test.go b/gecko/handleAppCard_test.go index 44991a4..02b734e 100644 --- a/gecko/handleAppCard_test.go +++ b/gecko/handleAppCard_test.go @@ -2,7 +2,6 @@ package gecko import ( "bytes" - "encoding/json" "log" "net/http" "net/http/httptest" @@ -10,13 +9,12 @@ import ( "testing" "github.com/DATA-DOG/go-sqlmock" - "github.com/calypr/gecko/gecko/config" "github.com/jmoiron/sqlx" "github.com/kataras/iris/v12" "github.com/stretchr/testify/assert" ) -func TestHandleAppCardGET_Success(t *testing.T) { +func TestHandleAppCardPOST_Update(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) @@ -24,46 +22,46 @@ func TestHandleAppCardGET_Success(t *testing.T) { defer db.Close() sqlxDB := sqlx.NewDb(db, "sqlmock") - // Create a server instance with the mock DB and initialized Logger srv := &Server{ db: sqlxDB, Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, } - // Create query response for AppCards config - // The handler calls configGETGeneric which does a SELECT + // Initial state: one card in config ID '1' rows := sqlmock.NewRows([]string{"name", "content"}). - AddRow("default", []byte(`{"appCards": [{"perms": "PROG-PROJ", "title": "Test Card"}]}`)) + AddRow("1", []byte(`{"appCards": [{"perms": "PROG-PROJ", "title": "Old Title"}]}`)) - // Using regex to match the query in sql.go: SELECT name, content FROM config_schema.apps_page WHERE name=$1 + // The handler now uses ID '1' mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). - WithArgs("default"). + WithArgs("1"). WillReturnRows(rows) - req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/PROG-PROJ", nil) + // Expect UPDATE (Upsert) back to ID '1' + mock.ExpectExec("INSERT INTO config_schema.apps_page"). + WithArgs("1", sqlmock.AnyArg()). + WillReturnResult(sqlmock.NewResult(1, 1)) + + body := `{"perms": "PROG-PROJ", "title": "New Title"}` + req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard", bytes.NewBufferString(body)) rec := httptest.NewRecorder() app := iris.New() ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "PROG-PROJ") - srv.handleAppCardGET(ctx) + srv.handleAppCardPOST(ctx) assert.Equal(t, http.StatusOK, rec.Code) - - var card config.AppCard - json.Unmarshal(rec.Body.Bytes(), &card) - assert.Equal(t, "PROG-PROJ", card.Perms) - assert.Equal(t, "Test Card", card.Title) + assert.Contains(t, rec.Body.String(), "added or updated") + assert.Contains(t, rec.Body.String(), "perms PROG-PROJ") if err := mock.ExpectationsWereMet(); err != nil { - t.Errorf("there were unfulfilled expectations: %s", err) + t.Errorf("unfulfilled expectations: %s", err) } } -func TestHandleAppCardGET_NotFound(t *testing.T) { +func TestHandleAppCardDELETE_Success(t *testing.T) { db, mock, err := sqlmock.New() if err != nil { - t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + t.Fatalf("error opening mock db: %s", err) } defer db.Close() sqlxDB := sqlx.NewDb(db, "sqlmock") @@ -73,60 +71,29 @@ func TestHandleAppCardGET_NotFound(t *testing.T) { Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, } - // Case 1: Config exists but card not found rows := sqlmock.NewRows([]string{"name", "content"}). - AddRow("default", []byte(`{"appCards": [{"perms": "OTHER-PROJ"}]}`)) + AddRow("1", []byte(`{"appCards": [{"perms": "PROG-PROJ", "title": "Title"}]}`)) mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). - WithArgs("default"). + WithArgs("1"). WillReturnRows(rows) - req := httptest.NewRequest(http.MethodGet, "/config/apps_page/appcard/PROG-PROJ", nil) - rec := httptest.NewRecorder() - app := iris.New() - ctx := app.ContextPool.Acquire(rec, req) - ctx.Params().Set("projectId", "PROG-PROJ") - - srv.handleAppCardGET(ctx) - - // Should be 404 because card is not in the list - assert.Equal(t, http.StatusNotFound, rec.Code) -} - -func TestHandleAppCardPOST_Update(t *testing.T) { - db, mock, err := sqlmock.New() - if err != nil { - t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) - } - defer db.Close() - sqlxDB := sqlx.NewDb(db, "sqlmock") - - srv := &Server{ - db: sqlxDB, - Logger: &LogHandler{Logger: log.New(os.Stdout, "", 0)}, - } - - // Initial state: one card - rows := sqlmock.NewRows([]string{"name", "content"}). - AddRow("default", []byte(`{"appCards": [{"perms": "PROG-PROJ", "title": "Old Title"}]}`)) - - mock.ExpectQuery("^SELECT name, content FROM config_schema.apps_page WHERE name=.+"). - WithArgs("default"). - WillReturnRows(rows) - - // Expect UPDATE with modified data mock.ExpectExec("INSERT INTO config_schema.apps_page"). - WithArgs("default", sqlmock.AnyArg()). // We can't easily match JSON blob exactly without regex, AnyArg is safer for smoke test + WithArgs("1", sqlmock.AnyArg()). WillReturnResult(sqlmock.NewResult(1, 1)) - body := `{"perms": "PROG-PROJ", "title": "New Title"}` - req := httptest.NewRequest(http.MethodPost, "/config/apps_page/appcard", bytes.NewBufferString(body)) + req := httptest.NewRequest(http.MethodDelete, "/config/apps_page/appcard/PROG-PROJ", nil) rec := httptest.NewRecorder() app := iris.New() ctx := app.ContextPool.Acquire(rec, req) + ctx.Params().Set("projectId", "PROG-PROJ") - srv.handleAppCardPOST(ctx) + srv.handleAppCardDELETE(ctx) assert.Equal(t, http.StatusOK, rec.Code) - assert.Contains(t, rec.Body.String(), "added or updated") + assert.Contains(t, rec.Body.String(), "deleted") + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("unfulfilled expectations: %s", err) + } } diff --git a/gecko/handleConfig.go b/gecko/handleConfig.go index a9c99c3..d0d76ce 100644 --- a/gecko/handleConfig.go +++ b/gecko/handleConfig.go @@ -61,19 +61,20 @@ func (server *Server) handleConfigListGET(ctx iris.Context) { configType = ctx.URLParamDefault("type", "explorer") } + server.Logger.Info("Listing configs for type: %s", configType) + configList, err := configListByType(server.db, configType) - if (configList == nil || len(configList) == 0) && err == nil { - errResponse := newErrorResponse(fmt.Sprintf("No configs found for type: %s", configType), 404, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } if err != nil { errResponse := newErrorResponse(fmt.Sprintf("Database error: %s", err), 500, nil) errResponse.log.write(server.Logger) _ = errResponse.write(ctx) return } + + if configList == nil { + configList = []string{} + } + jsonResponseFrom(configList, http.StatusOK).write(ctx) } @@ -96,6 +97,7 @@ func (server *Server) handleConfigTypesGET(ctx iris.Context) { // @Router /config/{configType}/{configId} [get] func (server *Server) handleConfigGET(ctx iris.Context) { configType, configId := server.resolveConfigParams(ctx) + server.Logger.Info("Fetching config: type=%s, id=%s", configType, configId) var cfg config.Configurable // Use the interface type @@ -118,17 +120,14 @@ func (server *Server) handleConfigGET(ctx iris.Context) { // Pass configType to the generic GET function err := configGETGeneric(server.db, configId, configType, cfg) - // returning 404 on an empty config might be a bit controversial, - // but I think it will stock alot of edge cases - if cfg.IsZero() && err == nil || errors.Is(err, sql.ErrNoRows) { - msg := fmt.Sprintf("no config found with configId: %s of type: %s", configId, configType) - errResponse := newErrorResponse(msg, 404, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - return - } - if err != nil { + if errors.Is(err, sql.ErrNoRows) { + msg := fmt.Sprintf("no config found with configId: %s of type: %s", configId, configType) + errResponse := newErrorResponse(msg, 404, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + return + } msg := fmt.Sprintf("config query failed: %s", err.Error()) errResponse := newErrorResponse(msg, 500, nil) errResponse.log.write(server.Logger) diff --git a/gecko/middleware.go b/gecko/middleware.go index 1372a88..9518606 100644 --- a/gecko/middleware.go +++ b/gecko/middleware.go @@ -139,12 +139,12 @@ func (server *Server) GeneralAuthMware(jwtHandler middleware.JWTHandler, method, return } - project_split := strings.Split(ctx.Params().Get("projectId"), "-") + projectId := ctx.Params().Get("projectId") + project_split := strings.Split(projectId, "-") if len(project_split) != 2 { - errResponse := newErrorResponse(fmt.Sprintf("Failed to parse request body: incorrect path %s", ctx.Request().URL), http.StatusNotFound, nil) - errResponse.log.write(server.Logger) - _ = errResponse.write(ctx) - ctx.StopExecution() + // If it's not a program-project ID (like '1' or 'default'), + // it's a global config, so we skip the project-specific check. + ctx.Next() return } diff --git a/gecko/server.go b/gecko/server.go index f3a310d..5c12199 100644 --- a/gecko/server.go +++ b/gecko/server.go @@ -117,7 +117,6 @@ func (server *Server) MakeRouter() *iris.Application { router.Delete("/config/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) router.Delete("/config/{configType}/{configId}", server.ConfigAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleConfigDELETE) - router.Get("/config/apps_page/appcard/{projectId}", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardGET) router.Post("/config/apps_page/appcard", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardPOST) router.Delete("/config/apps_page/appcard/{projectId}", server.AppCardAuthMiddleware(&middleware.ProdJWTHandler{}), server.handleAppCardDELETE) } else { From 847c55479c33ce54f44bdfbc0d8169bb09d86396 Mon Sep 17 00:00:00 2001 From: matthewpeterkort Date: Fri, 20 Feb 2026 09:47:53 -0800 Subject: [PATCH 6/6] fix tests --- .github/workflows/tests.yaml | 18 +++++++- Makefile | 4 +- gecko/adapter/convert.go | 2 +- gecko/adapter/response.go | 72 +++++++++++++++++++++----------- gecko/handleVector.go | 2 +- gecko/middleware.go | 7 ++-- gecko/server.go | 16 ++++--- tests/integration/vector_test.go | 44 ++++++++++--------- 8 files changed, 107 insertions(+), 58 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index f39a791..9919c45 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -80,15 +80,22 @@ jobs: docker run -d \ --name gecko-qdrant-test \ + -p 6333:6333 \ -p 6334:6334 \ -e QDRANT__SERVICE__API_KEY=your_qdrant_api_key \ qdrant/qdrant:latest > /dev/null # Wait for Qdrant to be ready + echo "Waiting for Qdrant to be ready..." for i in {1..30}; do - if curl --silent --fail http://localhost:6334/readyz > /dev/null; then + if curl --silent --fail http://localhost:6333/readyz > /dev/null; then + echo "Qdrant is ready!" break fi + if [ $i -eq 30 ]; then + echo "Timeout waiting for Qdrant" + exit 1 + fi sleep 1 done @@ -102,11 +109,20 @@ jobs: -qdrant-api-key "your_qdrant_api_key" \ -qdrant-host localhost \ -qdrant-port 6334 & + # Wait for application to be ready + echo "Waiting for application to be ready..." for i in {1..30}; do if curl --silent --fail http://localhost:8080/health > /dev/null; then + echo "Application is ready!" break fi + if [ $i -eq 30 ]; then + echo "Timeout waiting for application" + # Show logs to help debug + kill $(jobs -p) || true + exit 1 + fi sleep 1 done go test -v ./... diff --git a/Makefile b/Makefile index a729215..915c2a3 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,8 @@ _default: bin/gecko @: # if we have a command this silences "nothing to be done" -bin/gecko: gecko/*.go # help: run the server - go build -o bin/gecko +bin/gecko: main.go gecko/*.go # help: run the server + go build -o bin/gecko . clean: rm -f bin/gecko diff --git a/gecko/adapter/convert.go b/gecko/adapter/convert.go index 00d0f88..a065a46 100644 --- a/gecko/adapter/convert.go +++ b/gecko/adapter/convert.go @@ -178,7 +178,7 @@ func ToQdrantFilter(filter *HeadFilter) *qdrant.Filter { return nil } - mustConditions := make([]*qdrant.Condition, len(filter.Must), len(filter.Must)) + mustConditions := make([]*qdrant.Condition, len(filter.Must)) for i, cond := range filter.Must { switch v := cond.Match.Value.(type) { diff --git a/gecko/adapter/response.go b/gecko/adapter/response.go index 88ca1a4..ebe10ae 100644 --- a/gecko/adapter/response.go +++ b/gecko/adapter/response.go @@ -2,38 +2,62 @@ package adapter import "github.com/qdrant/go-client/qdrant" -// convertQdrantPointsResponse transforms a Qdrant QueryResponse to simplify payloads. -func ConvertQdrantPointsResponse(resp []*qdrant.ScoredPoint) []map[string]any { - result := make([]map[string]any, len(resp)) - for i, point := range resp { - simplifiedPoint := map[string]any{ - "id": point.Id.GetUuid(), - "score": point.Score, +func convertVectors(v *qdrant.VectorsOutput) map[string]any { + if v == nil || v.VectorsOptions == nil { + return nil + } + vectors := make(map[string]any) + switch vo := v.VectorsOptions.(type) { + case *qdrant.VectorsOutput_Vector: + if vo.Vector != nil { + vectors["default"] = vo.Vector.Data } - - // Convert vectors if present - if point.Vectors != nil { - vectors := make(map[string]any) - if vectorsMap := point.Vectors.GetVectors(); vectorsMap != nil { - for name, vec := range vectorsMap.Vectors { + case *qdrant.VectorsOutput_Vectors: + if vo.Vectors != nil && vo.Vectors.Vectors != nil { + for name, vec := range vo.Vectors.Vectors { + if vec != nil { vectors[name] = vec.Data } - } else if vector := point.Vectors.GetVector(); vector != nil { - vectors["default"] = vector.Data } - simplifiedPoint["vectors"] = vectors } + } + return vectors +} - // Convert payload if present - if point.Payload != nil { - payload := make(map[string]any) - for key, value := range point.Payload { - payload[key] = convertQdrantValueToJSON(value) - } - simplifiedPoint["payload"] = payload +func convertPayload(p map[string]*qdrant.Value) map[string]any { + if p == nil { + return nil + } + payload := make(map[string]any) + for key, value := range p { + payload[key] = convertQdrantValueToJSON(value) + } + return payload +} + +// ConvertQdrantPointsResponse transforms a Qdrant QueryResponse to simplify payloads. +func ConvertQdrantPointsResponse(resp []*qdrant.ScoredPoint) []map[string]any { + result := make([]map[string]any, len(resp)) + for i, point := range resp { + result[i] = map[string]any{ + "id": point.Id.GetUuid(), + "score": point.Score, + "vectors": convertVectors(point.Vectors), + "payload": convertPayload(point.Payload), } + } + return result +} - result[i] = simplifiedPoint +// ConvertQdrantRetrievedPointsResponse transforms a Qdrant GetResponse to simplify payloads. +func ConvertQdrantRetrievedPointsResponse(resp []*qdrant.RetrievedPoint) []map[string]any { + result := make([]map[string]any, len(resp)) + for i, point := range resp { + result[i] = map[string]any{ + "id": point.Id.GetUuid(), + "vectors": convertVectors(point.Vectors), + "payload": convertPayload(point.Payload), + } } return result } diff --git a/gecko/handleVector.go b/gecko/handleVector.go index 55bf40a..edf5925 100644 --- a/gecko/handleVector.go +++ b/gecko/handleVector.go @@ -239,7 +239,7 @@ func (server *Server) handleGetPoint(ctx iris.Context) { _ = errResponse.write(ctx) return } - jsonResponseFrom(resp, http.StatusOK).write(ctx) + jsonResponseFrom(adapter.ConvertQdrantRetrievedPointsResponse(resp), http.StatusOK).write(ctx) } // handleQueryPoints godoc diff --git a/gecko/middleware.go b/gecko/middleware.go index 9518606..e706998 100644 --- a/gecko/middleware.go +++ b/gecko/middleware.go @@ -142,9 +142,10 @@ func (server *Server) GeneralAuthMware(jwtHandler middleware.JWTHandler, method, projectId := ctx.Params().Get("projectId") project_split := strings.Split(projectId, "-") if len(project_split) != 2 { - // If it's not a program-project ID (like '1' or 'default'), - // it's a global config, so we skip the project-specific check. - ctx.Next() + errResponse := newErrorResponse(fmt.Sprintf("Failed to parse request body: %v", fmt.Sprintf("incorrect path %s", ctx.Request().URL)), http.StatusNotFound, nil) + errResponse.log.write(server.Logger) + _ = errResponse.write(ctx) + ctx.StopExecution() return } diff --git a/gecko/server.go b/gecko/server.go index 5c12199..b8df61f 100644 --- a/gecko/server.go +++ b/gecko/server.go @@ -205,12 +205,16 @@ func recoveryMiddleware(ctx iris.Context) { // @Failure 500 {object} ErrorResponse "Database unavailable" // @Router /health [get] func (server *Server) handleHealth(ctx iris.Context) { - err := server.db.Ping() - if err != nil { - server.Logger.Error("Database ping failed: %v", err) - response := newErrorResponse("database unavailable", 500, nil) - _ = response.write(ctx) - return + if server.db != nil { + err := server.db.Ping() + if err != nil { + server.Logger.Error("Database ping failed: %v", err) + response := newErrorResponse("database unavailable", 500, nil) + _ = response.write(ctx) + return + } + } else { + server.Logger.Warning("Health check: Database connection not configured.") } server.Logger.Info("Health check passed") _ = jsonResponseFrom("Healthy", http.StatusOK).write(ctx) diff --git a/tests/integration/vector_test.go b/tests/integration/vector_test.go index d99f120..1b5fef7 100644 --- a/tests/integration/vector_test.go +++ b/tests/integration/vector_test.go @@ -8,6 +8,7 @@ import ( "net/http" "strconv" "testing" + "time" "github.com/calypr/gecko/gecko/adapter" "github.com/google/uuid" @@ -26,7 +27,8 @@ func generateRandomFloats(n int) []float32 { return randomFloats } -const testCollectionName = "test_collection_gecko" +var testCollectionName = fmt.Sprintf("test_collection_%x", time.Now().UnixNano()) + const vectorEndpoint = "http://localhost:8080/vector/collections" const queryEndpoint = "http://localhost:8080/vector/collections/%s/points/search" const VECTOR_NAME = "test_vector" @@ -178,7 +180,7 @@ func TestQdrantCollectionWorkflow(t *testing.T) { assert.Equal( t, "c3fb3d5c-e423-46ba-a47a-9ff97b94fc50", - respData[0]["id"].(map[string]any)["PointIdOptions"].(map[string]any)["Uuid"], + respData[0]["id"], "Expected point ID to be c3fb3d5c-e423-46ba-a47a-9ff97b94fc50", ) }) @@ -314,28 +316,30 @@ func TestQdrantCollectionWorkflow(t *testing.T) { }) t.Run("QueryPoints_ByVector_Success", func(t *testing.T) { - // First, get a point to extract its vector - pointID := ids[0] // From bulk upsert - getUrl := fmt.Sprintf("%s/%s", pointsEndpoint, pointID) - getResp, err := http.DefaultClient.Do(makeRequest(http.MethodGet, getUrl, nil)) + // Upsert a fresh point specifically for this test to ensure it exists and has a vector + pointID := uuid.NewString() + testVec := generateRandomFloats(128) + upsertPayload := map[string]any{ + "points": []map[string]any{ + { + "id": pointID, + "vector_name": VECTOR_NAME, + "vector": testVec, + }, + }, + } + marshalledJSON, err := json.Marshal(upsertPayload) assert.NoError(t, err) - defer getResp.Body.Close() - var pointData []map[string]any - buf := new(bytes.Buffer) - _, _ = buf.ReadFrom(getResp.Body) - _ = json.Unmarshal(buf.Bytes(), &pointData) - //t.Log("POINT DATA: ", pointData[0]["vectors"].(map[string]any)["VectorsOptions"].(map[string]any)["Vectors"].(map[string]any)["vectors"].(map[string]any)[VECTOR_NAME].(map[string]any)) - vectorMap := pointData[0]["vectors"].(map[string]any)["VectorsOptions"].(map[string]any)["Vectors"].(map[string]any)["vectors"].(map[string]any)[VECTOR_NAME].(map[string]any) - vector := vectorMap["data"].([]any) - queryVector := make([]float32, len(vector)) - for i, v := range vector { - queryVector[i] = float32(v.(float64)) - } + upsertResp, err := http.DefaultClient.Do(makeRequest(http.MethodPut, pointsEndpoint, marshalledJSON)) + assert.NoError(t, err) + upsertResp.Body.Close() + assert.Equal(t, http.StatusOK, upsertResp.StatusCode) + // Now query using the vector we just generated url := fmt.Sprintf(queryEndpoint, testCollectionName) requestBody := adapter.QueryPointsRequest{ - Query: queryVector, + Query: testVec, Limit: 10, VectorName: VECTOR_NAME, WithVector: ptr(true), @@ -350,7 +354,7 @@ func TestQdrantCollectionWorkflow(t *testing.T) { assert.Equal(t, http.StatusOK, resp.StatusCode, "Expected 200 OK for successful vector query") var actualResponse []map[string]any - buf = new(bytes.Buffer) + buf := new(bytes.Buffer) _, err = buf.ReadFrom(resp.Body) assert.NoError(t, err) err = json.Unmarshal(buf.Bytes(), &actualResponse)