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: 22 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 @@ -1186,6 +1186,28 @@ describe("Hackathon admin management edge cases", () => {
caller.admin.update({ adminId: ADMIN_ROW, role: "moderator" }),
).rejects.toThrow(/super admin/i);
});

// Ending the term now removes a super admin as surely as demoting them,
// and one whose term already ended cannot step in.
it("refuses to end the last live super admin's term", async () => {
const caller = adminCaller({}, "super_admin");
mockFindMany.mockReturnValue([
adminRow("super_admin"),
{
id: "other_admin_row",
role: "super_admin",
isActive: true,
expiresAt: new Date("2020-01-01"),
},
]);

await expect(
caller.admin.update({
adminId: ADMIN_ROW,
expiresAt: new Date("2020-06-01"),
}),
).rejects.toThrow(/last super admin/i);
});
});

// =====================================================================
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/.internal-tests/hackathon-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,12 @@ vi.mock("@query/db", () => {
groupBy: vi.fn().mockResolvedValue([]),
limit: vi.fn().mockResolvedValue([]),
offset: vi.fn().mockResolvedValue([]),
// Row locks. The only locked read in these flows is the team row
// leave/disband take, answered from the same wiring as findFirst.
for: vi.fn().mockImplementation(async () => {
const team = mockFindFirst("hackathonTeams");
return team ? [team] : [];
}),
})),
orderBy: vi.fn().mockResolvedValue([{ count: 0 }]),
groupBy: vi.fn().mockResolvedValue([]),
Expand Down
46 changes: 46 additions & 0 deletions packages/api/src/.internal-tests/judge-edge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2106,4 +2106,50 @@ describe("Judge edge cases", () => {
).rejects.toMatchObject({ code: "FORBIDDEN" });
});
});

// =====================================================================
/**
* An organiser pulling a project. Marking the judging entry withdrawn was
* not enough: every queue read kept routing judges to the pulled table.
*/
describe("14. Organiser withdrawal", () => {
const wireWithdraw = () =>
mockFindFirst.mockImplementation((table: string) => {
if (table === "admins") return ADMIN_ROW;
if (table === "hackathonProjects")
return { id: PROJECT_A, hackathonId: HACK_A, status: "submitted" };
return undefined;
});

it("drops the project's unscored queue slots", async () => {
wireWithdraw();
mockUpdate.mockReturnValue([{ id: "judging_row" }]);

await adminCaller().hackathon.adminWithdrawProject({
projectId: PROJECT_A,
});

expect(mockDelete).toHaveBeenCalledTimes(1);
});

it("touches no queue when the project was never promoted", async () => {
wireWithdraw();
mockUpdate.mockReturnValue([]);

await adminCaller().hackathon.adminWithdrawProject({
projectId: PROJECT_A,
});

expect(mockDelete).not.toHaveBeenCalled();
});
});

// isJudge reads ids before the procedure's schema runs, and each one goes
// into a uuid column: a malformed id was a Postgres error and a 500.
it("refuses a malformed id before it reaches the database", async () => {
await expect(
judgeCaller().judge.getNextTable({ hackathonId: "not-a-uuid" }),
).rejects.toMatchObject({ code: "BAD_REQUEST" });
expect(mockFindFirst).not.toHaveBeenCalled();
});
});
56 changes: 56 additions & 0 deletions packages/api/src/.internal-tests/routers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,62 @@ describe("Router Integration and Access Control Verification Suite", () => {
);
});

