From 17f53f1feb2189279974cd2db9b59be53568ff3d Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Mon, 30 May 2022 10:00:35 -0700 Subject: [PATCH 1/7] Add RID v2 endpoints --- Makefile | 1 + cmds/core-service/main.go | 58 +- cmds/http-gateway/main.go | 15 +- pkg/rid/models/api/v2/conversions.go | 228 ++++++++ pkg/rid/models/api/v2/doc.go | 3 + pkg/rid/server/v2/isa_handler.go | 267 +++++++++ pkg/rid/server/v2/server.go | 46 ++ pkg/rid/server/v2/server_test.go | 672 ++++++++++++++++++++++ pkg/rid/server/v2/subscription_handler.go | 253 ++++++++ 9 files changed, 1519 insertions(+), 24 deletions(-) create mode 100644 pkg/rid/models/api/v2/conversions.go create mode 100644 pkg/rid/models/api/v2/doc.go create mode 100644 pkg/rid/server/v2/isa_handler.go create mode 100644 pkg/rid/server/v2/server.go create mode 100644 pkg/rid/server/v2/server_test.go create mode 100644 pkg/rid/server/v2/subscription_handler.go diff --git a/Makefile b/Makefile index 2e8e416bb..a89e6fc26 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ format: clang-format -style=file -i pkg/api/v1/scdpb/scd.proto clang-format -style=file -i pkg/api/v1/auxpb/aux_service.proto cd monitoring/uss_qualifier && make format + gofmt -s -w . .PHONY: lint lint: go_lint shell_lint diff --git a/cmds/core-service/main.go b/cmds/core-service/main.go index 981346090..85d364f6d 100644 --- a/cmds/core-service/main.go +++ b/cmds/core-service/main.go @@ -18,6 +18,7 @@ import ( "github.com/interuss/dss/pkg/api/v1/auxpb" "github.com/interuss/dss/pkg/api/v1/ridpbv1" "github.com/interuss/dss/pkg/api/v1/scdpb" + "github.com/interuss/dss/pkg/api/v2/ridpbv2" "github.com/interuss/dss/pkg/auth" aux "github.com/interuss/dss/pkg/aux_" "github.com/interuss/dss/pkg/build" @@ -26,7 +27,8 @@ import ( uss_errors "github.com/interuss/dss/pkg/errors" "github.com/interuss/dss/pkg/logging" application "github.com/interuss/dss/pkg/rid/application" - ridserverv1 "github.com/interuss/dss/pkg/rid/server/v1" + rid_v1 "github.com/interuss/dss/pkg/rid/server/v1" + rid_v2 "github.com/interuss/dss/pkg/rid/server/v2" ridc "github.com/interuss/dss/pkg/rid/store/cockroach" "github.com/interuss/dss/pkg/scd" scdc "github.com/interuss/dss/pkg/scd/store/cockroach" @@ -105,16 +107,16 @@ func createKeyResolver() (auth.KeyResolver, error) { } } -func createRIDServer(ctx context.Context, locality string, logger *zap.Logger) (*ridserverv1.Server, error) { +func createRIDServer(ctx context.Context, locality string, logger *zap.Logger) (*rid_v1.Server, *rid_v2.Server, error) { connectParameters := flags.ConnectParameters() connectParameters.DBName = "rid" ridCrdb, err := cockroach.Dial(ctx, connectParameters) if err != nil { // TODO: More robustly detect failure to create RID server is due to a problem that may be temporary if strings.Contains(err.Error(), "connect: connection refused") { - return nil, stacktrace.PropagateWithCode(err, codeRetryable, "Failed to connect to CRDB server for remote ID store") + return nil, nil, stacktrace.PropagateWithCode(err, codeRetryable, "Failed to connect to CRDB server for remote ID store") } - return nil, stacktrace.Propagate(err, "Failed to connect to remote ID database; verify your database configuration is current with https://github.com/interuss/dss/tree/master/build#upgrading-database-schemas") + return nil, nil, stacktrace.Propagate(err, "Failed to connect to remote ID database; verify your database configuration is current with https://github.com/interuss/dss/tree/master/build#upgrading-database-schemas") } ridStore, err := ridc.NewStore(ctx, ridCrdb, connectParameters.DBName, logger) @@ -124,22 +126,22 @@ func createRIDServer(ctx context.Context, locality string, logger *zap.Logger) ( connectParameters.DBName = "defaultdb" ridCrdb, err := cockroach.Dial(ctx, connectParameters) if err != nil { - return nil, stacktrace.Propagate(err, "Failed to connect to remote ID database for older version ; verify your database configuration is current with https://github.com/interuss/dss/tree/master/build#upgrading-database-schemas") + return nil, nil, stacktrace.Propagate(err, "Failed to connect to remote ID database for older version ; verify your database configuration is current with https://github.com/interuss/dss/tree/master/build#upgrading-database-schemas") } ridStore, err = ridc.NewStore(ctx, ridCrdb, connectParameters.DBName, logger) if err != nil { // TODO: More robustly detect failure to create RID server is due to a problem that may be temporary if strings.Contains(err.Error(), "connect: connection refused") || strings.Contains(err.Error(), "database has not been bootstrapped with Schema Manager") { ridCrdb.Pool.Close() - return nil, stacktrace.PropagateWithCode(err, codeRetryable, "Failed to connect to CRDB server for remote ID store") + return nil, nil, stacktrace.PropagateWithCode(err, codeRetryable, "Failed to connect to CRDB server for remote ID store") } - return nil, stacktrace.Propagate(err, "Failed to create remote ID store") + return nil, nil, stacktrace.Propagate(err, "Failed to create remote ID store") } } repo, err := ridStore.Interact(ctx) if err != nil { - return nil, stacktrace.Propagate(err, "Unable to interact with store") + return nil, nil, stacktrace.Propagate(err, "Unable to interact with store") } gc := ridc.NewGarbageCollector(repo, locality) @@ -147,22 +149,29 @@ func createRIDServer(ctx context.Context, locality string, logger *zap.Logger) ( ridCron := cron.New() // schedule printing of DB connection stats every minute for the underlying storage for RID Server if _, err := ridCron.AddFunc("@every 1m", func() { getDBStats(ctx, ridCrdb, connectParameters.DBName) }); err != nil { - return nil, stacktrace.Propagate(err, "Failed to schedule periodic db stat check to %s", connectParameters.DBName) + return nil, nil, stacktrace.Propagate(err, "Failed to schedule periodic db stat check to %s", connectParameters.DBName) } cronLogger := cron.VerbosePrintfLogger(log.New(os.Stdout, "RIDGarbageCollectorJob: ", log.LstdFlags)) if _, err = ridCron.AddJob(*garbageCollectorSpec, cron.NewChain(cron.SkipIfStillRunning(cronLogger)).Then(RIDGarbageCollectorJob{"delete rid expired records", *gc, ctx})); err != nil { - return nil, stacktrace.Propagate(err, "Failed to schedule periodic delete rid expired records to %s", connectParameters.DBName) + return nil, nil, stacktrace.Propagate(err, "Failed to schedule periodic delete rid expired records to %s", connectParameters.DBName) } ridCron.Start() - return &ridserverv1.Server{ - App: application.NewFromTransactor(ridStore, logger), - Timeout: *timeout, - Locality: locality, - EnableHTTP: *enableHTTP, - Cron: ridCron, - }, nil + app := application.NewFromTransactor(ridStore, logger) + return &rid_v1.Server{ + App: app, + Timeout: *timeout, + Locality: locality, + EnableHTTP: *enableHTTP, + Cron: ridCron, + }, &rid_v2.Server{ + App: app, + Timeout: *timeout, + Locality: locality, + EnableHTTP: *enableHTTP, + Cron: ridCron, + }, nil } func createSCDServer(ctx context.Context, logger *zap.Logger) (*scd.Server, error) { @@ -211,20 +220,25 @@ func RunGRPCServer(ctx context.Context, ctxCanceler func(), address string, loca } var ( - ridServerV1 *ridserverv1.Server + ridServerV1 *rid_v1.Server + ridServerV2 *rid_v2.Server scdServer *scd.Server auxServer = &aux.Server{} ) // Initialize remote ID - server, err := createRIDServer(ctx, locality, logger) + serverV1, serverV2, err := createRIDServer(ctx, locality, logger) if err != nil { return stacktrace.Propagate(err, "Failed to create remote ID server") } - ridServerV1 = server + ridServerV1 = serverV1 + ridServerV2 = serverV2 scopesValidators := auth.MergeOperationsAndScopesValidators( - ridServerV1.AuthScopes(), auxServer.AuthScopes(), + ridServerV1.AuthScopes(), ridServerV2.AuthScopes(), + ) + scopesValidators = auth.MergeOperationsAndScopesValidators( + scopesValidators, auxServer.AuthScopes(), ) // Initialize strategic conflict detection @@ -233,6 +247,7 @@ func RunGRPCServer(ctx context.Context, ctxCanceler func(), address string, loca server, err := createSCDServer(ctx, logger) if err != nil { ridServerV1.Cron.Stop() + ridServerV2.Cron.Stop() return stacktrace.Propagate(err, "Failed to create strategic conflict detection server") } scdServer = server @@ -285,6 +300,7 @@ func RunGRPCServer(ctx context.Context, ctxCanceler func(), address string, loca logger.Info("build", zap.Any("description", build.Describe())) ridpbv1.RegisterDiscoveryAndSynchronizationServiceServer(s, ridServerV1) + ridpbv2.RegisterStandardRemoteIDAPIInterfacesServiceServer(s, ridServerV2) auxpb.RegisterDSSAuxServiceServer(s, auxServer) if *enableSCD { logger.Info("config", zap.Any("scd", "enabled")) diff --git a/cmds/http-gateway/main.go b/cmds/http-gateway/main.go index 093602145..504c4a278 100644 --- a/cmds/http-gateway/main.go +++ b/cmds/http-gateway/main.go @@ -18,6 +18,7 @@ import ( "github.com/interuss/dss/pkg/api/v1/auxpb" "github.com/interuss/dss/pkg/api/v1/ridpbv1" "github.com/interuss/dss/pkg/api/v1/scdpb" + "github.com/interuss/dss/pkg/api/v2/ridpbv2" "github.com/interuss/dss/pkg/build" "github.com/interuss/dss/pkg/errors" "github.com/interuss/dss/pkg/logging" @@ -74,13 +75,21 @@ func RunHTTPProxy(ctx context.Context, ctxCanceler func(), address, endpoint str grpc.WithTimeout(10 * time.Second), } - logger.Info("Registering RID service") + logger.Info("Registering RID v1 service") if err := ridpbv1.RegisterDiscoveryAndSynchronizationServiceHandlerFromEndpoint(ctx, grpcMux, endpoint, opts); err != nil { // TODO: More robustly detect failure to create RID server is due to a problem that may be temporary if strings.Contains(err.Error(), "context deadline exceeded") { - return stacktrace.PropagateWithCode(err, codeRetryable, "Failed to connect to core-service for remote ID") + return stacktrace.PropagateWithCode(err, codeRetryable, "Failed to connect to core-service for remote ID v1") } - return stacktrace.Propagate(err, "Error registering RID service handler") + return stacktrace.Propagate(err, "Error registering RID v1 service handler") + } + + logger.Info("Registering RID v2 service") + if err := ridpbv2.RegisterStandardRemoteIDAPIInterfacesServiceHandlerFromEndpoint(ctx, grpcMux, endpoint, opts); err != nil { + if strings.Contains(err.Error(), "context deadline exceeded") { + return stacktrace.PropagateWithCode(err, codeRetryable, "Failed to connect to core-service for remote ID v2") + } + return stacktrace.Propagate(err, "Error registering RID v2 service handler") } logger.Info("Registering aux service") diff --git a/pkg/rid/models/api/v2/conversions.go b/pkg/rid/models/api/v2/conversions.go new file mode 100644 index 000000000..46f868d8f --- /dev/null +++ b/pkg/rid/models/api/v2/conversions.go @@ -0,0 +1,228 @@ +package apiv2 + +import ( + "strings" + "time" + + ridpb "github.com/interuss/dss/pkg/api/v2/ridpbv2" + dssmodels "github.com/interuss/dss/pkg/models" + ridmodels "github.com/interuss/dss/pkg/rid/models" + "github.com/interuss/stacktrace" + tspb "google.golang.org/protobuf/types/known/timestamppb" +) + +// === RID -> Business === + +// FromTime converts proto to standard golang Time +func FromTime(t *ridpb.Time) (*time.Time, error) { + if t == nil { + return nil, nil + } + format := t.GetFormat() + if format != "RFC3339" { + return nil, stacktrace.NewError("Invalid time format '" + format + "'; expected 'RFC3339'") + } + value := t.GetValue() + if value == nil { + return nil, stacktrace.NewError("Time structure specified, but `value` was missing") + } + err := value.CheckValid() + if err != nil { + return nil, stacktrace.Propagate(err, "Error converting time from proto") + } + ts := value.AsTime() + return &ts, nil +} + +// FromAltitude converts proto to float +func FromAltitude(alt *ridpb.Altitude) (*float32, error) { + if alt == nil { + return nil, nil + } + ref := alt.GetReference() + if ref != "WGS84" { + return nil, stacktrace.NewError("Invalid altitude reference '" + ref + "'; expected 'WGS84'") + } + units := alt.GetUnits() + if units != "M" { + return nil, stacktrace.NewError("Invalid units '" + units + "'; expected 'M'") + } + value := float32(alt.GetValue()) + return &value, nil +} + +// FromVolume4D converts proto to business object +func FromVolume4D(vol4 *ridpb.Volume4D) (*dssmodels.Volume4D, error) { + vol3, err := FromVolume3D(vol4.GetVolume()) + if err != nil { + return nil, stacktrace.Propagate(err, "Error parsing spatial volume of Volume4D") + } + + result := &dssmodels.Volume4D{ + SpatialVolume: vol3, + } + + result.StartTime, err = FromTime(vol4.GetTimeStart()) + if err != nil { + return nil, stacktrace.Propagate(err, "Error parsing start time of Volume4D") + } + result.EndTime, err = FromTime(vol4.GetTimeEnd()) + if err != nil { + return nil, stacktrace.Propagate(err, "Error parsing end time of Volume4D") + } + + return result, nil +} + +// FromVolume3D converts proto to business object +func FromVolume3D(vol3 *ridpb.Volume3D) (*dssmodels.Volume3D, error) { + altitudeLo, err := FromAltitude(vol3.GetAltitudeLower()) + if err != nil { + stacktrace.Propagate(err, "Error parsing lower altitude of Volume3D") + } + altitudeHi, err := FromAltitude(vol3.GetAltitudeUpper()) + if err != nil { + stacktrace.Propagate(err, "Error parsing upper altitude of Volume3D") + } + + polygon := vol3.GetOutlinePolygon() + if polygon != nil { + footprint := FromPolygon(polygon) + + result := &dssmodels.Volume3D{ + Footprint: footprint, + AltitudeLo: altitudeLo, + AltitudeHi: altitudeHi, + } + + return result, nil + } + + circle := vol3.GetOutlineCircle() + if circle != nil { + footprint, err := FromCircle(circle) + if err != nil { + return nil, stacktrace.Propagate(err, "Error parsing outline_circle for Volume3D") + } + + result := &dssmodels.Volume3D{ + Footprint: footprint, + AltitudeLo: altitudeLo, + AltitudeHi: altitudeHi, + } + + return result, nil + } + + return nil, stacktrace.NewError("Neither outline_polygon nor outline_circle were specified in volume") +} + +// FromPolygon converts proto to business object +func FromPolygon(polygon *ridpb.Polygon) *dssmodels.GeoPolygon { + result := &dssmodels.GeoPolygon{} + + for _, ltlng := range polygon.Vertices { + result.Vertices = append(result.Vertices, FromLatLngPoint(ltlng)) + } + + return result +} + +// FromCircle converts proto to business object +func FromCircle(circle *ridpb.Circle) (*dssmodels.GeoCircle, error) { + center := circle.GetCenter() + if center == nil { + return nil, stacktrace.NewError("Missing `center` from circle") + } + radius := circle.GetRadius() + if radius == nil { + return nil, stacktrace.NewError("Missing `radius` from circle") + } + if radius.GetUnits() != "M" { + return nil, stacktrace.NewError("Only circle radius units of 'M' are acceptable for UTM") + } + result := &dssmodels.GeoCircle{ + Center: *FromLatLngPoint(center), + RadiusMeter: radius.GetValue(), + } + return result, nil +} + +// FromLatLngPoint converts proto to business object +func FromLatLngPoint(pt *ridpb.LatLngPoint) *dssmodels.LatLngPoint { + return &dssmodels.LatLngPoint{ + Lat: pt.Lat, + Lng: pt.Lng, + } +} + +// === Business -> RID === + +// ToTime converts standard golang Time to proto +func ToTime(t *time.Time) *ridpb.Time { + if t == nil { + return nil + } + + result := &ridpb.Time{ + Format: "RFC3339", + Value: tspb.New(*t), + } + + return result +} + +// ToLatLngPoint converts latlngpoint business object to proto +func ToLatLngPoint(pt *dssmodels.LatLngPoint) *ridpb.LatLngPoint { + result := &ridpb.LatLngPoint{ + Lat: pt.Lat, + Lng: pt.Lng, + } + + return result +} + +// IdentificationServiceAreaToProto converts an IdentificationServiceArea +// business object to v2 proto for API consumption. +func ToIdentificationServiceArea(i *ridmodels.IdentificationServiceArea) *ridpb.IdentificationServiceArea { + result := &ridpb.IdentificationServiceArea{ + Id: i.ID.String(), + Owner: i.Owner.String(), + UssBaseUrl: strings.TrimSuffix(strings.TrimSuffix(i.URL, "/v1/uss/flights"), "/uss/flights"), + Version: i.Version.String(), + TimeStart: ToTime(i.StartTime), + TimeEnd: ToTime(i.EndTime), + } + + return result +} + +// ToSubscriberToNotify converts a subscription to a SubscriberToNotify proto +// for API consumption. +func ToSubscriberToNotify(s *ridmodels.Subscription) *ridpb.SubscriberToNotify { + return &ridpb.SubscriberToNotify{ + Url: s.URL, + Subscriptions: []*ridpb.SubscriptionState{ + { + NotificationIndex: int32(s.NotificationIndex), + SubscriptionId: s.ID.String(), + }, + }, + } +} + +// ToSubscription converts a subscription business object to a Subscription +// proto for API consumption. +func ToSubscription(s *ridmodels.Subscription) *ridpb.Subscription { + result := &ridpb.Subscription{ + Id: s.ID.String(), + Owner: s.Owner.String(), + UssBaseUrl: strings.TrimSuffix(strings.TrimSuffix(s.URL, "/v1/uss/identification_service_areas"), "/uss/identification_service_areas"), + NotificationIndex: int32(s.NotificationIndex), + Version: s.Version.String(), + TimeStart: ToTime(s.StartTime), + TimeEnd: ToTime(s.EndTime), + } + + return result +} diff --git a/pkg/rid/models/api/v2/doc.go b/pkg/rid/models/api/v2/doc.go new file mode 100644 index 000000000..7e6bd0c24 --- /dev/null +++ b/pkg/rid/models/api/v2/doc.go @@ -0,0 +1,3 @@ +// Package apiv2 contains routines to convert between RID business objects and +// v2 API objects. +package apiv2 diff --git a/pkg/rid/server/v2/isa_handler.go b/pkg/rid/server/v2/isa_handler.go new file mode 100644 index 000000000..c9dbcec2f --- /dev/null +++ b/pkg/rid/server/v2/isa_handler.go @@ -0,0 +1,267 @@ +package server + +import ( + "context" + "time" + + ridpb "github.com/interuss/dss/pkg/api/v2/ridpbv2" + "github.com/interuss/dss/pkg/auth" + dsserr "github.com/interuss/dss/pkg/errors" + "github.com/interuss/dss/pkg/geo" + geoerr "github.com/interuss/dss/pkg/geo" + dssmodels "github.com/interuss/dss/pkg/models" + ridmodels "github.com/interuss/dss/pkg/rid/models" + apiv2 "github.com/interuss/dss/pkg/rid/models/api/v2" + "github.com/interuss/stacktrace" + "github.com/pkg/errors" +) + +// GetIdentificationServiceArea returns a single ISA for a given ID. +func (s *Server) GetIdentificationServiceArea( + ctx context.Context, req *ridpb.GetIdentificationServiceAreaRequest) ( + *ridpb.GetIdentificationServiceAreaResponse, error) { + + id, err := dssmodels.IDFromString(req.Id) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format") + } + + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + isa, err := s.App.GetISA(ctx, id) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not get ISA from application layer") + } + if isa == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.NotFound, "ISA %s not found", req.GetId()) + } + return &ridpb.GetIdentificationServiceAreaResponse{ + ServiceArea: apiv2.ToIdentificationServiceArea(isa), + }, nil +} + +// CreateIdentificationServiceArea creates an ISA +func (s *Server) CreateIdentificationServiceArea( + ctx context.Context, req *ridpb.CreateIdentificationServiceAreaRequest) ( + *ridpb.PutIdentificationServiceAreaResponse, error) { + + params := req.GetParams() + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + + owner, ok := auth.OwnerFromContext(ctx) + if !ok { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing owner from context") + } + if params == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Params not set") + } + // TODO: put the validation logic in the models layer + if params.UssBaseUrl == "" { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing required USS base URL") + } + if params.Extents == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing required extents") + } + extents, err := apiv2.FromVolume4D(params.Extents) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + } + id, err := dssmodels.IDFromString(req.Id) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format") + } + + if !s.EnableHTTP { + err = ridmodels.ValidateURL(params.GetUssBaseUrl()) + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Failed to validate base URL") + } + } + + isa := &ridmodels.IdentificationServiceArea{ + ID: id, + URL: params.GetUssBaseUrl() + "/uss/flights", + Owner: owner, + Writer: s.Locality, + } + + if err := isa.SetExtents(extents); err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid extents") + } + + insertedISA, subscribers, err := s.App.InsertISA(ctx, isa) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not insert ISA") + } + + pbISA := apiv2.ToIdentificationServiceArea(insertedISA) + + pbSubscribers := []*ridpb.SubscriberToNotify{} + for _, subscriber := range subscribers { + pbSubscribers = append(pbSubscribers, apiv2.ToSubscriberToNotify(subscriber)) + } + + return &ridpb.PutIdentificationServiceAreaResponse{ + ServiceArea: pbISA, + Subscribers: pbSubscribers, + }, nil +} + +// UpdateIdentificationServiceArea updates an existing ISA. +func (s *Server) UpdateIdentificationServiceArea( + ctx context.Context, req *ridpb.UpdateIdentificationServiceAreaRequest) ( + *ridpb.PutIdentificationServiceAreaResponse, error) { + + params := req.GetParams() + + version, err := dssmodels.VersionFromString(req.GetVersion()) + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid version") + } + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + + owner, ok := auth.OwnerFromContext(ctx) + if !ok { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing owner from context") + } + // TODO: put the validation logic in the models layer + if params == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Params not set") + } + if params.UssBaseUrl == "" { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing required USS base URL") + } + if params.Extents == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing required extents") + } + extents, err := apiv2.FromVolume4D(params.Extents) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + } + id, err := dssmodels.IDFromString(req.Id) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format") + } + + isa := &ridmodels.IdentificationServiceArea{ + ID: dssmodels.ID(id), + URL: params.UssBaseUrl + "/uss/flights", + Owner: owner, + Version: version, + Writer: s.Locality, + } + + if err := isa.SetExtents(extents); err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid extents") + } + + insertedISA, subscribers, err := s.App.UpdateISA(ctx, isa) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not update ISA") + } + + pbISA := apiv2.ToIdentificationServiceArea(insertedISA) + + pbSubscribers := []*ridpb.SubscriberToNotify{} + for _, subscriber := range subscribers { + pbSubscribers = append(pbSubscribers, apiv2.ToSubscriberToNotify(subscriber)) + } + + return &ridpb.PutIdentificationServiceAreaResponse{ + ServiceArea: pbISA, + Subscribers: pbSubscribers, + }, nil +} + +// DeleteIdentificationServiceArea deletes an existing ISA. +func (s *Server) DeleteIdentificationServiceArea( + ctx context.Context, req *ridpb.DeleteIdentificationServiceAreaRequest) ( + *ridpb.DeleteIdentificationServiceAreaResponse, error) { + + owner, ok := auth.OwnerFromContext(ctx) + if !ok { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing owner from context") + } + version, err := dssmodels.VersionFromString(req.GetVersion()) + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid version") + } + id, err := dssmodels.IDFromString(req.Id) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format") + } + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + isa, subscribers, err := s.App.DeleteISA(ctx, id, owner, version) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not delete ISA") + } + + p := apiv2.ToIdentificationServiceArea(isa) + sp := make([]*ridpb.SubscriberToNotify, len(subscribers)) + for i := range subscribers { + sp[i] = apiv2.ToSubscriberToNotify(subscribers[i]) + } + + return &ridpb.DeleteIdentificationServiceAreaResponse{ + ServiceArea: p, + Subscribers: sp, + }, nil +} + +// SearchIdentificationServiceAreas queries for all ISAs in the bounds. +func (s *Server) SearchIdentificationServiceAreas( + ctx context.Context, req *ridpb.SearchIdentificationServiceAreasRequest) ( + *ridpb.SearchIdentificationServiceAreasResponse, error) { + + cu, err := geo.AreaToCellIDs(req.GetArea()) + if err != nil { + if errors.Is(err, geoerr.ErrAreaTooLarge) { + return nil, stacktrace.Propagate(err, "Invalid area") + } + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid area") + } + + var ( + earliest *time.Time + latest *time.Time + ) + + if et := req.GetEarliestTime(); et != nil { + ts := et.AsTime() + err := et.CheckValid() + if err == nil { + earliest = &ts + } else { + return nil, stacktrace.Propagate(err, "Unable to convert earliest timestamp to ptype") + } + } + + if lt := req.GetLatestTime(); lt != nil { + ts := lt.AsTime() + err := lt.CheckValid() + if err == nil { + latest = &ts + } else { + return nil, stacktrace.Propagate(err, "Unable to convert latest timestamp to ptype") + } + } + + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + isas, err := s.App.SearchISAs(ctx, cu, earliest, latest) + if err != nil { + return nil, stacktrace.Propagate(err, "Unable to search ISAs") + } + + areas := make([]*ridpb.IdentificationServiceArea, len(isas)) + for i := range isas { + areas[i] = apiv2.ToIdentificationServiceArea(isas[i]) + } + + return &ridpb.SearchIdentificationServiceAreasResponse{ + ServiceAreas: areas, + }, nil +} diff --git a/pkg/rid/server/v2/server.go b/pkg/rid/server/v2/server.go new file mode 100644 index 000000000..2855d8d7d --- /dev/null +++ b/pkg/rid/server/v2/server.go @@ -0,0 +1,46 @@ +package server + +import ( + "time" + + "github.com/robfig/cron/v3" + + "github.com/interuss/dss/pkg/auth" + "github.com/interuss/dss/pkg/rid/application" +) + +var ( + // Scopes bundles up auth scopes for the remote-id server. + Scopes = struct { + ServiceProvider auth.Scope + DisplayProvider auth.Scope + }{ + ServiceProvider: "rid.server_provider", + DisplayProvider: "rid.display_provider", + } +) + +// Server implements ridpbv2.StandardRemoteIDAPIInterfacesServiceServer. +type Server struct { + App application.App + Timeout time.Duration + Locality string + EnableHTTP bool + Cron *cron.Cron +} + +// AuthScopes returns a map of endpoint to required Oauth scope. +func (s *Server) AuthScopes() map[auth.Operation]auth.KeyClaimedScopesValidator { + return map[auth.Operation]auth.KeyClaimedScopesValidator{ + "/ridpbv2.StandardRemoteIDAPIInterfacesService/CreateIdentificationServiceArea": auth.RequireAllScopes(Scopes.ServiceProvider), + "/ridpbv2.StandardRemoteIDAPIInterfacesService/DeleteIdentificationServiceArea": auth.RequireAllScopes(Scopes.ServiceProvider), + "/ridpbv2.StandardRemoteIDAPIInterfacesService/GetIdentificationServiceArea": auth.RequireAnyScope(Scopes.ServiceProvider, Scopes.DisplayProvider), + "/ridpbv2.StandardRemoteIDAPIInterfacesService/SearchIdentificationServiceAreas": auth.RequireAnyScope(Scopes.ServiceProvider, Scopes.DisplayProvider), + "/ridpbv2.StandardRemoteIDAPIInterfacesService/UpdateIdentificationServiceArea": auth.RequireAllScopes(Scopes.ServiceProvider), + "/ridpbv2.StandardRemoteIDAPIInterfacesService/CreateSubscription": auth.RequireAllScopes(Scopes.DisplayProvider), + "/ridpbv2.StandardRemoteIDAPIInterfacesService/DeleteSubscription": auth.RequireAllScopes(Scopes.DisplayProvider), + "/ridpbv2.StandardRemoteIDAPIInterfacesService/GetSubscription": auth.RequireAllScopes(Scopes.DisplayProvider), + "/ridpbv2.StandardRemoteIDAPIInterfacesService/SearchSubscriptions": auth.RequireAllScopes(Scopes.DisplayProvider), + "/ridpbv2.StandardRemoteIDAPIInterfacesService/UpdateSubscription": auth.RequireAllScopes(Scopes.DisplayProvider), + } +} diff --git a/pkg/rid/server/v2/server_test.go b/pkg/rid/server/v2/server_test.go new file mode 100644 index 000000000..582d877c6 --- /dev/null +++ b/pkg/rid/server/v2/server_test.go @@ -0,0 +1,672 @@ +package server + +import ( + "context" + "testing" + "time" + + "github.com/interuss/dss/pkg/api/v1/ridpb" + "github.com/interuss/dss/pkg/auth" + dsserr "github.com/interuss/dss/pkg/errors" + "github.com/interuss/dss/pkg/geo" + "github.com/interuss/dss/pkg/geo/testdata" + dssmodels "github.com/interuss/dss/pkg/models" + ridmodels "github.com/interuss/dss/pkg/rid/models" + apiv1 "github.com/interuss/dss/pkg/rid/models/api/v1" + + "github.com/golang/geo/s2" + "github.com/google/uuid" + "github.com/interuss/stacktrace" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + tspb "google.golang.org/protobuf/types/known/timestamppb" +) + +var timeout = time.Second * 10 + +func mustTimestamp(ts *tspb.Timestamp) *time.Time { + t := ts.AsTime() + err := ts.CheckValid() + if err != nil { + panic(err) + } + return &t +} + +func mustPolygonToCellIDs(p *ridpb.GeoPolygon) s2.CellUnion { + cells, err := apiv1.FromGeoPolygon(p).CalculateCovering() + if err != nil { + panic(err) + } + return cells +} + +type mockApp struct { + mock.Mock +} + +func (ma *mockApp) InsertSubscription(ctx context.Context, s *ridmodels.Subscription) (*ridmodels.Subscription, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + args := ma.Called(ctx, s) + return args.Get(0).(*ridmodels.Subscription), args.Error(1) +} + +func (ma *mockApp) UpdateSubscription(ctx context.Context, s *ridmodels.Subscription) (*ridmodels.Subscription, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + args := ma.Called(ctx, s) + return args.Get(0).(*ridmodels.Subscription), args.Error(1) +} + +func (ma *mockApp) GetSubscription(ctx context.Context, id dssmodels.ID) (*ridmodels.Subscription, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + args := ma.Called(ctx, id) + return args.Get(0).(*ridmodels.Subscription), args.Error(1) +} + +func (ma *mockApp) DeleteSubscription(ctx context.Context, id dssmodels.ID, owner dssmodels.Owner, version *dssmodels.Version) (*ridmodels.Subscription, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + args := ma.Called(ctx, id, owner, version) + return args.Get(0).(*ridmodels.Subscription), args.Error(1) +} + +func (ma *mockApp) SearchSubscriptionsByOwner(ctx context.Context, cells s2.CellUnion, owner dssmodels.Owner) ([]*ridmodels.Subscription, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + args := ma.Called(ctx, cells, owner) + return args.Get(0).([]*ridmodels.Subscription), args.Error(1) +} + +func (ma *mockApp) GetISA(ctx context.Context, id dssmodels.ID) (*ridmodels.IdentificationServiceArea, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + args := ma.Called(ctx, id) + return args.Get(0).(*ridmodels.IdentificationServiceArea), args.Error(1) +} + +func (ma *mockApp) DeleteISA(ctx context.Context, id dssmodels.ID, owner dssmodels.Owner, version *dssmodels.Version) (*ridmodels.IdentificationServiceArea, []*ridmodels.Subscription, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + args := ma.Called(ctx, id, owner, version) + return args.Get(0).(*ridmodels.IdentificationServiceArea), args.Get(1).([]*ridmodels.Subscription), args.Error(2) +} + +func (ma *mockApp) InsertISA(ctx context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, []*ridmodels.Subscription, error) { + args := ma.Called(ctx, isa) + return args.Get(0).(*ridmodels.IdentificationServiceArea), args.Get(1).([]*ridmodels.Subscription), args.Error(2) +} + +func (ma *mockApp) UpdateISA(ctx context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, []*ridmodels.Subscription, error) { + args := ma.Called(ctx, isa) + return args.Get(0).(*ridmodels.IdentificationServiceArea), args.Get(1).([]*ridmodels.Subscription), args.Error(2) +} + +func (ma *mockApp) SearchISAs(ctx context.Context, cells s2.CellUnion, earliest *time.Time, latest *time.Time) ([]*ridmodels.IdentificationServiceArea, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + args := ma.Called(ctx, cells, earliest, latest) + return args.Get(0).([]*ridmodels.IdentificationServiceArea), args.Error(1) +} + +func TestDeleteSubscription(t *testing.T) { + ctx := auth.ContextWithOwner(context.Background(), "foo") + version, _ := dssmodels.VersionFromString("bar") + + for _, r := range []struct { + name string + id dssmodels.ID + version *dssmodels.Version + subscription *ridmodels.Subscription + wantErr stacktrace.ErrorCode + }{ + { + name: "subscription-is-returned-if-returned-from-app", + id: dssmodels.ID(uuid.New().String()), + version: version, + subscription: &ridmodels.Subscription{}, + }, + { + name: "error-is-returned-if-returned-from-app", + id: dssmodels.ID(uuid.New().String()), + version: version, + wantErr: dsserr.NotFound, + }, + } { + t.Run(r.name, func(t *testing.T) { + ma := &mockApp{} + ma.On("DeleteSubscription", mock.Anything, r.id, mock.Anything, r.version).Return( + r.subscription, stacktrace.NewErrorWithCode(r.wantErr, "Expected error"), + ) + s := &Server{ + App: ma, + } + + _, err := s.DeleteSubscription(ctx, &ridpb.DeleteSubscriptionRequest{ + Id: r.id.String(), Version: r.version.String(), + }) + if r.wantErr != stacktrace.ErrorCode(0) { + require.Equal(t, stacktrace.GetCode(err), r.wantErr) + } + require.True(t, ma.AssertExpectations(t)) + }) + } +} + +func TestCreateSubscription(t *testing.T) { + ctx := auth.ContextWithOwner(context.Background(), "foo") + + for _, r := range []struct { + name string + id dssmodels.ID + callbacks *ridpb.SubscriptionCallbacks + extents *ridpb.Volume4D + wantSubscription *ridmodels.Subscription + wantErr stacktrace.ErrorCode + }{ + { + name: "success", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + callbacks: &ridpb.SubscriptionCallbacks{ + IdentificationServiceAreaUrl: "https://example.com", + }, + extents: testdata.LoopVolume4D, + wantSubscription: &ridmodels.Subscription{ + ID: "4348c8e5-0b1c-43cf-9114-2e67a4532765", + Owner: "foo", + URL: "https://example.com", + StartTime: mustTimestamp(testdata.LoopVolume4D.GetTimeStart()), + EndTime: mustTimestamp(testdata.LoopVolume4D.GetTimeEnd()), + AltitudeHi: &testdata.LoopVolume3D.AltitudeHi, + AltitudeLo: &testdata.LoopVolume3D.AltitudeLo, + Cells: mustPolygonToCellIDs(testdata.LoopPolygon), + }, + }, + { + name: "missing-extents", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + callbacks: &ridpb.SubscriptionCallbacks{ + IdentificationServiceAreaUrl: "https://example.com", + }, + wantErr: dsserr.BadRequest, + }, + { + name: "missing-extents-spatial-volume", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + callbacks: &ridpb.SubscriptionCallbacks{ + IdentificationServiceAreaUrl: "https://example.com", + }, + extents: &ridpb.Volume4D{}, + wantErr: dsserr.BadRequest, + }, + { + name: "missing-spatial-volume-footprint", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + callbacks: &ridpb.SubscriptionCallbacks{ + IdentificationServiceAreaUrl: "https://example.com", + }, + extents: &ridpb.Volume4D{ + SpatialVolume: &ridpb.Volume3D{}, + }, + wantErr: dsserr.BadRequest, + }, + { + name: "missing-spatial-volume-footprint", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + callbacks: &ridpb.SubscriptionCallbacks{ + IdentificationServiceAreaUrl: "https://example.com", + }, + extents: &ridpb.Volume4D{ + SpatialVolume: &ridpb.Volume3D{ + Footprint: &ridpb.GeoPolygon{}, + }, + }, + wantErr: dsserr.BadRequest, + }, + { + name: "missing-callbacks", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + extents: testdata.LoopVolume4D, + wantErr: dsserr.BadRequest, + }, + } { + t.Run(r.name, func(t *testing.T) { + ma := &mockApp{} + if r.wantErr == stacktrace.ErrorCode(0) { + ma.On("SearchISAs", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( + []*ridmodels.IdentificationServiceArea(nil), nil) + ma.On("InsertSubscription", mock.Anything, r.wantSubscription).Return( + r.wantSubscription, nil, + ) + } + s := &Server{App: ma} + + _, err := s.CreateSubscription(ctx, &ridpb.CreateSubscriptionRequest{ + Id: r.id.String(), + Params: &ridpb.CreateSubscriptionParameters{ + Callbacks: r.callbacks, + Extents: r.extents, + }, + }) + if r.wantErr != stacktrace.ErrorCode(0) { + require.Equal(t, stacktrace.GetCode(err), r.wantErr) + } + require.True(t, ma.AssertExpectations(t)) + }) + } +} + +func TestCreateSubscriptionResponseIncludesISAs(t *testing.T) { + ctx := auth.ContextWithOwner(context.Background(), "foo") + + isas := []*ridmodels.IdentificationServiceArea{ + { + ID: dssmodels.ID("8265221b-9528-4d45-900d-59a148e13850"), + Owner: dssmodels.Owner("me-myself-and-i"), + URL: "https://no/place/like/home", + }, + } + + cells := mustPolygonToCellIDs(testdata.LoopPolygon) + sub := &ridmodels.Subscription{ + ID: "4348c8e5-0b1c-43cf-9114-2e67a4532765", + Owner: "foo", + URL: "https://example.com", + StartTime: mustTimestamp(testdata.LoopVolume4D.GetTimeStart()), + EndTime: mustTimestamp(testdata.LoopVolume4D.GetTimeEnd()), + AltitudeHi: &testdata.LoopVolume3D.AltitudeHi, + AltitudeLo: &testdata.LoopVolume3D.AltitudeLo, + Cells: cells, + } + + ma := &mockApp{} + + ma.On("SearchISAs", mock.Anything, cells, mock.Anything, mock.Anything).Return(isas, nil) + ma.On("InsertSubscription", mock.Anything, sub).Return(sub, nil) + s := &Server{ + App: ma, + } + + resp, err := s.CreateSubscription(ctx, &ridpb.CreateSubscriptionRequest{ + Id: sub.ID.String(), + Params: &ridpb.CreateSubscriptionParameters{ + Callbacks: &ridpb.SubscriptionCallbacks{ + IdentificationServiceAreaUrl: sub.URL, + }, + Extents: testdata.LoopVolume4D, + }, + }) + require.Nil(t, err) + require.True(t, ma.AssertExpectations(t)) + + require.Equal(t, []*ridpb.IdentificationServiceArea{ + { + FlightsUrl: "https://no/place/like/home", + Id: "8265221b-9528-4d45-900d-59a148e13850", + Owner: "me-myself-and-i", + }, + }, resp.ServiceAreas) +} + +func TestGetSubscription(t *testing.T) { + for _, r := range []struct { + name string + id dssmodels.ID + subscription *ridmodels.Subscription + err stacktrace.ErrorCode + }{ + { + name: "subscription-is-returned-if-returned-from-app", + id: dssmodels.ID(uuid.New().String()), + subscription: &ridmodels.Subscription{}, + }, + { + name: "error-is-returned-if-returned-from-app", + id: dssmodels.ID(uuid.New().String()), + err: dsserr.NotFound, + }, + } { + t.Run(r.name, func(t *testing.T) { + ma := &mockApp{} + + ma.On("GetSubscription", mock.Anything, r.id).Return( + r.subscription, stacktrace.NewErrorWithCode(r.err, "Expected error"), + ) + s := &Server{ + App: ma, + } + + _, err := s.GetSubscription(context.Background(), &ridpb.GetSubscriptionRequest{ + Id: r.id.String(), + }) + require.Equal(t, stacktrace.GetCode(err), r.err) + require.True(t, ma.AssertExpectations(t)) + }) + } +} + +func TestSearchSubscriptionsFailsIfOwnerMissingFromContext(t *testing.T) { + var ( + ctx = context.Background() + ma = &mockApp{} + s = &Server{ + App: ma, + } + ) + + _, err := s.SearchSubscriptions(ctx, &ridpb.SearchSubscriptionsRequest{ + Area: testdata.Loop, + }) + + require.Error(t, err) + require.True(t, ma.AssertExpectations(t)) +} + +func TestSearchSubscriptionsFailsForInvalidArea(t *testing.T) { + var ( + ctx = auth.ContextWithOwner(context.Background(), "foo") + ma = &mockApp{} + s = &Server{ + App: ma, + } + ) + + _, err := s.SearchSubscriptions(ctx, &ridpb.SearchSubscriptionsRequest{ + Area: testdata.LoopWithOddNumberOfCoordinates, + }) + + require.Error(t, err) + require.True(t, ma.AssertExpectations(t)) +} + +func TestSearchSubscriptions(t *testing.T) { + var ( + owner = dssmodels.Owner("foo") + ctx = auth.ContextWithOwner(context.Background(), owner) + ma = &mockApp{} + s = &Server{ + App: ma, + } + ) + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ma.On("SearchSubscriptionsByOwner", mock.Anything, mock.Anything, owner).Return( + []*ridmodels.Subscription{ + { + ID: dssmodels.ID(uuid.New().String()), + Owner: owner, + URL: "https://no/place/like/home", + NotificationIndex: 42, + }, + }, error(nil), + ) + resp, err := s.SearchSubscriptions(ctx, &ridpb.SearchSubscriptionsRequest{ + Area: testdata.Loop, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Subscriptions, 1) + require.True(t, ma.AssertExpectations(t)) +} + +func TestCreateISA(t *testing.T) { + ctx := auth.ContextWithOwner(context.Background(), "foo") + + for _, r := range []struct { + name string + id dssmodels.ID + extents *ridpb.Volume4D + flightsURL string + wantISA *ridmodels.IdentificationServiceArea + wantErr stacktrace.ErrorCode + }{ + { + name: "success", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + extents: testdata.LoopVolume4D, + flightsURL: "https://example.com", + wantISA: &ridmodels.IdentificationServiceArea{ + ID: "4348c8e5-0b1c-43cf-9114-2e67a4532765", + URL: "https://example.com", + Owner: "foo", + Cells: mustPolygonToCellIDs(testdata.LoopPolygon), + StartTime: mustTimestamp(testdata.LoopVolume4D.GetTimeStart()), + EndTime: mustTimestamp(testdata.LoopVolume4D.GetTimeEnd()), + AltitudeHi: &testdata.LoopVolume3D.AltitudeHi, + AltitudeLo: &testdata.LoopVolume3D.AltitudeLo, + }, + }, + { + name: "missing-extents", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + flightsURL: "https://example.com", + wantErr: dsserr.BadRequest, + }, + { + name: "missing-extents-spatial-volume", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + extents: &ridpb.Volume4D{}, + flightsURL: "https://example.com", + wantErr: dsserr.BadRequest, + }, + { + name: "missing-spatial-volume-footprint", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + extents: &ridpb.Volume4D{ + SpatialVolume: &ridpb.Volume3D{}, + }, + flightsURL: "https://example.com", + wantErr: dsserr.BadRequest, + }, + { + name: "missing-spatial-volume-footprint", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + extents: &ridpb.Volume4D{ + SpatialVolume: &ridpb.Volume3D{ + Footprint: &ridpb.GeoPolygon{}, + }, + }, + flightsURL: "https://example.com", + wantErr: dsserr.BadRequest, + }, + { + name: "missing-flights-url", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + extents: testdata.LoopVolume4D, + wantErr: dsserr.BadRequest, + }, + } { + t.Run(r.name, func(t *testing.T) { + ma := &mockApp{} + if r.wantISA != nil { + ma.On("InsertISA", mock.Anything, r.wantISA).Return( + r.wantISA, []*ridmodels.Subscription(nil), nil) + } + s := &Server{ + App: ma, + } + + _, err := s.CreateIdentificationServiceArea(ctx, &ridpb.CreateIdentificationServiceAreaRequest{ + Id: r.id.String(), + Params: &ridpb.CreateIdentificationServiceAreaParameters{ + Extents: r.extents, + FlightsUrl: r.flightsURL, + }, + }) + if r.wantErr != stacktrace.ErrorCode(0) { + require.Equal(t, stacktrace.GetCode(err), r.wantErr) + } + require.True(t, ma.AssertExpectations(t)) + }) + } +} + +func TestUpdateISA(t *testing.T) { + ctx := auth.ContextWithOwner(context.Background(), "foo") + version, _ := dssmodels.VersionFromString("bar") + for _, r := range []struct { + name string + id dssmodels.ID + extents *ridpb.Volume4D + flightsURL string + wantISA *ridmodels.IdentificationServiceArea + wantErr stacktrace.ErrorCode + version *dssmodels.Version + }{ + { + name: "success", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + extents: testdata.LoopVolume4D, + flightsURL: "https://example.com", + version: version, + wantISA: &ridmodels.IdentificationServiceArea{ + ID: "4348c8e5-0b1c-43cf-9114-2e67a4532765", + URL: "https://example.com", + Owner: "foo", + Cells: mustPolygonToCellIDs(testdata.LoopPolygon), + StartTime: mustTimestamp(testdata.LoopVolume4D.GetTimeStart()), + EndTime: mustTimestamp(testdata.LoopVolume4D.GetTimeEnd()), + AltitudeHi: &testdata.LoopVolume3D.AltitudeHi, + AltitudeLo: &testdata.LoopVolume3D.AltitudeLo, + Writer: "locality value", + Version: version, + }, + }, + { + name: "missing-flights-url", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + extents: testdata.LoopVolume4D, + version: version, + wantErr: dsserr.BadRequest, + }, + { + name: "missing-extents", + id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), + flightsURL: "https://example.com", + version: version, + wantErr: dsserr.BadRequest, + }, + } { + t.Run(r.name, func(t *testing.T) { + ma := &mockApp{} + if r.wantISA != nil { + ma.On("UpdateISA", mock.Anything, r.wantISA).Return( + r.wantISA, []*ridmodels.Subscription(nil), nil) + } + s := &Server{ + App: ma, + Locality: "locality value", + } + _, err := s.UpdateIdentificationServiceArea(ctx, &ridpb.UpdateIdentificationServiceAreaRequest{ + Id: r.id.String(), + Version: r.version.String(), + Params: &ridpb.UpdateIdentificationServiceAreaParameters{ + Extents: r.extents, + FlightsUrl: r.flightsURL, + }, + }) + if r.wantErr != stacktrace.ErrorCode(0) { + require.Equal(t, stacktrace.GetCode(err), r.wantErr) + } + require.True(t, ma.AssertExpectations(t)) + }) + } +} + +func TestDeleteIdentificationServiceAreaRequiresOwnerInContext(t *testing.T) { + var ( + id = uuid.New().String() + ma = &mockApp{} + + s = &Server{ + App: ma, + } + ) + + _, err := s.DeleteIdentificationServiceArea(context.Background(), &ridpb.DeleteIdentificationServiceAreaRequest{ + Id: id, + }) + + require.Error(t, err) + require.True(t, ma.AssertExpectations(t)) +} + +func TestDeleteIdentificationServiceArea(t *testing.T) { + var ( + owner = dssmodels.Owner("foo") + id = dssmodels.ID(uuid.New().String()) + version, _ = dssmodels.VersionFromString("bar") + ctx = auth.ContextWithOwner(context.Background(), owner) + ma = &mockApp{} + + s = &Server{ + App: ma, + } + ) + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ma.On("DeleteISA", mock.Anything, id, owner, mock.Anything).Return( + &ridmodels.IdentificationServiceArea{ + ID: dssmodels.ID(id), + Owner: dssmodels.Owner("me-myself-and-i"), + URL: "https://no/place/like/home", + Version: version, + }, + []*ridmodels.Subscription{ + { + NotificationIndex: 42, + URL: "https://no/place/like/home", + }, + }, error(nil), + ) + resp, err := s.DeleteIdentificationServiceArea(ctx, &ridpb.DeleteIdentificationServiceAreaRequest{ + Id: id.String(), Version: version.String(), + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.Subscribers, 1) + require.True(t, ma.AssertExpectations(t)) +} + +func TestSearchIdentificationServiceAreas(t *testing.T) { + var ( + ctx = context.Background() + ma = &mockApp{} + + s = &Server{ + App: ma, + } + ) + + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + ma.On("SearchISAs", mock.Anything, mock.Anything, (*time.Time)(nil), (*time.Time)(nil)).Return( + []*ridmodels.IdentificationServiceArea{ + { + ID: dssmodels.ID(uuid.New().String()), + Owner: dssmodels.Owner("me-myself-and-i"), + URL: "https://no/place/like/home", + }, + }, error(nil), + ) + resp, err := s.SearchIdentificationServiceAreas(ctx, &ridpb.SearchIdentificationServiceAreasRequest{ + Area: testdata.Loop, + }) + + require.NoError(t, err) + require.NotNil(t, resp) + require.Len(t, resp.ServiceAreas, 1) + require.True(t, ma.AssertExpectations(t)) +} + +func TestDefaultRegionCovererProducesResults(t *testing.T) { + cover, err := geo.AreaToCellIDs(testdata.Loop) + require.NoError(t, err) + require.NotNil(t, cover) +} diff --git a/pkg/rid/server/v2/subscription_handler.go b/pkg/rid/server/v2/subscription_handler.go new file mode 100644 index 000000000..69a345b72 --- /dev/null +++ b/pkg/rid/server/v2/subscription_handler.go @@ -0,0 +1,253 @@ +package server + +import ( + "context" + + ridpb "github.com/interuss/dss/pkg/api/v2/ridpbv2" + "github.com/interuss/dss/pkg/auth" + dsserr "github.com/interuss/dss/pkg/errors" + "github.com/interuss/dss/pkg/geo" + geoerr "github.com/interuss/dss/pkg/geo" + dssmodels "github.com/interuss/dss/pkg/models" + ridmodels "github.com/interuss/dss/pkg/rid/models" + apiv2 "github.com/interuss/dss/pkg/rid/models/api/v2" + "github.com/interuss/stacktrace" + "github.com/pkg/errors" +) + +// DeleteSubscription deletes an existing subscription. +func (s *Server) DeleteSubscription( + ctx context.Context, req *ridpb.DeleteSubscriptionRequest) ( + *ridpb.DeleteSubscriptionResponse, error) { + + // TODO: simply verify the owner was set in an upper level. + owner, ok := auth.OwnerFromContext(ctx) + if !ok { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing owner from context") + } + version, err := dssmodels.VersionFromString(req.GetVersion()) + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid version") + } + id, err := dssmodels.IDFromString(req.Id) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format") + } + //TODO: put the context with timeout into an interceptor so it's always set. + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + subscription, err := s.App.DeleteSubscription(ctx, id, owner, version) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not delete Subscription") + } + return &ridpb.DeleteSubscriptionResponse{ + Subscription: apiv2.ToSubscription(subscription), + }, nil +} + +// SearchSubscriptions queries for existing subscriptions in the given bounds. +func (s *Server) SearchSubscriptions( + ctx context.Context, req *ridpb.SearchSubscriptionsRequest) ( + *ridpb.SearchSubscriptionsResponse, error) { + + owner, ok := auth.OwnerFromContext(ctx) + if !ok { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing owner from context") + } + + cu, err := geo.AreaToCellIDs(req.GetArea()) + if err != nil { + if errors.Is(err, geoerr.ErrAreaTooLarge) { + return nil, stacktrace.Propagate(err, "Invalid area") + } + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid area") + } + + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + subscriptions, err := s.App.SearchSubscriptionsByOwner(ctx, cu, owner) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not search Subscriptions") + } + sp := make([]*ridpb.Subscription, len(subscriptions)) + for i := range subscriptions { + sp[i] = apiv2.ToSubscription(subscriptions[i]) + } + + return &ridpb.SearchSubscriptionsResponse{ + Subscriptions: sp, + }, nil +} + +// GetSubscription gets a single subscription based on ID. +func (s *Server) GetSubscription( + ctx context.Context, req *ridpb.GetSubscriptionRequest) ( + *ridpb.GetSubscriptionResponse, error) { + + id, err := dssmodels.IDFromString(req.Id) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format") + } + + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + subscription, err := s.App.GetSubscription(ctx, id) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not get Subscription") + } + if subscription == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.NotFound, "Subscription %s not found", req.GetId()) + } + return &ridpb.GetSubscriptionResponse{ + Subscription: apiv2.ToSubscription(subscription), + }, nil +} + +// CreateSubscription creates a single subscription. +func (s *Server) CreateSubscription( + ctx context.Context, req *ridpb.CreateSubscriptionRequest) ( + *ridpb.PutSubscriptionResponse, error) { + + params := req.GetParams() + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + + owner, ok := auth.OwnerFromContext(ctx) + if !ok { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing owner from context") + } + if params == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Params not set") + } + if params.UssBaseUrl == "" { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing required USS base URL") + } + if params.Extents == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing required extents") + } + extents, err := apiv2.FromVolume4D(params.Extents) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + } + id, err := dssmodels.IDFromString(req.Id) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format") + } + + if !s.EnableHTTP { + err = ridmodels.ValidateURL(params.UssBaseUrl) + if err != nil { + return nil, stacktrace.PropagateWithCode( + err, dsserr.BadRequest, "Failed to validate IdentificationServiceAreaUrl") + } + } + + sub := &ridmodels.Subscription{ + ID: id, + Owner: owner, + URL: params.UssBaseUrl + "/uss/identification_service_areas", + Writer: s.Locality, + } + + if err := sub.SetExtents(extents); err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid extents") + } + + insertedSub, err := s.App.InsertSubscription(ctx, sub) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not insert Subscription") + } + + p := apiv2.ToSubscription(insertedSub) + + // Find ISAs that were in this subscription's area. + isas, err := s.App.SearchISAs(ctx, sub.Cells, nil, nil) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not search ISAs") + } + + // Convert the ISAs to protos. + isaProtos := make([]*ridpb.IdentificationServiceArea, len(isas)) + for i, isa := range isas { + isaProtos[i] = apiv2.ToIdentificationServiceArea(isa) + } + + return &ridpb.PutSubscriptionResponse{ + Subscription: p, + ServiceAreas: isaProtos, + }, nil +} + +// UpdateSubscription updates a single subscription. +func (s *Server) UpdateSubscription( + ctx context.Context, req *ridpb.UpdateSubscriptionRequest) ( + *ridpb.PutSubscriptionResponse, error) { + + params := req.GetParams() + + version, err := dssmodels.VersionFromString(req.GetVersion()) + if err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid version") + } + id, err := dssmodels.IDFromString(req.Id) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Invalid ID format") + } + + ctx, cancel := context.WithTimeout(ctx, s.Timeout) + defer cancel() + + owner, ok := auth.OwnerFromContext(ctx) + if !ok { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Missing owner from context") + } + if params == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Params not set") + } + if params.UssBaseUrl == "" { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing required USS base URL") + } + if params.Extents == nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Missing required extents") + } + extents, err := apiv2.FromVolume4D(params.Extents) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + } + + sub := &ridmodels.Subscription{ + ID: id, + Owner: owner, + URL: params.UssBaseUrl + "/uss/identification_service_areas", + Version: version, + Writer: s.Locality, + } + + if err := sub.SetExtents(extents); err != nil { + return nil, stacktrace.PropagateWithCode(err, dsserr.BadRequest, "Invalid extents") + } + + insertedSub, err := s.App.UpdateSubscription(ctx, sub) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not update Subscription") + } + + p := apiv2.ToSubscription(insertedSub) + + // Find ISAs that were in this subscription's area. + isas, err := s.App.SearchISAs(ctx, sub.Cells, nil, nil) + if err != nil { + return nil, stacktrace.Propagate(err, "Could not search ISAs") + } + + // Convert the ISAs to protos. + isaProtos := make([]*ridpb.IdentificationServiceArea, len(isas)) + for i, isa := range isas { + isaProtos[i] = apiv2.ToIdentificationServiceArea(isa) + } + + return &ridpb.PutSubscriptionResponse{ + Subscription: p, + ServiceAreas: isaProtos, + }, nil +} From 3173e0b2ca395b6d42f3581fd428a03763950f1f Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Mon, 30 May 2022 10:30:14 -0700 Subject: [PATCH 2/7] Add prober tests for RID v2 endpoints Also, improve missing scopes error message, fix RID v2 scope string, improve Volume4D parsing error messages --- monitoring/monitorlib/rid_v2.py | 39 +++ monitoring/prober/conftest.py | 20 +- monitoring/prober/infrastructure.py | 2 +- monitoring/prober/rid/v2/__init__.py | 0 monitoring/prober/rid/v2/common.py | 8 + monitoring/prober/rid/v2/test_isa_expiry.py | 88 +++++++ monitoring/prober/rid/v2/test_isa_simple.py | 226 +++++++++++++++++ .../prober/rid/v2/test_isa_validation.py | 212 ++++++++++++++++ .../v2/test_subscription_isa_interactions.py | 171 +++++++++++++ .../prober/rid/v2/test_subscription_simple.py | 146 +++++++++++ .../rid/v2/test_subscription_validation.py | 237 ++++++++++++++++++ .../prober/rid/v2/test_token_validation.py | 103 ++++++++ pkg/auth/auth.go | 49 +++- pkg/rid/models/api/v2/conversions.go | 2 +- pkg/rid/server/v1/isa_handler.go | 4 +- pkg/rid/server/v1/subscription_handler.go | 4 +- pkg/rid/server/v2/isa_handler.go | 4 +- pkg/rid/server/v2/server.go | 2 +- pkg/rid/server/v2/subscription_handler.go | 4 +- test/docker_e2e.sh | 1 + 20 files changed, 1303 insertions(+), 19 deletions(-) create mode 100644 monitoring/monitorlib/rid_v2.py create mode 100644 monitoring/prober/rid/v2/__init__.py create mode 100644 monitoring/prober/rid/v2/common.py create mode 100644 monitoring/prober/rid/v2/test_isa_expiry.py create mode 100644 monitoring/prober/rid/v2/test_isa_simple.py create mode 100644 monitoring/prober/rid/v2/test_isa_validation.py create mode 100644 monitoring/prober/rid/v2/test_subscription_isa_interactions.py create mode 100644 monitoring/prober/rid/v2/test_subscription_simple.py create mode 100644 monitoring/prober/rid/v2/test_subscription_validation.py create mode 100644 monitoring/prober/rid/v2/test_token_validation.py diff --git a/monitoring/monitorlib/rid_v2.py b/monitoring/monitorlib/rid_v2.py new file mode 100644 index 000000000..4b2fd4d11 --- /dev/null +++ b/monitoring/monitorlib/rid_v2.py @@ -0,0 +1,39 @@ +import datetime +from typing import Dict, Literal + +from monitoring.monitorlib.typing import ImplicitDict, StringBasedDateTime +from . import rid as rid_v1 + + +ISA_PATH = '/dss/identification_service_areas' +SUBSCRIPTION_PATH = '/dss/subscriptions' +SCOPE_DP = 'rid.display_provider' +SCOPE_SP = 'rid.service_provider' + + +class Time(ImplicitDict): + value: StringBasedDateTime + format: Literal['RFC3339'] + + @classmethod + def make(cls, t: datetime.datetime): + return Time(format='RFC3339', value=t.strftime(DATE_FORMAT)) + + +class Altitude(ImplicitDict): + reference: Literal['WGS84'] + units: Literal['M'] + value: float + + @classmethod + def make(cls, altitude_meters: float): + return Altitude(reference='WGS84', units='M', value=altitude_meters) + + +MAX_SUB_PER_AREA = rid_v1.MAX_SUB_PER_AREA +MAX_SUB_TIME_HRS = rid_v1.MAX_SUB_TIME_HRS +DATE_FORMAT = rid_v1.DATE_FORMAT +NetMaxNearRealTimeDataPeriod = rid_v1.NetMaxNearRealTimeDataPeriod +NetMaxDisplayAreaDiagonal = rid_v1.NetMaxDisplayAreaDiagonal +NetDetailsMaxDisplayAreaDiagonal = rid_v1.NetDetailsMaxDisplayAreaDiagonal +geo_polygon_string = rid_v1.geo_polygon_string diff --git a/monitoring/prober/conftest.py b/monitoring/prober/conftest.py index 8e3903c1a..a0b7b444a 100644 --- a/monitoring/prober/conftest.py +++ b/monitoring/prober/conftest.py @@ -9,10 +9,12 @@ OPT_RID_AUTH = 'rid_auth' +OPT_RID_V2_AUTH = 'rid_v2_auth' OPT_SCD_AUTH1 = 'scd_auth1' OPT_SCD_AUTH2 = 'scd_auth2' BASE_URL_RID = '' +BASE_URL_RID_V2 = '/rid/v2' BASE_URL_SCD = '/dss/v1' BASE_URL_AUX = '/aux/v1' @@ -37,10 +39,16 @@ def pytest_addoption(parser): parser.addoption( '--rid-auth', - help='Auth spec (see Authorization section of README.md) for performing remote ID actions in the DSS', + help='Auth spec (see Authorization section of README.md) for performing remote ID v1 actions in the DSS', metavar='SPEC', dest='rid_auth') + parser.addoption( + '--rid-v2-auth', + help='Auth spec (see Authorization section of README.md) for performing remote ID v2 actions in the DSS', + metavar='SPEC', + dest='rid_v2_auth') + parser.addoption( '--scd-auth1', help='Auth spec (see Authorization section of README.md) for performing primary strategic deconfliction actions in the DSS', @@ -120,6 +128,11 @@ def session_ridv1(pytestconfig) -> UTMClientSession: return make_session(pytestconfig, BASE_URL_RID, OPT_RID_AUTH) +@pytest.fixture(scope='session') +def session_ridv2(pytestconfig) -> UTMClientSession: + return make_session(pytestconfig, BASE_URL_RID_V2, OPT_RID_V2_AUTH) + + @pytest.fixture(scope='session') def session_ridv1_async(pytestconfig): session = make_session_async(pytestconfig, BASE_URL_RID, OPT_RID_AUTH) @@ -207,6 +220,11 @@ def no_auth_session_ridv1(pytestconfig) -> UTMClientSession: return make_session(pytestconfig, BASE_URL_RID) +@pytest.fixture(scope='function') +def no_auth_session_ridv2(pytestconfig) -> UTMClientSession: + return make_session(pytestconfig, BASE_URL_RID_V2) + + @pytest.fixture(scope='session') def scd_api(pytestconfig) -> str: api = pytestconfig.getoption('scd_api_version') diff --git a/monitoring/prober/infrastructure.py b/monitoring/prober/infrastructure.py index 6df328af5..fb9716c08 100644 --- a/monitoring/prober/infrastructure.py +++ b/monitoring/prober/infrastructure.py @@ -86,7 +86,7 @@ def wrapper_default_scope(*args, **kwargs): resource_type_code_descriptions: Dict[ResourceType, str] = {} -# Next code: 347 +# Next code: 367 def register_resource_type(code: int, description: str) -> ResourceType: """Register that the specified code refers to the described resource. diff --git a/monitoring/prober/rid/v2/__init__.py b/monitoring/prober/rid/v2/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/monitoring/prober/rid/v2/common.py b/monitoring/prober/rid/v2/common.py new file mode 100644 index 000000000..1bb7ddaa7 --- /dev/null +++ b/monitoring/prober/rid/v2/common.py @@ -0,0 +1,8 @@ +from monitoring.prober.rid.v1 import common as common_v1 + + +VERTICES = common_v1.VERTICES +GEO_POLYGON_STRING = common_v1.GEO_POLYGON_STRING +HUGE_GEO_POLYGON_STRING = common_v1.HUGE_GEO_POLYGON_STRING +LOOP_GEO_POLYGON_STRING = common_v1.LOOP_GEO_POLYGON_STRING +HUGE_VERTICES = common_v1.HUGE_VERTICES diff --git a/monitoring/prober/rid/v2/test_isa_expiry.py b/monitoring/prober/rid/v2/test_isa_expiry.py new file mode 100644 index 000000000..e55d22603 --- /dev/null +++ b/monitoring/prober/rid/v2/test_isa_expiry.py @@ -0,0 +1,88 @@ +"""Test ISAs aren't returned after they expire.""" + +import datetime +import time + +from monitoring.monitorlib.infrastructure import default_scope +from monitoring.monitorlib import rid_v2 +from monitoring.monitorlib.rid_v2 import SCOPE_DP, SCOPE_SP, ISA_PATH +from monitoring.prober.infrastructure import register_resource_type +from . import common + + +ISA_TYPE = register_resource_type(347, 'ISA') + + +def test_ensure_clean_workspace_v2(ids, session_ridv2): + print('In test_ensure_clean_workspace, scope = {}'.format(SCOPE_SP)) + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_SP) + if resp.status_code == 200: + version = resp.json()['service_area']['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected. + pass + else: + assert False, resp.content + + +@default_scope(SCOPE_SP) +def test_create(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(seconds=5) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': 'https://example.com/ridv2', + }) + assert resp.status_code == 200, resp.content + + +@default_scope(SCOPE_DP) +def test_valid_immediately(ids, session_ridv2): + # The ISA is still valid immediately after we create it. + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE))) + assert resp.status_code == 200, resp.content + + +def test_sleep_5_seconds(): + # But if we wait 5 seconds it will expire... + time.sleep(5) + + +@default_scope(SCOPE_DP) +def test_returned_by_id(ids, session_ridv2): + # We can get it explicitly by ID + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE))) + assert resp.status_code == 200, resp.content + + +@default_scope(SCOPE_DP) +def test_not_returned_by_search(ids, session_ridv2): + # ...but it's not included in a search. + resp = session_ridv2.get('{}?area={}'.format( + ISA_PATH, common.GEO_POLYGON_STRING)) + assert resp.status_code == 200, resp.content + assert ids(ISA_TYPE) not in [x['id'] for x in resp.json()['service_areas']] + + +@default_scope(SCOPE_DP) +def test_delete(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_DP) + assert resp.status_code == 200 + version = resp.json()['service_area']['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), scope=SCOPE_SP) + assert resp.status_code == 200, resp.content diff --git a/monitoring/prober/rid/v2/test_isa_simple.py b/monitoring/prober/rid/v2/test_isa_simple.py new file mode 100644 index 000000000..521698482 --- /dev/null +++ b/monitoring/prober/rid/v2/test_isa_simple.py @@ -0,0 +1,226 @@ +"""Basic ISA tests: + + - create the ISA with a 60 minute expiry + - get by ID + - search with earliest_time and latest_time + - delete +""" + +import datetime +import re + +from monitoring.monitorlib.infrastructure import default_scope +from monitoring.monitorlib import rid_v2 +from monitoring.monitorlib.rid_v2 import SCOPE_DP, SCOPE_SP, ISA_PATH +from monitoring.monitorlib.testing import assert_datetimes_are_equal +from monitoring.prober.infrastructure import register_resource_type +from . import common + + +ISA_TYPE = register_resource_type(348, 'ISA') +BASE_URL_V1 = 'https://example.com/rid/v2' +BASE_URL_V2 = 'https://s2.example.com/rid/v2' + +def test_ensure_clean_workspace(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_DP) + if resp.status_code == 200: + version = resp.json()['service_area']['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected. + pass + else: + assert False, resp.content + + +@default_scope(SCOPE_SP) +def test_create_isa(ids, session_ridv2): + """ASTM Compliance Test: DSS0030_A_PUT_ISA.""" + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + req_body = { + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL_V1, + } + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json=req_body) + assert resp.status_code == 200, resp.content + + data = resp.json() + assert data['service_area']['id'] == ids(ISA_TYPE) + assert data['service_area']['uss_base_url'] == BASE_URL_V1 + assert_datetimes_are_equal(data['service_area']['time_start']['value'], req_body['extents']['time_start']['value']) + assert_datetimes_are_equal(data['service_area']['time_end']['value'], req_body['extents']['time_end']['value']) + assert re.match(r'[a-z0-9]{10,}$', data['service_area']['version']) + assert 'subscribers' in data + + +@default_scope(SCOPE_DP) +def test_get_isa_by_id(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE))) + assert resp.status_code == 200, resp.content + + data = resp.json() + assert data['service_area']['id'] == ids(ISA_TYPE) + assert data['service_area']['uss_base_url'] == BASE_URL_V1 + + +@default_scope(SCOPE_SP) +def test_update_isa(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE))) + version = resp.json()['service_area']['version'] + + resp = session_ridv2.put( + '{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + }, + 'uss_base_url': BASE_URL_V2, + }) + assert resp.status_code == 200 + + data = resp.json() + assert data['service_area']['id'] == ids(ISA_TYPE) + assert data['service_area']['uss_base_url'] == BASE_URL_V2 + assert re.match(r'[a-z0-9]{10,}$', data['service_area']['version']) + assert 'subscribers' in data + + +@default_scope(SCOPE_DP) +def test_get_isa_by_id_after_update(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE))) + assert resp.status_code == 200, resp.content + + data = resp.json() + assert data['service_area']['id'] == ids(ISA_TYPE) + assert data['service_area']['uss_base_url'] == BASE_URL_V2 + + +@default_scope(SCOPE_DP) +def test_get_isa_by_search_missing_params(session_ridv2): + resp = session_ridv2.get(ISA_PATH) + assert resp.status_code == 400, resp.content + + +@default_scope(SCOPE_DP) +def test_get_isa_by_search(ids, session_ridv2): + resp = session_ridv2.get('{}?area={}'.format( + ISA_PATH, common.GEO_POLYGON_STRING)) + assert resp.status_code == 200, resp.content + assert ids(ISA_TYPE) in [x['id'] for x in resp.json()['service_areas']] + + +@default_scope(SCOPE_DP) +def test_get_isa_by_search_earliest_time_included(ids, session_ridv2): + earliest_time = datetime.datetime.utcnow() + datetime.timedelta(minutes=59) + resp = session_ridv2.get('{}?area={}&earliest_time={}'.format( + ISA_PATH, common.GEO_POLYGON_STRING, + earliest_time.strftime(rid_v2.DATE_FORMAT))) + assert resp.status_code == 200, resp.content + assert ids(ISA_TYPE) in [x['id'] for x in resp.json()['service_areas']] + + +@default_scope(SCOPE_DP) +def test_get_isa_by_search_earliest_time_excluded(ids, session_ridv2): + earliest_time = datetime.datetime.utcnow() + datetime.timedelta(minutes=61) + resp = session_ridv2.get('{}?area={}&earliest_time={}'.format( + ISA_PATH, common.GEO_POLYGON_STRING, + earliest_time.strftime(rid_v2.DATE_FORMAT))) + assert resp.status_code == 200, resp.content + assert ids(ISA_TYPE) not in [x['id'] for x in resp.json()['service_areas']] + + +@default_scope(SCOPE_DP) +def test_get_isa_by_search_latest_time_included(ids, session_ridv2): + latest_time = datetime.datetime.utcnow() + datetime.timedelta(minutes=1) + resp = session_ridv2.get('{}?area={}&latest_time={}'.format( + ISA_PATH, common.GEO_POLYGON_STRING, + latest_time.strftime(rid_v2.DATE_FORMAT))) + assert resp.status_code == 200, resp.content + assert ids(ISA_TYPE) in [x['id'] for x in resp.json()['service_areas']] + + +@default_scope(SCOPE_DP) +def test_get_isa_by_search_latest_time_excluded(ids, session_ridv2): + latest_time = datetime.datetime.utcnow() - datetime.timedelta(minutes=1) + resp = session_ridv2.get('{}?area={}&latest_time={}'.format( + ISA_PATH, common.GEO_POLYGON_STRING, + latest_time.strftime(rid_v2.DATE_FORMAT))) + assert resp.status_code == 200, resp.content + assert ids(ISA_TYPE) not in [x['id'] for x in resp.json()['service_areas']] + + +@default_scope(SCOPE_DP) +def test_get_isa_by_search_area_only(ids, session_ridv2): + resp = session_ridv2.get('{}?area={}'.format(ISA_PATH, common.GEO_POLYGON_STRING)) + assert resp.status_code == 200, resp.content + assert ids(ISA_TYPE) in [x['id'] for x in resp.json()['service_areas']] + + +@default_scope(SCOPE_DP) +def test_get_isa_by_search_huge_area(session_ridv2): + resp = session_ridv2.get('{}?area={}'.format(ISA_PATH, common.HUGE_GEO_POLYGON_STRING)) + assert resp.status_code == 413, resp.content + + +@default_scope(SCOPE_SP) +def test_delete_isa_wrong_version(ids, session_ridv2): + resp = session_ridv2.delete('{}/{}/fake_version'.format(ISA_PATH, ids(ISA_TYPE))) + assert resp.status_code == 400, resp.content + + +@default_scope(SCOPE_SP) +def test_delete_isa_empty_version(ids, session_ridv2): + resp = session_ridv2.delete('{}/{}/'.format(ISA_PATH, ids(ISA_TYPE))) + assert resp.status_code == 400, resp.content + + +def test_delete_isa(ids, session_ridv2): + """ASTM Compliance Test: DSS0030_B_DELETE_ISA.""" + # GET the ISA first to find its version. + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_DP) + assert resp.status_code == 200, resp.content + version = resp.json()['service_area']['version'] + + # Then delete it. + resp = session_ridv2.delete('{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + + +@default_scope(SCOPE_DP) +def test_get_deleted_isa_by_id(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE))) + assert resp.status_code == 404, resp.content + + +@default_scope(SCOPE_DP) +def test_get_deleted_isa_by_search(ids, session_ridv2): + resp = session_ridv2.get('{}?area={}'.format(ISA_PATH, common.GEO_POLYGON_STRING)) + assert resp.status_code == 200, resp.content + assert ids(ISA_TYPE) not in [x['id'] for x in resp.json()['service_areas']] + + +@default_scope(SCOPE_DP) +def test_get_isa_search_area_with_loop(session_ridv2): + resp = session_ridv2.get('{}?area={}'.format(ISA_PATH, common.LOOP_GEO_POLYGON_STRING)) + assert resp.status_code == 400, resp.content diff --git a/monitoring/prober/rid/v2/test_isa_validation.py b/monitoring/prober/rid/v2/test_isa_validation.py new file mode 100644 index 000000000..dea36aaac --- /dev/null +++ b/monitoring/prober/rid/v2/test_isa_validation.py @@ -0,0 +1,212 @@ +"""ISA input validation tests: + + - check we can't create the ISA with a huge area + - check we can't create the ISA with missing fields + - check we can't create the ISA with a time_start in the past + - check we can't create the ISA with a time_start after time_end +""" + +import datetime + +from monitoring.monitorlib.infrastructure import default_scope +from monitoring.monitorlib import rid_v2 +from monitoring.monitorlib.rid_v2 import SCOPE_DP, SCOPE_SP, ISA_PATH +from monitoring.prober.infrastructure import register_resource_type +from . import common + + +ISA_TYPE = register_resource_type(366, 'ISA') +BASE_URL = 'https://example.com/rid/v2' + + +def test_ensure_clean_workspace(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_DP) + if resp.status_code == 200: + version = resp.json()["service_area"]['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected. + pass + else: + assert False, resp.content + + +@default_scope(SCOPE_SP) +def test_isa_huge_area(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.HUGE_VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 400, resp.content + assert 'too large' in resp.json()['message'] + + +@default_scope(SCOPE_SP) +def test_isa_empty_vertices(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': [], + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 400, resp.content + assert 'Not enough points in polygon' in resp.json()['message'] + + +@default_scope(SCOPE_SP) +def test_isa_missing_outline(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 400, resp.content + assert 'Error parsing Volume4D' in resp.json()['message'] + + +@default_scope(SCOPE_SP) +def test_isa_missing_volume(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 400, resp.content + assert 'Error parsing Volume4D' in resp.json()['message'] + + +@default_scope(SCOPE_SP) +def test_isa_missing_extents(ids, session_ridv2): + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 400, resp.content + assert resp.json()['message'] == 'Missing required extents' + + +@default_scope(SCOPE_SP) +def test_isa_start_time_in_past(ids, session_ridv2): + time_start = datetime.datetime.utcnow() - datetime.timedelta(minutes=10) + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 400, resp.content + assert resp.json()['message'] == 'IdentificationServiceArea time_start must not be in the past' + + +@default_scope(SCOPE_SP) +def test_isa_start_time_after_time_end(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + datetime.timedelta(minutes=10) + time_end = time_start - datetime.timedelta(minutes=5) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 400, resp.content + assert resp.json()['message'] == 'IdentificationServiceArea time_end must be after time_start' + + +@default_scope(SCOPE_SP) +def test_isa_not_on_earth(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': [ + {'lat': 130.6205, 'lng': -23.6558}, + {'lat': 130.6301, 'lng': -23.6898}, + {'lat': 130.6700, 'lng': -23.6709}, + {'lat': 130.6466, 'lng': -23.6407}, + ], + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 400, resp.content diff --git a/monitoring/prober/rid/v2/test_subscription_isa_interactions.py b/monitoring/prober/rid/v2/test_subscription_isa_interactions.py new file mode 100644 index 000000000..f6724b4d3 --- /dev/null +++ b/monitoring/prober/rid/v2/test_subscription_isa_interactions.py @@ -0,0 +1,171 @@ +"""Test subscriptions interact with ISAs: + + - Create an ISA. + - Create a subscription, response should include the pre-existing ISA. + - Modify the ISA, response should include the subscription. + - Delete the ISA, response should include the subscription. + - Delete the subscription. +""" + +import datetime + +from monitoring.monitorlib.infrastructure import default_scope +from monitoring.monitorlib import rid_v2 +from monitoring.monitorlib.rid_v2 import SCOPE_DP, SCOPE_SP, ISA_PATH, SUBSCRIPTION_PATH +from monitoring.prober.infrastructure import register_resource_type +from . import common + + +ISA_TYPE = register_resource_type(364, 'ISA') +SUB_TYPE = register_resource_type(365, 'Subscription') +BASE_URL = 'https://example.com/rid/v2' + + +def test_ensure_clean_workspace(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_DP) + if resp.status_code == 200: + version = resp.json()['service_area']['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected. + pass + else: + assert False, resp.content + + resp = session_ridv2.get('{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), scope=SCOPE_DP) + if resp.status_code == 200: + version = resp.json()['subscription']['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE), version), scope=SCOPE_DP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected + pass + else: + assert False, resp.content + + +@default_scope(SCOPE_SP) +def test_create_isa(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 200, resp.content + + +@default_scope(SCOPE_DP) +def test_create_subscription(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }) + assert resp.status_code == 200, resp.content + + # The response should include our ISA. + data = resp.json() + assert data['subscription']['notification_index'] == 0 + assert ids(ISA_TYPE) in [x['id'] for x in data['service_areas']] + + +def test_modify_isa(ids, session_ridv2): + # GET the ISA first to find its version. + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_DP) + assert resp.status_code == 200, resp.content + version = resp.json()['service_area']['version'] + + # Then modify it. + time_end = datetime.datetime.utcnow() + datetime.timedelta(minutes=60) + resp = session_ridv2.put( + '{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(12345), + 'altitude_upper': rid_v2.Altitude.make(67890), + }, + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }, scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + + # The response should include our subscription. + data = resp.json() + assert { + 'url': BASE_URL, + 'subscriptions': [{ + 'notification_index': 1, + 'subscription_id': ids(SUB_TYPE), + },], + } in data['subscribers'] + + +def test_delete_isa(ids, session_ridv2): + # GET the ISA first to find its version. + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_DP) + assert resp.status_code == 200, resp.content + version = resp.json()['service_area']['version'] + + # Then delete it. + resp = session_ridv2.delete('{}/{}/{}'.format( + ISA_PATH, ids(ISA_TYPE), version), scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + + # The response should include our subscription. + data = resp.json() + assert { + 'url': BASE_URL, + 'subscriptions': [{ + 'notification_index': 2, + 'subscription_id': ids(SUB_TYPE), + },], + } in data['subscribers'] + + +@default_scope(SCOPE_DP) +def test_delete_subscription(ids, session_ridv2): + # GET the sub first to find its version. + resp = session_ridv2.get('{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE))) + assert resp.status_code == 200, resp.content + + data = resp.json() + version = data['subscription']['version'] + assert data['subscription']['notification_index'] == 2 + + # Then delete it. + resp = session_ridv2.delete('{}/{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE), version)) + assert resp.status_code == 200, resp.content diff --git a/monitoring/prober/rid/v2/test_subscription_simple.py b/monitoring/prober/rid/v2/test_subscription_simple.py new file mode 100644 index 000000000..3e12e675b --- /dev/null +++ b/monitoring/prober/rid/v2/test_subscription_simple.py @@ -0,0 +1,146 @@ +"""Basic subscription tests: + + - create the subscription with a 60 minute expiry + - get by ID + - get by search + - delete +""" + +import datetime +import re + +from monitoring.monitorlib.infrastructure import default_scope +from monitoring.monitorlib import rid_v2 +from monitoring.monitorlib.rid_v2 import SCOPE_DP, SUBSCRIPTION_PATH +from monitoring.monitorlib.testing import assert_datetimes_are_equal +from monitoring.prober.infrastructure import register_resource_type +from . import common + + +SUB_TYPE = register_resource_type(349, 'Subscription') +BASE_URL = 'https://example.com/rid/v2' + + +def test_ensure_clean_workspace(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), scope=SCOPE_DP) + if resp.status_code == 200: + version = resp.json()['subscription']['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE), version), scope=SCOPE_DP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected. + pass + else: + assert False, resp.content + + +@default_scope(SCOPE_DP) +def test_sub_does_not_exist(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE))) + assert resp.status_code == 404, resp.content + assert 'Subscription {} not found'.format(ids(SUB_TYPE)) in resp.json()['message'] + + +@default_scope(SCOPE_DP) +def test_create_sub(ids, session_ridv2): + """ASTM Compliance Test: DSS0030_C_PUT_SUB.""" + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + req_body = { + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL + } + resp = session_ridv2.put( + '{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), + json=req_body) + assert resp.status_code == 200, resp.content + + data = resp.json() + assert data['subscription']['id'] == ids(SUB_TYPE) + assert data['subscription']['notification_index'] == 0 + assert data['subscription']['uss_base_url'] == BASE_URL + assert_datetimes_are_equal(data['subscription']['time_start']['value'], req_body['extents']['time_start']['value']) + assert_datetimes_are_equal(data['subscription']['time_end']['value'], req_body['extents']['time_end']['value']) + assert re.match(r'[a-z0-9]{10,}$', data['subscription']['version']) + assert 'service_areas' in data + + +@default_scope(SCOPE_DP) +def test_get_sub_by_id(ids, session_ridv2): + """ASTM Compliance Test: DSS0030_E_GET_SUB_BY_ID.""" + resp = session_ridv2.get('{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE))) + assert resp.status_code == 200, resp.content + + data = resp.json() + assert data['subscription']['id'] == ids(SUB_TYPE) + assert data['subscription']['notification_index'] == 0 + assert data['subscription']['uss_base_url'] == BASE_URL + + +@default_scope(SCOPE_DP) +def test_get_sub_by_search(ids, session_ridv2): + """ASTM Compliance Test: DSS0030_F_GET_SUBS_BY_AREA.""" + resp = session_ridv2.get('{}?area={}'.format(SUBSCRIPTION_PATH, common.GEO_POLYGON_STRING)) + assert resp.status_code == 200, resp.content + assert ids(SUB_TYPE) in [x['id'] for x in resp.json()['subscriptions']] + + +@default_scope(SCOPE_DP) +def test_get_sub_by_searching_huge_area(session_ridv2): + resp = session_ridv2.get('{}?area={}'.format(SUBSCRIPTION_PATH, common.HUGE_GEO_POLYGON_STRING)) + assert resp.status_code == 413, resp.content + + +@default_scope(SCOPE_DP) +def test_delete_sub_empty_version(ids, session_ridv2): + resp = session_ridv2.delete('{}/{}/'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE))) + assert resp.status_code == 400, resp.content + + +@default_scope(SCOPE_DP) +def test_delete_sub_wrong_version(ids, session_ridv2): + resp = session_ridv2.delete('{}/{}/fake_version'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE))) + assert resp.status_code == 400, resp.content + + +@default_scope(SCOPE_DP) +def test_delete_sub(ids, session_ridv2): + """ASTM Compliance Test: DSS0030_D_DELETE_SUB.""" + # GET the sub first to find its version. + resp = session_ridv2.get('{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE))) + assert resp.status_code == 200, resp.content + version = resp.json()['subscription']['version'] + + # Then delete it. + resp = session_ridv2.delete('{}/{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE), version)) + assert resp.status_code == 200, resp.content + + +@default_scope(SCOPE_DP) +def test_get_deleted_sub_by_id(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE))) + assert resp.status_code == 404, resp.content + + +@default_scope(SCOPE_DP) +def test_get_deleted_sub_by_search(ids, session_ridv2): + resp = session_ridv2.get('{}?area={}'.format(SUBSCRIPTION_PATH, common.GEO_POLYGON_STRING)) + assert resp.status_code == 200, resp.content + assert ids(SUB_TYPE) not in [x['id'] for x in resp.json()['subscriptions']] + + +@default_scope(SCOPE_DP) +def test_get_sub_with_loop_area(session_ridv2): + resp = session_ridv2.get('{}?area={}'.format(SUBSCRIPTION_PATH, common.LOOP_GEO_POLYGON_STRING)) + assert resp.status_code == 400, resp.content diff --git a/monitoring/prober/rid/v2/test_subscription_validation.py b/monitoring/prober/rid/v2/test_subscription_validation.py new file mode 100644 index 000000000..898b5a8b1 --- /dev/null +++ b/monitoring/prober/rid/v2/test_subscription_validation.py @@ -0,0 +1,237 @@ + +"""Subscription input validation tests: + - check we can't create too many SUBS (common.MAX_SUBS_PER_AREA) + - check we can't create the SUB with a huge area + - check we can't create the SUB with missing fields + - check we can't create the SUB with a time_start in the past + - check we can't create the SUB with a time_start after time_end +""" +import datetime + +from monitoring.monitorlib.infrastructure import default_scope +from monitoring.monitorlib import rid_v2 +from monitoring.monitorlib.rid_v2 import SCOPE_DP, SUBSCRIPTION_PATH +from monitoring.prober.infrastructure import register_resource_type +from . import common + + +SUB_TYPE = register_resource_type(350, 'Subscription') +MULTI_SUB_TYPES = [register_resource_type(351 + i, 'Subscription limit Subscription {}'.format(i)) for i in range(11)] +BASE_URL = 'http://example.com/rid/v2' + + +def test_ensure_clean_workspace(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), scope=SCOPE_DP) + if resp.status_code == 200: + version = resp.json()['subscription']['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE), version), scope=SCOPE_DP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected. + pass + else: + assert False, resp.content + + +@default_scope(SCOPE_DP) +def test_create_sub_empty_vertices(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(seconds=10) + + resp = session_ridv2.put( + '{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': [], + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL + }) + assert resp.status_code == 400, resp.content + + +@default_scope(SCOPE_DP) +def test_create_sub_missing_outline_polygon(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(seconds=10) + + resp = session_ridv2.put( + '{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), + json={ + 'extents': { + 'volume': { + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL + }) + assert resp.status_code == 400, resp.content + + +@default_scope(SCOPE_DP) +def test_create_sub_with_huge_area(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(seconds=10) + + resp = session_ridv2.put( + '{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.HUGE_VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL + }) + assert resp.status_code == 400, resp.content + + +@default_scope(SCOPE_DP) +def test_create_too_many_subs(ids, session_ridv2): + """ASTM Compliance Test: DSS0050_MAX_SUBS_PER_AREA.""" + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(seconds=30) + + # create 1 more than the max allowed Subscriptions per area + versions = [] + for index in range(rid_v2.MAX_SUB_PER_AREA + 1): + resp = session_ridv2.put( + '{}/{}'.format(SUBSCRIPTION_PATH, ids(MULTI_SUB_TYPES[index])), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': [ + { + "lat": 37.440, + "lng": -131.745, + }, + { + "lat": 37.459, + "lng": -131.745, + }, + { + "lat": 37.459, + "lng": -131.706, + }, + { + "lat": 37.440, + "lng": -131.706, + }, + ], + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL + }) + if index < rid_v2.MAX_SUB_PER_AREA: + assert resp.status_code == 200, resp.content + resp_json = resp.json() + assert 'subscription' in resp_json + assert 'version' in resp_json['subscription'] + versions.append(resp_json['subscription']['version']) + else: + assert resp.status_code == 429, resp.content + + # clean up Subscription-limit Subscriptions + for index in range(rid_v2.MAX_SUB_PER_AREA): + resp = session_ridv2.delete('{}/{}/{}'.format(SUBSCRIPTION_PATH, ids(MULTI_SUB_TYPES[index]), versions[index])) + assert resp.status_code == 200 + + +@default_scope(SCOPE_DP) +def test_create_sub_with_too_long_end_time(ids, session_ridv2): + """ASTM Compliance Test: DSS0060_MAX_SUBS_DURATION.""" + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(hours=(rid_v2.MAX_SUB_TIME_HRS + 1)) + + resp = session_ridv2.put( + "{}/{}".format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), + json={ + "extents": { + "volume": { + "outline_polygon": {"vertices": common.VERTICES}, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL + }, + ) + assert resp.status_code == 400, resp.content + + +@default_scope(SCOPE_DP) +def test_update_sub_with_too_long_end_time(ids, session_ridv2): + """ASTM Compliance Test: DSS0060_MAX_SUBS_DURATION.""" + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(seconds=10) + + resp = session_ridv2.put( + '{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), + json={ + "extents": { + "volume": { + "outline_polygon": {"vertices": common.VERTICES}, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL + }, + ) + assert resp.status_code == 200, resp.content + + time_end = time_start + datetime.timedelta(hours=(rid_v2.MAX_SUB_TIME_HRS + 1)) + resp = session_ridv2.put( + '{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)) + '/' + resp.json()["subscription"]["version"], + json={ + "extents": { + "volume": { + "outline_polygon": {"vertices": common.VERTICES}, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL + }, + ) + assert resp.status_code == 400, resp.content + + +@default_scope(SCOPE_DP) +def test_delete(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE)), scope=SCOPE_DP) + if resp.status_code == 200: + version = resp.json()['subscription']['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(SUBSCRIPTION_PATH, ids(SUB_TYPE), version), scope=SCOPE_DP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected. + pass diff --git a/monitoring/prober/rid/v2/test_token_validation.py b/monitoring/prober/rid/v2/test_token_validation.py new file mode 100644 index 000000000..88c06117c --- /dev/null +++ b/monitoring/prober/rid/v2/test_token_validation.py @@ -0,0 +1,103 @@ +"""Test Authentication validation + - Try to read DSS without Token + - Try to read DSS with Token that cannot be decoded + - Try to read and write DSS with Token missing and wrong Scope + + ASTM Compliance Test: DSS0010_USS_AUTH + This entire file is used to demonstrate that the DSS requires proper + authentication tokens to perform actions on the DSS +""" + +import datetime + +from monitoring.monitorlib import rid_v2 +from monitoring.monitorlib.rid_v2 import SCOPE_DP, SCOPE_SP, ISA_PATH +from monitoring.prober.infrastructure import register_resource_type +from . import common + + +ISA_TYPE = register_resource_type(363, 'ISA') +BASE_URL = 'https://example.com/rid/v2' + + +def test_ensure_clean_workspace(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_DP) + if resp.status_code == 200: + version = resp.json()["service_area"]['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected. + pass + else: + assert False, resp.content + + +def test_put_isa_with_read_only_scope_token(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }, scope=SCOPE_DP) + assert resp.status_code == 403, resp.content + + +def test_create_isa(ids, session_ridv2): + time_start = datetime.datetime.utcnow() + time_end = time_start + datetime.timedelta(minutes=60) + + resp = session_ridv2.put( + '{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), + json={ + 'extents': { + 'volume': { + 'outline_polygon': { + 'vertices': common.VERTICES, + }, + 'altitude_lower': rid_v2.Altitude.make(20), + 'altitude_upper': rid_v2.Altitude.make(400), + }, + 'time_start': rid_v2.Time.make(time_start), + 'time_end': rid_v2.Time.make(time_end), + }, + 'uss_base_url': BASE_URL, + }, scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + + +def test_get_isa_without_token(ids, no_auth_session_ridv2): + resp = no_auth_session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE))) + assert resp.status_code == 401, resp.content + assert resp.json()['message'] == 'Missing access token' + + +def test_get_isa_with_fake_token(ids, no_auth_session_ridv2): + no_auth_session_ridv2.headers['Authorization'] = 'Bearer fake_token' + resp = no_auth_session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE))) + assert resp.status_code == 401, resp.content + assert resp.json()['message'] == 'token contains an invalid number of segments' + + +def test_delete(ids, session_ridv2): + resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_DP) + if resp.status_code == 200: + version = resp.json()["service_area"]['version'] + resp = session_ridv2.delete('{}/{}/{}'.format(ISA_PATH, ids(ISA_TYPE), version), scope=SCOPE_SP) + assert resp.status_code == 200, resp.content + elif resp.status_code == 404: + # As expected. + pass diff --git a/pkg/auth/auth.go b/pkg/auth/auth.go index 0a90b7af4..667801025 100644 --- a/pkg/auth/auth.go +++ b/pkg/auth/auth.go @@ -161,6 +161,10 @@ type KeyClaimedScopesValidator interface { // ValidateKeyClaimedScopes returns an error if 'scopes' are not sufficient // to authorize an operation, nil otherwise. ValidateKeyClaimedScopes(ctx context.Context, scopes ScopeSet) error + + // Expectation returns a string indicating the scopes expected to validate + // successfully. + Expectation() string } type allScopesRequiredValidator struct { @@ -187,6 +191,26 @@ func (v *allScopesRequiredValidator) ValidateKeyClaimedScopes(ctx context.Contex return nil } +func scopeSetToString(scopes ScopeSet, separator string) string { + var stringScopes []string + for scope := range scopes { + stringScopes = append(stringScopes, scope.String()) + } + return strings.Join(stringScopes, separator) +} + +func scopesToString(scopes []Scope, separator string) string { + var stringScopes []string + for _, scope := range scopes { + stringScopes = append(stringScopes, scope.String()) + } + return strings.Join(stringScopes, separator) +} + +func (v *allScopesRequiredValidator) Expectation() string { + return scopesToString(v.scopes, " and ") +} + // RequireAllScopes returns a KeyClaimedScopesValidator instance ensuring that // every element in scopes is claimed by an incoming set of scopes. func RequireAllScopes(scopes ...Scope) KeyClaimedScopesValidator { @@ -216,6 +240,10 @@ func (v *anyScopesRequiredValidator) ValidateKeyClaimedScopes(ctx context.Contex } } +func (v *anyScopesRequiredValidator) Expectation() string { + return scopesToString(v.scopes, " or ") +} + // RequireAnyScope returns a KeyClaimedScopesValidator instance ensuring that // at least one element in scopes is claimed by an incoming set of scopes. func RequireAnyScope(scopes ...Scope) KeyClaimedScopesValidator { @@ -327,21 +355,28 @@ func (a *Authorizer) AuthInterceptor(ctx context.Context, req interface{}, info "Invalid access token audience: %v", keyClaims.Audience) } - if err := a.validateKeyClaimedScopes(ctx, info, keyClaims.Scopes); err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Access token missing scopes") + expectation, err := a.validateKeyClaimedScopes(ctx, info, keyClaims.Scopes) + if err != nil { + return nil, stacktrace.NewErrorWithCode(dsserr.PermissionDenied, "Access token missing scopes; found %v while expecting %v", scopeSetToString(keyClaims.Scopes, ", "), expectation) } return handler(ContextWithOwner(ctx, models.Owner(keyClaims.Subject)), req) } -// Matches keyClaimedScopes against the required scopes and returns true if -// keyClaimedScopes contains at least one of the required scopes in a. -func (a *Authorizer) validateKeyClaimedScopes(ctx context.Context, info *grpc.UnaryServerInfo, keyClaimedScopes ScopeSet) error { +// Matches keyClaimedScopes against the required scopes and returns nil, nil if +// keyClaimedScopes satisifies the authorizer, otherwise returns the expectation +// and the error. +func (a *Authorizer) validateKeyClaimedScopes(ctx context.Context, info *grpc.UnaryServerInfo, keyClaimedScopes ScopeSet) (string, error) { if validator, known := a.scopesValidators[Operation(info.FullMethod)]; known { - return validator.ValidateKeyClaimedScopes(ctx, keyClaimedScopes) + err := validator.ValidateKeyClaimedScopes(ctx, keyClaimedScopes) + expectation := "" + if err != nil { + expectation = validator.Expectation() + } + return expectation, err } - return nil + return "", nil } func getToken(ctx context.Context) (string, bool) { diff --git a/pkg/rid/models/api/v2/conversions.go b/pkg/rid/models/api/v2/conversions.go index 46f868d8f..b5728dd10 100644 --- a/pkg/rid/models/api/v2/conversions.go +++ b/pkg/rid/models/api/v2/conversions.go @@ -201,7 +201,7 @@ func ToIdentificationServiceArea(i *ridmodels.IdentificationServiceArea) *ridpb. // for API consumption. func ToSubscriberToNotify(s *ridmodels.Subscription) *ridpb.SubscriberToNotify { return &ridpb.SubscriberToNotify{ - Url: s.URL, + Url: strings.TrimSuffix(strings.TrimSuffix(s.URL, "/v1/uss/identification_service_areas"), "/uss/identification_service_areas"), Subscriptions: []*ridpb.SubscriptionState{ { NotificationIndex: int32(s.NotificationIndex), diff --git a/pkg/rid/server/v1/isa_handler.go b/pkg/rid/server/v1/isa_handler.go index 9ba1aca1e..a25d1d9bb 100644 --- a/pkg/rid/server/v1/isa_handler.go +++ b/pkg/rid/server/v1/isa_handler.go @@ -65,7 +65,7 @@ func (s *Server) CreateIdentificationServiceArea( } extents, err := apiv1.FromVolume4D(params.Extents) if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D: %v", stacktrace.RootCause(err)) } id, err := dssmodels.IDFromString(req.Id) if err != nil { @@ -138,7 +138,7 @@ func (s *Server) UpdateIdentificationServiceArea( } extents, err := apiv1.FromVolume4D(params.Extents) if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D: %v", stacktrace.RootCause(err)) } id, err := dssmodels.IDFromString(req.Id) if err != nil { diff --git a/pkg/rid/server/v1/subscription_handler.go b/pkg/rid/server/v1/subscription_handler.go index 90fc51912..9a6d302ab 100644 --- a/pkg/rid/server/v1/subscription_handler.go +++ b/pkg/rid/server/v1/subscription_handler.go @@ -127,7 +127,7 @@ func (s *Server) CreateSubscription( } extents, err := apiv1.FromVolume4D(params.Extents) if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D: %v", stacktrace.RootCause(err)) } id, err := dssmodels.IDFromString(req.Id) if err != nil { @@ -212,7 +212,7 @@ func (s *Server) UpdateSubscription( } extents, err := apiv1.FromVolume4D(params.Extents) if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D: %v", stacktrace.RootCause(err)) } sub := &ridmodels.Subscription{ diff --git a/pkg/rid/server/v2/isa_handler.go b/pkg/rid/server/v2/isa_handler.go index c9dbcec2f..620860bf4 100644 --- a/pkg/rid/server/v2/isa_handler.go +++ b/pkg/rid/server/v2/isa_handler.go @@ -65,7 +65,7 @@ func (s *Server) CreateIdentificationServiceArea( } extents, err := apiv2.FromVolume4D(params.Extents) if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D: %v", stacktrace.RootCause(err)) } id, err := dssmodels.IDFromString(req.Id) if err != nil { @@ -138,7 +138,7 @@ func (s *Server) UpdateIdentificationServiceArea( } extents, err := apiv2.FromVolume4D(params.Extents) if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D: %v", stacktrace.RootCause(err)) } id, err := dssmodels.IDFromString(req.Id) if err != nil { diff --git a/pkg/rid/server/v2/server.go b/pkg/rid/server/v2/server.go index 2855d8d7d..bc4c8808e 100644 --- a/pkg/rid/server/v2/server.go +++ b/pkg/rid/server/v2/server.go @@ -15,7 +15,7 @@ var ( ServiceProvider auth.Scope DisplayProvider auth.Scope }{ - ServiceProvider: "rid.server_provider", + ServiceProvider: "rid.service_provider", DisplayProvider: "rid.display_provider", } ) diff --git a/pkg/rid/server/v2/subscription_handler.go b/pkg/rid/server/v2/subscription_handler.go index 69a345b72..6f7af294e 100644 --- a/pkg/rid/server/v2/subscription_handler.go +++ b/pkg/rid/server/v2/subscription_handler.go @@ -127,7 +127,7 @@ func (s *Server) CreateSubscription( } extents, err := apiv2.FromVolume4D(params.Extents) if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D: %v", stacktrace.RootCause(err)) } id, err := dssmodels.IDFromString(req.Id) if err != nil { @@ -212,7 +212,7 @@ func (s *Server) UpdateSubscription( } extents, err := apiv2.FromVolume4D(params.Extents) if err != nil { - return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D") + return nil, stacktrace.NewErrorWithCode(dsserr.BadRequest, "Error parsing Volume4D: %v", stacktrace.RootCause(err)) } sub := &ridmodels.Subscription{ diff --git a/test/docker_e2e.sh b/test/docker_e2e.sh index 3b8e33d32..4ae80aa82 100755 --- a/test/docker_e2e.sh +++ b/test/docker_e2e.sh @@ -152,6 +152,7 @@ if ! docker run --link dummy-oauth-for-testing:oauth \ --junitxml=/app/test_result \ --dss-endpoint http://local-gateway:8082 \ --rid-auth "DummyOAuth(http://oauth:8085/token,sub=fake_uss)" \ + --rid-v2-auth "DummyOAuth(http://oauth:8085/token,sub=fake_uss)" \ --scd-auth1 "DummyOAuth(http://oauth:8085/token,sub=fake_uss)" \ --scd-auth2 "DummyOAuth(http://oauth:8085/token,sub=fake_uss2)" \ --scd-api-version 1.0.0; then From 7c40d53fc5f669007110c2fefa51ba4b4dcae0c6 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Mon, 30 May 2022 13:14:21 -0700 Subject: [PATCH 3/7] Remove v1-v2 URL conversion --- pkg/rid/models/api/v2/conversions.go | 7 +++---- pkg/rid/server/v2/isa_handler.go | 4 ++-- pkg/rid/server/v2/subscription_handler.go | 4 ++-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/pkg/rid/models/api/v2/conversions.go b/pkg/rid/models/api/v2/conversions.go index b5728dd10..384055171 100644 --- a/pkg/rid/models/api/v2/conversions.go +++ b/pkg/rid/models/api/v2/conversions.go @@ -1,7 +1,6 @@ package apiv2 import ( - "strings" "time" ridpb "github.com/interuss/dss/pkg/api/v2/ridpbv2" @@ -188,7 +187,7 @@ func ToIdentificationServiceArea(i *ridmodels.IdentificationServiceArea) *ridpb. result := &ridpb.IdentificationServiceArea{ Id: i.ID.String(), Owner: i.Owner.String(), - UssBaseUrl: strings.TrimSuffix(strings.TrimSuffix(i.URL, "/v1/uss/flights"), "/uss/flights"), + UssBaseUrl: i.URL, Version: i.Version.String(), TimeStart: ToTime(i.StartTime), TimeEnd: ToTime(i.EndTime), @@ -201,7 +200,7 @@ func ToIdentificationServiceArea(i *ridmodels.IdentificationServiceArea) *ridpb. // for API consumption. func ToSubscriberToNotify(s *ridmodels.Subscription) *ridpb.SubscriberToNotify { return &ridpb.SubscriberToNotify{ - Url: strings.TrimSuffix(strings.TrimSuffix(s.URL, "/v1/uss/identification_service_areas"), "/uss/identification_service_areas"), + Url: s.URL, Subscriptions: []*ridpb.SubscriptionState{ { NotificationIndex: int32(s.NotificationIndex), @@ -217,7 +216,7 @@ func ToSubscription(s *ridmodels.Subscription) *ridpb.Subscription { result := &ridpb.Subscription{ Id: s.ID.String(), Owner: s.Owner.String(), - UssBaseUrl: strings.TrimSuffix(strings.TrimSuffix(s.URL, "/v1/uss/identification_service_areas"), "/uss/identification_service_areas"), + UssBaseUrl: s.URL, NotificationIndex: int32(s.NotificationIndex), Version: s.Version.String(), TimeStart: ToTime(s.StartTime), diff --git a/pkg/rid/server/v2/isa_handler.go b/pkg/rid/server/v2/isa_handler.go index 620860bf4..6e7509aaa 100644 --- a/pkg/rid/server/v2/isa_handler.go +++ b/pkg/rid/server/v2/isa_handler.go @@ -81,7 +81,7 @@ func (s *Server) CreateIdentificationServiceArea( isa := &ridmodels.IdentificationServiceArea{ ID: id, - URL: params.GetUssBaseUrl() + "/uss/flights", + URL: params.GetUssBaseUrl(), Owner: owner, Writer: s.Locality, } @@ -147,7 +147,7 @@ func (s *Server) UpdateIdentificationServiceArea( isa := &ridmodels.IdentificationServiceArea{ ID: dssmodels.ID(id), - URL: params.UssBaseUrl + "/uss/flights", + URL: params.UssBaseUrl, Owner: owner, Version: version, Writer: s.Locality, diff --git a/pkg/rid/server/v2/subscription_handler.go b/pkg/rid/server/v2/subscription_handler.go index 6f7af294e..d46593256 100644 --- a/pkg/rid/server/v2/subscription_handler.go +++ b/pkg/rid/server/v2/subscription_handler.go @@ -145,7 +145,7 @@ func (s *Server) CreateSubscription( sub := &ridmodels.Subscription{ ID: id, Owner: owner, - URL: params.UssBaseUrl + "/uss/identification_service_areas", + URL: params.UssBaseUrl, Writer: s.Locality, } @@ -218,7 +218,7 @@ func (s *Server) UpdateSubscription( sub := &ridmodels.Subscription{ ID: id, Owner: owner, - URL: params.UssBaseUrl + "/uss/identification_service_areas", + URL: params.UssBaseUrl, Version: version, Writer: s.Locality, } From fc7d1b2fcbbeda7b40c2f9c09ea4c1ff9233e3db Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Mon, 30 May 2022 14:16:26 -0700 Subject: [PATCH 4/7] Add mixed-version documentation --- README.md | 4 +++- interfaces/rid/README.md | 49 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 interfaces/rid/README.md diff --git a/README.md b/README.md index 7ad9a009a..edd01c7b9 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ framework to test UAS Service Suppliers (USS). See the [InterUSS website](https: The DSS implementation and associated monitoring tools target compliance with the following standards and regulations: - [ASTM F3411-19](https://www.astm.org/Standards/F3411.htm): Remote ID. -[See OpenAPI interface](./interfaces/rid/v1/remoteid) + - [F3411-19](./interfaces/rid/v1/remoteid) + - [F3411-xx](./interfaces/rid/v2/remoteid) + - See [documentation](./interfaces/rid/README.md) before mixing versions in a single ecosystem. - [ASTM WK63418](https://www.astm.org/DATABASE.CART/WORKITEMS/WK63418.htm): UAS Traffic Management (UTM) UAS Service Supplier (USS) Interoperability Specification. [See OpenAPI interface](./interfaces/astm-utm) diff --git a/interfaces/rid/README.md b/interfaces/rid/README.md new file mode 100644 index 000000000..8bceb5fcf --- /dev/null +++ b/interfaces/rid/README.md @@ -0,0 +1,49 @@ +# ASTM F3411 Network remote identification + +## F3411-19 + +[OpenAPI specification](v1/remoteid/augmented.yaml) + +## F3411-xx (second version) + +[OpenAPI specification](v2/remoteid/canonical.yaml) + +## Mixing versions in a single ecosystem + +If all USSs in an ecosystem use the v1 API, then everything is fine. If all USSs in an ecosystem use the v2 API, then everything is fine. If some USSs in an ecosystem use v1 while others use v2, there may be interoperability problems. To avoid accidentally missing ISAs, this DSS implementation stores v1 and v2 ISAs alongside each other. The URL field for v1 ISAs contains the `/flights` resource URL (e.g., `https://example.com/v1/uss/flights`), but this same URL field contains the base URL (e.g., `http://example.com/rid/v2`) for v2 ISAs. This means a v1 USS may try to query `http://example.com/rid/v2` if reading a v2 USS's ISA, or a v2 USS may try to query `http://example.com/v1/uss/flights/uss/flights` if reading a v1 USS's ISA. This issue is somewhat intentional because even though v1 and v2 both have a `/flights` endpoint, the communications protocol for these two endpoints is not compatible. If v1 and v2 ISAs are going to co-exist in the same ecosystem, then every USS in that ecosystem must infer the USS-USS communications protocol based on the content of the URL field (`flights_url` and `identification_service_area_url` in v1 and `uss_base_url` in v2). + +### v1 ISAs + +If a USS in a mixed-version ecosystem reads a v1 ISA, it must communicate with the managing USS in the following ways: + +| If the `flights_url` | Then reach `/flights` URL at | Using data exchange protocol | +| --- | --- | --- | +| Ends with "/flights" | `flights_url` without any changes | [F3411-19 (v1)](https://github.com/uastech/standards/blob/36e7ea23a010ff91053f82ac4f6a9bfc698503f9/remoteid/canonical.yaml#L325) | +| Does not end with "/flights" | `flights_url` + "[/uss/flights](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L338)" | [F3411-xx (v2)](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L338) | + +### v1 SubscriberToNotify + +If a USS in a mixed-version ecosystem makes a change to a v1 ISA, the response will [contain](https://github.com/uastech/standards/blob/36e7ea23a010ff91053f82ac4f6a9bfc698503f9/remoteid/canonical.yaml#L1263) a list of SubscriberToNotify. The POST notification should be sent differently depending on the contents of the [`url` field](https://github.com/uastech/standards/blob/36e7ea23a010ff91053f82ac4f6a9bfc698503f9/remoteid/canonical.yaml#L1330): + +| If the `url` | Then reach `/identification_service_areas` URL at | Using data exchange protocol | +| --- | --- | --- | +| Ends with "/identification_service_areas" | `url` + "/" + ISA ID | [F3411-19 (v1)](https://github.com/uastech/standards/blob/36e7ea23a010ff91053f82ac4f6a9bfc698503f9/remoteid/canonical.yaml#L767) | +| Does not end with "/identification_service_areas" | `url` + ["/uss/identification_service_areas/" + ISA ID](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L778) | [F3411-xx (v2)](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L822) | + +### v2 ISAs + +If a USS in a mixed-version ecosystem reads a v2 ISA, it must communicate with the managing USS in the following ways: + +| If the `uss_base_url` | Then reach `/flights` URL at | Using data exchange protocol | +| --- | --- | --- | +| Ends with "/flights" | `uss_base_url` without any changes | [F3411-19 (v1)](https://github.com/uastech/standards/blob/36e7ea23a010ff91053f82ac4f6a9bfc698503f9/remoteid/canonical.yaml#L325) | +| Does not end with "/flights" | `uss_base_url` + "[/uss/flights](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L338)" | [F3411-xx (v2)](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L338) | + +### v2 SubscriberToNotify + +If a USS in a mixed-version ecosystem makes a change to a v2 ISA, the response will [contain](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L1475) a list of SubscriberToNotify. The POST notification should be sent differently depending on the contents of the [`url` field](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L1545): + +| If the `url` | Then reach `/identification_service_areas` URL at | Using data exchange protocol | +| --- | --- | --- | +| Ends with "/identification_service_areas" | `url` + "/" + ISA ID | [F3411-19 (v1)](https://github.com/uastech/standards/blob/36e7ea23a010ff91053f82ac4f6a9bfc698503f9/remoteid/canonical.yaml#L767) | +| Does not end with "/identification_service_areas" | `url` + ["/uss/identification_service_areas/" + ISA ID](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L778) | [F3411-xx (v2)](https://github.com/uastech/standards/blob/ab6037442d97e868183ed60d35dab4954d9f15d0/remoteid/canonical.yaml#L822) | From ab04789f014fa6db5d443eaf627b24c0caa3a8e5 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Mon, 30 May 2022 14:29:44 -0700 Subject: [PATCH 5/7] Fix unit tests --- pkg/auth/auth_test.go | 3 +- pkg/rid/server/v2/server_test.go | 672 ------------------------------- 2 files changed, 2 insertions(+), 673 deletions(-) delete mode 100644 pkg/rid/server/v2/server_test.go diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go index af5ebfb5d..10214e668 100644 --- a/pkg/auth/auth_test.go +++ b/pkg/auth/auth_test.go @@ -164,7 +164,8 @@ func TestMissingScopes(t *testing.T) { }, } for _, tc := range tests { - require.Equal(t, tc.matchesRequiredScopes, ac.validateKeyClaimedScopes(context.Background(), tc.info, tc.scopes) == nil) + _, err := ac.validateKeyClaimedScopes(context.Background(), tc.info, tc.scopes) + require.Equal(t, tc.matchesRequiredScopes, err == nil) } } diff --git a/pkg/rid/server/v2/server_test.go b/pkg/rid/server/v2/server_test.go deleted file mode 100644 index 582d877c6..000000000 --- a/pkg/rid/server/v2/server_test.go +++ /dev/null @@ -1,672 +0,0 @@ -package server - -import ( - "context" - "testing" - "time" - - "github.com/interuss/dss/pkg/api/v1/ridpb" - "github.com/interuss/dss/pkg/auth" - dsserr "github.com/interuss/dss/pkg/errors" - "github.com/interuss/dss/pkg/geo" - "github.com/interuss/dss/pkg/geo/testdata" - dssmodels "github.com/interuss/dss/pkg/models" - ridmodels "github.com/interuss/dss/pkg/rid/models" - apiv1 "github.com/interuss/dss/pkg/rid/models/api/v1" - - "github.com/golang/geo/s2" - "github.com/google/uuid" - "github.com/interuss/stacktrace" - "github.com/stretchr/testify/mock" - "github.com/stretchr/testify/require" - tspb "google.golang.org/protobuf/types/known/timestamppb" -) - -var timeout = time.Second * 10 - -func mustTimestamp(ts *tspb.Timestamp) *time.Time { - t := ts.AsTime() - err := ts.CheckValid() - if err != nil { - panic(err) - } - return &t -} - -func mustPolygonToCellIDs(p *ridpb.GeoPolygon) s2.CellUnion { - cells, err := apiv1.FromGeoPolygon(p).CalculateCovering() - if err != nil { - panic(err) - } - return cells -} - -type mockApp struct { - mock.Mock -} - -func (ma *mockApp) InsertSubscription(ctx context.Context, s *ridmodels.Subscription) (*ridmodels.Subscription, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - args := ma.Called(ctx, s) - return args.Get(0).(*ridmodels.Subscription), args.Error(1) -} - -func (ma *mockApp) UpdateSubscription(ctx context.Context, s *ridmodels.Subscription) (*ridmodels.Subscription, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - args := ma.Called(ctx, s) - return args.Get(0).(*ridmodels.Subscription), args.Error(1) -} - -func (ma *mockApp) GetSubscription(ctx context.Context, id dssmodels.ID) (*ridmodels.Subscription, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - args := ma.Called(ctx, id) - return args.Get(0).(*ridmodels.Subscription), args.Error(1) -} - -func (ma *mockApp) DeleteSubscription(ctx context.Context, id dssmodels.ID, owner dssmodels.Owner, version *dssmodels.Version) (*ridmodels.Subscription, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - args := ma.Called(ctx, id, owner, version) - return args.Get(0).(*ridmodels.Subscription), args.Error(1) -} - -func (ma *mockApp) SearchSubscriptionsByOwner(ctx context.Context, cells s2.CellUnion, owner dssmodels.Owner) ([]*ridmodels.Subscription, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - args := ma.Called(ctx, cells, owner) - return args.Get(0).([]*ridmodels.Subscription), args.Error(1) -} - -func (ma *mockApp) GetISA(ctx context.Context, id dssmodels.ID) (*ridmodels.IdentificationServiceArea, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - args := ma.Called(ctx, id) - return args.Get(0).(*ridmodels.IdentificationServiceArea), args.Error(1) -} - -func (ma *mockApp) DeleteISA(ctx context.Context, id dssmodels.ID, owner dssmodels.Owner, version *dssmodels.Version) (*ridmodels.IdentificationServiceArea, []*ridmodels.Subscription, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - args := ma.Called(ctx, id, owner, version) - return args.Get(0).(*ridmodels.IdentificationServiceArea), args.Get(1).([]*ridmodels.Subscription), args.Error(2) -} - -func (ma *mockApp) InsertISA(ctx context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, []*ridmodels.Subscription, error) { - args := ma.Called(ctx, isa) - return args.Get(0).(*ridmodels.IdentificationServiceArea), args.Get(1).([]*ridmodels.Subscription), args.Error(2) -} - -func (ma *mockApp) UpdateISA(ctx context.Context, isa *ridmodels.IdentificationServiceArea) (*ridmodels.IdentificationServiceArea, []*ridmodels.Subscription, error) { - args := ma.Called(ctx, isa) - return args.Get(0).(*ridmodels.IdentificationServiceArea), args.Get(1).([]*ridmodels.Subscription), args.Error(2) -} - -func (ma *mockApp) SearchISAs(ctx context.Context, cells s2.CellUnion, earliest *time.Time, latest *time.Time) ([]*ridmodels.IdentificationServiceArea, error) { - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - args := ma.Called(ctx, cells, earliest, latest) - return args.Get(0).([]*ridmodels.IdentificationServiceArea), args.Error(1) -} - -func TestDeleteSubscription(t *testing.T) { - ctx := auth.ContextWithOwner(context.Background(), "foo") - version, _ := dssmodels.VersionFromString("bar") - - for _, r := range []struct { - name string - id dssmodels.ID - version *dssmodels.Version - subscription *ridmodels.Subscription - wantErr stacktrace.ErrorCode - }{ - { - name: "subscription-is-returned-if-returned-from-app", - id: dssmodels.ID(uuid.New().String()), - version: version, - subscription: &ridmodels.Subscription{}, - }, - { - name: "error-is-returned-if-returned-from-app", - id: dssmodels.ID(uuid.New().String()), - version: version, - wantErr: dsserr.NotFound, - }, - } { - t.Run(r.name, func(t *testing.T) { - ma := &mockApp{} - ma.On("DeleteSubscription", mock.Anything, r.id, mock.Anything, r.version).Return( - r.subscription, stacktrace.NewErrorWithCode(r.wantErr, "Expected error"), - ) - s := &Server{ - App: ma, - } - - _, err := s.DeleteSubscription(ctx, &ridpb.DeleteSubscriptionRequest{ - Id: r.id.String(), Version: r.version.String(), - }) - if r.wantErr != stacktrace.ErrorCode(0) { - require.Equal(t, stacktrace.GetCode(err), r.wantErr) - } - require.True(t, ma.AssertExpectations(t)) - }) - } -} - -func TestCreateSubscription(t *testing.T) { - ctx := auth.ContextWithOwner(context.Background(), "foo") - - for _, r := range []struct { - name string - id dssmodels.ID - callbacks *ridpb.SubscriptionCallbacks - extents *ridpb.Volume4D - wantSubscription *ridmodels.Subscription - wantErr stacktrace.ErrorCode - }{ - { - name: "success", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - callbacks: &ridpb.SubscriptionCallbacks{ - IdentificationServiceAreaUrl: "https://example.com", - }, - extents: testdata.LoopVolume4D, - wantSubscription: &ridmodels.Subscription{ - ID: "4348c8e5-0b1c-43cf-9114-2e67a4532765", - Owner: "foo", - URL: "https://example.com", - StartTime: mustTimestamp(testdata.LoopVolume4D.GetTimeStart()), - EndTime: mustTimestamp(testdata.LoopVolume4D.GetTimeEnd()), - AltitudeHi: &testdata.LoopVolume3D.AltitudeHi, - AltitudeLo: &testdata.LoopVolume3D.AltitudeLo, - Cells: mustPolygonToCellIDs(testdata.LoopPolygon), - }, - }, - { - name: "missing-extents", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - callbacks: &ridpb.SubscriptionCallbacks{ - IdentificationServiceAreaUrl: "https://example.com", - }, - wantErr: dsserr.BadRequest, - }, - { - name: "missing-extents-spatial-volume", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - callbacks: &ridpb.SubscriptionCallbacks{ - IdentificationServiceAreaUrl: "https://example.com", - }, - extents: &ridpb.Volume4D{}, - wantErr: dsserr.BadRequest, - }, - { - name: "missing-spatial-volume-footprint", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - callbacks: &ridpb.SubscriptionCallbacks{ - IdentificationServiceAreaUrl: "https://example.com", - }, - extents: &ridpb.Volume4D{ - SpatialVolume: &ridpb.Volume3D{}, - }, - wantErr: dsserr.BadRequest, - }, - { - name: "missing-spatial-volume-footprint", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - callbacks: &ridpb.SubscriptionCallbacks{ - IdentificationServiceAreaUrl: "https://example.com", - }, - extents: &ridpb.Volume4D{ - SpatialVolume: &ridpb.Volume3D{ - Footprint: &ridpb.GeoPolygon{}, - }, - }, - wantErr: dsserr.BadRequest, - }, - { - name: "missing-callbacks", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - extents: testdata.LoopVolume4D, - wantErr: dsserr.BadRequest, - }, - } { - t.Run(r.name, func(t *testing.T) { - ma := &mockApp{} - if r.wantErr == stacktrace.ErrorCode(0) { - ma.On("SearchISAs", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return( - []*ridmodels.IdentificationServiceArea(nil), nil) - ma.On("InsertSubscription", mock.Anything, r.wantSubscription).Return( - r.wantSubscription, nil, - ) - } - s := &Server{App: ma} - - _, err := s.CreateSubscription(ctx, &ridpb.CreateSubscriptionRequest{ - Id: r.id.String(), - Params: &ridpb.CreateSubscriptionParameters{ - Callbacks: r.callbacks, - Extents: r.extents, - }, - }) - if r.wantErr != stacktrace.ErrorCode(0) { - require.Equal(t, stacktrace.GetCode(err), r.wantErr) - } - require.True(t, ma.AssertExpectations(t)) - }) - } -} - -func TestCreateSubscriptionResponseIncludesISAs(t *testing.T) { - ctx := auth.ContextWithOwner(context.Background(), "foo") - - isas := []*ridmodels.IdentificationServiceArea{ - { - ID: dssmodels.ID("8265221b-9528-4d45-900d-59a148e13850"), - Owner: dssmodels.Owner("me-myself-and-i"), - URL: "https://no/place/like/home", - }, - } - - cells := mustPolygonToCellIDs(testdata.LoopPolygon) - sub := &ridmodels.Subscription{ - ID: "4348c8e5-0b1c-43cf-9114-2e67a4532765", - Owner: "foo", - URL: "https://example.com", - StartTime: mustTimestamp(testdata.LoopVolume4D.GetTimeStart()), - EndTime: mustTimestamp(testdata.LoopVolume4D.GetTimeEnd()), - AltitudeHi: &testdata.LoopVolume3D.AltitudeHi, - AltitudeLo: &testdata.LoopVolume3D.AltitudeLo, - Cells: cells, - } - - ma := &mockApp{} - - ma.On("SearchISAs", mock.Anything, cells, mock.Anything, mock.Anything).Return(isas, nil) - ma.On("InsertSubscription", mock.Anything, sub).Return(sub, nil) - s := &Server{ - App: ma, - } - - resp, err := s.CreateSubscription(ctx, &ridpb.CreateSubscriptionRequest{ - Id: sub.ID.String(), - Params: &ridpb.CreateSubscriptionParameters{ - Callbacks: &ridpb.SubscriptionCallbacks{ - IdentificationServiceAreaUrl: sub.URL, - }, - Extents: testdata.LoopVolume4D, - }, - }) - require.Nil(t, err) - require.True(t, ma.AssertExpectations(t)) - - require.Equal(t, []*ridpb.IdentificationServiceArea{ - { - FlightsUrl: "https://no/place/like/home", - Id: "8265221b-9528-4d45-900d-59a148e13850", - Owner: "me-myself-and-i", - }, - }, resp.ServiceAreas) -} - -func TestGetSubscription(t *testing.T) { - for _, r := range []struct { - name string - id dssmodels.ID - subscription *ridmodels.Subscription - err stacktrace.ErrorCode - }{ - { - name: "subscription-is-returned-if-returned-from-app", - id: dssmodels.ID(uuid.New().String()), - subscription: &ridmodels.Subscription{}, - }, - { - name: "error-is-returned-if-returned-from-app", - id: dssmodels.ID(uuid.New().String()), - err: dsserr.NotFound, - }, - } { - t.Run(r.name, func(t *testing.T) { - ma := &mockApp{} - - ma.On("GetSubscription", mock.Anything, r.id).Return( - r.subscription, stacktrace.NewErrorWithCode(r.err, "Expected error"), - ) - s := &Server{ - App: ma, - } - - _, err := s.GetSubscription(context.Background(), &ridpb.GetSubscriptionRequest{ - Id: r.id.String(), - }) - require.Equal(t, stacktrace.GetCode(err), r.err) - require.True(t, ma.AssertExpectations(t)) - }) - } -} - -func TestSearchSubscriptionsFailsIfOwnerMissingFromContext(t *testing.T) { - var ( - ctx = context.Background() - ma = &mockApp{} - s = &Server{ - App: ma, - } - ) - - _, err := s.SearchSubscriptions(ctx, &ridpb.SearchSubscriptionsRequest{ - Area: testdata.Loop, - }) - - require.Error(t, err) - require.True(t, ma.AssertExpectations(t)) -} - -func TestSearchSubscriptionsFailsForInvalidArea(t *testing.T) { - var ( - ctx = auth.ContextWithOwner(context.Background(), "foo") - ma = &mockApp{} - s = &Server{ - App: ma, - } - ) - - _, err := s.SearchSubscriptions(ctx, &ridpb.SearchSubscriptionsRequest{ - Area: testdata.LoopWithOddNumberOfCoordinates, - }) - - require.Error(t, err) - require.True(t, ma.AssertExpectations(t)) -} - -func TestSearchSubscriptions(t *testing.T) { - var ( - owner = dssmodels.Owner("foo") - ctx = auth.ContextWithOwner(context.Background(), owner) - ma = &mockApp{} - s = &Server{ - App: ma, - } - ) - - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - ma.On("SearchSubscriptionsByOwner", mock.Anything, mock.Anything, owner).Return( - []*ridmodels.Subscription{ - { - ID: dssmodels.ID(uuid.New().String()), - Owner: owner, - URL: "https://no/place/like/home", - NotificationIndex: 42, - }, - }, error(nil), - ) - resp, err := s.SearchSubscriptions(ctx, &ridpb.SearchSubscriptionsRequest{ - Area: testdata.Loop, - }) - - require.NoError(t, err) - require.NotNil(t, resp) - require.Len(t, resp.Subscriptions, 1) - require.True(t, ma.AssertExpectations(t)) -} - -func TestCreateISA(t *testing.T) { - ctx := auth.ContextWithOwner(context.Background(), "foo") - - for _, r := range []struct { - name string - id dssmodels.ID - extents *ridpb.Volume4D - flightsURL string - wantISA *ridmodels.IdentificationServiceArea - wantErr stacktrace.ErrorCode - }{ - { - name: "success", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - extents: testdata.LoopVolume4D, - flightsURL: "https://example.com", - wantISA: &ridmodels.IdentificationServiceArea{ - ID: "4348c8e5-0b1c-43cf-9114-2e67a4532765", - URL: "https://example.com", - Owner: "foo", - Cells: mustPolygonToCellIDs(testdata.LoopPolygon), - StartTime: mustTimestamp(testdata.LoopVolume4D.GetTimeStart()), - EndTime: mustTimestamp(testdata.LoopVolume4D.GetTimeEnd()), - AltitudeHi: &testdata.LoopVolume3D.AltitudeHi, - AltitudeLo: &testdata.LoopVolume3D.AltitudeLo, - }, - }, - { - name: "missing-extents", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - flightsURL: "https://example.com", - wantErr: dsserr.BadRequest, - }, - { - name: "missing-extents-spatial-volume", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - extents: &ridpb.Volume4D{}, - flightsURL: "https://example.com", - wantErr: dsserr.BadRequest, - }, - { - name: "missing-spatial-volume-footprint", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - extents: &ridpb.Volume4D{ - SpatialVolume: &ridpb.Volume3D{}, - }, - flightsURL: "https://example.com", - wantErr: dsserr.BadRequest, - }, - { - name: "missing-spatial-volume-footprint", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - extents: &ridpb.Volume4D{ - SpatialVolume: &ridpb.Volume3D{ - Footprint: &ridpb.GeoPolygon{}, - }, - }, - flightsURL: "https://example.com", - wantErr: dsserr.BadRequest, - }, - { - name: "missing-flights-url", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - extents: testdata.LoopVolume4D, - wantErr: dsserr.BadRequest, - }, - } { - t.Run(r.name, func(t *testing.T) { - ma := &mockApp{} - if r.wantISA != nil { - ma.On("InsertISA", mock.Anything, r.wantISA).Return( - r.wantISA, []*ridmodels.Subscription(nil), nil) - } - s := &Server{ - App: ma, - } - - _, err := s.CreateIdentificationServiceArea(ctx, &ridpb.CreateIdentificationServiceAreaRequest{ - Id: r.id.String(), - Params: &ridpb.CreateIdentificationServiceAreaParameters{ - Extents: r.extents, - FlightsUrl: r.flightsURL, - }, - }) - if r.wantErr != stacktrace.ErrorCode(0) { - require.Equal(t, stacktrace.GetCode(err), r.wantErr) - } - require.True(t, ma.AssertExpectations(t)) - }) - } -} - -func TestUpdateISA(t *testing.T) { - ctx := auth.ContextWithOwner(context.Background(), "foo") - version, _ := dssmodels.VersionFromString("bar") - for _, r := range []struct { - name string - id dssmodels.ID - extents *ridpb.Volume4D - flightsURL string - wantISA *ridmodels.IdentificationServiceArea - wantErr stacktrace.ErrorCode - version *dssmodels.Version - }{ - { - name: "success", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - extents: testdata.LoopVolume4D, - flightsURL: "https://example.com", - version: version, - wantISA: &ridmodels.IdentificationServiceArea{ - ID: "4348c8e5-0b1c-43cf-9114-2e67a4532765", - URL: "https://example.com", - Owner: "foo", - Cells: mustPolygonToCellIDs(testdata.LoopPolygon), - StartTime: mustTimestamp(testdata.LoopVolume4D.GetTimeStart()), - EndTime: mustTimestamp(testdata.LoopVolume4D.GetTimeEnd()), - AltitudeHi: &testdata.LoopVolume3D.AltitudeHi, - AltitudeLo: &testdata.LoopVolume3D.AltitudeLo, - Writer: "locality value", - Version: version, - }, - }, - { - name: "missing-flights-url", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - extents: testdata.LoopVolume4D, - version: version, - wantErr: dsserr.BadRequest, - }, - { - name: "missing-extents", - id: dssmodels.ID("4348c8e5-0b1c-43cf-9114-2e67a4532765"), - flightsURL: "https://example.com", - version: version, - wantErr: dsserr.BadRequest, - }, - } { - t.Run(r.name, func(t *testing.T) { - ma := &mockApp{} - if r.wantISA != nil { - ma.On("UpdateISA", mock.Anything, r.wantISA).Return( - r.wantISA, []*ridmodels.Subscription(nil), nil) - } - s := &Server{ - App: ma, - Locality: "locality value", - } - _, err := s.UpdateIdentificationServiceArea(ctx, &ridpb.UpdateIdentificationServiceAreaRequest{ - Id: r.id.String(), - Version: r.version.String(), - Params: &ridpb.UpdateIdentificationServiceAreaParameters{ - Extents: r.extents, - FlightsUrl: r.flightsURL, - }, - }) - if r.wantErr != stacktrace.ErrorCode(0) { - require.Equal(t, stacktrace.GetCode(err), r.wantErr) - } - require.True(t, ma.AssertExpectations(t)) - }) - } -} - -func TestDeleteIdentificationServiceAreaRequiresOwnerInContext(t *testing.T) { - var ( - id = uuid.New().String() - ma = &mockApp{} - - s = &Server{ - App: ma, - } - ) - - _, err := s.DeleteIdentificationServiceArea(context.Background(), &ridpb.DeleteIdentificationServiceAreaRequest{ - Id: id, - }) - - require.Error(t, err) - require.True(t, ma.AssertExpectations(t)) -} - -func TestDeleteIdentificationServiceArea(t *testing.T) { - var ( - owner = dssmodels.Owner("foo") - id = dssmodels.ID(uuid.New().String()) - version, _ = dssmodels.VersionFromString("bar") - ctx = auth.ContextWithOwner(context.Background(), owner) - ma = &mockApp{} - - s = &Server{ - App: ma, - } - ) - - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - ma.On("DeleteISA", mock.Anything, id, owner, mock.Anything).Return( - &ridmodels.IdentificationServiceArea{ - ID: dssmodels.ID(id), - Owner: dssmodels.Owner("me-myself-and-i"), - URL: "https://no/place/like/home", - Version: version, - }, - []*ridmodels.Subscription{ - { - NotificationIndex: 42, - URL: "https://no/place/like/home", - }, - }, error(nil), - ) - resp, err := s.DeleteIdentificationServiceArea(ctx, &ridpb.DeleteIdentificationServiceAreaRequest{ - Id: id.String(), Version: version.String(), - }) - - require.NoError(t, err) - require.NotNil(t, resp) - require.Len(t, resp.Subscribers, 1) - require.True(t, ma.AssertExpectations(t)) -} - -func TestSearchIdentificationServiceAreas(t *testing.T) { - var ( - ctx = context.Background() - ma = &mockApp{} - - s = &Server{ - App: ma, - } - ) - - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - ma.On("SearchISAs", mock.Anything, mock.Anything, (*time.Time)(nil), (*time.Time)(nil)).Return( - []*ridmodels.IdentificationServiceArea{ - { - ID: dssmodels.ID(uuid.New().String()), - Owner: dssmodels.Owner("me-myself-and-i"), - URL: "https://no/place/like/home", - }, - }, error(nil), - ) - resp, err := s.SearchIdentificationServiceAreas(ctx, &ridpb.SearchIdentificationServiceAreasRequest{ - Area: testdata.Loop, - }) - - require.NoError(t, err) - require.NotNil(t, resp) - require.Len(t, resp.ServiceAreas, 1) - require.True(t, ma.AssertExpectations(t)) -} - -func TestDefaultRegionCovererProducesResults(t *testing.T) { - cover, err := geo.AreaToCellIDs(testdata.Loop) - require.NoError(t, err) - require.NotNil(t, cover) -} From 4b3edc81643d9232afb12a3a472638a858676f17 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Mon, 30 May 2022 14:36:25 -0700 Subject: [PATCH 6/7] Fix lint --- pkg/auth/auth_test.go | 2 +- pkg/rid/models/api/v2/conversions.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/auth/auth_test.go b/pkg/auth/auth_test.go index 10214e668..ff76da25d 100644 --- a/pkg/auth/auth_test.go +++ b/pkg/auth/auth_test.go @@ -164,7 +164,7 @@ func TestMissingScopes(t *testing.T) { }, } for _, tc := range tests { - _, err := ac.validateKeyClaimedScopes(context.Background(), tc.info, tc.scopes) + _, err := ac.validateKeyClaimedScopes(context.Background(), tc.info, tc.scopes) require.Equal(t, tc.matchesRequiredScopes, err == nil) } } diff --git a/pkg/rid/models/api/v2/conversions.go b/pkg/rid/models/api/v2/conversions.go index 384055171..2c7590059 100644 --- a/pkg/rid/models/api/v2/conversions.go +++ b/pkg/rid/models/api/v2/conversions.go @@ -77,11 +77,11 @@ func FromVolume4D(vol4 *ridpb.Volume4D) (*dssmodels.Volume4D, error) { func FromVolume3D(vol3 *ridpb.Volume3D) (*dssmodels.Volume3D, error) { altitudeLo, err := FromAltitude(vol3.GetAltitudeLower()) if err != nil { - stacktrace.Propagate(err, "Error parsing lower altitude of Volume3D") + return nil, stacktrace.Propagate(err, "Error parsing lower altitude of Volume3D") } altitudeHi, err := FromAltitude(vol3.GetAltitudeUpper()) if err != nil { - stacktrace.Propagate(err, "Error parsing upper altitude of Volume3D") + return nil, stacktrace.Propagate(err, "Error parsing upper altitude of Volume3D") } polygon := vol3.GetOutlinePolygon() From 2e3418964e877000f694e11339c899d979dfbf04 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Fri, 10 Jun 2022 13:37:27 -0700 Subject: [PATCH 7/7] Address comments --- monitoring/prober/rid/v2/test_isa_expiry.py | 1 - pkg/rid/models/api/v1/conversions.go | 4 ++-- pkg/rid/models/api/v2/conversions.go | 3 +++ pkg/rid/server/v1/isa_handler.go | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/monitoring/prober/rid/v2/test_isa_expiry.py b/monitoring/prober/rid/v2/test_isa_expiry.py index e55d22603..875da16e7 100644 --- a/monitoring/prober/rid/v2/test_isa_expiry.py +++ b/monitoring/prober/rid/v2/test_isa_expiry.py @@ -14,7 +14,6 @@ def test_ensure_clean_workspace_v2(ids, session_ridv2): - print('In test_ensure_clean_workspace, scope = {}'.format(SCOPE_SP)) resp = session_ridv2.get('{}/{}'.format(ISA_PATH, ids(ISA_TYPE)), scope=SCOPE_SP) if resp.status_code == 200: version = resp.json()['service_area']['version'] diff --git a/pkg/rid/models/api/v1/conversions.go b/pkg/rid/models/api/v1/conversions.go index cb04b7501..9d88e22bb 100644 --- a/pkg/rid/models/api/v1/conversions.go +++ b/pkg/rid/models/api/v1/conversions.go @@ -23,20 +23,20 @@ func FromVolume4D(vol4 *ridpb.Volume4D) (*dssmodels.Volume4D, error) { } if startTime := vol4.GetTimeStart(); startTime != nil { - ts := startTime.AsTime() err := startTime.CheckValid() if err != nil { return nil, stacktrace.Propagate(err, "Error converting start time from proto") } + ts := startTime.AsTime() result.StartTime = &ts } if endTime := vol4.GetTimeEnd(); endTime != nil { - ts := endTime.AsTime() err := endTime.CheckValid() if err != nil { return nil, stacktrace.Propagate(err, "Error converting end time from proto") } + ts := endTime.AsTime() result.EndTime = &ts } diff --git a/pkg/rid/models/api/v2/conversions.go b/pkg/rid/models/api/v2/conversions.go index 2c7590059..28f844051 100644 --- a/pkg/rid/models/api/v2/conversions.go +++ b/pkg/rid/models/api/v2/conversions.go @@ -86,6 +86,9 @@ func FromVolume3D(vol3 *ridpb.Volume3D) (*dssmodels.Volume3D, error) { polygon := vol3.GetOutlinePolygon() if polygon != nil { + if vol3.GetOutlineCircle() != nil { + return nil, stacktrace.NewError("Only one of outline_circle or outline_polygon may be specified") + } footprint := FromPolygon(polygon) result := &dssmodels.Volume3D{ diff --git a/pkg/rid/server/v1/isa_handler.go b/pkg/rid/server/v1/isa_handler.go index a25d1d9bb..e678abfd7 100644 --- a/pkg/rid/server/v1/isa_handler.go +++ b/pkg/rid/server/v1/isa_handler.go @@ -230,9 +230,9 @@ func (s *Server) SearchIdentificationServiceAreas( ) if et := req.GetEarliestTime(); et != nil { - ts := et.AsTime() err := et.CheckValid() if err == nil { + ts := et.AsTime() earliest = &ts } else { return nil, stacktrace.Propagate(err, "Unable to convert earliest timestamp to ptype") @@ -240,9 +240,9 @@ func (s *Server) SearchIdentificationServiceAreas( } if lt := req.GetLatestTime(); lt != nil { - ts := lt.AsTime() err := lt.CheckValid() if err == nil { + ts := lt.AsTime() latest = &ts } else { return nil, stacktrace.Propagate(err, "Unable to convert latest timestamp to ptype")