1
0
Fork 0
mirror of https://github.com/classchartsapi/classcharts-api-js.git synced 2026-05-14 11:58:13 +00:00

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.
This commit is contained in:
Gerudo 2026-05-06 10:41:37 +01:00 committed by GitHub
parent 65ae5d4a54
commit 670e60f3a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -1,35 +1,191 @@
import { assertRejects } from "@std/assert"; import { BaseClient } from "./baseClient.js";
import { ParentClient } from "../core/parentClient.ts"; import { API_BASE_PARENT, BASE_URL } from "../utils/consts.js";
import { parseCookies } from "../utils/utils.js";
import type { ChangePasswordResponse, GetPupilsResponse } from "../types.js";
Deno.test("Throws when no email is provided", async () => { /**
const client = new ParentClient("", "password"); * Parent Client.
await assertRejects( * See {@link BaseClient} for all shared methods.
async () => { *
await client.login(); * @example
}, * ```ts
Error, * import { ParentClient } from "classcharts-api";
"Email not provided", * 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<void> {
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",
}); });
Deno.test("Throws when no password is provided", async () => { const response = await fetch(`${BASE_URL}/parent/login`, {
const client = new ParentClient("email", ""); method: "POST",
await assertRejects( body: formData,
async () => { headers: headers,
await client.login(); redirect: "manual",
},
Error,
"Password not provided",
);
}); });
Deno.test("Throws with invalid username and password", async () => { if (response.status !== 302 || !response.headers.has("set-cookie")) {
const client = new ParentClient("invalid", "invalid"); await response.body?.cancel();
await assertRejects( throw new Error(
async () => { "Unauthenticated: ClassCharts didn't return authentication cookies"
await client.login();
},
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<string, string>
): Promise<void> {
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<GetPupilsResponse> {
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<ChangePasswordResponse> {
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",
},
});
}
}