// An organiser pulled it before judging: no judging row exists, so only
// the column on the project stops it going straight back into the gallery.
it("refuses to resubmit a project an organiser withdrew", async () => {
const ctx = createMockCtx("captain_user_id");
const hackathonId = "00000000-0000-4000-8000-000000000001";
const teamId = "00000000-0000-4000-8000-000000000002";

const startDate20hAgo = new Date(Date.now() - 20 * 60 * 60 * 1000); // inside the edit window

mockFindFirst.mockImplementation((table) => {
if (table === "hackathonParticipants") {
return {
id: "participant_1",
userId: "captain_user_id",
hackathonId,
teamId,
// Submitting requires the badge scan; these tests are about the
// window, so admission is deliberately out of the way.
registrationStatus: "checked_in",
};
}
if (table === "hackathons") {
return {
id: hackathonId,
startDate: startDate20hAgo,
hackingStartTime: null,
};
}
if (table === "hackathonTeams") {
return { id: teamId, captainId: "captain_user_id", hackathonId };
}
if (table === "hackathonProjects") {
return {
id: "project_1",
hackathonId,
teamId,
name: "Old Name",
description: "Old Description",
status: "draft",
withdrawnByAdminAt: new Date(),
};
}
return null;
});

const caller = appRouter.createCaller(ctx);
await expect(
caller.team.submitProject({
hackathonId,
teamId,
name: "Awesome Project",
description: "This is a long description of the awesome project.",
}),
).rejects.toThrowError(/organiser withdrew this project/);
});

