diff --git a/backend/.env.test b/backend/.env.test index 535628cd..4444ec29 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -11,3 +11,10 @@ DWENGO_PORT=3000 DWENGO_DB_NAME=":memory:" DWENGO_DB_UPDATE=true + +DWENGO_AUTH_STUDENT_URL=http://localhost:7080/realms/student +DWENGO_AUTH_STUDENT_JWKS_ENDPOINT=http://localhost:7080/realms/student/protocol/openid-connect/certs +DWENGO_AUTH_TEACHER_URL=http://localhost:7080/realms/teacher +DWENGO_AUTH_TEACHER_JWKS_ENDPOINT=http://localhost:7080/realms/teacher/protocol/openid-connect/certs + +DWENGO_CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000 diff --git a/backend/package.json b/backend/package.json index 275bad7d..ac5ebb48 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,7 +7,8 @@ "main": "dist/app.js", "scripts": { "build": "cross-env NODE_ENV=production tsc --build", - "dev": "cross-env NODE_ENV=development tsx tool/seed.ts; tsx watch --env-file=.env.development.local src/app.ts", + "predev": "tsc --build ../common/tsconfig.json", + "dev": "cross-env NODE_ENV=development tsx tool/seed.ts && tsx watch --env-file=.env.development.local src/app.ts", "start": "cross-env NODE_ENV=production node --env-file=.env dist/app.js", "format": "prettier --write src/", "format-check": "prettier --check src/", diff --git a/backend/src/controllers/groups.ts b/backend/src/controllers/groups.ts index 53bc96ec..217510f6 100644 --- a/backend/src/controllers/groups.ts +++ b/backend/src/controllers/groups.ts @@ -3,8 +3,6 @@ import { createGroup, deleteGroup, getAllGroups, getGroup, getGroupSubmissions, import { GroupDTO } from '@dwengo-1/common/interfaces/group'; import { requireFields } from './error-helper.js'; import { BadRequestException } from '../exceptions/bad-request-exception.js'; -import { EntityDTO } from '@mikro-orm/core'; -import { Group } from '../entities/assignments/group.entity.js'; function checkGroupFields(classId: string, assignmentId: number, groupId: number): void { requireFields({ classId, assignmentId, groupId }); @@ -35,7 +33,11 @@ export async function putGroupHandler(req: Request, res: Response): Promise>); + // Only members field can be changed + const members = req.body.members; + requireFields({ members }); + + const group = await putGroup(classId, assignmentId, groupId, { members } as Partial); res.json({ group }); } diff --git a/backend/src/logging/initalize.ts b/backend/src/logging/initalize.ts index 5c94a25f..f89518c4 100644 --- a/backend/src/logging/initalize.ts +++ b/backend/src/logging/initalize.ts @@ -26,13 +26,15 @@ function initializeLogger(): Logger { const consoleTransport = new transports.Console({ level: getEnvVar(envVars.LogLevel), - format: format.combine(format.cli(), format.colorize()), + format: format.combine(format.cli(), format.simple()), }); if (getEnvVar(envVars.RunMode) === 'dev') { - return createLogger({ + logger = createLogger({ transports: [consoleTransport], }); + logger.debug(`Logger initialized with level ${logLevel} to console`); + return logger; } const lokiHost = getEnvVar(envVars.LokiHost); diff --git a/backend/src/logging/mikroOrmLogger.ts b/backend/src/logging/mikroOrmLogger.ts index 9cb797a8..0fc18b87 100644 --- a/backend/src/logging/mikroOrmLogger.ts +++ b/backend/src/logging/mikroOrmLogger.ts @@ -11,7 +11,7 @@ export class MikroOrmLogger extends DefaultLogger { }; let message: string; - if (context?.label) { + if (context !== undefined && context.labels !== undefined) { message = `[${namespace}] (${context.label}) ${messageArg}`; } else { message = `[${namespace}] ${messageArg}`; diff --git a/backend/src/services/groups.ts b/backend/src/services/groups.ts index 0c73c8c5..382780d8 100644 --- a/backend/src/services/groups.ts +++ b/backend/src/services/groups.ts @@ -7,8 +7,17 @@ import { GroupDTO, GroupDTOId } from '@dwengo-1/common/interfaces/group'; import { SubmissionDTO, SubmissionDTOId } from '@dwengo-1/common/interfaces/submission'; import { fetchAssignment } from './assignments.js'; import { NotFoundException } from '../exceptions/not-found-exception.js'; -import { putObject } from './service-helper.js'; import { fetchStudents } from './students.js'; +import { fetchClass } from './classes.js'; +import { BadRequestException } from '../exceptions/bad-request-exception.js'; +import { Student } from '../entities/users/student.entity.js'; +import { Class } from '../entities/classes/class.entity.js'; + +async function assertMembersInClass(members: Student[], cls: Class): Promise { + if (!members.every((student) => cls.students.contains(student))) { + throw new BadRequestException('Student does not belong to class'); + } +} export async function fetchGroup(classId: string, assignmentNumber: number, groupNumber: number): Promise { const assignment = await fetchAssignment(classId, assignmentNumber); @@ -28,15 +37,18 @@ export async function getGroup(classId: string, assignmentNumber: number, groupN return mapToGroupDTO(group, group.assignment.within); } -export async function putGroup( - classId: string, - assignmentNumber: number, - groupNumber: number, - groupData: Partial> -): Promise { +export async function putGroup(classId: string, assignmentNumber: number, groupNumber: number, groupData: Partial): Promise { const group = await fetchGroup(classId, assignmentNumber, groupNumber); - await putObject(group, groupData, getGroupRepository()); + const memberUsernames = groupData.members as string[]; + const members = await fetchStudents(memberUsernames); + + const cls = await fetchClass(classId); + await assertMembersInClass(members, cls); + + const groupRepository = getGroupRepository(); + groupRepository.assign(group, { members } as Partial>); + await groupRepository.getEntityManager().persistAndFlush(group); return mapToGroupDTO(group, group.assignment.within); } @@ -63,6 +75,9 @@ export async function createGroup(groupData: GroupDTO, classid: string, assignme const memberUsernames = (groupData.members as string[]) || []; const members = await fetchStudents(memberUsernames); + const cls = await fetchClass(classid); + await assertMembersInClass(members, cls); + const assignment = await fetchAssignment(classid, assignmentNumber); const groupRepository = getGroupRepository(); diff --git a/backend/tests/test_assets/assignments/groups.testdata.ts b/backend/tests/test_assets/assignments/groups.testdata.ts index 16674843..4361383b 100644 --- a/backend/tests/test_assets/assignments/groups.testdata.ts +++ b/backend/tests/test_assets/assignments/groups.testdata.ts @@ -61,7 +61,7 @@ export function makeTestGroups(em: EntityManager, students: Student[], assignmen */ group1ConditionalLearningPath = em.create(Group, { assignment: getConditionalPathAssignment(), - groupNumber: 1, + groupNumber: 21005, members: [getTestleerling1()], }); diff --git a/backend/tool/seed.ts b/backend/tool/seed.ts index 3cb4543c..00e3c0bf 100644 --- a/backend/tool/seed.ts +++ b/backend/tool/seed.ts @@ -14,14 +14,12 @@ import { makeTestQuestions } from '../tests/test_assets/questions/questions.test import { makeTestStudents } from '../tests/test_assets/users/students.testdata.js'; import { makeTestTeachers } from '../tests/test_assets/users/teachers.testdata.js'; import { getLogger, Logger } from '../src/logging/initalize.js'; -import { Collection } from '@mikro-orm/core'; +import { Collection, MikroORM } from '@mikro-orm/core'; import { Group } from '../src/entities/assignments/group.entity'; const logger: Logger = getLogger(); -export async function seedDatabase(): Promise { - dotenv.config({ path: '.env.development.local' }); - const orm = await initORM(); +export async function seedORM(orm: MikroORM): Promise { await orm.schema.clearDatabase(); const em = forkEntityManager(); @@ -68,8 +66,17 @@ export async function seedDatabase(): Promise { ]); logger.info('Development database seeded successfully!'); +} + +export async function seedDatabase(envFile = '.env.development.local', testMode = false): Promise { + dotenv.config({ path: envFile }); + const orm = await initORM(testMode); + + await seedORM(orm); await orm.close(); } -seedDatabase().catch(logger.error); +seedDatabase().catch((err) => { + logger.error(err); +}); diff --git a/backend/tool/startTestApp.ts b/backend/tool/startTestApp.ts new file mode 100644 index 00000000..a5d0f852 --- /dev/null +++ b/backend/tool/startTestApp.ts @@ -0,0 +1,29 @@ +import express, { Express } from 'express'; +import { initORM } from '../src/orm.js'; +import apiRouter from '../src/routes/router.js'; +import { errorHandler } from '../src/middleware/error-handling/error-handler.js'; +import dotenv from 'dotenv'; +import cors from '../src/middleware/cors'; +import { authenticateUser } from '../src/middleware/auth/auth'; +import { seedORM } from './seed'; + +const envFile = '../.env.test'; + +dotenv.config({ path: envFile }); + +const app: Express = express(); + +app.use(express.json()); +app.use(cors); +app.use(authenticateUser); + +app.use('/api', apiRouter); +app.use(errorHandler); + +async function startServer(): Promise { + await seedORM(await initORM(true)); + + app.listen(9876); +} + +await startServer(); diff --git a/frontend/package.json b/frontend/package.json index c5224a1a..cdd7dda1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,14 +10,12 @@ "preview": "vite preview", "type-check": "vue-tsc --build", "format": "prettier --write src/", - "test:e2e": "playwright test", "format-check": "prettier --check src/", "lint": "eslint . --fix", - "pretest:unit": "tsx ../docs/api/generate.ts && npm run build", - "test:unit": "vitest --run" + "test:unit": "vitest --run", + "test:e2e": "playwright test" }, "dependencies": { - "@dwengo-1/common": "^0.1.1", "@tanstack/react-query": "^5.69.0", "@tanstack/vue-query": "^5.69.0", "axios": "^1.8.2", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b1207448..dbb62e79 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -3,6 +3,9 @@ import MenuBar from "@/components/MenuBar.vue"; import { useRoute } from "vue-router"; import { computed } from "vue"; + import authService from "@/services/auth/auth-service.ts"; + + void authService.loadUser(); const route = useRoute(); interface RouteMeta { diff --git a/frontend/src/components/MenuBar.vue b/frontend/src/components/MenuBar.vue index 0954707f..e3734976 100644 --- a/frontend/src/components/MenuBar.vue +++ b/frontend/src/components/MenuBar.vue @@ -11,7 +11,7 @@ const { t, locale } = useI18n(); const role = auth.authState.activeRole; - const router = useRouter(); + const _router = useRouter(); // Zonder '_' gaf dit een linter error voor unused variable const name: string = auth.authState.user!.profile.name!; const initials: string = name diff --git a/frontend/src/config.ts b/frontend/src/config.ts index 53d6f253..cff1d771 100644 --- a/frontend/src/config.ts +++ b/frontend/src/config.ts @@ -1,8 +1,24 @@ export const apiConfig = { - baseUrl: - window.location.hostname === "localhost" && !(window.location.port === "80" || window.location.port === "") - ? "http://localhost:3000/api" - : window.location.origin + "/api", + baseUrl: ((): string => { + if (import.meta.env.MODE === "test") { + // TODO Remove hardcoding + return "http://localhost:9876/api"; + } + + if (import.meta.env.VITE_API_BASE_URL) { + return import.meta.env.VITE_API_BASE_URL; + } + + if ( + window.location.hostname === "localhost" && + !(window.location.port === "80" || window.location.port === "") + ) { + return "http://localhost:3000/api"; + } + + // Fallback to the current origin with "/api" suffix + return `${window.location.origin}/api`; + })(), }; export const loginRoute = "/login"; diff --git a/frontend/src/controllers/base-controller.ts b/frontend/src/controllers/base-controller.ts index 3d22e099..64f2363d 100644 --- a/frontend/src/controllers/base-controller.ts +++ b/frontend/src/controllers/base-controller.ts @@ -1,6 +1,7 @@ import apiClient from "@/services/api-client/api-client.ts"; import type { AxiosResponse, ResponseType } from "axios"; import { HttpErrorResponseException } from "@/exception/http-error-response-exception.ts"; +import { apiConfig } from "@/config.ts"; export abstract class BaseController { protected basePath: string; @@ -16,9 +17,18 @@ export abstract class BaseController { } protected async get(path: string, queryParams?: QueryParams, responseType?: ResponseType): Promise { - const response = await apiClient.get(this.absolutePathFor(path), { params: queryParams, responseType }); - BaseController.assertSuccessResponse(response); - return response.data; + try { + const response = await apiClient.get(this.absolutePathFor(path), { params: queryParams, responseType }); + BaseController.assertSuccessResponse(response); + return response.data; + } catch (error) { + if (error instanceof HttpErrorResponseException) { + throw error; + } + throw new Error( + `An unexpected error occurred while fetching data from ${apiConfig.baseUrl}${this.absolutePathFor(path)}: ${error}`, + ); + } } protected async post(path: string, body: unknown, queryParams?: QueryParams): Promise { diff --git a/frontend/src/main.ts b/frontend/src/main.ts index e4bdde40..b5315634 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -12,11 +12,9 @@ import App from "./App.vue"; import router from "./router"; import { aliases, mdi } from "vuetify/iconsets/mdi"; import { VueQueryPlugin, QueryClient } from "@tanstack/vue-query"; -import authService from "./services/auth/auth-service.ts"; const app = createApp(App); -await authService.loadUser(); app.use(router); const link = document.createElement("link"); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 1eeb8865..8092ffd5 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -138,7 +138,7 @@ const router = createRouter({ router.beforeEach(async (to, _from, next) => { // Verify if user is logged in before accessing certain routes if (to.meta.requiresAuth) { - if (!authService.isLoggedIn.value) { + if (!authService.isLoggedIn.value && !(await authService.loadUser())) { next("/login"); } else { next(); diff --git a/frontend/src/services/auth/auth-service.ts b/frontend/src/services/auth/auth-service.ts index 9151e0a9..a813cd6e 100644 --- a/frontend/src/services/auth/auth-service.ts +++ b/frontend/src/services/auth/auth-service.ts @@ -37,7 +37,20 @@ async function loadUser(): Promise { if (!activeRole) { return null; } - const user = await (await getUserManagers())[activeRole].getUser(); + + const userManager = (await getUserManagers())[activeRole]; + let user = await userManager.getUser(); // Load the user from the local storage. + if (!user) { + // If the user is not in the local storage, he could still be authenticated in Keycloak. + try { + user = await userManager.signinSilent(); + } catch (_: unknown) { + // When the user was previously logged in and then logged out, signinSilent throws an error. + // In that case, the user is not authenticated anymore, so we set him to null. + user = null; + } + } + setUserAuthInfo(user); authState.activeRole = activeRole ?? null; return user; diff --git a/frontend/tests/controllers/student-controller.test.ts b/frontend/tests/controllers/student-controller.test.ts new file mode 100644 index 00000000..89c8224e --- /dev/null +++ b/frontend/tests/controllers/student-controller.test.ts @@ -0,0 +1,19 @@ +import { StudentController } from "../../src/controllers/students"; +import { expect, it, describe, afterAll, beforeAll } from "vitest"; +import { setup, teardown } from "../setup-backend.js"; + +describe("Test controller students", () => { + beforeAll(async () => { + await setup(); + }); + + afterAll(async () => { + await teardown(); + }); + + it("Get students", async () => { + const controller = new StudentController(); + const data = await controller.getAll(true); + expect(data.students).to.have.length.greaterThan(0); + }); +}); diff --git a/frontend/tests/setup-backend.ts b/frontend/tests/setup-backend.ts new file mode 100644 index 00000000..6a0a256d --- /dev/null +++ b/frontend/tests/setup-backend.ts @@ -0,0 +1,40 @@ +import { spawn } from "child_process"; +import { ChildProcess, spawnSync } from "node:child_process"; + +let backendProcess: ChildProcess; + +async function waitForEndpoint(url: string, delay = 1000, retries = 60): Promise { + try { + await fetch(url); + } catch { + // Endpoint is not ready yet + await new Promise((resolve) => setTimeout(resolve, delay)); + // Retry + await waitForEndpoint(url, delay, retries - 1); + } +} + +export async function setup(): Promise { + // Precompile needed packages + spawnSync("npx", ["tsc", "--build", "tsconfig.json"], { + cwd: `../common`, + }); + + // Spin up the backend + backendProcess = spawn("npx", ["tsx", "--env-file=.env.test", "tool/startTestApp.ts"], { + cwd: `../backend`, + env: { + ...process.env, + NODE_ENV: "test", + }, + }); + + // Wait until you can curl the backend + await waitForEndpoint("http://localhost:9876/api"); +} + +export async function teardown(): Promise { + if (backendProcess) { + backendProcess.kill(); + } +} diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index ba2d72b6..51fc91ab 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -9,6 +9,13 @@ export default mergeConfig( environment: "jsdom", exclude: [...configDefaults.exclude, "e2e/**"], root: fileURLToPath(new URL("./", import.meta.url)), + + /* + * The test-backend server can be started for each test-file individually using `beforeAll(() => setup())`, + * or for all tests once using: + globalSetup: ["./tests/setup-backend.ts"], + * In this project, the backend server is started for each test-file individually. + */ }, }), );