From 3b27a1dcf0663701cc108a8639d4026d38ee5da0 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Mon, 30 Sep 2024 00:16:43 -0400 Subject: [PATCH 01/12] Only type-import Sequelize in test utils. --- tests/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/utils.ts b/tests/utils.ts index 53b4712..b102d3b 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,7 +1,7 @@ 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"; From e2a5a0b263718993e0ab679e2c127839821add2a Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Mon, 30 Sep 2024 01:13:47 -0400 Subject: [PATCH 02/12] Start working on setup up global setup/teardown for tests. --- jest.config.ts | 12 +++++++++--- tests/root.test.ts | 20 ++++---------------- tests/setup.ts | 14 ++++++++++++++ tests/students.test.ts | 24 ++++++++++++++++++++++++ tests/teardown.ts | 5 +++++ tests/utils.ts | 8 +++++++- 6 files changed, 63 insertions(+), 20 deletions(-) create mode 100644 tests/setup.ts create mode 100644 tests/students.test.ts create mode 100644 tests/teardown.ts 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/tests/root.test.ts b/tests/root.test.ts index 826f411..f09ed2c 100644 --- a/tests/root.test.ts +++ b/tests/root.test.ts @@ -1,24 +1,12 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ -import { afterAll, beforeAll, describe, it } from "@jest/globals"; -import type { Express } from "express"; -import type { Sequelize } from "sequelize"; +import { describe, it } from "@jest/globals"; import request from "supertest"; -import { authorize, createTestApp, setupTestDatabase, teardownTestDatabase } from "./utils"; -import { setupApp } from "../src/app"; +import { testApp } from "./setup"; +import { authorize } from "./utils"; -let testDB: Sequelize; -let testApp: Express; -beforeAll(async () => { - testDB = await setupTestDatabase(); - testApp = createTestApp(testDB); - setupApp(testApp, testDB); -}, 100_000); - -afterAll(async () => { - await teardownTestDatabase(); -}); +console.log(testApp); describe("Test root route", () => { diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..9128136 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,14 @@ +import type { Express } from "express"; +import type { Sequelize } from "sequelize"; +import { createTestApp, setupTestDatabase } from "./utils"; +import { setupApp } from "../src/app"; + +export let testDB: Sequelize; +export let testApp: Express; +export default async () => { + console.log("SETUP"); + testDB = await setupTestDatabase(); + testApp = createTestApp(testDB); + setupApp(testApp, testDB); + 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..2cf6db9 --- /dev/null +++ b/tests/students.test.ts @@ -0,0 +1,24 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ + +import { describe, expect, it } from "@jest/globals"; +import request from "supertest"; + +import { testApp } from "./setup"; +import { authorize } from "./utils"; +import { Student } from "../src/models"; + +describe("Test educator routes", () => { + + it("Should initially have no students", async () => { + const students = await Student.findAll(); + expect(students.length).toBe(0); + + authorize(request(testApp).get("/students")) + .expect(200) + .expect("Content-Type", /json/) + .expect([]); + }); + + it("Should sign up a student", async () => { + }); +}); 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 b102d3b..69b3f8c 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -6,6 +6,7 @@ import type { Sequelize } from "sequelize"; import { setUpAssociations } from "../src/associations"; import { 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"; @@ -44,6 +45,7 @@ export async function setupTestDatabase(): Promise { // 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(); await addTestData(); return db; @@ -54,9 +56,13 @@ export async function teardownTestDatabase(): Promise { await connection.query("DROP DATABASE test;"); } +export async function syncTables(): Promise { + await APIKey.sync({ force: true }); + await Student.sync({ force: true }); +} + export async function addAPIKey(): Promise { // Set up some basic data that we're going to want - await APIKey.sync({ force: true }); return APIKey.create({ hashed_key: process.env.HASHED_API_KEY as string, client: "Tests", From 1f59672406f38f15fd9121319dbff7294070c343 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Mon, 30 Sep 2024 01:13:58 -0400 Subject: [PATCH 03/12] Fix typo in student model index. --- src/models/student.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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"] } ] }); From 01e4fa09f96d1c71327ddd2f553a082c02adeb92 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Mon, 30 Sep 2024 10:18:12 -0400 Subject: [PATCH 04/12] Set up test database in global setup/teardown and set up app instances in each test. --- tests/root.test.ts | 21 +++++++++++++++++---- tests/setup.ts | 12 ++---------- tests/students.test.ts | 40 +++++++++++++++++++++++++++------------- tests/utils.ts | 18 +++++++++++------- 4 files changed, 57 insertions(+), 34 deletions(-) diff --git a/tests/root.test.ts b/tests/root.test.ts index f09ed2c..e980819 100644 --- a/tests/root.test.ts +++ b/tests/root.test.ts @@ -1,12 +1,25 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ -import { 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 { testApp } from "./setup"; -import { authorize } from "./utils"; +import { authorize, getTestDatabaseConnection } from "./utils"; +import { setupApp } from "../src/app"; +import { createApp } from "../src/server"; -console.log(testApp); +let testDB: Sequelize; +let testApp: Express; +beforeAll(() => { + testDB = getTestDatabaseConnection(); + testApp = createApp(testDB); + setupApp(testApp, testDB); +}); + +afterAll(() => { + testDB.close(); +}); describe("Test root route", () => { diff --git a/tests/setup.ts b/tests/setup.ts index 9128136..24c54dc 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -1,14 +1,6 @@ -import type { Express } from "express"; -import type { Sequelize } from "sequelize"; -import { createTestApp, setupTestDatabase } from "./utils"; -import { setupApp } from "../src/app"; +import { setupTestDatabase } from "./utils"; -export let testDB: Sequelize; -export let testApp: Express; export default async () => { - console.log("SETUP"); - testDB = await setupTestDatabase(); - testApp = createTestApp(testDB); - setupApp(testApp, testDB); + await setupTestDatabase(); await new Promise(r => setTimeout(r, 5_000)); }; diff --git a/tests/students.test.ts b/tests/students.test.ts index 2cf6db9..30acf10 100644 --- a/tests/students.test.ts +++ b/tests/students.test.ts @@ -1,24 +1,38 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ -import { describe, expect, it } from "@jest/globals"; +import { beforeAll, afterAll, describe, it } from "@jest/globals"; import request from "supertest"; +import type { Sequelize } from "sequelize"; +import type { Express } from "express"; -import { testApp } from "./setup"; -import { authorize } from "./utils"; -import { Student } from "../src/models"; +import { authorize, getTestDatabaseConnection } from "./utils"; +import { setupApp } from "../src/app"; +import { createApp } from "../src/server"; -describe("Test educator routes", () => { +let testDB: Sequelize; +let testApp: Express; +beforeAll(() => { + testDB = getTestDatabaseConnection(); + testApp = createApp(testDB); + setupApp(testApp, testDB); +}); - it("Should initially have no students", async () => { - const students = await Student.findAll(); - expect(students.length).toBe(0); +afterAll(() => { + testDB.close(); +}); - authorize(request(testApp).get("/students")) - .expect(200) - .expect("Content-Type", /json/) - .expect([]); - }); +describe("Test educator routes", () => { it("Should sign up a student", async () => { + const data = { username: "abcde", password: "fghij" }; + authorize(request(testApp).post("/students/create")) + .send(data) + .expect(200) + .expect("Content-Type", /json/) + .expect({ + success: true, + status: "ok", + student_info: data, + }); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 69b3f8c..58e6ba9 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -24,19 +24,23 @@ export async function createTestMySQLConnection(): Promise { }); } -export async function setupTestDatabase(): Promise { - config(); +export function getTestDatabaseConnection() { 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", + return getDatabaseConnection({ + dbName: "test", username, password, - host, + host }); +} + +export async function setupTestDatabase(): Promise { + config(); + const connection = await createTestMySQLConnection(); + await connection.query("CREATE DATABASE IF NOT EXISTS test;"); + const db = getTestDatabaseConnection(); await db.query("USE test;"); initializeModels(db); setUpAssociations(); From afee9a75274f04f38e8ec750d99b07f06683bd4a Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Mon, 30 Sep 2024 18:55:46 -0400 Subject: [PATCH 05/12] Return 201 on student creation. --- src/request_results.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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: From a5d1d91c84653fbb253503686b4d41a6d6ff1fd1 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Mon, 30 Sep 2024 18:56:47 -0400 Subject: [PATCH 06/12] Minor formatting cleanup. --- src/server.ts | 174 +++++++++++++++++++++++++------------------------- 1 file changed, 87 insertions(+), 87 deletions(-) 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({ From a5bce1de805ae820d27d0da2649b80b0082d6a83 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Mon, 30 Sep 2024 18:58:13 -0400 Subject: [PATCH 07/12] Refactor out getting the Sequelize test connection from the test DB setup. Get student creation test working. --- tests/root.test.ts | 4 ++-- tests/students.test.ts | 25 ++++++++++++++++++------- tests/utils.ts | 25 +++++++++++++++---------- 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/tests/root.test.ts b/tests/root.test.ts index e980819..01943b7 100644 --- a/tests/root.test.ts +++ b/tests/root.test.ts @@ -11,8 +11,8 @@ import { createApp } from "../src/server"; let testDB: Sequelize; let testApp: Express; -beforeAll(() => { - testDB = getTestDatabaseConnection(); +beforeAll(async () => { + testDB = await getTestDatabaseConnection(); testApp = createApp(testDB); setupApp(testApp, testDB); }); diff --git a/tests/students.test.ts b/tests/students.test.ts index 30acf10..0881b64 100644 --- a/tests/students.test.ts +++ b/tests/students.test.ts @@ -1,18 +1,19 @@ /* eslint-disable @typescript-eslint/no-floating-promises */ -import { beforeAll, afterAll, describe, it } from "@jest/globals"; +import { beforeAll, afterAll, describe, it, expect } from "@jest/globals"; import request from "supertest"; import type { 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(() => { - testDB = getTestDatabaseConnection(); +beforeAll(async () => { + testDB = await getTestDatabaseConnection(); testApp = createApp(testDB); setupApp(testApp, testDB); }); @@ -21,18 +22,28 @@ afterAll(() => { testDB.close(); }); -describe("Test educator routes", () => { +describe("Test student routes", () => { it("Should sign up a student", async () => { - const data = { username: "abcde", password: "fghij" }; - authorize(request(testApp).post("/students/create")) + const data = { + email: "e@mail.com", + username: "abcde", + password: "fghij", + verification_code: "verification", + }; + + await authorize(request(testApp).post("/students/create")) .send(data) - .expect(200) + .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(); + }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 58e6ba9..c3af7b2 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -4,7 +4,7 @@ import type { Test } from "supertest"; 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"; @@ -24,16 +24,22 @@ export async function createTestMySQLConnection(): Promise { }); } -export function getTestDatabaseConnection() { +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; - return getDatabaseConnection({ + const db = getDatabaseConnection({ dbName: "test", username, password, host }); + + await db.query("USE test;"); + initializeModels(db); + setUpAssociations(); + + return db; } export async function setupTestDatabase(): Promise { @@ -41,15 +47,12 @@ export async function setupTestDatabase(): Promise { const connection = await createTestMySQLConnection(); await connection.query("CREATE DATABASE IF NOT EXISTS test;"); const db = getTestDatabaseConnection(); - await db.query("USE test;"); - initializeModels(db); - setUpAssociations(); // 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(); + await syncTables(true); await addTestData(); return db; @@ -60,9 +63,11 @@ export async function teardownTestDatabase(): Promise { await connection.query("DROP DATABASE test;"); } -export async function syncTables(): Promise { - await APIKey.sync({ force: true }); - await Student.sync({ force: true }); +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 { From 6f53f1d8de1d05167eb9e8d174230388d9a350a2 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Tue, 1 Oct 2024 10:23:33 -0400 Subject: [PATCH 08/12] Add test of student fetching. --- tests/students.test.ts | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/tests/students.test.ts b/tests/students.test.ts index 0881b64..7fe2f59 100644 --- a/tests/students.test.ts +++ b/tests/students.test.ts @@ -2,7 +2,7 @@ import { beforeAll, afterAll, describe, it, expect } from "@jest/globals"; import request from "supertest"; -import type { Sequelize } from "sequelize"; +import type { InferAttributes, Sequelize } from "sequelize"; import type { Express } from "express"; import { authorize, getTestDatabaseConnection } from "./utils"; @@ -45,5 +45,35 @@ describe("Test student routes", () => { 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"); }); }); From 36e79942a0015436ff446b21975bfcbfd2b82c29 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Tue, 1 Oct 2024 11:10:44 -0400 Subject: [PATCH 09/12] Destroy student object at the end of GET test. --- tests/students.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/students.test.ts b/tests/students.test.ts index 7fe2f59..a38c8b9 100644 --- a/tests/students.test.ts +++ b/tests/students.test.ts @@ -75,5 +75,7 @@ describe("Test student routes", () => { expect(typeof resStudent.profile_created).toBe("string"); expect(resStudent).toHaveProperty("last_visit"); expect(typeof resStudent.last_visit).toBe("string"); + + student.destroy(); }); }); From c3c747773f16128a0e94000c878d5dd4c8bc4c1a Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Tue, 1 Oct 2024 11:33:35 -0400 Subject: [PATCH 10/12] Pass unhashed API key to environment. --- .github/workflows/main.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 66e15fe..28634f4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -32,10 +32,11 @@ 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 + CDS_API_KEY: ${{ secrets.CDS_API_KEY }} HASHED_API_KEY: ${{ secrets.HASHED_API_KEY }} run: npm run test From 042347accf402c67f2df0fd9a755ee1777fc8061 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Tue, 1 Oct 2024 13:40:54 -0400 Subject: [PATCH 11/12] Refactor out functionality for hashing keys. Create hashed key for test directly from API key. --- src/authorization.ts | 11 +++++++++-- src/database.ts | 2 +- src/middleware.ts | 3 +++ tests/utils.ts | 9 +++++++-- 4 files changed, 20 insertions(+), 5 deletions(-) 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/middleware.ts b/src/middleware.ts index 001f80e..62849b3 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -17,6 +17,9 @@ export async function apiKeyMiddleware(req: Request, res: ExpressResponse, next: const key = req.get("Authorization"); const apiKey = key ? await getAPIKey(key) : null; const apiKeyExists = apiKey !== null; + console.log(`key is null: ${key === null}`); + console.log(`key is undefined: ${key === undefined}`); + console.log(`API key exists: ${apiKeyExists}`); if (validOrigin || (apiKeyExists && hasPermission(apiKey, req))) { next(); } else { diff --git a/tests/utils.ts b/tests/utils.ts index c3af7b2..bc31b37 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -11,6 +11,7 @@ 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 }); @@ -72,10 +73,14 @@ export async function syncTables(force=false): Promise { export async function addAPIKey(): Promise { // Set up some basic data that we're going to want - return APIKey.create({ - hashed_key: process.env.HASHED_API_KEY as string, + const hashedKey = hashAPIKey(process.env.CDS_API_KEY as string); + await APIKey.create({ + hashed_key: hashedKey, client: "Tests", }); + + const keys = await APIKey.findAll(); + console.log(`There are ${keys.length} keys`); } export async function addTestData() { From cdef6ada4c2205228295dfdb3e0dae08054d8bd5 Mon Sep 17 00:00:00 2001 From: Carifio24 Date: Tue, 1 Oct 2024 17:15:53 -0400 Subject: [PATCH 12/12] It really doesn't matter what the API key is since it's not real. --- .github/workflows/main.yml | 3 +-- src/middleware.ts | 3 --- tests/utils.ts | 5 +---- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 28634f4..1549253 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -37,6 +37,5 @@ jobs: DB_TEST_HOSTNAME: 127.0.0.1 DB_TEST_USERNAME: root DB_TEST_PASSWORD: root - CDS_API_KEY: ${{ secrets.CDS_API_KEY }} - HASHED_API_KEY: ${{ secrets.HASHED_API_KEY }} + CDS_API_KEY: TEST_API_KEY run: npm run test diff --git a/src/middleware.ts b/src/middleware.ts index 62849b3..001f80e 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -17,9 +17,6 @@ export async function apiKeyMiddleware(req: Request, res: ExpressResponse, next: const key = req.get("Authorization"); const apiKey = key ? await getAPIKey(key) : null; const apiKeyExists = apiKey !== null; - console.log(`key is null: ${key === null}`); - console.log(`key is undefined: ${key === undefined}`); - console.log(`API key exists: ${apiKeyExists}`); if (validOrigin || (apiKeyExists && hasPermission(apiKey, req))) { next(); } else { diff --git a/tests/utils.ts b/tests/utils.ts index bc31b37..f9e68d5 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -74,13 +74,10 @@ export async function syncTables(force=false): Promise { export async function addAPIKey(): Promise { // Set up some basic data that we're going to want const hashedKey = hashAPIKey(process.env.CDS_API_KEY as string); - await APIKey.create({ + return APIKey.create({ hashed_key: hashedKey, client: "Tests", }); - - const keys = await APIKey.findAll(); - console.log(`There are ${keys.length} keys`); } export async function addTestData() {