Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
2b33c3c
harden TPO review decisions and surface review history
SSVP-debug Sep 27, 2026
c7bdfc1
test TPO review decision hardening
SSVP-debug Sep 27, 2026
1f260e0
stabilize TPO review queue test mocks
SSVP-debug Sep 27, 2026
b3f7fed
allow custom review content in confirm dialog
SSVP-debug Sep 27, 2026
679142c
add TPO review details and rejection reason UI
SSVP-debug Sep 27, 2026
2c3e1c2
send TPO review decision reasons from admin queue
SSVP-debug Sep 27, 2026
58d1b70
wire TPO review details and decision reasons
SSVP-debug Sep 27, 2026
cee35e1
test rejected TPO resubmission lifecycle
SSVP-debug Sep 27, 2026
354ac2e
test TPO review history in admin queue
SSVP-debug Sep 27, 2026
440897b
document TPO review decision and lifecycle contract
SSVP-debug Sep 27, 2026
6e10e0e
preserve generic queue reject callback contract
SSVP-debug Sep 27, 2026
73986a7
clarify explicit authority for TPO team invites
SSVP-debug Sep 27, 2026
995088f
include reviewer identity in TPO review history
SSVP-debug Sep 27, 2026
5240199
show reviewer identity in TPO review history
SSVP-debug Sep 27, 2026
c9c4408
test TPO review evidence and rejection reason UI
SSVP-debug Sep 27, 2026
c9eab79
cover individual TPO rejection and resubmission lifecycle
SSVP-debug Sep 27, 2026
b8f84da
validate TPO review reason type
SSVP-debug Sep 27, 2026
12d9b30
fix(tpo): keep rejected role authorization consistent
SSVP-debug Sep 27, 2026
479c26a
fix(admin): preserve generic rejection callback contract
SSVP-debug Sep 27, 2026
384d8f1
test(tpo): enforce rejection role normalization independently
SSVP-debug Sep 27, 2026
1d56366
test(tpo): provide rejection reason in lifecycle test
SSVP-debug Sep 27, 2026
8d1c634
test(tpo): model pending verification state in primary claim flow
SSVP-debug Sep 27, 2026
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
79 changes: 77 additions & 2 deletions backend/controllers/adminController.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,35 @@ export async function getPendingQueue(req, res) {
(u) => !pendingCollegeRequesterIds.has(u._id.toString())
);

const reviewHistoryByUser = new Map();
if (individualTpoRequests.length) {
const reviews = await TpoVerificationReview.find({
userId: { $in: individualTpoRequests.map((u) => u._id) },
})
.populate("reviewedBy", "displayName email")
.sort({ reviewedAt: -1 })
.lean();

for (const review of reviews) {
const key = review.userId.toString();
const history = reviewHistoryByUser.get(key) || [];
if (history.length < 5) {
history.push({
decision: review.decision,
decisionReason: review.decisionReason,
reviewedAt: review.reviewedAt,
reviewedBy: review.reviewedBy
? {
displayName: review.reviewedBy.displayName || null,
email: review.reviewedBy.email || null,
}
: null,
});
reviewHistoryByUser.set(key, history);
}
}
}

const studentColleges = pendingColleges.filter(
(c) => c.submittedByRole === "student" || c.submittedByRole === "auto"
);
Expand Down Expand Up @@ -128,6 +157,7 @@ export async function getPendingQueue(req, res) {
verificationStatus: u.tpoVerification?.status || "pending",
additionalEvidenceRecommended: (u.tpoVerification?.emailRoleSignal || "unknown") !== "staff_candidate",
evidence: u.tpoVerification?.evidence || [],
reviewHistory: reviewHistoryByUser.get(u._id.toString()) || [],
reviewTarget: "user",
})),
],
Expand Down Expand Up @@ -1027,11 +1057,45 @@ async function resolvePendingTpoCollege(user) {
return College.findByDomain(domain);
}

function getReviewDecisionReason(req, { required = false } = {}) {
const raw = req.body?.decisionReason;
if (raw != null && typeof raw !== "string") {
return { error: "decisionReason must be a string." };
}
if (raw == null) {
if (required) {
return { error: "A decision reason is required when rejecting a TPO verification request." };
}
return { value: null };
}

const value = String(raw).trim();
if (!value) {
if (required) {
return { error: "A decision reason is required when rejecting a TPO verification request." };
}
return { value: null };
}

if (value.length > 1000) {
return { error: "Decision reason must be at most 1000 characters." };
}

return { value };
}

export async function approveTpoUser(req, res) {
try {
const user = await User.findById(req.params.userId);
if (!user || user.role !== "tpo") return res.status(404).json({ error: "TPO verification request not found." });
if (user.tpoProfile?.verified) return res.json({ success: true, alreadyVerified: true });
if (user.tpoVerification?.status !== "pending") {
return res.status(409).json({ error: "Only a pending TPO verification request can be approved." });
}

const reasonResult = getReviewDecisionReason(req);
if (reasonResult.error) return res.status(400).json({ error: reasonResult.error });
const decisionReason = reasonResult.value || "Approved through individual TPO verification.";

const college = await resolvePendingTpoCollege(user);
if (!college || college.status !== "verified") {
Expand All @@ -1053,7 +1117,7 @@ export async function approveTpoUser(req, res) {
emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown",
evidence: user.tpoVerification?.evidence || [],
decision: "approved",
decisionReason: "Approved through individual TPO verification.",
decisionReason,
reviewedBy: req.actingAdminDoc?._id || req.userDoc?._id,
reviewedAt: now,
});
Expand Down Expand Up @@ -1091,6 +1155,13 @@ export async function rejectTpoUser(req, res) {
try {
const user = await User.findById(req.params.userId);
if (!user || user.role !== "tpo") return res.status(404).json({ error: "TPO verification request not found." });
if (user.tpoVerification?.status !== "pending") {
return res.status(409).json({ error: "Only a pending TPO verification request can be rejected." });
}

const reasonResult = getReviewDecisionReason(req, { required: true });
if (reasonResult.error) return res.status(400).json({ error: reasonResult.error });
const decisionReason = reasonResult.value;

const college = await resolvePendingTpoCollege(user);
const reviewerId = req.actingAdminDoc?._id || req.userDoc?._id;
Expand All @@ -1105,7 +1176,7 @@ export async function rejectTpoUser(req, res) {
emailRoleSignal: user.tpoVerification?.emailRoleSignal || "unknown",
evidence: user.tpoVerification?.evidence || [],
decision: "rejected",
decisionReason: "Individual TPO verification request rejected.",
decisionReason,
reviewedBy: reviewerId,
reviewedAt: now,
});
Expand All @@ -1115,6 +1186,10 @@ export async function rejectTpoUser(req, res) {
}

user.revokeRole("tpo");
// Keep the authorization set consistent even if a lightweight test/mock
// user does not implement revokeRole exactly like the Mongoose model.
user.roles = (user.roles || []).filter((role) => role !== "tpo");
if (!user.roles.includes("student")) user.roles.unshift("student");
user.role = "student";
user.tpoProfile = { collegeDomain: null, collegeName: null, verified: false, requestedAt: null, verifiedAt: null };
user.tpoVerification = { ...(user.tpoVerification || {}), status: "rejected" };
Expand Down
205 changes: 203 additions & 2 deletions backend/controllers/adminController.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, it, vi, beforeEach } from "vitest";

vi.mock("../models/TpoVerificationReview.js", () => ({
default: { create: vi.fn().mockResolvedValue({}) },
default: {
create: vi.fn().mockResolvedValue({}),
find: vi.fn(),
},
}));
vi.mock("../models/College.js", () => ({
default: {
Expand Down Expand Up @@ -143,6 +146,7 @@ describe("adminController", () => {

beforeEach(() => {
vi.clearAllMocks();
TpoVerificationReview.find.mockReturnValue(chainableQuery([]));
res = mockRes();
});

Expand Down Expand Up @@ -186,6 +190,59 @@ describe("adminController", () => {
);
});

it("includes recent review history for a pending individual TPO applicant", async () => {
User.find.mockReturnValueOnce(chainableQuery([]));
College.find.mockReturnValueOnce(chainableQuery([]));
User.find.mockReturnValueOnce(
chainableQuery([
{
_id: "tpo1",
email: "staff@mit.edu",
displayName: "Staff",
tpoProfile: { collegeName: "MIT", collegeDomain: "mit.edu", requestedAt: "now" },
tpoVerification: {
status: "pending",
emailRoleSignal: "staff_candidate",
submittedAt: "now",
evidence: [{ kind: "staff_id", label: "Staff ID" }],
},
createdAt: "created",
},
])
);
TpoVerificationReview.find.mockReturnValueOnce(
chainableQuery([
{
userId: "tpo1",
decision: "rejected",
decisionReason: "Previous evidence was insufficient.",
reviewedAt: "2026-09-20T10:00:00Z",
reviewedBy: "admin1",
},
])
);

await getPendingQueue({}, res);

expect(TpoVerificationReview.find).toHaveBeenCalledWith({ userId: { $in: ["tpo1"] } });
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
tpos: [
expect.objectContaining({
userId: "tpo1",
reviewTarget: "user",
reviewHistory: [
expect.objectContaining({
decision: "rejected",
decisionReason: "Previous evidence was insufficient.",
}),
],
}),
],
})
);
});

it("returns 500 if the query fails", async () => {
User.find.mockImplementationOnce(() => {
throw new Error("db down");
Expand Down Expand Up @@ -245,6 +302,143 @@ describe("adminController", () => {
});
});

describe("individual TPO verification decisions", () => {
function makePendingTpo(overrides = {}) {
return makeUser({
_id: "tpo1",
firebaseUid: "fb-tpo1",
email: "staff@mit.edu",
role: "tpo",
roles: ["student", "tpo"],
tpoProfile: {
collegeDomain: "mit.edu",
collegeName: "MIT",
verified: false,
requestedAt: "request-time",
verifiedAt: null,
},
tpoVerification: {
status: "pending",
emailRoleSignal: "staff_candidate",
submittedEmail: "staff@mit.edu",
submittedAt: "request-time",
evidence: [],
},
...overrides,
});
}

it("approves only a pending applicant, records the review reason, and claims primary after approval", async () => {
const user = makePendingTpo();
const college = {
_id: "c1",
name: "MIT",
status: "verified",
domains: ["mit.edu"],
};
const admin = makeAdmin();
User.findById.mockResolvedValueOnce(user);
College.findByDomain.mockResolvedValueOnce(college);
claimPrimaryIfNone.mockResolvedValueOnce(true);

await approveTpoUser({
params: { userId: "tpo1" },
body: { decisionReason: "Staff identity matched the submitted institutional evidence." },
userDoc: admin,
}, res);

expect(user.tpoProfile.verified).toBe(true);
expect(user.tpoVerification.status).toBe("approved");
expect(user.save).toHaveBeenCalledOnce();
expect(TpoVerificationReview.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: "tpo1",
collegeId: "c1",
decision: "approved",
decisionReason: "Staff identity matched the submitted institutional evidence.",
reviewedBy: "admin1",
})
);
expect(claimPrimaryIfNone).toHaveBeenCalledWith("c1", "tpo1");
expect(recordAdminAction).toHaveBeenCalledWith(
expect.objectContaining({ action: "tpo.user.approve", targetType: "User", targetId: "tpo1" })
);
expect(res.json).toHaveBeenCalledWith({ success: true });
});

it("rejects without mutating the applicant when a non-pending request is reviewed", async () => {
const user = makePendingTpo({
tpoVerification: {
status: "rejected",
emailRoleSignal: "unknown",
submittedEmail: "staff@mit.edu",
evidence: [],
},
});
User.findById.mockResolvedValueOnce(user);

await rejectTpoUser({ params: { userId: "tpo1" }, body: { decisionReason: "Insufficient evidence." }, userDoc: makeAdmin() }, res);

expect(res.status).toHaveBeenCalledWith(409);
expect(user.save).not.toHaveBeenCalled();
expect(TpoVerificationReview.create).not.toHaveBeenCalled();
});

it("requires a reason when rejecting a pending applicant", async () => {
const user = makePendingTpo();
User.findById.mockResolvedValueOnce(user);

await rejectTpoUser({ params: { userId: "tpo1" }, body: {}, userDoc: makeAdmin() }, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(user.save).not.toHaveBeenCalled();
expect(TpoVerificationReview.create).not.toHaveBeenCalled();
});

it("rejects a pending applicant and preserves the reason in the immutable review", async () => {
const user = makePendingTpo();
const college = { _id: "c1", name: "MIT", status: "verified", domains: ["mit.edu"] };
const admin = makeAdmin();
User.findById.mockResolvedValueOnce(user);
College.findByDomain.mockResolvedValueOnce(college);

await rejectTpoUser({
params: { userId: "tpo1" },
body: { decisionReason: "The submitted staff evidence could not be verified." },
userDoc: admin,
}, res);

expect(user.role).toBe("student");
expect(user.roles).toEqual(["student"]);
expect(user.tpoVerification.status).toBe("rejected");
expect(TpoVerificationReview.create).toHaveBeenCalledWith(
expect.objectContaining({
userId: "tpo1",
collegeId: "c1",
decision: "rejected",
decisionReason: "The submitted staff evidence could not be verified.",
reviewedBy: "admin1",
})
);
expect(invalidateCachedUserByFirebaseUid).toHaveBeenCalledWith("fb-tpo1");
expect(res.json).toHaveBeenCalledWith({ success: true });
});

it("does not allow an oversized decision reason", async () => {
const user = makePendingTpo();
User.findById.mockResolvedValueOnce(user);

await rejectTpoUser({
params: { userId: "tpo1" },
body: { decisionReason: "x".repeat(1001) },
userDoc: makeAdmin(),
}, res);

expect(res.status).toHaveBeenCalledWith(400);
expect(user.save).not.toHaveBeenCalled();
});
});

describe("approveTpo", () => {
it("verifies the college without bulk-authorizing individual TPOs", async () => {
const college = {
Expand Down Expand Up @@ -1173,11 +1367,18 @@ describe("individual TPO verification", () => {
},
tpoVerification: { status: "pending", emailRoleSignal: "student_candidate", evidence: [] },
});
// Deliberately make revokeRole a no-op here. The controller must
// enforce the persisted authorization invariant itself: TPO is removed
// from roles and student remains available after an individual rejection.
user.revokeRole = vi.fn();
User.findById.mockResolvedValueOnce(user);
College.findByDomain.mockResolvedValueOnce({ _id: "c1", name: "MIT", status: "verified", domains: ["mit.edu"] });
const res = { status: vi.fn().mockReturnThis(), json: vi.fn() };

await rejectTpoUser({ params: { userId: "t2" }, userDoc: makeAdmin() }, res);
await rejectTpoUser(
{ params: { userId: "t2" }, userDoc: makeAdmin(), body: { decisionReason: "Staff identity could not be verified." } },
res
);

expect(user.roles).toEqual(["student"]);
expect(user.role).toBe("student");
Expand Down
11 changes: 5 additions & 6 deletions backend/routes/tpo.js
Original file line number Diff line number Diff line change
Expand Up @@ -796,12 +796,11 @@ router.get("/team", requireRole("tpo", "admin"), requireVerified, resolveTpoInst
// the candidate must already have signed up (and therefore already gone
// through Firebase auth) before the primary can add them, mirroring the
// "candidate authenticates, then institution relationship established"
// flow the phase doc describes. This is the same instant-verification
// trust decision POST /register already makes for a second TPO registering
// on an already-verified college domain (see isDomainAutoVerified/
// existingCollege.status === "verified" above): a known institutional-
// domain account, vouched for by the college's own primary TPO, doesn't
// need a second manual admin review.
// flow the phase doc describes. Unlike self-registration, however,
// this path is an explicit authorization action by an already-verified
// primary TPO. The primary's authenticated team-management action is the
// authority for this grant; institutional email/domain checks only enforce
// that the invited account belongs to the same college.
router.post("/team/invite", requireRole("tpo", "admin"), requireVerified, resolveTpoInstitution, requirePrimaryOnly, async (req, res) => {
if (b2bGate(req, res)) return;

Expand Down
Loading
Loading