diff --git a/CHANGELOG.md b/CHANGELOG.md index c2abfa80..88d4c43b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,9 @@ written while it was being built. See [RELEASING.md](RELEASING.md). ### Fixed +- Participants can see published results after voting has closed; the results + page used to fail with an error once an organizer turned voting off. + ## [0.12.1](https://github.com/SwissDataScienceCenter/hackagon/compare/v0.12.0...v0.12.1) - 2026-10-27 ## [0.12.0](https://github.com/SwissDataScienceCenter/hackagon/compare/v0.11.0...v0.12.0) - 2026-10-23 diff --git a/components/backend/internal/service/vote_service.go b/components/backend/internal/service/vote_service.go index d3b51f80..88e42581 100644 --- a/components/backend/internal/service/vote_service.go +++ b/components/backend/internal/service/vote_service.go @@ -48,13 +48,28 @@ func (s *VoteService) ListVoteCategories( return nil, status.Errorf(codes.InvalidArgument, "invalid hackathon_id: %v", err) } - if err := s.enforcer.RequirePermission( + // Results are fetched per category, so anyone who may read results must + // also be able to list the categories, even once voting is off. + canReadCategories, err := s.enforcer.Enforce( ctx, hackathonID.String(), m.VoteCategory, m.Read, - ); err != nil { - return nil, err + ) + if err != nil { + slog.Error("enforce list vote categories", "err", err) + + return nil, status.Error(codes.Internal, "authorization error") + } + if !canReadCategories { + if err := s.enforcer.RequirePermission( + ctx, + hackathonID.String(), + m.VoteResult, + m.Read, + ); err != nil { + return nil, err + } } categories, err := s.dbClient.VoteCategory.Query(). diff --git a/components/backend/internal/service/vote_service_test.go b/components/backend/internal/service/vote_service_test.go index 03c9e520..51f74ea7 100644 --- a/components/backend/internal/service/vote_service_test.go +++ b/components/backend/internal/service/vote_service_test.go @@ -370,6 +370,55 @@ var _ = Describe("VoteService", func() { Expect(err).NotTo(HaveOccurred()) Expect(resp.GetVoteCategories()).To(BeEmpty()) }) + + Context("as a member", func() { + var memberCtx context.Context + BeforeEach(func() { + createVoteCategory( + hackathonID, + "Best", + voteEntities.VotingMethod_VOTING_METHOD_SINGLE_CHOICE, + voteEntities.VoterType_VOTER_TYPE_ALL_PARTICIPANTS, + nil, + ) + _, kc := createAndJoin(hackathonID, "member") + memberCtx = metadata.NewOutgoingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+testutils.CreateTestJWTToken(kc)), + ) + }) + + It("lists categories while voting is enabled", func() { + setCapabilities(hackathonID, hackathonEntities.Capability_CAPABILITY_VOTE) + resp, err := voteClient.ListVoteCategories( + memberCtx, + &voteMsgs.ListVoteCategoriesRequest{HackathonId: hackathonID}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetVoteCategories()).To(HaveLen(1)) + }) + + It("lists categories when only results are visible", func() { + setCapabilities( + hackathonID, + hackathonEntities.Capability_CAPABILITY_VIEW_RESULTS, + ) + resp, err := voteClient.ListVoteCategories( + memberCtx, + &voteMsgs.ListVoteCategoriesRequest{HackathonId: hackathonID}, + ) + Expect(err).NotTo(HaveOccurred()) + Expect(resp.GetVoteCategories()).To(HaveLen(1)) + }) + + It("is denied when neither voting nor results are enabled", func() { + _, err := voteClient.ListVoteCategories( + memberCtx, + &voteMsgs.ListVoteCategoriesRequest{HackathonId: hackathonID}, + ) + Expect(status.Code(err)).To(Equal(codes.PermissionDenied)) + }) + }) }) Describe("EditVoteCategory", func() { diff --git a/components/frontend/src/routes/(app)/my/hackathon/[id]/results/+page.server.ts b/components/frontend/src/routes/(app)/my/hackathon/[id]/results/+page.server.ts index f74f1a04..64011ec3 100644 --- a/components/frontend/src/routes/(app)/my/hackathon/[id]/results/+page.server.ts +++ b/components/frontend/src/routes/(app)/my/hackathon/[id]/results/+page.server.ts @@ -47,11 +47,25 @@ export const load: PageServerLoad = async (event) => { // Every category, including ranked and jury ones the booth cannot cast a // vote for — results are results however they were produced, and a category // decided elsewhere (a jury meeting off the platform, say) still deserves to - // show its podium. Reading a result needs only `vote_result:read`, which the - // capability grants to every member. - const { voteCategories } = await vote.listVoteCategories({ - hackathonId: event.params.id, - }) + // show its podium. Listing categories takes `vote_category:read` *or* + // `vote_result:read`, and the capability grants the latter to every member, + // so this works after voting has closed. + // + // The catch is a guard, same as the per-category one below: a refusal here + // once took the whole page down with a 500. Losing the podiums still leaves + // the entries directory. + let voteCategories: Awaited< + ReturnType + >["voteCategories"] = [] + try { + ;({ voteCategories } = await vote.listVoteCategories({ + hackathonId: event.params.id, + })) + } catch (e) { + if (!(e instanceof ClientError && e.code === Status.PERMISSION_DENIED)) { + throw e + } + } const categories = await Promise.all( voteCategories.map(async (c) => {