it("should prevent project submissions more than 36 hours after hacking starts", async () => {
const ctx = createMockCtx("captain_user_id");
const hackathonId = "00000000-0000-4000-8000-000000000001";
Expand Down
11 changes: 11 additions & 0 deletions packages/api/src/middleware/procedures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,9 @@ export const isProjectLeader = protectedProcedure.use(async ({ ctx, next }) => {

// Verifies the caller is an active judge for a specific hackathon. Cached 60s
// per user per hackathon.
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export const isJudge = protectedProcedure.use(async ({ ctx, next, getRawInput }) => {
const db = ctx.db as NonNullable<typeof ctx.db>;

Expand All @@ -170,6 +173,14 @@ export const isJudge = protectedProcedure.use(async ({ ctx, next, getRawInput })
let hackathonId: string | undefined;
if (rawInput && typeof rawInput === "object") {
const inputObj = rawInput as Record<string, unknown>;
// This runs before the procedure's own schema, and every id below goes into
// a uuid column: a malformed one made Postgres throw, surfacing as a 500.
for (const key of ["hackathonId", "projectId", "queueId"]) {
const value = inputObj[key];
if (typeof value === "string" && !UUID_PATTERN.test(value)) {
throw new TRPCError({ code: "BAD_REQUEST", message: `Invalid ${key}` });
}
}
if (typeof inputObj.hackathonId === "string") {
hackathonId = inputObj.hackathonId;
} else if (typeof inputObj.projectId === "string") {
Expand Down
25 changes: 16 additions & 9 deletions packages/api/src/routers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,22 +394,29 @@ export const adminRouter = createTRPCRouter({
});
}

// Only a super admin can hand the role back out, so demoting the last one
// locks the org out of admin management with no in-app recovery.
if (
// Only a super admin can hand the role back out, so losing the last one
// locks the org out of admin management with no in-app recovery. Demoting
// is one way; deactivating or ending the term now are the others, and an
// expired super admin is not a remaining one.
const removesSuperAdmin =
targetAdmin.role === "super_admin" &&
input.role &&
input.role !== "super_admin"
) {
((input.role !== undefined && input.role !== "super_admin") ||
input.isActive === false ||
(input.expiresAt != null && input.expiresAt.getTime() <= Date.now()));

if (removesSuperAdmin) {
const superAdmins = await (ctx.db as DrizzleDB).query.admins.findMany({
where: and(eq(admins.role, "super_admin"), eq(admins.isActive, true)),
columns: { id: true },
columns: { id: true, expiresAt: true },
});

if (!superAdmins.some((other) => other.id !== targetAdmin.id)) {
const remaining = superAdmins.filter(
(other) => other.id !== targetAdmin.id && !isExpiredAdmin(other),
);
if (remaining.length === 0) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Cannot demote the last super admin",
message: "Cannot remove the last super admin",
});
}
}
Expand Down
9 changes: 9 additions & 0 deletions packages/api/src/routers/hackathon/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,15 @@ export const hackathonAdminRouter = createTRPCRouter({
}

const { wave, picked } = await db.transaction(async (tx) => {
// Serialises waves for this hackathon. SKIP LOCKED below keeps two
// concurrent waves from picking the same people, but both still read
// the same max and were recorded as one wave number.
await tx
.select({ id: hackathons.id })
.from(hackathons)
.where(eq(hackathons.id, input.hackathonId))
.for("update");

const [highest] = await tx
.select({
max: sql<
Expand Down
28 changes: 25 additions & 3 deletions packages/api/src/routers/hackathon/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
hackathonParticipants,
hackathonProjects,
hackathonResults,
judgeQueue,
judgingProjects,
} from "@query/db";
import { eq, and, inArray, isNotNull } from "drizzle-orm";
Expand Down Expand Up @@ -102,16 +103,37 @@ export const hackathonContentRouter = createTRPCRouter({

await db
.update(hackathonProjects)
.set({ status: "draft", submittedAt: null, updatedAt: new Date() })
.set({
status: "draft",
submittedAt: null,
withdrawnByAdminAt: new Date(),
updatedAt: new Date(),
})
.where(eq(hackathonProjects.id, input.projectId));

// The judging entry has to go with it, or the CONFLICT message above is a
// lie: judges keep being routed to the table, the votes keep counting, and
// the project can still be published as a placing.
await db
const pulled = await db
.update(judgingProjects)
.set({ withdrawnAt: new Date() })
.where(eq(judgingProjects.sourceProjectId, input.projectId));
.where(eq(judgingProjects.sourceProjectId, input.projectId))
.returning({ id: judgingProjects.id });

// Unscored slots go too. Every queue read (next table, vote, complete and
// next) otherwise kept sending judges to a table scan-to-start refuses.
// Completed slots stay: their votes are the record.
if (pulled.length > 0) {
await db.delete(judgeQueue).where(
and(
inArray(
judgeQueue.projectId,
pulled.map((row) => row.id),
),
eq(judgeQueue.isCompleted, false),
),
);
}

await recordAdminAction(db, {
userId: ctx.userId,
Expand Down
28 changes: 23 additions & 5 deletions packages/api/src/routers/judge/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,13 +478,27 @@ export const judgeAdminRouter = createTRPCRouter({
? eligibleProjects
: shuffleArray(eligibleProjects);

if (assignedProjects.length > 0) {
// initializeQueue may already have built this judge a queue, and
// judge_queue has no unique on (judge, project): appending the full pool
// would put every project in it twice.
const queued = await (ctx.db as DrizzleDB).query.judgeQueue.findMany({
where: and(
eq(judgeQueue.judgeId, input.judgeId),
eq(judgeQueue.hackathonId, input.hackathonId),
),
columns: { projectId: true, order: true },
});
const alreadyQueued = new Set(queued.map((row) => row.projectId));
const lastOrder = queued.reduce((max, row) => Math.max(max, row.order), 0);
const toQueue = assignedProjects.filter((p) => !alreadyQueued.has(p.id));

if (toQueue.length > 0) {
await (ctx.db as DrizzleDB).insert(judgeQueue).values(
assignedProjects.map((p, idx) => ({
toQueue.map((p, idx) => ({
judgeId: input.judgeId,
hackathonId: input.hackathonId,
projectId: p.id,
order: idx + 1,
order: lastOrder + idx + 1,
})),
);
}
Expand Down Expand Up @@ -924,8 +938,8 @@ export const judgeAdminRouter = createTRPCRouter({
.input(
z.object({
hackathonId: z.string().uuid(),
minProjects: z.number().min(1).default(3),
maxProjects: z.number().min(1).default(9),
minProjects: z.number().int().min(1).default(3),
maxProjects: z.number().int().min(1).default(9),
shuffle: z.boolean().default(true),
// False (default) randomizes special-label/sponsor pools; true keeps them in
// table order.
Expand All @@ -934,6 +948,10 @@ export const judgeAdminRouter = createTRPCRouter({
// Rebuild even though judging is live or work is done. Completed slots still
// carry over; this only waives the refusal, so the admin saw the count.
force: z.boolean().default(false),
}).refine((input) => input.maxProjects >= input.minProjects, {
// min 9 / max 3 silently capped every judge at 3.
message: "Maximum projects per judge must be at least the minimum.",
path: ["maxProjects"],
}),
)
.mutation(async ({ ctx, input }) => {
Expand Down
Loading
Loading