diff --git a/README.md b/README.md index 12e00c9e..924345b9 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ Recipes are grouped into namespaced modules (`tools/just/*.just`) addressed with After changing `*.proto` use `just api-change`; after changing `db/schema/*.go` use `just schema-change`; `just changes` will tell you which you need. +> Note: `just api-change` depends on `protoc-gen-ts_proto` which is installed by +> the frontend. You must build the frontend once before calling +> `just api-change` for the first time. + ## Versioning `VERSION` at the repo root holds the product version, and it is the only place diff --git a/api/proto/API.md b/api/proto/API.md index f7ce78ea..fb583878 100644 --- a/api/proto/API.md +++ b/api/proto/API.md @@ -5106,6 +5106,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | team_id | [string](#string) | | | +| hackathon_id | [string](#string) | | | @@ -5136,7 +5137,7 @@ casbin role for this hackathon; `is_waiting` is false once approved. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | -| submissions | [hackathon.entities.Submission](#hackathon-entities-Submission) | repeated | | +| submissions | [hackathon.entities.Submission](#hackathon-entities-Submission) | repeated | Ordered by team_id ascending, then by version ascending (oldest first). (team_id, version) is unique, so the order is total. | diff --git a/api/proto/hackathon/messages/team_svc/list_submissions_request.proto b/api/proto/hackathon/messages/team_svc/list_submissions_request.proto index 4533ebf9..b2dadf36 100644 --- a/api/proto/hackathon/messages/team_svc/list_submissions_request.proto +++ b/api/proto/hackathon/messages/team_svc/list_submissions_request.proto @@ -2,8 +2,13 @@ syntax = "proto3"; package hackathon.messages.team_svc; +import "buf/validate/validate.proto"; + option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc"; message ListSubmissionsRequest { - string team_id = 1; + oneof value { + string team_id = 1 [(buf.validate.field).string.uuid = true]; + string hackathon_id = 2 [(buf.validate.field).string.uuid = true]; + } } diff --git a/api/proto/hackathon/messages/team_svc/list_submissions_response.proto b/api/proto/hackathon/messages/team_svc/list_submissions_response.proto index efef0e59..4034d3a2 100644 --- a/api/proto/hackathon/messages/team_svc/list_submissions_response.proto +++ b/api/proto/hackathon/messages/team_svc/list_submissions_response.proto @@ -7,5 +7,7 @@ import "hackathon/entities/submission.proto"; option go_package = "github.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svc"; message ListSubmissionsResponse { + // Ordered by team_id ascending, then by version ascending (oldest first). + // (team_id, version) is unique, so the order is total. repeated hackathon.entities.Submission submissions = 1; } diff --git a/components/backend/internal/proto/hackathon/messages/team_svc/list_submissions_request.pb.go b/components/backend/internal/proto/hackathon/messages/team_svc/list_submissions_request.pb.go index b4990282..b9147060 100644 --- a/components/backend/internal/proto/hackathon/messages/team_svc/list_submissions_request.pb.go +++ b/components/backend/internal/proto/hackathon/messages/team_svc/list_submissions_request.pb.go @@ -7,6 +7,7 @@ package team_svc import ( + _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" @@ -22,8 +23,12 @@ const ( ) type ListSubmissionsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - TeamId string `protobuf:"bytes,1,opt,name=team_id,json=teamId,proto3" json:"team_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // Types that are valid to be assigned to Value: + // + // *ListSubmissionsRequest_TeamId + // *ListSubmissionsRequest_HackathonId + Value isListSubmissionsRequest_Value `protobuf_oneof:"value"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -58,20 +63,56 @@ func (*ListSubmissionsRequest) Descriptor() ([]byte, []int) { return file_hackathon_messages_team_svc_list_submissions_request_proto_rawDescGZIP(), []int{0} } +func (x *ListSubmissionsRequest) GetValue() isListSubmissionsRequest_Value { + if x != nil { + return x.Value + } + return nil +} + func (x *ListSubmissionsRequest) GetTeamId() string { if x != nil { - return x.TeamId + if x, ok := x.Value.(*ListSubmissionsRequest_TeamId); ok { + return x.TeamId + } + } + return "" +} + +func (x *ListSubmissionsRequest) GetHackathonId() string { + if x != nil { + if x, ok := x.Value.(*ListSubmissionsRequest_HackathonId); ok { + return x.HackathonId + } } return "" } +type isListSubmissionsRequest_Value interface { + isListSubmissionsRequest_Value() +} + +type ListSubmissionsRequest_TeamId struct { + TeamId string `protobuf:"bytes,1,opt,name=team_id,json=teamId,proto3,oneof"` +} + +type ListSubmissionsRequest_HackathonId struct { + HackathonId string `protobuf:"bytes,2,opt,name=hackathon_id,json=hackathonId,proto3,oneof"` +} + +func (*ListSubmissionsRequest_TeamId) isListSubmissionsRequest_Value() {} + +func (*ListSubmissionsRequest_HackathonId) isListSubmissionsRequest_Value() {} + var File_hackathon_messages_team_svc_list_submissions_request_proto protoreflect.FileDescriptor const file_hackathon_messages_team_svc_list_submissions_request_proto_rawDesc = "" + "\n" + - ":hackathon/messages/team_svc/list_submissions_request.proto\x12\x1bhackathon.messages.team_svc\"1\n" + - "\x16ListSubmissionsRequest\x12\x17\n" + - "\ateam_id\x18\x01 \x01(\tR\x06teamIdBjZhgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svcb\x06proto3" + ":hackathon/messages/team_svc/list_submissions_request.proto\x12\x1bhackathon.messages.team_svc\x1a\x1bbuf/validate/validate.proto\"u\n" + + "\x16ListSubmissionsRequest\x12#\n" + + "\ateam_id\x18\x01 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01H\x00R\x06teamId\x12-\n" + + "\fhackathon_id\x18\x02 \x01(\tB\b\xbaH\x05r\x03\xb0\x01\x01H\x00R\vhackathonIdB\a\n" + + "\x05valueBjZhgithub.com/swissdatasciencecenter/hackagon/components/backend/internal/proto/hackathon/messages/team_svcb\x06proto3" var ( file_hackathon_messages_team_svc_list_submissions_request_proto_rawDescOnce sync.Once @@ -102,6 +143,10 @@ func file_hackathon_messages_team_svc_list_submissions_request_proto_init() { if File_hackathon_messages_team_svc_list_submissions_request_proto != nil { return } + file_hackathon_messages_team_svc_list_submissions_request_proto_msgTypes[0].OneofWrappers = []any{ + (*ListSubmissionsRequest_TeamId)(nil), + (*ListSubmissionsRequest_HackathonId)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/components/backend/internal/proto/hackathon/messages/team_svc/list_submissions_response.pb.go b/components/backend/internal/proto/hackathon/messages/team_svc/list_submissions_response.pb.go index 2be9d65a..1c3cb5f6 100644 --- a/components/backend/internal/proto/hackathon/messages/team_svc/list_submissions_response.pb.go +++ b/components/backend/internal/proto/hackathon/messages/team_svc/list_submissions_response.pb.go @@ -23,7 +23,9 @@ const ( ) type ListSubmissionsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` + state protoimpl.MessageState `protogen:"open.v1"` + // Ordered by team_id ascending, then by version ascending (oldest first). + // (team_id, version) is unique, so the order is total. Submissions []*entities.Submission `protobuf:"bytes,1,rep,name=submissions,proto3" json:"submissions,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/components/backend/internal/service/team_service.go b/components/backend/internal/service/team_service.go index 76e3a0c3..d7c76511 100644 --- a/components/backend/internal/service/team_service.go +++ b/components/backend/internal/service/team_service.go @@ -1081,39 +1081,84 @@ func (s *TeamService) ListSubmissions( ctx context.Context, req *msgs.ListSubmissionsRequest, ) (*msgs.ListSubmissionsResponse, error) { - teamID, err := uuid.Parse(req.GetTeamId()) - if err != nil { - return nil, status.Errorf(codes.InvalidArgument, "invalid team_id: %v", err) - } + query := s.dbClient.Submission.Query() - t, err := getTeamById(ctx, s, teamID) - if err != nil { - return nil, err - } + switch req.GetValue().(type) { + case *msgs.ListSubmissionsRequest_TeamId: + teamID, err := uuid.Parse(req.GetTeamId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid team_id: %v", err) + } - if t.Edges.Project == nil || t.Edges.Project.Edges.Hackathon == nil { - return nil, status.Error(codes.Internal, "team project or hackathon not found") - } + t, err := getTeamById(ctx, s, teamID) + if err != nil { + return nil, err + } - hackathonID := t.Edges.Project.Edges.Hackathon.ID.String() - if err := s.enforcer.RequirePermission( - ctx, hackathonID, m.Submission, m.Read, - m.WithTeam(t.ID.String()), - ); err != nil { + if t.Edges.Project == nil || t.Edges.Project.Edges.Hackathon == nil { + return nil, status.Error(codes.Internal, "team project or hackathon not found") + } + + // Team members read their own team; owners hold submission:read + // hackathon-wide. + hackathonID := t.Edges.Project.Edges.Hackathon.ID.String() if err := s.enforcer.RequirePermission( ctx, hackathonID, m.Submission, m.Read, + m.WithTeam(t.ID.String()), + ); err != nil { + if err := s.enforcer.RequirePermission( + ctx, hackathonID, m.Submission, m.Read, + ); err != nil { + return nil, err + } + } + + query = query.Where(entsubmission.HasTeamWith(entteam.IDEQ(teamID))) + + case *msgs.ListSubmissionsRequest_HackathonId: + hackathonID, err := uuid.Parse(req.GetHackathonId()) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) + } + + // If the hackathon doesn't exist we still return access denied. We do this + // to avoid giving unnecessary information to unauthorized users. + if err := s.enforcer.RequirePermission( + ctx, hackathonID.String(), m.Submission, m.Read, ); err != nil { return nil, err } + + exists, err := s.dbClient.Hackathon.Query().Where(enthackathon.IDEQ(hackathonID)).Exist(ctx) + if err != nil { + slog.Error("query hackathon", "err", err) + + return nil, status.Error(codes.Internal, "couldn't query database") + } + if !exists { + return nil, status.Errorf(codes.NotFound, "hackathon %s not found", hackathonID) + } + + query = query.Where(entsubmission.HasTeamWith( + entteam.HasProjectWith(entproject.HasHackathonWith(enthackathon.IDEQ(hackathonID))), + )) + + default: + return nil, status.Error( + codes.InvalidArgument, + "one of team_id or hackathon_id is required", + ) } - submissions, err := s.dbClient.Submission.Query(). - Where(entsubmission.HasTeamWith(entteam.IDEQ(teamID))). + submissions, err := query. WithTeam(). WithProject(). WithCreator(). WithModifier(). - Order(ent.Asc(entsubmission.FieldVersion)). + Order( + ent.Asc(entsubmission.TeamColumn), + ent.Asc(entsubmission.FieldVersion), + ). All(ctx) if err != nil { return nil, status.Errorf(codes.Internal, "query submissions: %v", err) diff --git a/components/backend/internal/service/team_service_test.go b/components/backend/internal/service/team_service_test.go index 8e14303a..57fb6341 100644 --- a/components/backend/internal/service/team_service_test.go +++ b/components/backend/internal/service/team_service_test.go @@ -1712,12 +1712,59 @@ var _ = Describe("TeamService", func() { ) resp, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ - TeamId: teamID, + Value: &teamMsgs.ListSubmissionsRequest_TeamId{TeamId: teamID}, }) Expect(err).NotTo(HaveOccurred()) Expect(resp.GetSubmissions()).To(HaveLen(3)) }) + It("orders a team's submissions by version ascending", func() { + token := testutils.CreateTestJWTToken(memberID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + resp, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + Value: &teamMsgs.ListSubmissionsRequest_TeamId{TeamId: teamID}, + }) + Expect(err).NotTo(HaveOccurred()) + + versions := make([]int32, 0, len(resp.GetSubmissions())) + for _, s := range resp.GetSubmissions() { + versions = append(versions, s.GetVersion()) + } + Expect(versions).To(Equal([]int32{1, 2, 3})) + }) + + It("returns INVALID_ARGUMENT when neither team_id nor hackathon_id is set", func() { + token := testutils.CreateTestJWTToken(ownerID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{}) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + }) + + // The handler calls uuid.MustParse, relying on the proto's + // `string.uuid` rule. If that rule is dropped this panics instead. + It("returns INVALID_ARGUMENT for a malformed team_id", func() { + token := testutils.CreateTestJWTToken(memberID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + Value: &teamMsgs.ListSubmissionsRequest_TeamId{TeamId: "not-a-uuid"}, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + }) + It("returns empty list when no submissions exist", func() { // Create a team without submissions. token := testutils.CreateTestJWTToken(ownerID) @@ -1754,7 +1801,7 @@ var _ = Describe("TeamService", func() { emptyTeamID := teamResp.GetTeamId() resp, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ - TeamId: emptyTeamID, + Value: &teamMsgs.ListSubmissionsRequest_TeamId{TeamId: emptyTeamID}, }) Expect(err).NotTo(HaveOccurred()) Expect(resp.GetSubmissions()).To(HaveLen(0)) @@ -1768,7 +1815,7 @@ var _ = Describe("TeamService", func() { ) _, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ - TeamId: uuid.NewString(), + Value: &teamMsgs.ListSubmissionsRequest_TeamId{TeamId: uuid.NewString()}, }) Expect(err).To(HaveOccurred()) Expect(status.Code(err)).To(Equal(codes.NotFound)) @@ -1789,7 +1836,7 @@ var _ = Describe("TeamService", func() { ) _, err = teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ - TeamId: teamID, + Value: &teamMsgs.ListSubmissionsRequest_TeamId{TeamId: teamID}, }) Expect(err).To(HaveOccurred()) Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) @@ -1803,7 +1850,7 @@ var _ = Describe("TeamService", func() { ) resp, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ - TeamId: teamID, + Value: &teamMsgs.ListSubmissionsRequest_TeamId{TeamId: teamID}, }) Expect(err).NotTo(HaveOccurred()) Expect(resp.GetSubmissions()).To(HaveLen(3)) @@ -1827,11 +1874,276 @@ var _ = Describe("TeamService", func() { ) _, err = teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ - TeamId: teamID, + Value: &teamMsgs.ListSubmissionsRequest_TeamId{TeamId: teamID}, }) Expect(err).To(HaveOccurred()) Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) }) + + Context("by hackathon", func() { + var otherTeamID string + + BeforeEach(func() { + token := testutils.CreateTestJWTToken(ownerID) + ownerCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + // A second team in the same hackathon, with its own member. + tResp, err := teamClient.Create(ownerCtx, &teamMsgs.CreateRequest{ + Name: "ListSubmissions Other Team", + ProjectId: projectID, + }) + Expect(err).NotTo(HaveOccurred()) + otherTeamID = tResp.GetTeamId() + + otherMemberID := "team-listsub-other-member" + otherMember, err := dbClient.User.Create(). + SetKeycloakID(otherMemberID). + SetUsername("team-listsub-other-member"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + _, err = teamClient.AssignUser(ownerCtx, &teamMsgs.AssignUserRequest{ + TeamId: otherTeamID, + UserId: otherMember.ID.String(), + }) + Expect(err).NotTo(HaveOccurred()) + + token = testutils.CreateTestJWTToken(memberID) + memberCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + token = testutils.CreateTestJWTToken(otherMemberID) + otherMemberCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + // Interleave the two teams so that insertion order matches + // neither possible team order: the outer BeforeEach made v1-v3 + // of the first team, so rows go in as A1 A2 A3 B1 A4 B2. + _, err = teamClient.CreateSubmission( + otherMemberCtx, + &teamMsgs.CreateSubmissionRequest{ + TeamId: otherTeamID, + ProjectId: projectID, + }, + ) + Expect(err).NotTo(HaveOccurred()) + _, err = teamClient.CreateSubmission(memberCtx, &teamMsgs.CreateSubmissionRequest{ + TeamId: teamID, + ProjectId: projectID, + }) + Expect(err).NotTo(HaveOccurred()) + _, err = teamClient.CreateSubmission( + otherMemberCtx, + &teamMsgs.CreateSubmissionRequest{ + TeamId: otherTeamID, + ProjectId: projectID, + }, + ) + Expect(err).NotTo(HaveOccurred()) + + // A submission in another hackathon, which must not be listed. + now := time.Now() + hResp, err := hackathonClient.Create(ownerCtx, &msgs.CreateRequest{ + Name: "ListSubmissions Unrelated Hackathon", + Visibility: ents.Visibility_VISIBILITY_PUBLIC, + StartsAt: timestamppb.New(now.Add(24 * time.Hour)), + EndsAt: timestamppb.New(now.Add(48 * time.Hour)), + }) + Expect(err).NotTo(HaveOccurred()) + _, err = hackathonClient.SetCapabilities(ownerCtx, &msgs.SetCapabilitiesRequest{ + HackathonId: hResp.GetHackathonId(), + Capabilities: []*msgs.CapabilityState{ + { + Capability: ents.Capability_CAPABILITY_CREATE_PROJECT_SUBMISSIONS, + Enabled: true, + }, + }, + }) + Expect(err).NotTo(HaveOccurred()) + pResp, err := projectClient.Propose(ownerCtx, &projectMsgs.ProposeRequest{ + HackathonId: hResp.GetHackathonId(), + Title: "ListSubmissions Unrelated Project", + Description: "Desc", + }) + Expect(err).NotTo(HaveOccurred()) + _, err = projectClient.Approve(ownerCtx, &projectMsgs.ApproveRequest{ + ProjectId: pResp.GetProjectId(), + }) + Expect(err).NotTo(HaveOccurred()) + uResp, err := teamClient.Create(ownerCtx, &teamMsgs.CreateRequest{ + Name: "ListSubmissions Unrelated Team", + ProjectId: pResp.GetProjectId(), + }) + Expect(err).NotTo(HaveOccurred()) + + unrelatedMemberID := "team-listsub-unrelated-member" + unrelatedMember, err := dbClient.User.Create(). + SetKeycloakID(unrelatedMemberID). + SetUsername("team-listsub-unrelated-member"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + _, err = teamClient.AssignUser(ownerCtx, &teamMsgs.AssignUserRequest{ + TeamId: uResp.GetTeamId(), + UserId: unrelatedMember.ID.String(), + }) + Expect(err).NotTo(HaveOccurred()) + + token = testutils.CreateTestJWTToken(unrelatedMemberID) + unrelatedCtx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + _, err = teamClient.CreateSubmission( + unrelatedCtx, + &teamMsgs.CreateSubmissionRequest{ + TeamId: uResp.GetTeamId(), + ProjectId: pResp.GetProjectId(), + }, + ) + Expect(err).NotTo(HaveOccurred()) + }) + + It("lets the owner list every submission, ordered by team_id then version", func() { + token := testutils.CreateTestJWTToken(ownerID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + resp, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + Value: &teamMsgs.ListSubmissionsRequest_HackathonId{HackathonId: hackathonID}, + }) + Expect(err).NotTo(HaveOccurred()) + + type row struct { + TeamID string + Version int32 + } + got := make([]row, 0, len(resp.GetSubmissions())) + for _, s := range resp.GetSubmissions() { + got = append(got, row{s.GetTeamId(), s.GetVersion()}) + } + + // Lowercase hex UUIDs compare as strings the way Postgres + // compares the uuid column. + versions := map[string][]int32{ + teamID: {1, 2, 3, 4}, + otherTeamID: {1, 2}, + } + first, second := teamID, otherTeamID + if second < first { + first, second = second, first + } + want := make([]row, 0, 6) + for _, t := range []string{first, second} { + for _, v := range versions[t] { + want = append(want, row{t, v}) + } + } + Expect(got).To(Equal(want)) + }) + + It("denies a team member, who may only read their own team", func() { + token := testutils.CreateTestJWTToken(memberID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + Value: &teamMsgs.ListSubmissionsRequest_HackathonId{HackathonId: hackathonID}, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("denies a hackathon member not in any team", func() { + hackathonMemberID := "hackathon-member-listsub-by-hackathon" + _, err := dbClient.User.Create(). + SetKeycloakID(hackathonMemberID). + SetUsername("hackathon-member-listsub-by-hackathon"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + _, err = enf.AddRole(hackathonMemberID, middleware.Member, hackathonID) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(hackathonMemberID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err = teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + Value: &teamMsgs.ListSubmissionsRequest_HackathonId{HackathonId: hackathonID}, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("denies an unknown hackathon_id rather than reporting NOT_FOUND", func() { + token := testutils.CreateTestJWTToken(ownerID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + Value: &teamMsgs.ListSubmissionsRequest_HackathonId{ + HackathonId: uuid.NewString(), + }, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + + It("returns NOT_FOUND for an unknown hackathon_id to a global admin", func() { + adminID := "global-admin-listsub" + _, err := dbClient.User.Create(). + SetKeycloakID(adminID). + SetUsername("global-admin-listsub"). + Save(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + _, err = enf.AddGlobalRole(adminID, middleware.Admin) + Expect(err).NotTo(HaveOccurred()) + + token := testutils.CreateTestJWTToken(adminID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err = teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + Value: &teamMsgs.ListSubmissionsRequest_HackathonId{ + HackathonId: uuid.NewString(), + }, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.NotFound)) + }) + + // The handler calls uuid.MustParse, relying on the proto's + // `string.uuid` rule. If that rule is dropped this panics instead. + It("returns INVALID_ARGUMENT for a malformed hackathon_id", func() { + token := testutils.CreateTestJWTToken(ownerID) + ctx := metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + _, err := teamClient.ListSubmissions(ctx, &teamMsgs.ListSubmissionsRequest{ + Value: &teamMsgs.ListSubmissionsRequest_HackathonId{HackathonId: "not-a-uuid"}, + }) + Expect(err).To(HaveOccurred()) + Expect(status.Code(err)).To(Equal(codes.InvalidArgument)) + }) + }) }) Describe("BulkAssignUsers", func() { diff --git a/components/frontend/src/lib/server/grpc/generated/hackathon/messages/team_svc/list_submissions_request.ts b/components/frontend/src/lib/server/grpc/generated/hackathon/messages/team_svc/list_submissions_request.ts index 3cb73280..1321daf0 100644 --- a/components/frontend/src/lib/server/grpc/generated/hackathon/messages/team_svc/list_submissions_request.ts +++ b/components/frontend/src/lib/server/grpc/generated/hackathon/messages/team_svc/list_submissions_request.ts @@ -10,18 +10,22 @@ import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; export const protobufPackage = "hackathon.messages.team_svc"; export interface ListSubmissionsRequest { - teamId: string; + teamId?: string | undefined; + hackathonId?: string | undefined; } function createBaseListSubmissionsRequest(): ListSubmissionsRequest { - return { teamId: "" }; + return { teamId: undefined, hackathonId: undefined }; } export const ListSubmissionsRequest: MessageFns = { encode(message: ListSubmissionsRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter { - if (message.teamId !== "") { + if (message.teamId !== undefined) { writer.uint32(10).string(message.teamId); } + if (message.hackathonId !== undefined) { + writer.uint32(18).string(message.hackathonId); + } return writer; }, @@ -40,6 +44,14 @@ export const ListSubmissionsRequest: MessageFns = { message.teamId = reader.string(); continue; } + case 2: { + if (tag !== 18) { + break; + } + + message.hackathonId = reader.string(); + continue; + } } if ((tag & 7) === 4 || tag === 0) { break; @@ -55,15 +67,23 @@ export const ListSubmissionsRequest: MessageFns = { ? globalThis.String(object.teamId) : isSet(object.team_id) ? globalThis.String(object.team_id) - : "", + : undefined, + hackathonId: isSet(object.hackathonId) + ? globalThis.String(object.hackathonId) + : isSet(object.hackathon_id) + ? globalThis.String(object.hackathon_id) + : undefined, }; }, toJSON(message: ListSubmissionsRequest): unknown { const obj: any = {}; - if (message.teamId !== "") { + if (message.teamId !== undefined) { obj.teamId = message.teamId; } + if (message.hackathonId !== undefined) { + obj.hackathonId = message.hackathonId; + } return obj; }, @@ -72,7 +92,8 @@ export const ListSubmissionsRequest: MessageFns = { }, fromPartial(object: DeepPartial): ListSubmissionsRequest { const message = createBaseListSubmissionsRequest(); - message.teamId = object.teamId ?? ""; + message.teamId = object.teamId ?? undefined; + message.hackathonId = object.hackathonId ?? undefined; return message; }, }; diff --git a/components/frontend/src/lib/server/grpc/generated/hackathon/messages/team_svc/list_submissions_response.ts b/components/frontend/src/lib/server/grpc/generated/hackathon/messages/team_svc/list_submissions_response.ts index eb58ccd6..6929cdad 100644 --- a/components/frontend/src/lib/server/grpc/generated/hackathon/messages/team_svc/list_submissions_response.ts +++ b/components/frontend/src/lib/server/grpc/generated/hackathon/messages/team_svc/list_submissions_response.ts @@ -11,6 +11,10 @@ import { Submission } from "../../entities/submission"; export const protobufPackage = "hackathon.messages.team_svc"; export interface ListSubmissionsResponse { + /** + * Ordered by team_id ascending, then by version ascending (oldest first). + * (team_id, version) is unique, so the order is total. + */ submissions: Submission[]; } diff --git a/justfile b/justfile index d7e98d28..623aeddd 100644 --- a/justfile +++ b/justfile @@ -82,6 +82,7 @@ changes ref="HEAD~1": echo " Seed script changed — if dev data needs refreshing: just schema-change" fi + # Handle a DB schema change: regenerate proto + ent, wipe state, restart and reseed. # Run after changing db/schema/*.go (also safe to run after *.proto changes). [group('general')] @@ -162,6 +163,10 @@ build *args: list *args: just quitsh list "$@" +[private] +format: + just quitsh format + [private] alias q := quitsh quitsh *args: