diff --git a/prisma/migrations/20260624152325_add_certificate_template/migration.sql b/prisma/migrations/20260624152325_add_certificate_template/migration.sql new file mode 100644 index 0000000..0727339 --- /dev/null +++ b/prisma/migrations/20260624152325_add_certificate_template/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `project` ADD COLUMN `certificate_template` TEXT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0b9799d..f55a0f5 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -212,6 +212,7 @@ model Project { start_date DateTime @db.Date end_date DateTime @db.Date status ProjectStatus @default(active) + certificate_template String? @db.Text created_at DateTime @default(now()) updated_at DateTime @updatedAt diff --git a/src/modules/certificate/controllers/certificate.controller.js b/src/modules/certificate/controllers/certificate.controller.js index 77f6d16..deffe36 100644 --- a/src/modules/certificate/controllers/certificate.controller.js +++ b/src/modules/certificate/controllers/certificate.controller.js @@ -1,7 +1,7 @@ const certificateService = require('../services/certificate.service'); const { response, error } = require('../../../helpers/utils/wrapper'); const { SUCCESS, ERROR } = require('../../../helpers/http-status/status_code'); -const { InternalServerError } = require('../../../helpers/error'); +const { InternalServerError, BadRequestError } = require('../../../helpers/error'); const { isValidPayload } = require('../../../helpers/utils/validator'); const { claimCertificateModel } = require('../models/certificate.model'); @@ -172,6 +172,40 @@ class CertificateController { ); } } + + async uploadCertificateTemplate(req, res) { + try { + const { id_project } = req.params; + const file = req.file; + + if (!file) { + return response( + res, + 'fail', + error(new BadRequestError('Certificate template file is required')), + 'Certificate template file is required', + ERROR.BAD_REQUEST + ); + } + + const actor = getActor(req); + const result = await certificateService.uploadCertificateTemplate(id_project, file, actor); + + if (result.err) { + return response(res, 'fail', result); + } + + return response(res, 'success', result, 'Certificate template uploaded successfully', SUCCESS.OK); + } catch (err) { + return response( + res, + 'fail', + error(new InternalServerError(err.message)), + 'Unexpected error occurred', + ERROR.INTERNAL_ERROR + ); + } + } } module.exports = new CertificateController(); diff --git a/src/modules/certificate/repositories/certificate.repository.js b/src/modules/certificate/repositories/certificate.repository.js index 51e2221..4741657 100644 --- a/src/modules/certificate/repositories/certificate.repository.js +++ b/src/modules/certificate/repositories/certificate.repository.js @@ -95,6 +95,19 @@ class CertificateRepository { }); } + async findProjectById(id_project) { + return prisma.project.findUnique({ + where: { id: parseInt(id_project) }, + }); + } + + async updateProjectTemplate(id_project, templatePath) { + return prisma.project.update({ + where: { id: parseInt(id_project) }, + data: { certificate_template: templatePath }, + }); + } + certificateDetailInclude() { return { user: { @@ -109,6 +122,7 @@ class CertificateRepository { id: true, project_name: true, description: true, + certificate_template: true, }, }, }; diff --git a/src/modules/certificate/services/certificate.service.js b/src/modules/certificate/services/certificate.service.js index 2487e5c..4b61a99 100644 --- a/src/modules/certificate/services/certificate.service.js +++ b/src/modules/certificate/services/certificate.service.js @@ -199,6 +199,47 @@ class CertificateService { } } + async uploadCertificateTemplate(id_project, file, actor) { + try { + if (!id_project) { + return error(new BadRequestError('Project ID is required')); + } + + if (!file) { + return error(new BadRequestError('No file uploaded')); + } + + if (!actor?.id || !actor?.role) { + return error(new ForbiddenError('Access denied: authentication data is missing')); + } + + const project = await certificateRepository.findProjectById(id_project); + if (!project) { + return error(new NotFoundError('Project not found')); + } + + // Authorization checks: + if (actor.role === 'mentor') { + if (project.id_admin !== parseInt(actor.id)) { + return error(new ForbiddenError('Access denied: you are not the mentor of this project')); + } + } else if (actor.role !== 'admin') { + return error(new ForbiddenError('Access denied: only admins and mentors can upload certificate templates')); + } + + const templatePath = `/uploads/${file.filename}`; + + await certificateRepository.updateProjectTemplate(id_project, templatePath); + + return data({ + id_project: parseInt(id_project), + certificate_template: templatePath, + }); + } catch (err) { + return error(err); + } + } + mapCertificate(cert) { if (!cert) return null; return { @@ -217,6 +258,7 @@ class CertificateService { id: cert.project.id, project_name: cert.project.project_name, description: cert.project.description, + certificate_template: cert.project.certificate_template, } : null, }; } diff --git a/src/modules/project/services/project.service.js b/src/modules/project/services/project.service.js index e9e7a11..fb7bf3b 100644 --- a/src/modules/project/services/project.service.js +++ b/src/modules/project/services/project.service.js @@ -302,6 +302,7 @@ class ProjectService { project_icon: project.project_icon, project_name: project.project_name, description: project.description, + certificate_template: project.certificate_template, start_date: project.start_date, end_date: project.end_date, status: project.status, diff --git a/src/routes/certificate.routes.js b/src/routes/certificate.routes.js index fec42f2..e58cf1c 100644 --- a/src/routes/certificate.routes.js +++ b/src/routes/certificate.routes.js @@ -2,6 +2,9 @@ const express = require('express'); const { certificateController } = require('../modules/certificate'); const { verifyJWT } = require('../middleware/verifyJWT'); const isAdmin = require('../middleware/isAdmin'); +const isMentorOrAdmin = require('../middleware/isMentorOrAdmin'); +const multer = require('../middleware/multer'); +const { multerErrorHandler, checkFileSizes } = require('../middleware/multer'); const router = express.Router(); @@ -366,4 +369,83 @@ router.get('/verify', certificateController.verifyCertificate); */ router.get('/verify-uuid/:uuid', certificateController.verifyCertificateByUuid); +/** + * @swagger + * /certificate-api/projects/{id_project}/template: + * post: + * summary: Upload certificate template for a project + * description: Admin or Mentor can upload a certificate template file (image/pdf) for a specific project. Mentors can only upload to projects they own. + * tags: [Certificate] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id_project + * schema: + * type: integer + * required: true + * description: The ID of the project to associate the template with + * example: 1 + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * required: + * - template + * properties: + * template: + * type: string + * format: binary + * description: The certificate template file (JPEG, JPG, PNG, or PDF). Max size 5MB. + * responses: + * 200: + * description: Certificate template uploaded successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: boolean + * example: true + * data: + * type: object + * properties: + * id_project: + * type: integer + * example: 1 + * certificate_template: + * type: string + * example: "/uploads/1718000000000-987654321.png" + * message: + * type: string + * example: "Certificate template uploaded successfully" + * code: + * type: integer + * example: 200 + * 400: + * description: Bad request (missing file or invalid input) + * 401: + * description: Unauthorized + * 403: + * description: Forbidden (Mentor does not own the project, or unauthorized role) + * 404: + * description: Project not found + * 413: + * description: File size too large (max 5MB) + * 500: + * description: Internal server error + */ +router.post( + '/projects/:id_project/template', + verifyJWT, + isMentorOrAdmin, + multer.single('template'), + checkFileSizes, + multerErrorHandler, + certificateController.uploadCertificateTemplate +); + module.exports = router; diff --git a/test/test_certificate_template.js b/test/test_certificate_template.js new file mode 100644 index 0000000..ffd06f6 --- /dev/null +++ b/test/test_certificate_template.js @@ -0,0 +1,297 @@ +const axios = require('axios'); +const bcrypt = require('bcrypt'); +const { PrismaClient } = require('../src/generated/prisma'); + +const BASE_URL = 'http://localhost:9000'; +const prisma = new PrismaClient(); + +function createMultipartPayload(boundary, fields, files) { + const chunks = []; + + for (const [key, value] of Object.entries(fields)) { + if (value !== undefined && value !== null) { + chunks.push(Buffer.from(`--${boundary}\r\n` + + `Content-Disposition: form-data; name="${key}"\r\n\r\n` + + `${value}\r\n`)); + } + } + + for (const [key, file] of Object.entries(files)) { + if (file) { + chunks.push(Buffer.from(`--${boundary}\r\n` + + `Content-Disposition: form-data; name="${key}"; filename="${file.name}"\r\n` + + `Content-Type: ${file.type}\r\n\r\n`)); + chunks.push(file.content); + chunks.push(Buffer.from('\r\n')); + } + } + + chunks.push(Buffer.from(`--${boundary}--\r\n`)); + return Buffer.concat(chunks); +} + +async function runTests() { + console.log('=== STARTING INTEGRATION TESTS FOR CERTIFICATE TEMPLATE UPLOAD ===\n'); + + let adminToken = ''; + let mentor1Token = ''; + let mentor2Token = ''; + let internToken = ''; + let project1Id = null; + let project2Id = null; + + const password = 'password123'; + const internEmail = `new_intern_cert_${Date.now()}@internify.com`; + + const getHeader = (token) => ({ + headers: { Authorization: `Bearer ${token}` } + }); + + const getMultipartHeader = (token, boundary) => ({ + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': `multipart/form-data; boundary=${boundary}` + } + }); + + try { + // 1. Login as Admin + console.log('1. Logging in as Admin (admin1@internify.com)...'); + const adminLoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'admin1@internify.com', + password: password + }); + adminToken = adminLoginRes.data.data.token; + console.log(' Admin logged in successfully!\n'); + + // 2. Logging in as Mentors + console.log('2. Logging in as Mentors...'); + const mentor1LoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'mentor1@internify.com', + password: password + }); + mentor1Token = mentor1LoginRes.data.data.token; + + const mentor2LoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: 'mentor2@internify.com', + password: password + }); + mentor2Token = mentor2LoginRes.data.data.token; + console.log(' Mentors logged in successfully!\n'); + + // 3. Registering a new Intern to assign to Mentor 1 Project + console.log(`3. Registering a new Intern (${internEmail})...`); + + // Create a new vacancy with an image upload to get a valid UUID id_lowongan_magang + const lwnBoundary = '----WebKitFormBoundaryLowonganUpload'; + const lwnFields = { + posisi: 'Web Developer Test', + kelompok_peminatan: 'Software Engineering', + jobdesk: 'Testing task and certificate templates.', + lokasi: 'Remote', + kualifikasi: 'NodeJS', + benefit: 'Certificate', + durasi_awal: '2026-07-01', + durasi_akhir: '2026-10-01', + paid: 'unpaid' + }; + const lwnFiles = { + image: { name: 'poster.png', type: 'image/png', content: Buffer.from('fake-image-data') } + }; + const lwnPayload = createMultipartPayload(lwnBoundary, lwnFields, lwnFiles); + + console.log(' Creating lowongan magang (vacancy) dynamically...'); + const createLwnRes = await axios.post( + `${BASE_URL}/lowongan-magang-api/add`, + lwnPayload, + getMultipartHeader(adminToken, lwnBoundary) + ); + const lowonganId = createLwnRes.data.data.id; + console.log(' Vacancy created successfully! ID:', lowonganId); + + // Submit application (lamaran) using the add-mobile endpoint which also registers the student + console.log(' Submitting internship application (which registers the student profile)...'); + const appBoundary = '----WebKitFormBoundaryApplicationSubmission'; + const appFields = { + nama_depan: 'Intern', + nama_belakang: 'Cert', + email: internEmail, + kontak: '08123456789', + jurusan: 'Computer Science', + universitas: 'Intern University', + negara: 'Indonesia', + motivasi: 'I want to learn.', + relevant_skills: 'NodeJS, React' + }; + const appFiles = { + cv: { name: 'cv.pdf', type: 'application/pdf', content: Buffer.from('fake-pdf-cv') }, + portofolio: { name: 'portfolio.pdf', type: 'application/pdf', content: Buffer.from('fake-pdf-portfolio') } + }; + const appPayload = createMultipartPayload(appBoundary, appFields, appFiles); + + const applyRes = await axios.post( + `${BASE_URL}/lamaran-magang-api/add-mobile/${lowonganId}`, + appPayload, + { + headers: { + 'Content-Type': `multipart/form-data; boundary=${appBoundary}` + } + } + ); + const idMahasiswa = applyRes.data.data.id_mahasiswa; + console.log(' Application submitted successfully! Student ID:', idMahasiswa); + + // Fetch all lamaran to get the lamaran ID + console.log(' Retrieving lamaran list to find the newly created lamaran ID...'); + const listLamaranRes = await axios.get(`${BASE_URL}/lamaran-magang-api/get?limit=100`, getHeader(adminToken)); + const lamaranList = listLamaranRes.data.data; + const lamaranItem = lamaranList.find(l => l.id_mahasiswa === idMahasiswa); + if (!lamaranItem) { + throw new Error('TEST FAILED: Created lamaran not found in lamaran list!'); + } + const lamaranId = lamaranItem.id; + console.log(' Found Lamaran ID:', lamaranId); + + // Accept application to create user account + console.log(' Accepting application to generate Intern user account...'); + await axios.patch(`${BASE_URL}/lamaran-magang-api/update/${lamaranId}`, { + status: 'diterima' + }, getHeader(adminToken)); + + // Wait a brief moment for the user insertion to complete + await new Promise(resolve => setTimeout(resolve, 1000)); + + // Force override user's password in the database directly + console.log(' Overriding intern user password in DB directly to password123...'); + const hashedPassword = await bcrypt.hash('password123', 10); + await prisma.user.update({ + where: { email: internEmail }, + data: { password: hashedPassword } + }); + + // Login as new Intern + const internLoginRes = await axios.post(`${BASE_URL}/auth-api/login`, { + email: internEmail, + password: 'password123' + }); + internToken = internLoginRes.data.data.token; + console.log(' Intern logged in successfully!\n'); + + // 4. Mentors create projects dynamically to establish ownership + console.log('4. Creating projects dynamically for Mentors...'); + + // Mentor 1 creates Project 1 and assigns Intern (newly registered) + const project1Res = await axios.post(`${BASE_URL}/project-api/add`, { + project_icon: 'code', + project_name: `Mentor 1 Project - ${Date.now()}`, + description: 'Owned by Mentor 1.', + start_date: '2026-07-01', + end_date: '2026-10-01', + member_emails: [internEmail] + }, getHeader(mentor1Token)); + project1Id = project1Res.data.data.id; + + // Mentor 2 creates Project 2 + const project2Res = await axios.post(`${BASE_URL}/project-api/add`, { + project_icon: 'shield', + project_name: `Mentor 2 Project - ${Date.now()}`, + description: 'Owned by Mentor 2.', + start_date: '2026-07-01', + end_date: '2026-10-01', + member_emails: [] + }, getHeader(mentor2Token)); + project2Id = project2Res.data.data.id; + + console.log(` Projects created: Project 1 ID = ${project1Id}, Project 2 ID = ${project2Id}\n`); + + // Prepare dummy file contents for upload + const uploadBoundary = '----WebKitFormBoundaryTemplateUpload'; + const dummyTemplateContent = Buffer.from('fake-png-template-content'); + const uploadPayload = createMultipartPayload(uploadBoundary, {}, { + template: { name: 'cert_template.png', type: 'image/png', content: dummyTemplateContent } + }); + + // 5. Mentor 1 uploads template to Project 1 (Should succeed) + console.log(`5. Mentor 1 uploading certificate template to Project 1...`); + const uploadRes = await axios.post( + `${BASE_URL}/certificate-api/projects/${project1Id}/template`, + uploadPayload, + getMultipartHeader(mentor1Token, uploadBoundary) + ); + console.log(' Response status:', uploadRes.status); + console.log(' Uploaded template path:', uploadRes.data.data.certificate_template); + if (!uploadRes.data.data.certificate_template.startsWith('/uploads/')) { + throw new Error('TEST FAILED: Uploaded template path does not start with /uploads/'); + } + console.log(' PASSED: Mentor 1 successfully uploaded template to their own project.\n'); + + // 6. Mentor 2 tries to upload to Project 1 (Should fail - 403) + console.log(`6. Testing isolation: Mentor 2 trying to upload template to Project 1 (owned by Mentor 1)...`); + try { + await axios.post( + `${BASE_URL}/certificate-api/projects/${project1Id}/template`, + uploadPayload, + getMultipartHeader(mentor2Token, uploadBoundary) + ); + throw new Error('TEST FAILED: Mentor 2 was able to upload template to Project 1!'); + } catch (err) { + if (err.response && err.response.status === 403) { + console.log(' PASSED: Access denied with 403 Forbidden as expected.\n'); + } else { + throw err; + } + } + + // 7. Admin uploads template to Project 1 (Should succeed) + console.log(`7. Admin uploading certificate template to Project 1...`); + const adminUploadRes = await axios.post( + `${BASE_URL}/certificate-api/projects/${project1Id}/template`, + uploadPayload, + getMultipartHeader(adminToken, uploadBoundary) + ); + console.log(' Response status:', adminUploadRes.status); + console.log(' PASSED: Admin successfully uploaded template to project.\n'); + + // 8. Intern tries to upload template to Project 1 (Should fail - 401/403) + console.log(`8. Testing role restriction: Intern trying to upload template to Project 1...`); + try { + await axios.post( + `${BASE_URL}/certificate-api/projects/${project1Id}/template`, + uploadPayload, + getMultipartHeader(internToken, uploadBoundary) + ); + throw new Error('TEST FAILED: Intern was able to upload template to Project 1!'); + } catch (err) { + if (err.response && (err.response.status === 403 || err.response.status === 401)) { + console.log(` PASSED: Access denied with ${err.response.status} as expected.\n`); + } else { + throw err; + } + } + + // 9. Intern retrieves project details and verifies certificate template path is returned + console.log(`9. Intern retrieving Project 1 details to verify certificate template exposure...`); + const projectDetailRes = await axios.get(`${BASE_URL}/project-api/get/${project1Id}`, getHeader(internToken)); + console.log(' Project detail certificate_template:', projectDetailRes.data.data.certificate_template); + if (!projectDetailRes.data.data.certificate_template) { + throw new Error('TEST FAILED: Project detail response does not include certificate_template!'); + } + console.log(' PASSED: Intern successfully retrieved project detail containing certificate template URL.\n'); + + console.log('=== ALL INTEGRATION TESTS PASSED SUCCESSFULLY! ==='); + await prisma.$disconnect(); + process.exit(0); + } catch (err) { + console.error('=== TEST EXECUTION ENCOUNTERED AN ERROR ==='); + if (err.response) { + console.error(`Status: ${err.response.status}`); + console.error('Response Data:', JSON.stringify(err.response.data, null, 2)); + } else { + console.error(err); + } + await prisma.$disconnect(); + process.exit(1); + } +} + +runTests();