From 670e60f3a6769c4a95bac4f51b3bf32673eee634 Mon Sep 17 00:00:00 2001 From: Gerudo <18513852+s2owl@users.noreply.github.com> Date: Wed, 6 May 2026 10:41:37 +0100 Subject: [PATCH] Refactor ParentClient to extend BaseClient and add login Refactor ParentClient class to extend BaseClient and implement login functionality with TES SSO handshake. Remove old tests and add new methods for pupil management and password change. --- src/core/parentClient_test.ts | 226 ++++++++++++++++++++++++++++------ 1 file changed, 191 insertions(+), 35 deletions(-) diff --git a/src/core/parentClient_test.ts b/src/core/parentClient_test.ts index 3c828e8..e3a07e6 100644 --- a/src/core/parentClient_test.ts +++ b/src/core/parentClient_test.ts @@ -1,35 +1,191 @@ -import { assertRejects } from "@std/assert"; -import { ParentClient } from "../core/parentClient.ts"; - -Deno.test("Throws when no email is provided", async () => { - const client = new ParentClient("", "password"); - await assertRejects( - async () => { - await client.login(); - }, - Error, - "Email not provided", - ); -}); - -Deno.test("Throws when no password is provided", async () => { - const client = new ParentClient("email", ""); - await assertRejects( - async () => { - await client.login(); - }, - Error, - "Password not provided", - ); -}); - -Deno.test("Throws with invalid username and password", async () => { - const client = new ParentClient("invalid", "invalid"); - await assertRejects( - async () => { - await client.login(); - }, - Error, - "Unauthenticated: ClassCharts didn't return authentication cookies", - ); -}); +import { BaseClient } from "./baseClient.js"; +import { API_BASE_PARENT, BASE_URL } from "../utils/consts.js"; +import { parseCookies } from "../utils/utils.js"; +import type { ChangePasswordResponse, GetPupilsResponse } from "../types.js"; + +/** + * Parent Client. + * See {@link BaseClient} for all shared methods. + * + * @example + * ```ts + * import { ParentClient } from "classcharts-api"; + * const client = new ParentClient("username", "password"); + * await client.login(); + * ``` + */ +export class ParentClient extends BaseClient { + private password = ""; + private email = ""; + public pupils: GetPupilsResponse = []; + + /** + * @param email Parent's email address + * @param password Parent's password + */ + constructor(email: string, password: string) { + super(API_BASE_PARENT); + this.email = String(email); + this.password = String(password); + this.pupils = []; + } + + /** + * Authenticates with ClassCharts. + * + * Handles the TES SSO verification step introduced in April 2026. + * Without the handshake, getPupils() would trigger an infinite redirect loop + * between classcharts.com and session.tes.com, causing a + * "redirect count exceeded" error. + */ + async login(): Promise { + if (!this.email) { + throw new Error("Email not provided"); + } + if (!this.password) { + throw new Error("Password not provided"); + } + + const formData = new URLSearchParams(); + formData.append("_method", "POST"); + formData.append("email", this.email); + formData.append("logintype", "existing"); + formData.append("password", this.password); + formData.append("recaptcha-token", "no-token-available"); + + const headers = new Headers({ + "Content-Type": "application/x-www-form-urlencoded", + }); + + const response = await fetch(`${BASE_URL}/parent/login`, { + method: "POST", + body: formData, + headers: headers, + redirect: "manual", + }); + + if (response.status !== 302 || !response.headers.has("set-cookie")) { + await response.body?.cancel(); + throw new Error( + "Unauthenticated: ClassCharts didn't return authentication cookies" + ); + } + + const cookies = String(response.headers.get("set-cookie")); + const sessionCookies = parseCookies(cookies); + const sessionID = JSON.parse( + String(sessionCookies.parent_session_credentials) + ); + this.sessionId = sessionID.session_id; + + // Perform the TES SSO handshake before calling getPupils(), otherwise the + // /apiv2parent/pupils endpoint redirects to session.tes.com/v1/verify and + // back in an infinite loop (introduced ~27 April 2026). + await this.performTesSsoHandshake(sessionCookies); + + this.pupils = await this.getPupils(); + if (!this.pupils) { + throw new Error("Account has no pupils attached"); + } + this.studentId = this.pupils[0].id; + } + + /** + * Satisfies the TES SSO verification handshake introduced in April 2026. + * + * ClassCharts now redirects the first authenticated request to + * `session.tes.com/v1/verify`, which redirects straight back, creating a + * loop. Following the two-hop chain once (with `redirect: "manual"`) sets + * the necessary TES cookies and allows subsequent API calls to succeed. + * + * @param sessionCookies Parsed session cookies from the login POST response + */ + private async performTesSsoHandshake( + sessionCookies: Record + ): Promise { + const ccSession = sessionCookies["cc-session"]; + const parentCreds = sessionCookies["parent_session_credentials"]; + + if (!ccSession || !parentCreds) { + // Cookies absent — skip handshake gracefully (guards against future + // auth flow changes where these cookie names may differ). + return; + } + + const cookieHeader = `cc-session=${ccSession}; parent_session_credentials=${parentCreds}`; + + const verifyUrl = + "https://session.tes.com/v1/verify?returnUrl=" + + encodeURIComponent(`${BASE_URL}/apiv2parent/pupils`); + + // Hop 1: classcharts.com → session.tes.com/v1/verify + const tesRes = await fetch(verifyUrl, { + headers: { Cookie: cookieHeader }, + redirect: "manual", + }); + + // Hop 2: session.tes.com → classcharts.com (back-redirect completing the handshake) + const tesLocation = tesRes.headers.get("location"); + if (tesLocation) { + await fetch(tesLocation, { + headers: { Cookie: cookieHeader }, + redirect: "manual", + }); + } + } + + /** + * Get a list of pupils connected to this parent's account + * @returns an array of Pupils connected to this parent's account + */ + async getPupils(): Promise { + const response = await this.makeAuthedRequest(`${this.API_BASE}/pupils`, { + method: "GET", + }); + return response.data; + } + + /** + * Selects a pupil to be used with API requests + * @param pupilId Pupil ID obtained from this.pupils or getPupils() + * + * @see getPupils + */ + selectPupil(pupilId: number): void { + if (!pupilId) { + throw new Error("No pupil ID specified"); + } + const pupils = this.pupils; + for (let i = 0; i < pupils.length; i++) { + const pupil = pupils[i]; + if (pupil.id === pupilId) { + this.studentId = pupil.id; + return; + } + } + throw new Error("No pupil with specified ID returned"); + } + + /** + * Changes the login password for the current parent account + * @param currentPassword Current password + * @param newPassword New password + * @returns Whether the request was successful + */ + async changePassword( + currentPassword: string, + newPassword: string + ): Promise { + const formData = new URLSearchParams(); + formData.append("current", currentPassword); + formData.append("new", newPassword); + formData.append("repeat", newPassword); + return await this.makeAuthedRequest(`${this.API_BASE}/password`, { + method: "POST", + body: formData, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + } +}