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
66 changes: 66 additions & 0 deletions backend/routes/tpo.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,72 @@ router.post("/register", async (req, res) => {
}
});

// ── TPO verification evidence ─────────────────────────────────────────────
// Evidence supplements the advisory email signal; it never grants TPO access.
// Only a pending TPO applicant can add evidence, and the email evidence entry
// created during registration remains system-controlled.
const TPO_EVIDENCE_KINDS = new Set(["invitation", "staff_id", "document", "manual_note"]);
const MAX_TPO_EVIDENCE_ITEMS = 10;

router.post("/verification/evidence", requireRole("tpo"), async (req, res) => {
try {
const verification = req.userDoc?.tpoVerification;
if (!verification || verification.status !== "pending") {
return res.status(409).json({ error: "Only a pending TPO verification request can receive evidence." });
}

const input = req.body?.evidence;
if (!Array.isArray(input) || input.length === 0) {
return res.status(400).json({ error: "evidence must be a non-empty array." });
}

if (input.length > MAX_TPO_EVIDENCE_ITEMS) {
return res.status(400).json({ error: "A maximum of " + MAX_TPO_EVIDENCE_ITEMS + " evidence items is allowed." });
}

const now = new Date();
const additions = [];
for (const item of input) {
const kind = String(item?.kind || "").trim().toLowerCase();
const label = String(item?.label || "").trim();
const reference = item?.reference == null ? null : String(item.reference).trim();
const note = item?.note == null ? null : String(item.note).trim();

if (!TPO_EVIDENCE_KINDS.has(kind)) {
return res.status(400).json({ error: "Unsupported evidence kind: " + (kind || "unknown") + "." });
}
if (!label || label.length > 120) {
return res.status(400).json({ error: "Each evidence label must be 1–120 characters." });
}
if (reference && reference.length > 500) {
return res.status(400).json({ error: "Evidence reference must be at most 500 characters." });
}
if (note && note.length > 1000) {
return res.status(400).json({ error: "Evidence note must be at most 1000 characters." });
}

additions.push({ kind, label, reference: reference || null, note: note || null, addedAt: now });
}

const existing = Array.isArray(verification.evidence) ? verification.evidence : [];
if (existing.length + additions.length > MAX_TPO_EVIDENCE_ITEMS) {
return res.status(400).json({ error: "A maximum of " + MAX_TPO_EVIDENCE_ITEMS + " evidence items is allowed in total." });
}

verification.evidence = [...existing, ...additions];
await req.userDoc.save();

return res.status(200).json({
success: true,
status: verification.status,
evidence: verification.evidence,
});
} catch (err) {
(req.log || logger).error({ err, userId: req.userDoc?._id }, "[TPO] verification evidence update error");
return res.status(500).json({ error: "Failed to update TPO verification evidence." });
}
});

// ── TPO-6 entitlement enforcement ─────────────────────────────────────────
// Registration and billing-status must remain reachable without a paid plan.
// Students using the college TPO directory are also unaffected because this
Expand Down
109 changes: 109 additions & 0 deletions backend/routes/tpo.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -924,3 +924,112 @@ describe("GET /college-directory", () => {
expect(User.find).not.toHaveBeenCalled();
});
});

describe("POST /verification/evidence", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("appends applicant evidence while keeping the request pending", async () => {
const userDoc = {
role: "tpo",
tpoVerification: {
status: "pending",
emailRoleSignal: "student_candidate",
evidence: [
{ kind: "email", label: "Institutional sign-in email" },
],
},
save: vi.fn().mockResolvedValue(true),
};

const res = await runRoute("post", "/verification/evidence", {
userDoc,
body: {
evidence: [
{
kind: "staff_id",
label: "Staff ID reference",
reference: "STAFF-2026-123",
note: "Current institutional staff identifier.",
},
],
},
});

expect(res.status).not.toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
success: true,
status: "pending",
evidence: expect.arrayContaining([
expect.objectContaining({ kind: "email", label: "Institutional sign-in email" }),
expect.objectContaining({
kind: "staff_id",
label: "Staff ID reference",
reference: "STAFF-2026-123",
}),
]),
}));
expect(userDoc.save).toHaveBeenCalledOnce();
});

it("rejects system-controlled email evidence submitted by the applicant", async () => {
const userDoc = {
role: "tpo",
tpoVerification: { status: "pending", evidence: [] },
save: vi.fn(),
};

const res = await runRoute("post", "/verification/evidence", {
userDoc,
body: { evidence: [{ kind: "email", label: "Forged email evidence" }] },
});

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

it("rejects evidence updates after verification is no longer pending", async () => {
const userDoc = {
role: "tpo",
tpoVerification: { status: "approved", evidence: [] },
save: vi.fn(),
};

const res = await runRoute("post", "/verification/evidence", {
userDoc,
body: { evidence: [{ kind: "manual_note", label: "Extra context" }] },
});

expect(res.status).toHaveBeenCalledWith(409);
expect(userDoc.save).not.toHaveBeenCalled();
});

it("enforces the total evidence cap", async () => {
const userDoc = {
role: "tpo",
tpoVerification: {
status: "pending",
evidence: Array.from({ length: 9 }, (_, index) => ({
kind: "manual_note",
label: "Existing " + index,
})),
},
save: vi.fn(),
};

const res = await runRoute("post", "/verification/evidence", {
userDoc,
body: {
evidence: [
{ kind: "manual_note", label: "One more" },
{ kind: "manual_note", label: "Too many" },
],
},
});

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

1 change: 1 addition & 0 deletions docs/api-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,5 +178,6 @@ College email-role patterns are institution-specific advisory evidence. They may
| PATCH | `/api/admin/colleges/:collegeId/email-role-patterns` | Admin-only configuration of staff/student email patterns for a college. |
| POST | `/api/admin/tpo-verification/:userId/approve` | Admin-only approval of an individual pending TPO request after the college is verified. |
| POST | `/api/admin/tpo-verification/:userId/reject` | Admin-only rejection of an individual pending TPO request. |
| POST | `/api/tpo/verification/evidence` | Adds applicant-supplied verification evidence to a pending TPO request; evidence is advisory and does not grant access. |

TPO registration keeps the requester pending even when the institution itself is already recognized. This separates **institution trust** from **individual TPO authorization**. Student/staff email patterns are evidence shown to the reviewer, not an authorization shortcut.
Loading