From f446e2884d96cd37c056da10dfdf9e143ccc53e4 Mon Sep 17 00:00:00 2001 From: Taras Kushnir Date: Thu, 18 Dec 2025 18:17:51 +0100 Subject: [PATCH] Add API to get a single property. related PrivateCaptcha/issues#45 --- pkg/api/property.go | 44 +++++++++++++++ pkg/api/property_test.go | 106 +++++++++++++++++++++++++++++++++++ pkg/api/response.go | 13 +++++ pkg/api/server_enterprise.go | 18 ++++++ 4 files changed, 181 insertions(+) diff --git a/pkg/api/property.go b/pkg/api/property.go index 49142c85..c4683d67 100644 --- a/pkg/api/property.go +++ b/pkg/api/property.go @@ -742,3 +742,47 @@ func (s *Server) getOrgProperties(w http.ResponseWriter, r *http.Request) { } s.sendAPISuccessResponseEx(ctx, response, w, cacheHeaders) } + +func (s *Server) getOrgProperty(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + user, _, err := s.requestUser(ctx) + if err != nil { + s.sendHTTPErrorResponse(err, w) + return + } + + org, err := s.requestOrg(user, r, false /*only owner*/) + if err != nil { + if err == db.ErrInvalidInput { + s.sendAPIErrorResponse(ctx, common.StatusOrgIDInvalidError, r, w) + } else { + s.sendHTTPErrorResponse(err, w) + } + return + } + + property, err := s.requestProperty(org, r) + if err != nil { + if err == db.ErrSoftDeleted { + s.sendAPIErrorResponse(ctx, common.StatusPropertyIDInvalidError, r, w) + } else { + s.sendHTTPErrorResponse(err, w) + } + return + } + + data := &apiPropertyOutput{ + ID: s.IDHasher.Encrypt(int(property.ID)), + Name: property.Name, + Domain: property.Domain, + Sitekey: db.UUIDToSiteKey(property.ExternalID), + Level: int(property.Level.Int16), + Growth: string(property.Growth), + ValiditySeconds: int(property.ValidityInterval.Seconds()), + AllowSubdomains: property.AllowSubdomains, + AllowLocalhost: property.AllowLocalhost, + MaxReplayCount: int(property.MaxReplayCount), + } + + s.sendAPISuccessResponse(ctx, data, w) +} diff --git a/pkg/api/property_test.go b/pkg/api/property_test.go index c5a28a9e..ebbbdbd1 100644 --- a/pkg/api/property_test.go +++ b/pkg/api/property_test.go @@ -634,3 +634,109 @@ func TestApiGetProperties(t *testing.T) { t.Fatalf("Unexpected number of properties: %v", actual) } } + +func TestApiGetProperty(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := common.TraceContext(t.Context(), t.Name()) + + user, org, apiKey, err := setupAPISuite(ctx, t.Name()) + if err != nil { + t.Fatal(err) + } + + property, _, err := s.BusinessDB.Impl().CreateNewProperty(ctx, db_test.CreateNewPropertyParams(user.ID, "example.com"), org) + if err != nil { + t.Fatal(err) + } + + propertyID := s.IDHasher.Encrypt(int(property.ID)) + output, meta, err := requestResponseAPISuite[*apiPropertyOutput](ctx, nil, + http.MethodGet, + fmt.Sprintf("/%s/%s/%s/%s", common.OrgEndpoint, s.IDHasher.Encrypt(int(org.ID)), + common.PropertyEndpoint, propertyID), + apiKey) + if err != nil { + t.Fatal(err) + } + + if !meta.Code.Success() { + t.Fatalf("Unexpected status code: %v", meta.Description) + } + + if output.ID != propertyID { + t.Errorf("Received property ID %v but %v expected", output.ID, property.ID) + } + + if output.Name != property.Name { + t.Errorf("Received property Name %v, but %v expected", output.Name, property.Name) + } + + if output.Sitekey != db.UUIDToSiteKey(property.ExternalID) { + t.Errorf("Unexpected property sitekey: %v", output.Sitekey) + } + + if output.Level != int(property.Level.Int16) { + t.Errorf("Received property Level %v but %v expected", output.Level, property.Level.Int16) + } + + if output.Growth != string(property.Growth) { + t.Errorf("Received property Growth %v but %v expected", output.Growth, property.Growth) + } + + if output.ValiditySeconds != int(property.ValidityInterval.Seconds()) { + t.Errorf("Received property Validity Seconds %v but %v expected", output.ValiditySeconds, property.ValidityInterval.Seconds()) + } + + if output.AllowSubdomains != property.AllowSubdomains { + t.Errorf("Received property Subdomains %v but %v expected", output.AllowSubdomains, property.AllowSubdomains) + } + + if output.AllowLocalhost != property.AllowLocalhost { + t.Errorf("Received property Localhost %v but %v expected", output.AllowLocalhost, property.AllowLocalhost) + } + + if output.MaxReplayCount != int(property.MaxReplayCount) { + t.Errorf("Received property MaxReplayCount %v but %v expected", output.MaxReplayCount, property.MaxReplayCount) + } +} + +func TestApiGetPropertyPermissions(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + ctx := common.TraceContext(t.Context(), t.Name()) + + owner, org, _, err := setupAPISuite(ctx, t.Name()) + if err != nil { + t.Fatal(err) + } + + property, _, err := s.BusinessDB.Impl().CreateNewProperty(ctx, db_test.CreateNewPropertyParams(owner.ID, "example.com"), org) + if err != nil { + t.Fatal(err) + } + + _, _, apiKey, err := setupAPISuite(ctx, t.Name()+"_user2") + if err != nil { + t.Fatal(err) + } + + propertyID := s.IDHasher.Encrypt(int(property.ID)) + + resp, err := apiRequestSuite(ctx, nil, + http.MethodGet, + fmt.Sprintf("/%s/%s/%s/%s", common.OrgEndpoint, s.IDHasher.Encrypt(int(org.ID)), + common.PropertyEndpoint, propertyID), + apiKey) + if err != nil { + t.Fatal(err) + } + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("Unexpected status code: %v", resp.StatusCode) + } +} diff --git a/pkg/api/response.go b/pkg/api/response.go index e8617853..c22126bf 100644 --- a/pkg/api/response.go +++ b/pkg/api/response.go @@ -69,3 +69,16 @@ type apiAsyncTaskResultOutput struct { Finished bool `json:"finished"` Result interface{} `json:"result"` } + +type apiPropertyOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Domain string `json:"domain"` + Sitekey string `json:"sitekey"` + Level int `json:"level,omitempty"` + Growth string `json:"growth,omitempty"` + ValiditySeconds int `json:"validity_seconds,omitempty"` + AllowSubdomains bool `json:"allow_subdomains,omitempty"` + AllowLocalhost bool `json:"allow_localhost,omitempty"` + MaxReplayCount int `json:"max_replay_count,omitempty"` +} diff --git a/pkg/api/server_enterprise.go b/pkg/api/server_enterprise.go index e9eb27eb..14c2e0b6 100644 --- a/pkg/api/server_enterprise.go +++ b/pkg/api/server_enterprise.go @@ -42,6 +42,7 @@ func (s *Server) setupEnterprise(rg *common.RouteGenerator, publicChain alice.Ch rg.Handle(rg.Post(common.OrgEndpoint, arg(common.ParamOrg), common.PropertiesEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.postNewProperties), maxPostPropertiesBodySize)) rg.Handle(rg.Delete(common.PropertiesEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.deleteProperties), maxDeletePropertiesBodySize)) rg.Handle(rg.Patch(common.PropertiesEndpoint), portalAPIChain, http.MaxBytesHandler(http.HandlerFunc(s.updateProperties), maxUpdatePropertiesBodySize)) + rg.Handle(rg.Get(common.OrgEndpoint, arg(common.ParamOrg), common.PropertyEndpoint, arg(common.ParamProperty)), portalAPIChain, http.HandlerFunc(s.getOrgProperty)) } func (s *Server) RegisterTaskHandlers(ctx context.Context) { @@ -98,6 +99,23 @@ func (s *Server) requestOrg(user *dbgen.User, r *http.Request, onlyOwner bool) ( return org, nil } +func (s *Server) requestProperty(org *dbgen.Organization, r *http.Request) (*dbgen.Property, error) { + ctx := r.Context() + + propertyID, value, err := common.IntPathArg(r, common.ParamProperty, s.IDHasher) + if err != nil { + slog.ErrorContext(ctx, "Failed to parse property path parameter", "value", value, common.ErrAttr(err)) + return nil, db.ErrInvalidInput + } + + property, err := s.BusinessDB.Impl().RetrieveOrgProperty(ctx, org, propertyID) + if err != nil { + return nil, err + } + + return property, nil +} + func (s *Server) sendHTTPErrorResponse(err error, w http.ResponseWriter) { switch err { case db.ErrRecordNotFound: