Skip to content
Merged
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
22 changes: 19 additions & 3 deletions apphosting.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Monorepo - app is in sites/portal
# Monorepo — the deployed app is sites/mainweb (workspace name: web).
# There is no sites/portal; the portal is a route group inside mainweb.

scripts:
buildCommand: pnpm install && pnpm turbo run build --filter=web && mkdir -p sites/mainweb/.next/standalone/sites/mainweb/.next && cp -r sites/mainweb/.next/static sites/mainweb/.next/standalone/sites/mainweb/.next/static && (cp -r sites/mainweb/public sites/mainweb/.next/standalone/sites/mainweb/public || true)
Expand Down Expand Up @@ -27,6 +28,15 @@ env:
secret: AUTH_GOOGLE_ID
- variable: GOOGLE_CLIENT_SECRET
secret: AUTH_GOOGLE_SECRET
# GitHub sign-in. The provider is only registered when BOTH of these are
# present (packages/auth/src/config.ts), so until the secrets exist the
# login page hides the button rather than offering one that cannot work.
# Callback URL to register on the GitHub OAuth app:
# https://datasciencegt.org/api/auth/callback/github
- variable: GITHUB_CLIENT_ID
secret: AUTH_GITHUB_ID
- variable: GITHUB_CLIENT_SECRET
secret: AUTH_GITHUB_SECRET
- variable: AUTH_URL
value: https://datasciencegt.org
- variable: NEXTAUTH_URL
Expand All @@ -49,8 +59,6 @@ env:
secret: projects/672446353769/secrets/EMAIL_SERVER_PASSWORD
- variable: EMAIL_FROM
value: datascience.gt@gmail.com
- variable: CRON_SECRET
secret: CRON_SECRET
# Flood-protection thresholds, sized per instance for a full venue.
# These are the ceiling for one signed-in person, not for the building —
# the limiter keys on user id when somebody is signed in. The short block
Expand All @@ -64,3 +72,11 @@ env:
value: "14000"
- variable: DDOS_BLOCK_DURATION_MS
value: "30000"
# Proxies between the client and this process that append to
# X-Forwarded-For. Set explicitly rather than left to the code default so
# the value is reviewable here. Cloud Run behind Google's load balancer is
# 1; if a CDN is added in front, this becomes 2 — and getting it wrong is
# silent. The process logs the observed header shape once at startup
# ("[Security] x-forwarded-for has N entries"); expect hops = entries - 1.
- variable: TRUSTED_PROXY_HOPS
value: "1"
14 changes: 0 additions & 14 deletions firebase.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
{
"hosting": [
{
"site": "dsgt-portal",
"public": "sites/portal/public",
"cleanUrls": true,
"rewrites": [
{
"source": "**",
"run": {
"serviceId": "portal",
"region": "us-central1"
}
}
]
},
{
"site": "dsgt-website",
"public": "sites/mainweb/out",
Expand Down
37 changes: 37 additions & 0 deletions packages/api/src/.internal-tests/announcements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,43 @@ describe("Announcements", () => {
).rejects.toMatchObject({ code: "FORBIDDEN" });
});

/**
* Rejected and waitlisted applicants are excluded from every other
* audience, which left nothing in the product able to reach them at all.
* This audience exists to be chosen deliberately, for a message written
* for it — so it must be selectable, and must not leak into the others.
*/
it("accepts the not_accepted audience", async () => {
asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined));
mockSelectRows.mockReturnValue([
{ userId: "u1", email: "turned.down@example.com" },
]);

const res = await callerFor(ADMIN).hackathon.createAnnouncement({
...compose,
audience: "not_accepted",
subject: "An update on your application",
});

expect(res.totalRecipients).toBe(1);
const announcementRow = mockInsert.mock.calls
.map((c) => c[2]?.[0])
.find((row) => row && !Array.isArray(row) && "audience" in row);
expect(announcementRow.audience).toBe("not_accepted");
});

it("refuses an audience name that does not exist", async () => {
asAdmin((table) => (table === "hackathons" ? { id: HACK } : undefined));

await expect(
callerFor(ADMIN).hackathon.createAnnouncement({
...compose,
// @ts-expect-error deliberately not one of the five audiences
audience: "everyone",
}),
).rejects.toBeDefined();
});

});

