Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 10 additions & 11 deletions components/frontend/src/lib/components/hackathon/TeamCard.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Pencil } from 'lucide-svelte';

type TeamMember = {
name: string;
Expand Down Expand Up @@ -72,6 +71,16 @@
the list scrolls. -->
<span class="tnum shrink-0 text-ink-3">{num}.</span>
<span class="min-w-0">{title}</span>
<!-- Which of these rows is yours, said the same way the
submissions page says it. This is all `isOwn` draws
now: it used to draw an "Edit team" pencil with no
handler on it, and editing a team is done from the
organiser's Manage Teams board — so the one control
this participant-facing list offered was an organiser
affordance that could not have worked. -->
{#if isOwn}
<span class="badge shrink-0 badge-accent">Your team</span>
{/if}
</h3>
<div class="block w-2/3 min-w-0">
<p class="m-0 text-xs leading-snug text-ink-2">
Expand Down Expand Up @@ -115,16 +124,6 @@
</div>
</div>

{#if isOwn}
<button
type="button"
class="btn btn-sm btn-ghost"
aria-label="Edit team"
>
<Pencil class="size-4" />
</button>
{/if}

<!-- eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic path from page data; resolve() is route-literal typed -->
<a href={resolve(moreInfoHref as any)} class="btn btn-sm btn-ghost">
More Information
Expand Down
63 changes: 63 additions & 0 deletions components/frontend/src/lib/components/hackathon/TeamCard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { cleanup, render, screen } from "@testing-library/svelte"
import { afterEach, describe, expect, it } from "vitest"

import TeamCard from "./TeamCard.svelte"

/*
* The participant-facing Teams list (issue #182).
*
* This card used to draw an "Edit team" pencil on any row the viewer was a
* member of. It carried no handler of any kind — no `onclick`, no `href`, no
* form — and editing a team is done from the organiser's Manage Teams board,
* which lives behind a route guard. So the ONE control this list offered a
* participant was an organiser affordance that could not have worked.
*
* What replaces it is the fact the flag actually carries: which row is yours,
* worded the way the submissions page words it.
*/

const props = {
num: 1,
title: "Sensor Dashboard",
projectDescription: "Realtime charts for the field sensors",
members: [{ name: "Bob Barker" }],
moreInfoHref: "/my/hackathon/h1/teams",
}

afterEach(cleanup)

describe("TeamCard", () => {
it("offers no edit control on the viewer's own team", () => {
render(TeamCard, { ...props, isOwn: true })

expect(screen.queryByRole("button", { name: /edit/i })).toBeNull()
expect(screen.queryByLabelText(/edit team/i)).toBeNull()
})

it("offers no edit control on anyone else's team either", () => {
render(TeamCard, { ...props, isOwn: false })

expect(screen.queryByRole("button", { name: /edit/i })).toBeNull()
})

it("marks the viewer's own team", () => {
// The positive control on the two absence assertions above: `isOwn` still
// reaches the DOM, so those zeros are about the edit control and not about
// a prop that stopped being rendered at all.
render(TeamCard, { ...props, isOwn: true })

expect(screen.getByText("Your team")).toBeTruthy()
})

it("does not mark a team the viewer is not on", () => {
render(TeamCard, { ...props, isOwn: false })

expect(screen.queryByText("Your team")).toBeNull()
})

it("keeps the one link the card is for", () => {
render(TeamCard, { ...props, isOwn: false })

expect(screen.getByRole("link", { name: "More Information" })).toBeTruthy()
})
})
116 changes: 116 additions & 0 deletions components/frontend/src/lib/server/hackathon/capabilities.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* The property behind issue #182: a participant must never be shown the
* organiser's controls.
*
* Every gate in this module answers one question — "may this viewer be OFFERED
* an organiser action?" — and the answer has to be no whenever the viewer's
* membership is not known to be an owner. The `mayManageVoting` case is why
* this file exists: the voting route used to decide that a viewer with NO
* membership row was an admin looking in, reasoning that
* `HackathonService.Get` admits nobody else without one. That is true of the
* backend's view and not of the frontend's, because `myMembership` is matched
* against `locals.platformUser`, which `hooks.server.ts` deliberately leaves
* unset when `WhoAmI` answers `UNAVAILABLE` — so an absent row means "I could
* not ask", not "an admin".
*
* The sweep below is the part worth keeping. It asserts the property over
* EVERY exported gate rather than over the one that was wrong, so a helper
* added later cannot reintroduce the same default without turning this red.
* `mayPreferProjects` is excluded by name and with its reason: it is the one
* gate here that is not an organiser gate at all.
*/
import { describe, expect, it } from "vitest"
import type { HackathonMember } from "$lib/server/grpc/generated/hackathon/entities/hackathon_member"
import { HackathonRole } from "$lib/server/grpc/generated/hackathon/entities/hackathon_role"
import * as capabilities from "./capabilities"
import {
mayManagePages,
mayManageParticipants,
mayManagePhases,
mayManageTracks,
mayManageVoting,
mayPreferProjects,
} from "./capabilities"

/** Only the two fields every gate here reads. */
function member(role: HackathonRole, isWaiting = false): HackathonMember {
return { role, isWaiting } as HackathonMember
}

const OWNER = member(HackathonRole.HACKATHON_ROLE_OWNER)
const MEMBER = member(HackathonRole.HACKATHON_ROLE_MEMBER)

/**
* Every organiser gate this module exports, discovered from the module rather
* than listed by hand: a new helper joins the sweep by existing.
*/
const organiserGates = Object.entries(capabilities).filter(
([name, fn]) => typeof fn === "function" && name !== "mayPreferProjects",
) as [string, (m: HackathonMember | undefined, isAdmin?: boolean) => boolean][]

describe("organiser gates", () => {
it("covers every exported gate but the one that is not one", () => {
// A positive control on the sweep itself: an empty or accidentally
// filtered list would make all four assertions below vacuous.
expect(organiserGates.length).toBeGreaterThanOrEqual(5)
expect(organiserGates.map(([name]) => name)).toContain("mayManageVoting")
expect(organiserGates.map(([name]) => name)).not.toContain(
"mayPreferProjects",
)
})

it.each(organiserGates)("%s refuses an unknown membership", (_name, gate) => {
expect(gate(undefined, false)).toBe(false)
})

it.each(organiserGates)("%s refuses a plain member", (_name, gate) => {
expect(gate(MEMBER, false)).toBe(false)
})

it.each(organiserGates)("%s admits the owner", (_name, gate) => {
expect(gate(OWNER, false)).toBe(true)
})

it.each(organiserGates)(
"%s admits a global admin who never joined",
(_name, gate) => {
// The escape hatch casbin gives an admin, and the reason "no membership
// row" was ever read as "organiser". It is stated now, not inferred.
expect(gate(undefined, true)).toBe(true)
},
)
})

describe("mayManageVoting", () => {
it("does not read an absent membership as an admin", () => {
expect(mayManageVoting(undefined, false)).toBe(false)
})

it("agrees with the other hackathon:write gates", () => {
// All of these mirror the same casbin rule (`hackathon:write`, granted to
// Owner and to an admin through the global escape hatch), so a viewer who
// is offered one must be offered all of them — otherwise the sidebar and
// the page it leads to can disagree about who the organiser is.
for (const m of [undefined, MEMBER, OWNER]) {
expect(mayManageVoting(m)).toBe(mayManageParticipants(m))
expect(mayManageVoting(m)).toBe(mayManagePhases(m))
expect(mayManageVoting(m)).toBe(mayManagePages(m))
expect(mayManageVoting(m)).toBe(mayManageTracks(m))
}
})
})

describe("mayPreferProjects", () => {
it("is offered to a plain member, unlike the organiser gates", () => {
// The control that proves the sweep above is testing a real distinction
// rather than a list that happens to hold.
expect(mayPreferProjects(MEMBER)).toBe(true)
})

it("is withheld from a waitlisted member and from a non-participant", () => {
expect(
mayPreferProjects(member(HackathonRole.HACKATHON_ROLE_MEMBER, true)),
).toBe(false)
expect(mayPreferProjects(undefined)).toBe(false)
})
})
36 changes: 36 additions & 0 deletions components/frontend/src/lib/server/hackathon/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,3 +126,39 @@ export function mayManageParticipants(

return membership?.role === HackathonRole.HACKATHON_ROLE_OWNER
}

/**
* Whether to show the organiser's voting panel — open and close voting, set the
* ballot rules, create and edit categories, record placements, export ballots.
*
* Mirrors the backend exactly, same as `mayManageParticipants`: every mutation
* behind that panel enforces hackathon-scoped `hackathon:write` —
* `CreateVoteCategory` (`vote_service.go:257`), `EditVoteCategory` (`:315`),
* `DeleteVoteCategory` (`:418`), `ExportVotes` (`:1071`), `CreateVoteResult`
* (`:1286`), `EditVoteResult` (`:1329`), `DeleteVoteResult` (`:1378`),
* `SuggestResults` (`:1419`), `ExportResults` (`:1593`) — which casbin grants to
* `Owner` outright and to an admin through the global escape hatch
* (`rbac.go:176`). It is the same rule `VoteService.isOrganizer`
* (`vote_service.go:791`) applies when it decides who may not cast a ballot.
*
* **Fails closed, and that is the point.** The voting route used to read
* "no membership row" as "an admin looking in", on the reasoning that
* `HackathonService.Get` admits nobody else without one. The premise holds for
* the BACKEND's view; it does not hold for the frontend's, because
* `myMembership` is matched against `locals.platformUser`, and
* `hooks.server.ts` deliberately proceeds with that unset when `WhoAmI` answers
* `UNAVAILABLE`. A backend that has come back by the time the layout issues its
* `Get` — the reconnect backoff is capped at 2s — therefore serves a plain
* member a page with no membership row on it, and the old default handed them
* the whole organiser panel. Unknown membership is now not-an-organiser, which
* is also what the backend's own `isOrganizer` does with a role lookup that
* errors.
*/
export function mayManageVoting(
membership: HackathonMember | undefined,
isAdmin = false,
): boolean {
if (isAdmin) return true

return membership?.role === HackathonRole.HACKATHON_ROLE_OWNER
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Actions, PageServerLoad } from "./$types"
import type { ActionFailure, Cookies } from "@sveltejs/kit"
import { requireGrpc } from "$lib/server/grpc/client"
import { mayManageVoting } from "$lib/server/hackathon/capabilities"
import { fail } from "@sveltejs/kit"
import { ClientError, Status } from "nice-grpc-common"

Expand Down Expand Up @@ -38,9 +39,6 @@ const SUBMISSION_STATUS_LABEL: Partial<Record<number, string>> = {
const EXPORT_CSV = 1
const EXPORT_JSON = 2

/** HackathonRole: UNSPECIFIED=0, OWNER=1, MEMBER=2 */
const HACKATHON_ROLE_OWNER = 1

/** Every action answers with this one shape, so `form?.x` stays typed. */
type VotingForm = {
message?: string
Expand Down Expand Up @@ -171,15 +169,17 @@ function safeName(raw: string): string {

export const load: PageServerLoad = async (event) => {
const { vote, team } = requireGrpc(event.locals.grpc)
const { hackathon, myMembership } = await event.parent()
const { hackathon, myMembership, isGlobalAdmin } = await event.parent()
const hackathonId = event.params.id
const myUserId = event.locals.platformUser?.id ?? ""

// The parent layout's Get only admits confirmed participants, hackathon
// owners and global admins — so a viewer who reached this page with no
// membership row at all is an admin looking in.
const isOrganizer =
!myMembership || myMembership.role === HACKATHON_ROLE_OWNER
// Owner-or-admin, and nothing else — see `mayManageVoting`. This used to read
// "no membership row" as "an admin looking in", which is the one gate in the
// app that failed OPEN: `myMembership` is matched against
// `locals.platformUser`, which `hooks.server.ts` leaves unset when `WhoAmI`
// answers `UNAVAILABLE`, so a plain member could be handed the whole
// organiser panel. The admin escape hatch is now stated rather than inferred.
const isOrganizer = mayManageVoting(myMembership ?? undefined, isGlobalAdmin)

let categories: Category[] = []
let serviceAvailable = true
Expand Down
Loading