diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 66e15fe..1549253 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,10 +32,10 @@ jobs: - name: Test # This is just the default settings for GH Actions MySQL - # Nothing secret here aside from the hashed API key + # Nothing secret here aside from the API key env: DB_TEST_HOSTNAME: 127.0.0.1 DB_TEST_USERNAME: root DB_TEST_PASSWORD: root - HASHED_API_KEY: ${{ secrets.HASHED_API_KEY }} + CDS_API_KEY: TEST_API_KEY run: npm run test diff --git a/jest.config.ts b/jest.config.ts index 3929f00..7155cd0 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -6,9 +6,15 @@ const config: Config = { preset: "ts-jest", testEnvironment: "node", coveragePathIgnorePatterns: [ - "/node_modules/", - "/dist", - ] + "./node_modules/", + "./dist/", + ], + testPathIgnorePatterns: [ + "./node_modules/", + "./dist/", + ], + globalSetup: "./tests/setup.ts", + globalTeardown: "./tests/teardown.ts" }; export default config; diff --git a/src/authorization.ts b/src/authorization.ts index e673197..e65ca50 100644 --- a/src/authorization.ts +++ b/src/authorization.ts @@ -6,13 +6,20 @@ const HASHER = new SHA3(256); const validKeys = new Map(); +export function hashAPIKey(key: string): string { + HASHER.reset(); + HASHER.update(key); + const hashed = HASHER.digest("hex"); + HASHER.reset(); + return hashed; +} + export async function getAPIKey(key: string): Promise { const cachedKey = validKeys.get(key); if (cachedKey !== undefined) { return cachedKey; } - HASHER.update(key); - const hashedKey = HASHER.digest("hex"); + const hashedKey = hashAPIKey(key); const apiKey = await APIKey.findOne({ where: { hashed_key: hashedKey } }); HASHER.reset(); if (apiKey !== null) { diff --git a/src/database.ts b/src/database.ts index b78b66e..b700fbb 100644 --- a/src/database.ts +++ b/src/database.ts @@ -253,7 +253,7 @@ export const SignUpStudentSchema = S.struct({ export type SignUpStudentOptions = S.Schema.To; export async function signUpStudent(options: SignUpStudentOptions): Promise { - + const encryptedPassword = encryptPassword(options.password); let validCode; diff --git a/src/models/student.ts b/src/models/student.ts index 880de77..60d8f64 100644 --- a/src/models/student.ts +++ b/src/models/student.ts @@ -121,7 +121,7 @@ export function initializeStudentModel(sequelize: Sequelize) { }, { unique: true, - fields: ["verificationCode"] + fields: ["verification_code"] } ] }); diff --git a/src/request_results.ts b/src/request_results.ts index 715ea85..910c0ca 100644 --- a/src/request_results.ts +++ b/src/request_results.ts @@ -65,7 +65,7 @@ export namespace SignUpResult { export function statusCode(result: SignUpResult): number { switch (result) { case SignUpResult.Ok: - return 200; + return 201; case SignUpResult.EmailExists: return 409; case SignUpResult.BadRequest: diff --git a/src/server.ts b/src/server.ts index bb10ec8..fb417f2 100644 --- a/src/server.ts +++ b/src/server.ts @@ -79,19 +79,19 @@ export type GenericRequest = Request<{}, any, any, ParsedQs, Record export type GenericResponse = Response, number>; // eslint-disable-next-line @typescript-eslint/no-explicit-any -type VerificationRequest = Request<{verificationCode: string}, any, any, ParsedQs, Record>; +type VerificationRequest = Request<{ verificationCode: string }, any, any, ParsedQs, Record>; type CDSSession = session.Session & Partial & CosmicDSSession; -function _sendUserIdCookie(userId: number, res: ExpressResponse, production=true): void { +function _sendUserIdCookie(userId: number, res: ExpressResponse, production = true): void { const expirationTime = 24 * 60 * 60; // one day console.log("Sending cookie"); res.cookie("userId", userId, { - maxAge: expirationTime , + maxAge: expirationTime, httpOnly: production, - secure: production, + secure: production, }); } @@ -128,7 +128,7 @@ export function createApp(db: Sequelize): Express { } res.json({ message: message }); }); - + // Educator sign-up app.post([ "/educators/create", @@ -136,7 +136,7 @@ export function createApp(db: Sequelize): Express { ], async (req, res) => { const data = req.body; const maybe = S.decodeUnknownEither(SignUpEducatorSchema)(data); - + let result: SignUpResult; if (Either.isRight(maybe)) { result = await signUpEducator(maybe.right); @@ -150,7 +150,7 @@ export function createApp(db: Sequelize): Express { success: SignUpResult.success(result) }); }); - + // Student sign-up app.post([ "/students/create", @@ -158,7 +158,7 @@ export function createApp(db: Sequelize): Express { ], async (req, res) => { const data = req.body; const maybe = S.decodeUnknownEither(SignUpStudentSchema)(data); - + let result: SignUpResult; if (Either.isRight(maybe)) { result = await signUpStudent(maybe.right); @@ -172,7 +172,7 @@ export function createApp(db: Sequelize): Express { success: SignUpResult.success(result) }); }); - + async function handleLogin(request: GenericRequest, identifierField: string, checker: (identifier: string, pw: string) => Promise): Promise { const data = request.body; const schema = S.struct({ @@ -188,7 +188,7 @@ export function createApp(db: Sequelize): Express { } return res; } - + // app.put("/login", async (req, res) => { // const sess = req.session as CDSSession; // let result = LoginResult.BadSession; @@ -203,7 +203,7 @@ export function createApp(db: Sequelize): Express { // success: LoginResult.success(result) // }); // }); - + app.put("/login", async (req, res) => { let response = await handleLogin(req, "username", checkStudentLogin); let type = UserType.Student; @@ -211,7 +211,7 @@ export function createApp(db: Sequelize): Express { response = await handleLogin(req, "username", checkEducatorLogin); type = UserType.Educator; } - + if (response.success && response.user) { const sess = req.session as CDSSession; if (sess) { @@ -219,11 +219,11 @@ export function createApp(db: Sequelize): Express { sess.user_type = type; } } - + const status = response.success ? 200 : 401; res.status(status).json(response); }); - + app.put("/student-login", async (req, res) => { const loginResponse = await handleLogin(req, "username", checkStudentLogin); if (loginResponse.success && loginResponse.user) { @@ -234,7 +234,7 @@ export function createApp(db: Sequelize): Express { const status = loginResponse.success ? 200 : 401; res.status(status).json(loginResponse); }); - + app.put("/educator-login", async (req, res) => { const loginResponse = await handleLogin(req, "email", checkEducatorLogin); if (loginResponse.success && loginResponse.user) { @@ -245,12 +245,12 @@ export function createApp(db: Sequelize): Express { const status = loginResponse.success ? 200 : 401; res.status(status).json(loginResponse); }); - + async function verify(request: VerificationRequest, verifier: (code: string) => Promise): Promise<{ code: string; status: VerificationResult }> { const params = request.params; const verificationCode = params.verificationCode; const valid = typeof verificationCode === "string"; - + let result; if (valid) { result = await verifier(verificationCode); @@ -262,7 +262,7 @@ export function createApp(db: Sequelize): Express { status: result }; } - + function statusCodeForVericationResult(result: VerificationResult): number { switch (result) { case VerificationResult.Ok: @@ -277,7 +277,7 @@ export function createApp(db: Sequelize): Express { return 500; } } - + app.post("/verify-student/:verificationCode", async (req, res) => { const verificationResponse = await verify(req, verifyStudent); const statusCode = statusCodeForVericationResult(verificationResponse.status); @@ -286,7 +286,7 @@ export function createApp(db: Sequelize): Express { status: verificationResponse }); }); - + app.post("/verify-educator/:verificationCode", async (req, res) => { const verificationResponse = await verify(req, verifyEducator); const statusCode = statusCodeForVericationResult(verificationResponse.status); @@ -295,7 +295,7 @@ export function createApp(db: Sequelize): Express { status: verificationResponse }); }); - + app.get("/validate-classroom-code/:code", async (req, res) => { const code = req.params.code; const cls = await findClassByCode(code); @@ -306,33 +306,33 @@ export function createApp(db: Sequelize): Express { valid: cls !== null }); }); - - + + /* Users (students and educators) */ - + app.get("/students", async (_req, res) => { const queryResponse = await getAllStudents(); res.json(queryResponse); }); - + app.get("/educators", async (_req, res) => { const queryResponse = await getAllEducators(); res.json(queryResponse); }); - + app.get("/users", async (_req, res) => { const students = await getAllStudents(); const educators = await getAllEducators(); res.json({ students, educators }); }); - + app.get([ "/students/:identifier", "/student/:identifier", // Backwards compatibility ], async (req, res) => { const params = req.params; const id = Number(params.identifier); - + let student; if (isNaN(id)) { student = await findStudentByUsername(params.identifier); @@ -346,17 +346,17 @@ export function createApp(db: Sequelize): Express { student, }); }); - + app.get("/students/:identifier/classes", async (req, res) => { const id = Number(req.params.identifier); - + let student; if (isNaN(id)) { student = await findStudentByUsername(req.params.identifier); } else { student = await findStudentById(id); } - + if (student === null) { res.statusCode = 404; res.json({ @@ -365,13 +365,13 @@ export function createApp(db: Sequelize): Express { }); return; } - + const classes = await getClassesForStudent(student.id); res.json({ student_id: student.id, classes: classes }); - + }); app.delete("/students/:identifier/classes/:classID", async (req, res) => { @@ -421,24 +421,24 @@ export function createApp(db: Sequelize): Express { } join.destroy() - .then(() => { - res.statusCode = 204; - res.end(); - }) - .catch(error => { - console.log(error); - res.statusCode = 500; - res.json({ - error: "Operation failed. There was an internal server error while removing the student from the class." + .then(() => { + res.statusCode = 204; + res.end(); + }) + .catch(error => { + console.log(error); + res.statusCode = 500; + res.json({ + error: "Operation failed. There was an internal server error while removing the student from the class." + }); }); - }); }); - + app.get("/educators/:identifier", async (req, res) => { const params = req.params; const id = Number(params.identifier); - + let educator; if (isNaN(id)) { educator = await findEducatorByUsername(params.identifier); @@ -452,7 +452,7 @@ export function createApp(db: Sequelize): Express { educator, }); }); - + app.post("/classes/join", async (req, res) => { const username = req.body.username as string; const classCode = req.body.class_code as string; @@ -460,7 +460,7 @@ export function createApp(db: Sequelize): Express { const cls = await findClassByCode(classCode); const isStudent = student !== null; const isClass = cls !== null; - + if (!(isStudent && isClass)) { let message = "The following were invalid:"; const invalid: string[] = []; @@ -478,7 +478,7 @@ export function createApp(db: Sequelize): Express { }); return; } - + const [join, created] = await StudentsClasses.upsert({ class_id: cls.id, student_id: student.id @@ -493,10 +493,10 @@ export function createApp(db: Sequelize): Express { } else { message = "Student added to class successfully"; } - + res.json({ success, message }); }); - + /* Classes */ app.post([ "/classes/create", @@ -519,7 +519,7 @@ export function createApp(db: Sequelize): Express { success: CreateClassResult.success(response.result) }); }); - + app.delete("/classes/:code", async (req, res) => { const code = req.params.code; const cls = await findClassByCode(code); @@ -550,7 +550,7 @@ export function createApp(db: Sequelize): Express { message: "Class deleted", }); }); - + app.get("/classes/size/:classID", async (req, res) => { const classID = Number(req.params.classID); const cls = await findClassById(classID); @@ -579,7 +579,7 @@ export function createApp(db: Sequelize): Express { const students = await getClassRoster(classID); res.json(students); }); - + app.get("/story-state/:studentID/:storyName", async (req, res) => { const params = req.params; const studentID = Number(params.studentID); @@ -592,7 +592,7 @@ export function createApp(db: Sequelize): Express { state }); }); - + app.put("/story-state/:studentID/:storyName", async (req, res) => { const params = req.params; const studentID = Number(params.studentID); @@ -606,24 +606,24 @@ export function createApp(db: Sequelize): Express { state }); }); - + app.get("/stages/:storyName", async (req, res) => { const storyName = req.params.storyName; const story = await getStory(storyName); - + if (story === null) { res.status(404).json({ error: `No story found with name ${storyName}` }); return; } - + const stages = await getStages(req.params.storyName); res.json({ stages, }); }); - + // Use query parameters `student_id`, `class_id`, and `stage_name` to filter output // `stage_name` is optional. If not specified, return value will be an object of the form // { stage1: [], stage2: [], ... } @@ -633,14 +633,14 @@ export function createApp(db: Sequelize): Express { app.get("/stage-states/:storyName", async (req, res) => { const storyName = req.params.storyName; const story = await getStory(storyName); - + if (story === null) { res.status(404).json({ error: `No story found with name ${storyName}` }); return; } - + let query: StageStateQuery; const studentID = Number(req.query.student_id); const classID = Number(req.query.class_id); @@ -668,7 +668,7 @@ export function createApp(db: Sequelize): Express { }); return; } - + const stageName = req.query.stage_name as string; if (stageName != undefined) { query.stageName = stageName; @@ -677,7 +677,7 @@ export function createApp(db: Sequelize): Express { const results = (stageName != undefined) ? stageStates[stageName] : stageStates; res.json(results); }); - + app.get("/stage-state/:studentID/:storyName/:stageName", async (req, res) => { const params = req.params; const studentID = Number(params.studentID); @@ -692,7 +692,7 @@ export function createApp(db: Sequelize): Express { state }); }); - + app.put("/stage-state/:studentID/:storyName/:stageName", async (req, res) => { const params = req.params; const studentID = Number(params.studentID); @@ -708,7 +708,7 @@ export function createApp(db: Sequelize): Express { state }); }); - + app.delete("/stage-state/:studentID/:storyName/:stageName", async (req, res) => { const params = req.params; const studentID = Number(params.studentID); @@ -731,7 +731,7 @@ export function createApp(db: Sequelize): Express { }); } }); - + app.get("/educator-classes/:educatorID", async (req, res) => { const params = req.params; const educatorID = Number(params.educatorID); @@ -741,7 +741,7 @@ export function createApp(db: Sequelize): Express { classes: classes }); }); - + app.get("/student-classes/:studentID", async (req, res) => { const params = req.params; const studentID = Number(params.studentID); @@ -751,14 +751,14 @@ export function createApp(db: Sequelize): Express { classes: classes }); }); - + app.get("/roster-info/:classID", async (req, res) => { const params = req.params; const classID = Number(params.classID); const info = await getRosterInfo(classID); res.json(info); }); - + app.get("/roster-info/:classID/:storyName", async (req, res) => { const params = req.params; const classID = Number(params.classID); @@ -766,16 +766,16 @@ export function createApp(db: Sequelize): Express { const info = await getRosterInfoForStory(classID, storyName); res.json(info); }); - + app.get("/logout", (req, res) => { req.session.destroy(console.log); res.send({ "logout": true }); }); - - - + + + // Question information app.get("/question/:tag", async (req, res) => { const tag = req.params.tag; @@ -805,17 +805,17 @@ export function createApp(db: Sequelize): Express { }); return; } - + res.json({ question }); }); - + app.post("/question/:tag", async (req, res) => { - + const data = { ...req.body, tag: req.params.tag }; const maybe = S.decodeUnknownEither(QuestionInfoSchema)(data); - + if (Either.isLeft(maybe)) { res.statusCode = 400; res.json({ @@ -823,7 +823,7 @@ export function createApp(db: Sequelize): Express { }); return; } - + const currentQuestion = await findQuestion(req.params.tag); const version = currentQuestion !== null ? currentQuestion.version + 1 : 1; const questionInfo = { ...maybe.right, version }; @@ -835,12 +835,12 @@ export function createApp(db: Sequelize): Express { }); return; } - + res.json({ question: addedQuestion }); }); - + app.get("/questions/:storyName", async (req, res) => { const storyName = req.params.storyName; const newestOnlyString = req.query.newest_only as string; @@ -850,19 +850,19 @@ export function createApp(db: Sequelize): Express { questions }); }); - + /** Testing Endpoints * * These endpoints are intended for internal use only */ - + app.get("/new-dummy-student", async (_req, res) => { const student = await newDummyStudent(); res.json({ student: student }); }); - + app.post("/new-dummy-student", async (req, res) => { const seed = req.body.seed || false; const teamMember = req.body.team_member; @@ -872,7 +872,7 @@ export function createApp(db: Sequelize): Express { student: student }); }); - + app.get("/class-for-student-story/:studentID/:storyName", async (req, res) => { const studentID = Number(req.params.studentID); const storyName = req.params.storyName; @@ -886,7 +886,7 @@ export function createApp(db: Sequelize): Express { size }); }); - + app.get("/options/:studentID", async (req, res) => { const studentID = Number(req.params.studentID); const options = await getStudentOptions(studentID); @@ -895,7 +895,7 @@ export function createApp(db: Sequelize): Express { res.statusCode = 404; } }); - + app.put("/options/:studentID", async (req, res) => { const studentID = Number(req.params.studentID); const option = req.body.option; @@ -915,9 +915,9 @@ export function createApp(db: Sequelize): Express { } res.json(updatedOptions); }); - + app.get("/dashboard-group-classes/:code", async (req, res) => { - const classes= await getDashboardGroupClasses(req.params.code); + const classes = await getDashboardGroupClasses(req.params.code); if (classes === null) { res.statusCode = 404; res.json({ diff --git a/tests/root.test.ts b/tests/root.test.ts index 826f411..01943b7 100644 --- a/tests/root.test.ts +++ b/tests/root.test.ts @@ -1,23 +1,24 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ -import { afterAll, beforeAll, describe, it } from "@jest/globals"; +import { beforeAll, afterAll, describe, it } from "@jest/globals"; +import request from "supertest"; import type { Express } from "express"; import type { Sequelize } from "sequelize"; -import request from "supertest"; -import { authorize, createTestApp, setupTestDatabase, teardownTestDatabase } from "./utils"; +import { authorize, getTestDatabaseConnection } from "./utils"; import { setupApp } from "../src/app"; +import { createApp } from "../src/server"; let testDB: Sequelize; let testApp: Express; beforeAll(async () => { - testDB = await setupTestDatabase(); - testApp = createTestApp(testDB); + testDB = await getTestDatabaseConnection(); + testApp = createApp(testDB); setupApp(testApp, testDB); -}, 100_000); +}); -afterAll(async () => { - await teardownTestDatabase(); +afterAll(() => { + testDB.close(); }); describe("Test root route", () => { diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..24c54dc --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,6 @@ +import { setupTestDatabase } from "./utils"; + +export default async () => { + await setupTestDatabase(); + await new Promise(r => setTimeout(r, 5_000)); +}; diff --git a/tests/students.test.ts b/tests/students.test.ts new file mode 100644 index 0000000..a38c8b9 --- /dev/null +++ b/tests/students.test.ts @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ + +import { beforeAll, afterAll, describe, it, expect } from "@jest/globals"; +import request from "supertest"; +import type { InferAttributes, Sequelize } from "sequelize"; +import type { Express } from "express"; + +import { authorize, getTestDatabaseConnection } from "./utils"; +import { setupApp } from "../src/app"; +import { Student } from "../src/models"; +import { createApp } from "../src/server"; + +let testDB: Sequelize; +let testApp: Express; +beforeAll(async () => { + testDB = await getTestDatabaseConnection(); + testApp = createApp(testDB); + setupApp(testApp, testDB); +}); + +afterAll(() => { + testDB.close(); +}); + +describe("Test student routes", () => { + + it("Should sign up a student", async () => { + const data = { + email: "e@mail.com", + username: "abcde", + password: "fghij", + verification_code: "verification", + }; + + await authorize(request(testApp).post("/students/create")) + .send(data) + .expect(201) + .expect("Content-Type", /json/) + .expect({ + success: true, + status: "ok", + student_info: data, + }); + + const student = await Student.findOne({ where: { username: "abcde" } }); + expect(student).not.toBeNull(); + + student?.destroy(); + + }); + + it("Should return the correct student", async () => { + const student = await Student.create({ + email: "e@mail.com", + username: "abcde", + password: "fghij", + verification_code: "verification", + verified: 0, + }); + + const json: Partial> = student.toJSON(); + // The Sequelize object will return the `CURRENT_TIMESTAMP` literals, + // not the actual date values + delete json.profile_created; + delete json.last_visit; + const res = await authorize(request(testApp).get(`/students/${student.id}`)) + .expect(200) + .expect("Content-Type", /json/); + + const resStudent = res.body.student; + expect(resStudent).toMatchObject(json); + + // Check that the timestamp fields are present + expect(resStudent).toHaveProperty("profile_created"); + expect(typeof resStudent.profile_created).toBe("string"); + expect(resStudent).toHaveProperty("last_visit"); + expect(typeof resStudent.last_visit).toBe("string"); + + student.destroy(); + }); +}); diff --git a/tests/teardown.ts b/tests/teardown.ts new file mode 100644 index 0000000..281f7a7 --- /dev/null +++ b/tests/teardown.ts @@ -0,0 +1,5 @@ +import { teardownTestDatabase } from "./utils"; + +export default async () => { + await teardownTestDatabase(); +}; diff --git a/tests/utils.ts b/tests/utils.ts index 53b4712..f9e68d5 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,15 +1,17 @@ import type { Express } from "express"; import type { Server } from "http"; import type { Test } from "supertest"; -import { Sequelize } from "sequelize"; +import type { Sequelize } from "sequelize"; import { setUpAssociations } from "../src/associations"; -import { initializeModels } from "../src/models"; +import { Educator, initializeModels } from "../src/models"; import { createApp } from "../src/server"; +import { Student } from "../src/models"; import { APIKey } from "../src/models/api_key"; import { config } from "dotenv"; import { getDatabaseConnection } from "../src/database"; import { createConnection, Connection } from "mysql2/promise"; +import { hashAPIKey } from "../src/authorization"; export function authorize(request: Test): Test { return request.set({ Authorization: process.env.CDS_API_KEY }); @@ -23,27 +25,35 @@ export async function createTestMySQLConnection(): Promise { }); } -export async function setupTestDatabase(): Promise { - config(); +export async function getTestDatabaseConnection(): Promise { const username = process.env.DB_TEST_USERNAME as string; const password = process.env.DB_TEST_PASSWORD as string; const host = process.env.DB_TEST_HOSTNAME as string; - const connection = await createTestMySQLConnection(); - await connection.query("CREATE DATABASE IF NOT EXISTS test;"); const db = getDatabaseConnection({ - dbName: "test", + dbName: "test", username, password, - host, + host }); + await db.query("USE test;"); initializeModels(db); setUpAssociations(); + return db; +} + +export async function setupTestDatabase(): Promise { + config(); + const connection = await createTestMySQLConnection(); + await connection.query("CREATE DATABASE IF NOT EXISTS test;"); + const db = getTestDatabaseConnection(); + // We need to close when the connection terminates! // See https://github.com/sequelize/sequelize/issues/7953 // and https://stackoverflow.com/a/45114507 // db.sync({ force: true, match: /test/ }).finally(() => db.close()); + await syncTables(true); await addTestData(); return db; @@ -54,11 +64,18 @@ export async function teardownTestDatabase(): Promise { await connection.query("DROP DATABASE test;"); } +export async function syncTables(force=false): Promise { + const options = { force }; + await APIKey.sync(options); + await Student.sync(options); + await Educator.sync(options); +} + export async function addAPIKey(): Promise { // Set up some basic data that we're going to want - await APIKey.sync({ force: true }); + const hashedKey = hashAPIKey(process.env.CDS_API_KEY as string); return APIKey.create({ - hashed_key: process.env.HASHED_API_KEY as string, + hashed_key: hashedKey, client: "Tests", }); }