describe("Sending in batches", () => {
Expand Down
69 changes: 69 additions & 0 deletions packages/api/src/.internal-tests/hackathon-admin-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -626,6 +626,75 @@ describe("Hackathon admin management edge cases", () => {
expect(participantWrite?.[2][0].checkedInAt).toBeInstanceOf(Date);
});

/**
* Reported by review on #325.
*
* Submitting a project now requires `checked_in`, which makes it an
* authorisation state rather than a note about who turned up. Setting it on
* somebody still pending would hand them the whole event with no review
* having happened — and the attendees screen offers "Select all N
* matching", so one wrong click could do it to every applicant at once.
*/
it("refuses to check in an applicant who has not been accepted", async () => {
const caller = adminCaller({
hackathonParticipants: {
id: PART_A1,
hackathonId: HACK_A,
registrationStatus: "pending",
},
});

await expect(
caller.hackathon.updateParticipantStatus({
hackathonId: HACK_A,
participantId: PART_A1,
status: "checked_in",
}),
).rejects.toMatchObject({ code: "CONFLICT" });

expect(
mockUpdate.mock.calls.find((c) => c[1][0] === hackathonParticipants),
).toBeUndefined();
});

it("names the remedy rather than just refusing", async () => {
const caller = adminCaller({
hackathonParticipants: {
id: PART_A1,
hackathonId: HACK_A,
registrationStatus: "rejected",
},
});

await expect(
caller.hackathon.updateParticipantStatus({
hackathonId: HACK_A,
participantId: PART_A1,
status: "checked_in",
}),
).rejects.toThrow(/Accept them first/i);
});

// Re-scanning somebody already inside is ordinary, not a transition.
it("allows checking in somebody already checked in", async () => {
const caller = adminCaller({
hackathonParticipants: {
id: PART_A1,
hackathonId: HACK_A,
registrationStatus: "checked_in",
checkedInAt: new Date(),
},
});

await expect(
caller.hackathon.updateParticipantStatus({
hackathonId: HACK_A,
participantId: PART_A1,
status: "checked_in",
}),
).resolves.toMatchObject({ success: true });
});

// BUG: hackathons.currentParticipants is incremented on register and never
// decremented, so rejected applicants permanently consume capacity.
it("frees a seat when an admin rejects a registration", async () => {
Expand Down
29 changes: 25 additions & 4 deletions packages/api/src/.internal-tests/hackathon-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,13 @@ describe("Hackathon end-to-end flow", () => {
mockFindFirst.mockImplementation((table) => {
if (table === "hackathons") return hackathonStartedHoursAgo(hoursIn);
if (table === "hackathonParticipants")
return participant ?? { id: PARTICIPANT, teamId: null };
return (
participant ?? {
id: PARTICIPANT,
teamId: null,
registrationStatus: "checked_in",
}
);
if (table === "hackathonTeams")
return {
id: "team_1",
Expand Down Expand Up @@ -653,7 +659,13 @@ describe("Hackathon end-to-end flow", () => {
hackingStartTime: new Date(Date.now() - 20 * HOUR),
};
if (table === "hackathonParticipants")
return opts.participant ?? { id: PARTICIPANT, teamId: null };
return (
opts.participant ?? {
id: PARTICIPANT,
teamId: null,
registrationStatus: "approved",
}
);
if (table === "hackathonTeams")
return (
opts.team ?? {
Expand All @@ -680,7 +692,11 @@ describe("Hackathon end-to-end flow", () => {

it("refuses to let someone join two teams", async () => {
const caller = joinCaller({
participant: { id: PARTICIPANT, teamId: "team_existing" },
participant: {
id: PARTICIPANT,
teamId: "team_existing",
registrationStatus: "approved",
},
});

await expect(caller.team.joinTeam(join)).rejects.toThrow(
Expand Down Expand Up @@ -749,7 +765,12 @@ describe("Hackathon end-to-end flow", () => {
hackingStartTime: new Date(Date.now() - hoursIn * HOUR),
};
if (table === "hackathonParticipants")
return { id: PARTICIPANT, teamId: "team_1" };
// Submitting requires the badge scan — see checkCheckedIn.
return {
id: PARTICIPANT,
teamId: "team_1",
registrationStatus: "checked_in",
};
if (table === "hackathonProjects")
return existingProject ? { id: "project_1" } : undefined;
return undefined;
Expand Down
71 changes: 69 additions & 2 deletions packages/api/src/.internal-tests/participant-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,10 @@ describe("Participant edge cases", () => {
hackathonId: HACK_A,
userId: "user_a",
teamId: TEAM_A,
registrationStatus: opts.status ?? "approved",
// Submitting requires the badge scan, so "checked in" is the
// default state for these tests — they are about ownership and the
// clock, not about admission.
registrationStatus: opts.status ?? "checked_in",
team: { id: TEAM_A, captainId: opts.captainId ?? "captain_user" },
};
if (table === "hackathonTeams")
Expand Down Expand Up @@ -442,7 +445,15 @@ describe("Participant edge cases", () => {
hackingStartTime: new Date(FIXED.getTime() - offsetMs),
};
if (table === "hackathonParticipants")
return participant ?? { id: PARTICIPANT_A, teamId: null };
// Checked in by default: submitting requires it and forming a team
// requires acceptance; these tests are about the clock.
return (
participant ?? {
id: PARTICIPANT_A,
teamId: null,
registrationStatus: "checked_in",
}
);
if (table === "hackathonTeams")
return {
id: TEAM_A,
Expand Down Expand Up @@ -654,6 +665,62 @@ describe("Participant edge cases", () => {
).rejects.toThrow(new RegExp(status));
},
);

/**
* Registration is an application, not admission.
*
* Every participant is created `pending`, and `pending` used to pass — so
* teams formed and projects were submitted with no review having happened,
* while organisers were shown an approve/reject screen that decided nothing
* but an email.
*/
it("keeps a pending applicant out until they are accepted", async () => {
const caller = rejectedApplicant("pending");

await expect(
caller.team.createTeam({
hackathonId: HACK_A,
name: "meow",
maxMembers: 4,
}),
).rejects.toThrow(/still being reviewed/i);
await expect(
caller.team.joinTeam({ hackathonId: HACK_A, teamId: TEAM_A }),
).rejects.toThrow(/still being reviewed/i);
});

it("lets an accepted applicant form a team", async () => {
const caller = rejectedApplicant("approved");

await expect(
caller.team.createTeam({
hackathonId: HACK_A,
name: "meow",
maxMembers: 4,
}),
).resolves.toMatchObject({ id: TEAM_A });
});

/**
* Acceptance is a decision made weeks earlier; it says nothing about
* whether somebody turned up. Judging is in person against a table number,
* so a submission has to come from a team that is actually in the building.
*/
it("refuses a submission from someone accepted but not checked in", async () => {
const caller = rejectedApplicant("approved");

await expect(caller.team.submitProject(projectInput())).rejects.toThrow(
/check in at the event/i,
);
});

it("accepts a submission once they have been scanned in", async () => {
const caller = rejectedApplicant("checked_in");

await expect(
caller.team.submitProject(projectInput()),
).resolves.toBeDefined();
});
});

// =====================================================================
Expand Down
27 changes: 18 additions & 9 deletions packages/api/src/.internal-tests/resilience.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, it, expect, vi } from "vitest";
import { sanitizeInput } from "../middleware/security";
// The sanitizer the request path actually runs — see the note in
// security.test.ts about the second, uncalled implementation these used to
// target.
import { scrubMarkup } from "../trpc";

vi.mock("@query/db", () => ({
db: {},
Expand Down Expand Up @@ -33,22 +36,28 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => {
const zalgo =
"H\u033d\u0310\u0355e\u033d\u0310\u0355l\u033d\u0310\u0355l\u033d\u0310\u0355o\u033d\u0310\u0355";

expect(typeof sanitizeInput(zalgo)).toBe("string");
expect(typeof scrubMarkup(zalgo)).toBe("string");
// Backtracking on this input would take seconds, not 250ms.
expect(fastestRun(() => sanitizeInput(zalgo))).toBeLessThan(250);
expect(fastestRun(() => scrubMarkup(zalgo))).toBeLessThan(250);
});

it("should handle massive combined character strings efficiently", () => {
const hugeZalgo = "A" + "\u0301".repeat(5000);

expect(typeof sanitizeInput(hugeZalgo)).toBe("string");
expect(fastestRun(() => sanitizeInput(hugeZalgo))).toBeLessThan(500);
expect(typeof scrubMarkup(hugeZalgo)).toBe("string");
expect(fastestRun(() => scrubMarkup(hugeZalgo))).toBeLessThan(500);
});

it("should handle long plain strings up to the maximum slice length", () => {
/**
* Long input is passed through, not truncated. Silently cutting a 15,000
* character project description at 10,000 is the same class of bug as
* rewriting markup: the author is never told, and the loss is permanent.
* Length limits belong in each procedure's own schema, where the error can
* name the field.
*/
it("passes a long plain string through untouched", () => {
const normalLongString = "b".repeat(15000);
const result = sanitizeInput(normalLongString) as string;
expect(result.length).toBe(10000); // Truncation limit
expect(scrubMarkup(normalLongString)).toBe(normalLongString);
});
});

Expand All @@ -70,7 +79,7 @@ describe("Resilience and Domain Edge Cases Verification Suite", () => {

it("should handle double file extensions safely without modifications", () => {
const name = "document.pdf.png";
const result = sanitizeInput(name);
const result = scrubMarkup(name);
expect(result).toBe("document.pdf.png");
});
});
Expand Down
Loading
Loading