diff --git a/Deploy/Containerization/backend_dockerfile/.env b/Deploy/Containerization/backend_dockerfile/.env new file mode 100644 index 0000000..ba848e2 --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/.env @@ -0,0 +1 @@ +JWT_SECRET=testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestte== \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/Dockerfile b/Deploy/Containerization/backend_dockerfile/backend/Dockerfile new file mode 100644 index 0000000..dc46996 --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +EXPOSE 8000 + +CMD ["npm", "start"] diff --git a/Deploy/Containerization/backend_dockerfile/backend/__tests__/auth.test.js b/Deploy/Containerization/backend_dockerfile/backend/__tests__/auth.test.js new file mode 100644 index 0000000..ac99e2e --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/__tests__/auth.test.js @@ -0,0 +1,107 @@ +import request from 'supertest'; +import { httpServer } from '../src/index.js'; +import {dbReset} from "../src/data/dataServices.js"; + +describe('Authentication API', () => { + + afterAll((done) => { + httpServer.close(done); + }); + + describe('POST /api/auth/register', () => { + beforeEach(async() => { + await dbReset(); + + }); + + it('should register a new user successfully', async () => { + const response = await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty('username', 'testuser'); + }); + + it('should not allow duplicate usernames', async () => { + // Register first user + await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + // Try to register the same username + const response = await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'differentpassword' + }) + .timeout(4000); + + expect(response.status).toBe(409); + expect(response.body).toHaveProperty('message', 'Username already exists'); + }); + }); + + describe('POST /api/auth/login', () => { + beforeAll(async () => { + await dbReset(); + // Create a test user before login test + await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + }); + + it('should log in successfully with correct credentials', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('username', 'testuser'); + expect(response.body).toHaveProperty('token'); + }); + + it('should fail with incorrect password', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'wrongpassword' + }) + .timeout(4000); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', 'Invalid username or password'); + }); + + it('should fail with non-existent username', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'nonexistent', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', 'Invalid username or password'); + }); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/__tests__/dataServices.test.js b/Deploy/Containerization/backend_dockerfile/backend/__tests__/dataServices.test.js new file mode 100644 index 0000000..569ed5b --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/__tests__/dataServices.test.js @@ -0,0 +1,80 @@ +import {dbReset, userService, messageService} from '../src/data/dataServices.js'; + +describe('Test database-backed data layer', () => { + beforeEach(async () => { + await dbReset(); + }); + + describe('Test userService', () => { + it('Test new user creation', async () => { + const newUser = await userService.createUser('Tom', 'password123'); + expect(newUser.username).toBe('Tom'); + }); + + it('Test existing user creation', async () => { + await userService.createUser('John', '1234567890'); + + await expect(userService.createUser('John', 'newPassword')) + .rejects + .toThrow('Username already exists'); + }); + + it('Test get existing user', async () => { + const existingUser = await userService.createUser('Alice', 'mypassword'); + const fetchedUser = await userService.getUser('Alice'); + expect(fetchedUser.username).toEqual(existingUser.username); + }); + + it('Test get non-existing user', async () => { + const nonExistentUser = await userService.getUser('NonExistentUser'); + expect(nonExistentUser).toBeUndefined(); + }); + }); + + describe('Test messageService', () => { + it('Test add new message', async () => { + const message = await messageService.addMessage('Alice', 'Test message'); + expect(message.username).toBe('Alice'); + expect(message.content).toBe('Test message'); + expect(message.id).toBeDefined(); + }); + + it('Test get messages', async () => { + await messageService.addMessage('Tom', 'Message 1'); + await messageService.addMessage('John', 'Message 2'); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(2); + expect(messages[0].content).toBe('Message 1'); + expect(messages[1].content).toBe('Message 2'); + }); + + xit('Test delete the only message', async () => { + const newMessage = await messageService.addMessage('Alice', 'Single message'); + const result = await messageService.deleteMessage(newMessage.id); + expect(result).toBe(true); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(0); + }); + + xit('Test delete message with others', async () => { + await messageService.addMessage('Alice', 'Message A'); + const messageToDelete = await messageService.addMessage('Alice', 'Message B'); + await messageService.addMessage('Alice', 'Message C'); + + const result = await messageService.deleteMessage(messageToDelete.id); + expect(result).toBe(true); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(2); + expect(messages[0].content).toBe('Message A'); + expect(messages[1].content).toBe('Message C'); + }); + + xit('Test delete non-existing message', async () => { + const result = await messageService.deleteMessage(999999); + expect(result).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/__tests__/messages.test.js b/Deploy/Containerization/backend_dockerfile/backend/__tests__/messages.test.js new file mode 100644 index 0000000..996fd14 --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/__tests__/messages.test.js @@ -0,0 +1,157 @@ +import request from 'supertest'; +import {httpServer} from '../src/index.js'; +import {dbReset} from '../src/data/dataServices.js'; + +describe('Messages API', () => { + let authToken; + const testUser = { + username: 'User', + password: '1234' + }; + beforeAll(() => { + }); + + afterAll((done) => { + httpServer.close(done); + }); + + beforeEach(async () => { + // Clear data + await dbReset(); + + // Register and login test user + const registerResponse = await request(httpServer) + .post('/api/auth/register') + .send(testUser); + + authToken = registerResponse.body.token; + }); + + describe('Messages API - Unauthorized Access', () => { + + it('should return 401 for POST /api/messages without a token', async () => { + const response = await request(httpServer) + .post('/api/messages') + .send({ content: 'Unauthorized message' }); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + + it('should return 401 for GET /api/messages without a token', async () => { + const response = await request(httpServer) + .get('/api/messages'); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + + it('should return 401 for DELETE /api/messages/:id without a token', async () => { + const response = await request(httpServer) + .delete('/api/messages/1'); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + }); + + + describe('POST /api/messages', () => { + it('should create a new message with correct structure', async () => { + const response = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: 'Hello, World!' }) + .timeout(4000); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + content: 'Hello, World!', + username: 'User', + id: expect.any(Number), + }); + }); + + it('should fail without content', async () => { + const response = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send() + .timeout(4000); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('message', 'Message content is required'); + }); + }); + + describe('GET /api/messages', () => { + it('should get empty message list initially', async () => { + const response = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + + it('should get messages', async () => { + // Add test messages + for (let i = 0; i < 3; i++) { + await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: `Test message ${i}` }) + .timeout(4000); + } + + const response = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(3); + expect(response.body[2].content).toBe('Test message 2'); + }); + }); + + describe('DELETE /api/messages/:id', () => { + xit('should delete an existing message', async () => { + // Create a message + const createResponse = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: 'Test message' }) + .timeout(4000); + + const messageId = createResponse.body.id; + + // Delete the message + const deleteResponse = await request(httpServer) + .delete(`/api/messages/${messageId}`) + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(deleteResponse.status).toBe(204); + + // Verify the message is deleted + const getResponse = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(getResponse.body).toHaveLength(0); + }); + + xit('should return 404 for non-existent message', async () => { + const response = await request(httpServer) + .delete('/api/messages/999999') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(404); + }); + + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/__tests__/socket.test.js b/Deploy/Containerization/backend_dockerfile/backend/__tests__/socket.test.js new file mode 100644 index 0000000..c70dcbe --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/__tests__/socket.test.js @@ -0,0 +1,122 @@ +import {io as Client} from 'socket.io-client'; +import {httpServer} from '../src/index.js'; +import {messageService} from '../src/data/dataServices.js'; +import request from "supertest"; +import {dbReset} from "../src/data/dataServices.js"; + +describe('Socket.IO Chat', () => { + let clientSocket; + let authToken; + const testUser = { + username: 'User', + password: '1234' + }; + + beforeAll(async () => { + // Register and login test user + await dbReset(); + const registerResponse = await request(httpServer) + .post('/api/auth/register') + .send(testUser); + + authToken = registerResponse.body.token; + }) + + beforeEach((done) => { + clientSocket = new Client(`http://localhost:${httpServer.address().port}`, { + reconnection: false, + auth: { + token: authToken + } + }); + clientSocket.on('connect', done); + }); + + afterEach(() => { + clientSocket.close(); + }); + + afterAll((done) => { + httpServer.close(done); + }); + + + describe('Messaging', () => { + it('Should not allow unauthorized access', (done) => { + const noTokenSocket = new Client(`http://localhost:${httpServer.address().port}`, { + auth: {} + }); + + noTokenSocket.on('connect_error', (error) => { + try { + expect(error.message).toBe('Authentication token required'); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } finally { + clientSocket.close(); + } + }); + }); + + it('should broadcast messages to all clients', (done) => { + const testMessage = {username: 'User', content: 'Hello, WebSocket!'}; + + clientSocket.on('message', (message) => { + try { + expect(message).toMatchObject({ + content: 'Hello, WebSocket!', + username: 'User', + id: expect.any(Number), + }); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } + }); + + clientSocket.emit('message', testMessage); + }); + + it('should store message in database', (done) => { + const testMessage = {username: 'User', content: 'Test message for storage'}; + clientSocket.on('message', async (message) => { + try { + const storedMessages = await messageService.getMessages(); + expect(storedMessages.length).not.toBe(0); + expect(storedMessages.at(-1).content).toBe(testMessage.content); + expect(storedMessages.at(-1).username).toBe(testMessage.username); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } + }); + clientSocket.emit('message', testMessage); + }); + + xit('should delete message and broadcast deletion to all clients', (done) => { + const testMessage = {username: 'User', content: 'Temporary message'}; + + messageService.addMessage(testMessage.username, testMessage.content).then(async (addedMessage) => { + const messageIdToDelete = addedMessage.id; + clientSocket.on('messageDeleted', async (data) => { + try { + expect(data).toMatchObject({ + messageId: messageIdToDelete, + }); + // Check that the message no longer exists in the store + const messages = await messageService.getMessages(); + expect(messages.find(msg => msg.id === messageIdToDelete)).toBeUndefined(); + done(); // Mark test as successful if assertions pass + } catch (err) { + done(err); // Mark test as failed if assertions fail + } + }); + // Emit deleteMessage event + clientSocket.emit('deleteMessage', {messageId: messageIdToDelete}); + }); + + }); + + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/data/database.sqlite b/Deploy/Containerization/backend_dockerfile/backend/data/database.sqlite new file mode 100644 index 0000000..17f59da Binary files /dev/null and b/Deploy/Containerization/backend_dockerfile/backend/data/database.sqlite differ diff --git a/Deploy/Containerization/backend_dockerfile/backend/jest.setup.js b/Deploy/Containerization/backend_dockerfile/backend/jest.setup.js new file mode 100644 index 0000000..765cb5b --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/jest.setup.js @@ -0,0 +1,5 @@ +// Jest setup file +// This file sets up the test environment before running tests + +// Set NODE_ENV to 'test' to use the in-memory database +process.env.NODE_ENV = 'test'; \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/package.json b/Deploy/Containerization/backend_dockerfile/backend/package.json new file mode 100644 index 0000000..2d15d0d --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "vite_config-backend", + "version": "1.0.0", + "main": "src/index.js", + "type": "module", + "scripts": { + "start": "node src/index.js", + "generate-secret": "node scripts/generateSecret.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "morgan": "^1.10.0", + "bcryptjs": "^2.4.3", + "socket.io": "^4.7.1", + "dotenv": "^16.3.1", + "jsonwebtoken": "^9.0.1", + "sequelize": "^6.32.1", + "sqlite3": "^5.1.6" + }, + "devDependencies": { + "jest": "^29.3.1", + "@types/jest": "^29.2.5", + "supertest": "^6.3.3", + "@types/supertest": "^6.0.3" + }, + "jest": { + "testEnvironment": "node", + "testMatch": ["/__tests__/**/*.test.js"], + "setupFiles": ["/jest.setup.js"] + } +} diff --git a/Deploy/Containerization/backend_dockerfile/backend/scripts/generateSecret.js b/Deploy/Containerization/backend_dockerfile/backend/scripts/generateSecret.js new file mode 100644 index 0000000..4b407ca --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/scripts/generateSecret.js @@ -0,0 +1,8 @@ +import crypto from 'crypto'; + +// Generate a secure random string of 64 bytes and convert it to base64 +const secret = crypto.randomBytes(64).toString('base64'); + +console.log('Generated JWT_SECRET: '); +console.log(secret); +console.log('\nMake sure to update the .env file with this value'); \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/src/data/dataServices.js b/Deploy/Containerization/backend_dockerfile/backend/src/data/dataServices.js new file mode 100644 index 0000000..e4479b0 --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/src/data/dataServices.js @@ -0,0 +1,84 @@ +import { DataTypes } from 'sequelize'; +import sequelize from './dbConfig.js'; + +const { Users, Messages } = await (async () => { + + // Define a Users table model + const Users = sequelize.define('Users', { + username: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + password: { + type: DataTypes.STRING, + allowNull: false + } + }); + + // Define a Messages table model + const Messages = sequelize.define('Messages', { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + allowNull: false + }, + content: { + type: DataTypes.TEXT, + allowNull: false + } + }); + + await sequelize.sync(); + return { Users, Messages }; +})(); + +export const userService = { + createUser: async (username, hashedPassword) => { + + if (await Users.findByPk(username)) { + throw new Error('Username already exists'); + } + await Users.create({ username, password: hashedPassword }); + return {username}; + }, + + getUser: async (username) => { + const user = await Users.findByPk(username); + return user ? user.get({ plain: true }) : undefined; + }, + +}; + +export const messageService = { + addMessage: async (username, content) => { + const message = await Messages.create({ + username, + content + }); + return message.get({ plain: true }); + }, + + getMessages: async () => { + return await Messages.findAll({raw: true}); + }, + + // Optional task + deleteMessage: async (messageId) => { + const deleted = await Messages.destroy({ + where: { id: messageId } + }); + return deleted > 0; + } +}; + +// It is used only for testing purposes: +export const dbReset = async () => { + // Delete all records in Messages and Users tables + await Messages.destroy({ where: {}, truncate: true }); + await Users.destroy({ where: {}, truncate: true }); +}; diff --git a/Deploy/Containerization/backend_dockerfile/backend/src/data/dbConfig.js b/Deploy/Containerization/backend_dockerfile/backend/src/data/dbConfig.js new file mode 100644 index 0000000..bc895be --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/src/data/dbConfig.js @@ -0,0 +1,22 @@ +// Database configuration +// This file provides configuration for both real and test databases + +import { Sequelize } from 'sequelize'; + +// Determine if we're in test mode +const isTest = process.env.NODE_ENV === 'test'; + +// Create the appropriate Sequelize instance based on the environment +const sequelize = isTest + ? new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', // Use in-memory SQLite for tests + logging: false + }) + : new Sequelize({ + dialect: 'sqlite', + storage: './data/database.sqlite', // Use file-based SQLite for production + logging: false + }); + +export default sequelize; \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/src/index.js b/Deploy/Containerization/backend_dockerfile/backend/src/index.js new file mode 100644 index 0000000..3d420be --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/src/index.js @@ -0,0 +1,60 @@ +import express from 'express'; +import http from 'http'; +import cors from 'cors'; +import morgan from "morgan"; +import messageRoutes from './routes/messages.js'; +import authRoutes from './routes/auth.js'; +import { Server as SocketIO } from 'socket.io'; +import { initializeSocketIO } from './socket.js'; +import { authenticateRoute } from './middleware/auth.js'; + +const app = express(); +const httpServer = http.createServer(app); +const io = new SocketIO(httpServer); +initializeSocketIO(io); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Logging middleware +app.use(morgan('tiny')); + +// Adding message router +app.use('/api/messages', authenticateRoute, messageRoutes); +//app.use('/api/messages', messageRoutes); + +// Adding auth router +app.use('/api/auth', authRoutes); + +app.get('/health', (req, res) => { + const healthcheck = { + uptime: process.uptime(), // Server uptime in seconds + message: 'OK', + timestamp: Date.now() + }; + try { + res.status(200).json(healthcheck); // Respond with 200 OK and health data + } catch (error) { + healthcheck.message = error.message; + res.status(503).json(healthcheck); // Respond with 503 Service Unavailable on error + } +}); + +app.use((req, res) => { + res.status(404).type('text/plain').send('Page Not Found'); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ message: 'Something went wrong!' }); +}); + +const PORT = 8000; + +httpServer.listen(PORT, () => { + console.log(`Server is running at http://localhost:${PORT}/`); +}); + +export { httpServer, app }; diff --git a/Deploy/Containerization/backend_dockerfile/backend/src/middleware/auth.js b/Deploy/Containerization/backend_dockerfile/backend/src/middleware/auth.js new file mode 100644 index 0000000..1134b37 --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/src/middleware/auth.js @@ -0,0 +1,62 @@ +import dotenv from 'dotenv'; +import jwt from 'jsonwebtoken'; +import { userService } from '../data/dataServices.js'; + +dotenv.config(); +const { JWT_SECRET } = process.env; + +if (!JWT_SECRET) { + console.error('JWT_SECRET is not defined in environment variables.'); + process.exit(1); +} + +export const generateToken = (username) => { + return jwt.sign({ username }, JWT_SECRET); +}; + +export const authenticateRoute = async (req, res, next) => { + const authHeader = req.headers['authorization']; + + // Typically, the `authorization` header has the format `"Bearer "` + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ message: 'Authentication token required' }); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await userService.getUser(decoded.username); + + if (!user) { + return res.status(401).json({ message: 'User not found' }); + } + + req.username = decoded.username; + next(); + } catch (err) { + return res.status(401).json({ message: 'Invalid token' }); + } +}; + +export const authenticateSocket = async (socket, next) => { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error('Authentication token required')); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await userService.getUser(decoded.username); + + if (!user) { + return next(new Error('User not found')); + } + + socket.username = decoded.username; + next(); + } catch (err) { + next(new Error('Invalid token')); + } +}; \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/src/routes/auth.js b/Deploy/Containerization/backend_dockerfile/backend/src/routes/auth.js new file mode 100644 index 0000000..2ecd995 --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/src/routes/auth.js @@ -0,0 +1,63 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import { userService } from '../data/dataServices.js'; +import { generateToken } from '../middleware/auth.js'; + +const router = express.Router(); + +// Register new user +router.post('/register', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await userService.createUser(username, hashedPassword); + + const token = generateToken(username); + + res.status(201).json({ token, username: user.username }); + } catch (error) { + if (error.message === 'Username already exists') { + return res.status(409).json({ message: error.message }); + } + return res.status(500).json({ message: 'Error creating user: ' + error.message }); + } +}); + +// Login user +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + const user = await userService.getUser(username); + + if (!user) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + // Compare password + const isValidPassword = await bcrypt.compare(password, user.password); + + if (!isValidPassword) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + const token = generateToken(username); + + res.json({ token, username: user.username }); + } catch (error) { + return res.status(500).json({ message: 'Error during login' }); + } +}); + +export default router; \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/src/routes/messages.js b/Deploy/Containerization/backend_dockerfile/backend/src/routes/messages.js new file mode 100644 index 0000000..327a665 --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/src/routes/messages.js @@ -0,0 +1,54 @@ +import express from 'express'; +import { messageService } from '../data/dataServices.js'; + +const router = express.Router(); + +// Get all messages +router.get('/', async (req, res) => { + try { + const messages = await messageService.getMessages(); + return res.json(messages); + } catch (error) { + return res.status(500).json({ message: 'Error fetching messages' }); + } +}); + +// Create a new message +router.post('/', async (req, res) => { + try { + /**/ + const content = req.body.content; + + /**/ + + if (!content) { + return res.status(400).json({ message: 'Message content is required' }); + } + + const message = await messageService.addMessage(req.username, content); + return res.status(201).json(message); + + } catch (error) { + return res.status(500).json({ message: 'Error creating message' }); + } +}); + +// Optional task +// Delete a message +router.delete('/:id', async (req, res) => { + try { + const messageId = req.params.id; + // Now messageId should be converted to the number + const deleted = await messageService.deleteMessage(Number(messageId)); + + if (!deleted) { + return res.status(404).json({ message: 'Message not found' }); + } + + return res.status(204).send(); + } catch (error) { + return res.status(500).json({ message: 'Error deleting message' }); + } +}); + +export default router; \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/backend/src/socket.js b/Deploy/Containerization/backend_dockerfile/backend/src/socket.js new file mode 100644 index 0000000..accf35c --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/backend/src/socket.js @@ -0,0 +1,54 @@ +import { messageService } from './data/dataServices.js'; +import {authenticateSocket} from "./middleware/auth.js"; + +export const initializeSocketIO = (io) => { + + io.use(authenticateSocket); + + // Setup handlers for a new socket connection + io.on('connection', (socket) => { + console.log('User connected'); + + // Handle new messages + socket.on('message', async (data) => { + try { + const message = await messageService.addMessage(socket.username, data.content); + + io.emit('message', message); + } catch (error) { + socket.emit('error', { message: 'Error sending message' }); + } + }); + + // Handle message deletion + socket.on('deleteMessage', async (data) => { + try { + const deleted = await messageService.deleteMessage(data.messageId); + + if (!deleted) { + socket.emit('error', { message: 'Message not found' }); + return; + } + + io.emit('messageDeleted', { messageId: data.messageId }); + } catch (error) { + socket.emit('error', { message: 'Error deleting message' }); + } + }); + + // Handle user disconnection + socket.on('disconnect', () => { + console.log('User disconnected'); + }); + + // Handle errors + socket.on('error', (error) => { + console.error('Socket error:', error); + }); + }); + + // Handle server-side errors + io.on('error', (error) => { + console.error('Socket.IO error:', error); + }); +}; \ No newline at end of file diff --git a/Deploy/Containerization/backend_dockerfile/task-info.yaml b/Deploy/Containerization/backend_dockerfile/task-info.yaml new file mode 100644 index 0000000..a43e03d --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/task-info.yaml @@ -0,0 +1,38 @@ +type: theory +custom_name: Backend Dockerfile +files: + - name: backend/src/data/dbConfig.js + visible: true + - name: backend/src/data/dataServices.js + visible: true + - name: backend/src/routes/auth.js + visible: true + - name: backend/src/routes/messages.js + visible: true + - name: backend/src/index.js + visible: true + - name: backend/src/socket.js + visible: true + - name: backend/src/middleware/auth.js + visible: true + - name: backend/scripts/generateSecret.js + visible: true + - name: backend/__tests__/auth.test.js + visible: true + - name: backend/__tests__/socket.test.js + visible: true + - name: backend/__tests__/messages.test.js + visible: true + - name: backend/__tests__/dataServices.test.js + visible: true + - name: backend/package.json + visible: true + - name: backend/jest.setup.js + visible: true + - name: backend/data/database.sqlite + visible: true + is_binary: true + - name: .env + visible: true + - name: backend/Dockerfile + visible: true diff --git a/Deploy/Containerization/backend_dockerfile/task.md b/Deploy/Containerization/backend_dockerfile/task.md new file mode 100644 index 0000000..9a91d02 --- /dev/null +++ b/Deploy/Containerization/backend_dockerfile/task.md @@ -0,0 +1,42 @@ +Before we write the Dockerfile for the backend, we have made a few changes +to the project to simplify our work later on. Please review them below: + +### Backend changes +- The `database.sqlite` file has been moved from the backend root directory to the `backend/data` directory. +- The `storage` path has been updated in the [dbConfig.js][dbConfig] file. +- A `/health` route has been added to the [index.js][index] file. +- The `.env` file has been moved outside of the backend directory. + +### Dockerfile +Now it's time to create a Dockerfile in the project directory. +A Dockerfile is essentially a set of instructions for building an image, +which is a blueprint for our container to run from. +Let's go over the backend [Dockerfile][Dockerfile] line by line: + +```Dockerfile +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +EXPOSE 8000 + +CMD ["npm", "start"] +``` + +- The line `FROM node:20-alpine` uses the Node.js 20 image from [Docker Hub](https://hub.docker.com/_/node/) as the base image. This ensures that the container includes Node.js and all its dependencies. +- Using `WORKDIR /app` ensures that subsequent commands execute in that directory. +- The instruction `COPY package*.json ./` makes the file available in the Docker image. +- Then, we install dependencies using `RUN npm install`. +- The `COPY . .` instruction copies the rest of the source code into a subdirectory. +- The `EXPOSE 8000` instruction informs Docker that the container listens on port `8000` at runtime. We specified this port in [index.js][index]. Note: The `EXPOSE` instruction doesn't publish the port to the host machine; it simply declares that this port is intended to be used. +- Finally, the `CMD ["npm", "start"]` instruction specifies the command to run the application. + +We will run the backend container later, after making updates to the frontend. + +[dbConfig]: course://Deploy/Containerization/backend_dockerfile/backend/src/data/dbConfig.js +[index]: course://Deploy/Containerization/backend_dockerfile/backend/src/index.js +[Dockerfile]: course://Deploy/Containerization/backend_dockerfile/backend/Dockerfile diff --git a/Deploy/Containerization/docker_compose/.env b/Deploy/Containerization/docker_compose/.env new file mode 100644 index 0000000..ba848e2 --- /dev/null +++ b/Deploy/Containerization/docker_compose/.env @@ -0,0 +1 @@ +JWT_SECRET=testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestte== \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/Dockerfile b/Deploy/Containerization/docker_compose/backend/Dockerfile new file mode 100644 index 0000000..dc46996 --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +EXPOSE 8000 + +CMD ["npm", "start"] diff --git a/Deploy/Containerization/docker_compose/backend/__tests__/auth.test.js b/Deploy/Containerization/docker_compose/backend/__tests__/auth.test.js new file mode 100644 index 0000000..ac99e2e --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/__tests__/auth.test.js @@ -0,0 +1,107 @@ +import request from 'supertest'; +import { httpServer } from '../src/index.js'; +import {dbReset} from "../src/data/dataServices.js"; + +describe('Authentication API', () => { + + afterAll((done) => { + httpServer.close(done); + }); + + describe('POST /api/auth/register', () => { + beforeEach(async() => { + await dbReset(); + + }); + + it('should register a new user successfully', async () => { + const response = await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty('username', 'testuser'); + }); + + it('should not allow duplicate usernames', async () => { + // Register first user + await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + // Try to register the same username + const response = await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'differentpassword' + }) + .timeout(4000); + + expect(response.status).toBe(409); + expect(response.body).toHaveProperty('message', 'Username already exists'); + }); + }); + + describe('POST /api/auth/login', () => { + beforeAll(async () => { + await dbReset(); + // Create a test user before login test + await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + }); + + it('should log in successfully with correct credentials', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('username', 'testuser'); + expect(response.body).toHaveProperty('token'); + }); + + it('should fail with incorrect password', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'wrongpassword' + }) + .timeout(4000); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', 'Invalid username or password'); + }); + + it('should fail with non-existent username', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'nonexistent', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', 'Invalid username or password'); + }); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/__tests__/dataServices.test.js b/Deploy/Containerization/docker_compose/backend/__tests__/dataServices.test.js new file mode 100644 index 0000000..569ed5b --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/__tests__/dataServices.test.js @@ -0,0 +1,80 @@ +import {dbReset, userService, messageService} from '../src/data/dataServices.js'; + +describe('Test database-backed data layer', () => { + beforeEach(async () => { + await dbReset(); + }); + + describe('Test userService', () => { + it('Test new user creation', async () => { + const newUser = await userService.createUser('Tom', 'password123'); + expect(newUser.username).toBe('Tom'); + }); + + it('Test existing user creation', async () => { + await userService.createUser('John', '1234567890'); + + await expect(userService.createUser('John', 'newPassword')) + .rejects + .toThrow('Username already exists'); + }); + + it('Test get existing user', async () => { + const existingUser = await userService.createUser('Alice', 'mypassword'); + const fetchedUser = await userService.getUser('Alice'); + expect(fetchedUser.username).toEqual(existingUser.username); + }); + + it('Test get non-existing user', async () => { + const nonExistentUser = await userService.getUser('NonExistentUser'); + expect(nonExistentUser).toBeUndefined(); + }); + }); + + describe('Test messageService', () => { + it('Test add new message', async () => { + const message = await messageService.addMessage('Alice', 'Test message'); + expect(message.username).toBe('Alice'); + expect(message.content).toBe('Test message'); + expect(message.id).toBeDefined(); + }); + + it('Test get messages', async () => { + await messageService.addMessage('Tom', 'Message 1'); + await messageService.addMessage('John', 'Message 2'); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(2); + expect(messages[0].content).toBe('Message 1'); + expect(messages[1].content).toBe('Message 2'); + }); + + xit('Test delete the only message', async () => { + const newMessage = await messageService.addMessage('Alice', 'Single message'); + const result = await messageService.deleteMessage(newMessage.id); + expect(result).toBe(true); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(0); + }); + + xit('Test delete message with others', async () => { + await messageService.addMessage('Alice', 'Message A'); + const messageToDelete = await messageService.addMessage('Alice', 'Message B'); + await messageService.addMessage('Alice', 'Message C'); + + const result = await messageService.deleteMessage(messageToDelete.id); + expect(result).toBe(true); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(2); + expect(messages[0].content).toBe('Message A'); + expect(messages[1].content).toBe('Message C'); + }); + + xit('Test delete non-existing message', async () => { + const result = await messageService.deleteMessage(999999); + expect(result).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/__tests__/messages.test.js b/Deploy/Containerization/docker_compose/backend/__tests__/messages.test.js new file mode 100644 index 0000000..996fd14 --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/__tests__/messages.test.js @@ -0,0 +1,157 @@ +import request from 'supertest'; +import {httpServer} from '../src/index.js'; +import {dbReset} from '../src/data/dataServices.js'; + +describe('Messages API', () => { + let authToken; + const testUser = { + username: 'User', + password: '1234' + }; + beforeAll(() => { + }); + + afterAll((done) => { + httpServer.close(done); + }); + + beforeEach(async () => { + // Clear data + await dbReset(); + + // Register and login test user + const registerResponse = await request(httpServer) + .post('/api/auth/register') + .send(testUser); + + authToken = registerResponse.body.token; + }); + + describe('Messages API - Unauthorized Access', () => { + + it('should return 401 for POST /api/messages without a token', async () => { + const response = await request(httpServer) + .post('/api/messages') + .send({ content: 'Unauthorized message' }); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + + it('should return 401 for GET /api/messages without a token', async () => { + const response = await request(httpServer) + .get('/api/messages'); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + + it('should return 401 for DELETE /api/messages/:id without a token', async () => { + const response = await request(httpServer) + .delete('/api/messages/1'); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + }); + + + describe('POST /api/messages', () => { + it('should create a new message with correct structure', async () => { + const response = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: 'Hello, World!' }) + .timeout(4000); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + content: 'Hello, World!', + username: 'User', + id: expect.any(Number), + }); + }); + + it('should fail without content', async () => { + const response = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send() + .timeout(4000); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('message', 'Message content is required'); + }); + }); + + describe('GET /api/messages', () => { + it('should get empty message list initially', async () => { + const response = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + + it('should get messages', async () => { + // Add test messages + for (let i = 0; i < 3; i++) { + await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: `Test message ${i}` }) + .timeout(4000); + } + + const response = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(3); + expect(response.body[2].content).toBe('Test message 2'); + }); + }); + + describe('DELETE /api/messages/:id', () => { + xit('should delete an existing message', async () => { + // Create a message + const createResponse = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: 'Test message' }) + .timeout(4000); + + const messageId = createResponse.body.id; + + // Delete the message + const deleteResponse = await request(httpServer) + .delete(`/api/messages/${messageId}`) + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(deleteResponse.status).toBe(204); + + // Verify the message is deleted + const getResponse = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(getResponse.body).toHaveLength(0); + }); + + xit('should return 404 for non-existent message', async () => { + const response = await request(httpServer) + .delete('/api/messages/999999') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(404); + }); + + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/__tests__/socket.test.js b/Deploy/Containerization/docker_compose/backend/__tests__/socket.test.js new file mode 100644 index 0000000..c70dcbe --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/__tests__/socket.test.js @@ -0,0 +1,122 @@ +import {io as Client} from 'socket.io-client'; +import {httpServer} from '../src/index.js'; +import {messageService} from '../src/data/dataServices.js'; +import request from "supertest"; +import {dbReset} from "../src/data/dataServices.js"; + +describe('Socket.IO Chat', () => { + let clientSocket; + let authToken; + const testUser = { + username: 'User', + password: '1234' + }; + + beforeAll(async () => { + // Register and login test user + await dbReset(); + const registerResponse = await request(httpServer) + .post('/api/auth/register') + .send(testUser); + + authToken = registerResponse.body.token; + }) + + beforeEach((done) => { + clientSocket = new Client(`http://localhost:${httpServer.address().port}`, { + reconnection: false, + auth: { + token: authToken + } + }); + clientSocket.on('connect', done); + }); + + afterEach(() => { + clientSocket.close(); + }); + + afterAll((done) => { + httpServer.close(done); + }); + + + describe('Messaging', () => { + it('Should not allow unauthorized access', (done) => { + const noTokenSocket = new Client(`http://localhost:${httpServer.address().port}`, { + auth: {} + }); + + noTokenSocket.on('connect_error', (error) => { + try { + expect(error.message).toBe('Authentication token required'); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } finally { + clientSocket.close(); + } + }); + }); + + it('should broadcast messages to all clients', (done) => { + const testMessage = {username: 'User', content: 'Hello, WebSocket!'}; + + clientSocket.on('message', (message) => { + try { + expect(message).toMatchObject({ + content: 'Hello, WebSocket!', + username: 'User', + id: expect.any(Number), + }); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } + }); + + clientSocket.emit('message', testMessage); + }); + + it('should store message in database', (done) => { + const testMessage = {username: 'User', content: 'Test message for storage'}; + clientSocket.on('message', async (message) => { + try { + const storedMessages = await messageService.getMessages(); + expect(storedMessages.length).not.toBe(0); + expect(storedMessages.at(-1).content).toBe(testMessage.content); + expect(storedMessages.at(-1).username).toBe(testMessage.username); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } + }); + clientSocket.emit('message', testMessage); + }); + + xit('should delete message and broadcast deletion to all clients', (done) => { + const testMessage = {username: 'User', content: 'Temporary message'}; + + messageService.addMessage(testMessage.username, testMessage.content).then(async (addedMessage) => { + const messageIdToDelete = addedMessage.id; + clientSocket.on('messageDeleted', async (data) => { + try { + expect(data).toMatchObject({ + messageId: messageIdToDelete, + }); + // Check that the message no longer exists in the store + const messages = await messageService.getMessages(); + expect(messages.find(msg => msg.id === messageIdToDelete)).toBeUndefined(); + done(); // Mark test as successful if assertions pass + } catch (err) { + done(err); // Mark test as failed if assertions fail + } + }); + // Emit deleteMessage event + clientSocket.emit('deleteMessage', {messageId: messageIdToDelete}); + }); + + }); + + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/data/database.sqlite b/Deploy/Containerization/docker_compose/backend/data/database.sqlite new file mode 100644 index 0000000..17f59da Binary files /dev/null and b/Deploy/Containerization/docker_compose/backend/data/database.sqlite differ diff --git a/Deploy/Containerization/docker_compose/backend/jest.setup.js b/Deploy/Containerization/docker_compose/backend/jest.setup.js new file mode 100644 index 0000000..765cb5b --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/jest.setup.js @@ -0,0 +1,5 @@ +// Jest setup file +// This file sets up the test environment before running tests + +// Set NODE_ENV to 'test' to use the in-memory database +process.env.NODE_ENV = 'test'; \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/package.json b/Deploy/Containerization/docker_compose/backend/package.json new file mode 100644 index 0000000..2d15d0d --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "vite_config-backend", + "version": "1.0.0", + "main": "src/index.js", + "type": "module", + "scripts": { + "start": "node src/index.js", + "generate-secret": "node scripts/generateSecret.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "morgan": "^1.10.0", + "bcryptjs": "^2.4.3", + "socket.io": "^4.7.1", + "dotenv": "^16.3.1", + "jsonwebtoken": "^9.0.1", + "sequelize": "^6.32.1", + "sqlite3": "^5.1.6" + }, + "devDependencies": { + "jest": "^29.3.1", + "@types/jest": "^29.2.5", + "supertest": "^6.3.3", + "@types/supertest": "^6.0.3" + }, + "jest": { + "testEnvironment": "node", + "testMatch": ["/__tests__/**/*.test.js"], + "setupFiles": ["/jest.setup.js"] + } +} diff --git a/Deploy/Containerization/docker_compose/backend/scripts/generateSecret.js b/Deploy/Containerization/docker_compose/backend/scripts/generateSecret.js new file mode 100644 index 0000000..4b407ca --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/scripts/generateSecret.js @@ -0,0 +1,8 @@ +import crypto from 'crypto'; + +// Generate a secure random string of 64 bytes and convert it to base64 +const secret = crypto.randomBytes(64).toString('base64'); + +console.log('Generated JWT_SECRET: '); +console.log(secret); +console.log('\nMake sure to update the .env file with this value'); \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/src/data/dataServices.js b/Deploy/Containerization/docker_compose/backend/src/data/dataServices.js new file mode 100644 index 0000000..e4479b0 --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/src/data/dataServices.js @@ -0,0 +1,84 @@ +import { DataTypes } from 'sequelize'; +import sequelize from './dbConfig.js'; + +const { Users, Messages } = await (async () => { + + // Define a Users table model + const Users = sequelize.define('Users', { + username: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + password: { + type: DataTypes.STRING, + allowNull: false + } + }); + + // Define a Messages table model + const Messages = sequelize.define('Messages', { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + allowNull: false + }, + content: { + type: DataTypes.TEXT, + allowNull: false + } + }); + + await sequelize.sync(); + return { Users, Messages }; +})(); + +export const userService = { + createUser: async (username, hashedPassword) => { + + if (await Users.findByPk(username)) { + throw new Error('Username already exists'); + } + await Users.create({ username, password: hashedPassword }); + return {username}; + }, + + getUser: async (username) => { + const user = await Users.findByPk(username); + return user ? user.get({ plain: true }) : undefined; + }, + +}; + +export const messageService = { + addMessage: async (username, content) => { + const message = await Messages.create({ + username, + content + }); + return message.get({ plain: true }); + }, + + getMessages: async () => { + return await Messages.findAll({raw: true}); + }, + + // Optional task + deleteMessage: async (messageId) => { + const deleted = await Messages.destroy({ + where: { id: messageId } + }); + return deleted > 0; + } +}; + +// It is used only for testing purposes: +export const dbReset = async () => { + // Delete all records in Messages and Users tables + await Messages.destroy({ where: {}, truncate: true }); + await Users.destroy({ where: {}, truncate: true }); +}; diff --git a/Deploy/Containerization/docker_compose/backend/src/data/dbConfig.js b/Deploy/Containerization/docker_compose/backend/src/data/dbConfig.js new file mode 100644 index 0000000..bc895be --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/src/data/dbConfig.js @@ -0,0 +1,22 @@ +// Database configuration +// This file provides configuration for both real and test databases + +import { Sequelize } from 'sequelize'; + +// Determine if we're in test mode +const isTest = process.env.NODE_ENV === 'test'; + +// Create the appropriate Sequelize instance based on the environment +const sequelize = isTest + ? new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', // Use in-memory SQLite for tests + logging: false + }) + : new Sequelize({ + dialect: 'sqlite', + storage: './data/database.sqlite', // Use file-based SQLite for production + logging: false + }); + +export default sequelize; \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/src/index.js b/Deploy/Containerization/docker_compose/backend/src/index.js new file mode 100644 index 0000000..3d420be --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/src/index.js @@ -0,0 +1,60 @@ +import express from 'express'; +import http from 'http'; +import cors from 'cors'; +import morgan from "morgan"; +import messageRoutes from './routes/messages.js'; +import authRoutes from './routes/auth.js'; +import { Server as SocketIO } from 'socket.io'; +import { initializeSocketIO } from './socket.js'; +import { authenticateRoute } from './middleware/auth.js'; + +const app = express(); +const httpServer = http.createServer(app); +const io = new SocketIO(httpServer); +initializeSocketIO(io); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Logging middleware +app.use(morgan('tiny')); + +// Adding message router +app.use('/api/messages', authenticateRoute, messageRoutes); +//app.use('/api/messages', messageRoutes); + +// Adding auth router +app.use('/api/auth', authRoutes); + +app.get('/health', (req, res) => { + const healthcheck = { + uptime: process.uptime(), // Server uptime in seconds + message: 'OK', + timestamp: Date.now() + }; + try { + res.status(200).json(healthcheck); // Respond with 200 OK and health data + } catch (error) { + healthcheck.message = error.message; + res.status(503).json(healthcheck); // Respond with 503 Service Unavailable on error + } +}); + +app.use((req, res) => { + res.status(404).type('text/plain').send('Page Not Found'); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ message: 'Something went wrong!' }); +}); + +const PORT = 8000; + +httpServer.listen(PORT, () => { + console.log(`Server is running at http://localhost:${PORT}/`); +}); + +export { httpServer, app }; diff --git a/Deploy/Containerization/docker_compose/backend/src/middleware/auth.js b/Deploy/Containerization/docker_compose/backend/src/middleware/auth.js new file mode 100644 index 0000000..1134b37 --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/src/middleware/auth.js @@ -0,0 +1,62 @@ +import dotenv from 'dotenv'; +import jwt from 'jsonwebtoken'; +import { userService } from '../data/dataServices.js'; + +dotenv.config(); +const { JWT_SECRET } = process.env; + +if (!JWT_SECRET) { + console.error('JWT_SECRET is not defined in environment variables.'); + process.exit(1); +} + +export const generateToken = (username) => { + return jwt.sign({ username }, JWT_SECRET); +}; + +export const authenticateRoute = async (req, res, next) => { + const authHeader = req.headers['authorization']; + + // Typically, the `authorization` header has the format `"Bearer "` + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ message: 'Authentication token required' }); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await userService.getUser(decoded.username); + + if (!user) { + return res.status(401).json({ message: 'User not found' }); + } + + req.username = decoded.username; + next(); + } catch (err) { + return res.status(401).json({ message: 'Invalid token' }); + } +}; + +export const authenticateSocket = async (socket, next) => { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error('Authentication token required')); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await userService.getUser(decoded.username); + + if (!user) { + return next(new Error('User not found')); + } + + socket.username = decoded.username; + next(); + } catch (err) { + next(new Error('Invalid token')); + } +}; \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/src/routes/auth.js b/Deploy/Containerization/docker_compose/backend/src/routes/auth.js new file mode 100644 index 0000000..2ecd995 --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/src/routes/auth.js @@ -0,0 +1,63 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import { userService } from '../data/dataServices.js'; +import { generateToken } from '../middleware/auth.js'; + +const router = express.Router(); + +// Register new user +router.post('/register', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await userService.createUser(username, hashedPassword); + + const token = generateToken(username); + + res.status(201).json({ token, username: user.username }); + } catch (error) { + if (error.message === 'Username already exists') { + return res.status(409).json({ message: error.message }); + } + return res.status(500).json({ message: 'Error creating user: ' + error.message }); + } +}); + +// Login user +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + const user = await userService.getUser(username); + + if (!user) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + // Compare password + const isValidPassword = await bcrypt.compare(password, user.password); + + if (!isValidPassword) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + const token = generateToken(username); + + res.json({ token, username: user.username }); + } catch (error) { + return res.status(500).json({ message: 'Error during login' }); + } +}); + +export default router; \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/src/routes/messages.js b/Deploy/Containerization/docker_compose/backend/src/routes/messages.js new file mode 100644 index 0000000..327a665 --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/src/routes/messages.js @@ -0,0 +1,54 @@ +import express from 'express'; +import { messageService } from '../data/dataServices.js'; + +const router = express.Router(); + +// Get all messages +router.get('/', async (req, res) => { + try { + const messages = await messageService.getMessages(); + return res.json(messages); + } catch (error) { + return res.status(500).json({ message: 'Error fetching messages' }); + } +}); + +// Create a new message +router.post('/', async (req, res) => { + try { + /**/ + const content = req.body.content; + + /**/ + + if (!content) { + return res.status(400).json({ message: 'Message content is required' }); + } + + const message = await messageService.addMessage(req.username, content); + return res.status(201).json(message); + + } catch (error) { + return res.status(500).json({ message: 'Error creating message' }); + } +}); + +// Optional task +// Delete a message +router.delete('/:id', async (req, res) => { + try { + const messageId = req.params.id; + // Now messageId should be converted to the number + const deleted = await messageService.deleteMessage(Number(messageId)); + + if (!deleted) { + return res.status(404).json({ message: 'Message not found' }); + } + + return res.status(204).send(); + } catch (error) { + return res.status(500).json({ message: 'Error deleting message' }); + } +}); + +export default router; \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/backend/src/socket.js b/Deploy/Containerization/docker_compose/backend/src/socket.js new file mode 100644 index 0000000..accf35c --- /dev/null +++ b/Deploy/Containerization/docker_compose/backend/src/socket.js @@ -0,0 +1,54 @@ +import { messageService } from './data/dataServices.js'; +import {authenticateSocket} from "./middleware/auth.js"; + +export const initializeSocketIO = (io) => { + + io.use(authenticateSocket); + + // Setup handlers for a new socket connection + io.on('connection', (socket) => { + console.log('User connected'); + + // Handle new messages + socket.on('message', async (data) => { + try { + const message = await messageService.addMessage(socket.username, data.content); + + io.emit('message', message); + } catch (error) { + socket.emit('error', { message: 'Error sending message' }); + } + }); + + // Handle message deletion + socket.on('deleteMessage', async (data) => { + try { + const deleted = await messageService.deleteMessage(data.messageId); + + if (!deleted) { + socket.emit('error', { message: 'Message not found' }); + return; + } + + io.emit('messageDeleted', { messageId: data.messageId }); + } catch (error) { + socket.emit('error', { message: 'Error deleting message' }); + } + }); + + // Handle user disconnection + socket.on('disconnect', () => { + console.log('User disconnected'); + }); + + // Handle errors + socket.on('error', (error) => { + console.error('Socket error:', error); + }); + }); + + // Handle server-side errors + io.on('error', (error) => { + console.error('Socket.IO error:', error); + }); +}; \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/docker-compose.yaml b/Deploy/Containerization/docker_compose/docker-compose.yaml new file mode 100644 index 0000000..df1e4f9 --- /dev/null +++ b/Deploy/Containerization/docker_compose/docker-compose.yaml @@ -0,0 +1,52 @@ +version: '3.8' +name: chat-app +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + - NODE_ENV=production + env_file: + - ./.env + volumes: + - chat-db-data:/app/data + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - chat-network + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + environment: + - NODE_ENV=production + - BACKEND_URL=http://backend:8000 + networks: + - chat-network + restart: unless-stopped + +volumes: + chat-db-data: + driver: local + +networks: + chat-network: + driver: bridge \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/frontend/Dockerfile b/Deploy/Containerization/docker_compose/frontend/Dockerfile new file mode 100644 index 0000000..53fe6f4 --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +RUN npm run build + +# Production stage with Nginx +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 3000 + +# Nginx starts automatically in the foreground when the container starts diff --git a/Deploy/Containerization/docker_compose/frontend/__tests__/chat_test.jsx b/Deploy/Containerization/docker_compose/frontend/__tests__/chat_test.jsx new file mode 100644 index 0000000..420fe9e --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/__tests__/chat_test.jsx @@ -0,0 +1,155 @@ +import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import { jest } from '@jest/globals'; +import {act} from "react"; + +const socketMock = { + connect: jest.fn(), + disconnect: jest.fn(), + emit: jest.fn(), + on: jest.fn(), +}; + +jest.unstable_mockModule("socket.io-client", () => ({ + __esModule: true, + default: (...args) => { + socketMock.params.url = args[0] ?? null; + socketMock.params.opts = args[1] ?? null; + return socketMock; + }, +})); + +const chat_module = await import('../src/pages/Chat.jsx'); +const { default: Chat } = chat_module; + +describe('Chat tests', () => { + let onLogoutMock= jest.fn(); + + beforeEach(() => { + onLogoutMock = jest.fn(); + socketMock.connect = jest.fn(); + socketMock.disconnect = jest.fn(); + socketMock.emit = jest.fn(); + socketMock.on = jest.fn(); + socketMock.params = {}; + jest.clearAllMocks(); + localStorage.clear(); + localStorage.setItem('token', 'mockToken'); + }); + + it('creating a new WebSocket connection', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + render(); + await waitFor(() => { + expect(socketMock.params.url).toBe('/'); + expect(socketMock.params.opts).toStrictEqual({ auth: { token: "mockToken" } }); + }, { timeout: 1000 }); // Timeout of 1 second + + }) + + it('sending a new message', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + + render(); + + const inputField = screen.getByPlaceholderText('Type a message...'); + fireEvent.change(inputField, { target: { value: 'New test message' } }); + + const sendButton = screen.getByText('Send'); + fireEvent.click(sendButton); + + await waitFor(() => { + expect(socketMock.emit).toHaveBeenCalledWith('message', + { content: 'New test message' }, + ); + }, { timeout: 1000 }); // Timeout of 1 second + + expect(inputField.value).toBe(''); + }); + + it('displays new messages received via WebSocket', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' }, + ]; + + // Mock Axios `get` response for initial messages + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + + render(); + + // Check that initial mock messages are displayed + await screen.findByText('First message'); + expect(screen.getByText('Second message')).toBeInTheDocument(); + + // Simulate receiving a new "message" event via the WebSocket + const newMessage = { id: 3, username: 'User3', content: 'New incoming message' }; + const messageCallback = socketMock.on.mock.calls.find(call => call[0] === 'message')[1]; + act(() => { + messageCallback(newMessage); + }); + + expect(await screen.getByText('User3:')).toBeInTheDocument(); + expect(screen.getByText('New incoming message')).toBeInTheDocument(); + }); + + it('deletes a message when delete button is clicked', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + + render(); + + // Wait until the mock messages are displayed + await screen.findByText('First message'); + expect(screen.getByText('Second message')).toBeInTheDocument(); + + // Locate and click the delete button for the first message + const deleteButton = screen.getAllByAltText('Delete')[0]; + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(socketMock.emit).toHaveBeenCalledWith('deleteMessage', { messageId: 1 }); + }); + }); + + it('removes a message from the list when messageDeleted event is received', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + render(); + + // Wait until the mock messages are displayed + await screen.findByText('First message'); + expect(screen.getByText('Second message')).toBeInTheDocument(); + + // Simulate receiving a "messageDeleted" event + const messageDeletedCallback = socketMock.on.mock.calls.find(call => call[0] === 'messageDeleted')[1]; + act(() => { + messageDeletedCallback({ messageId: 1 }); + }); + + // Verify that the first message is removed + expect(screen.queryByText('First message')).not.toBeInTheDocument(); + expect(screen.getByText('Second message')).toBeInTheDocument(); + }); + + +}); diff --git a/Deploy/Containerization/docker_compose/frontend/__tests__/login_test.jsx b/Deploy/Containerization/docker_compose/frontend/__tests__/login_test.jsx new file mode 100644 index 0000000..3cb8e1f --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/__tests__/login_test.jsx @@ -0,0 +1,42 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import Login from '../src/pages/Login'; +import { jest } from '@jest/globals'; + +describe('Login component tests', () => { + let onLoginMock= jest.fn(); + + beforeEach(() => { + onLoginMock = jest.fn(); + jest.clearAllMocks(); + }); + + it('calls the backend API and logs in successfully', async () => { + axios.post = jest.fn().mockResolvedValue({ + data: { token: 'mockToken' }, + }); + + let {container} = render(); + + fireEvent.change(screen.getByLabelText('Username'), { + target: { value: 'user' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'password123' }, + }); + + console.log(container.innerHTML) + + fireEvent.click(screen.getByText('Login')); + + expect(axios.post).toHaveBeenCalledWith('/api/auth/login', { + username: 'user', + password: 'password123', + }); + await screen.findByText('Login to Chat'); // Wait for any potential render update + + expect(localStorage.getItem('token')).toBe('mockToken'); + expect(onLoginMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Deploy/Containerization/docker_compose/frontend/__tests__/register_test.jsx b/Deploy/Containerization/docker_compose/frontend/__tests__/register_test.jsx new file mode 100644 index 0000000..81622c5 --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/__tests__/register_test.jsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import Register from '../src/pages/Register'; +import { jest } from '@jest/globals'; + + +describe('Register component tests', () => { + let onLoginMock= jest.fn(); + + beforeEach(() => { + onLoginMock = jest.fn(); + }); + + it('displays an error when passwords do not match', async () => { + render(); + + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'user' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password456' } }); + + fireEvent.click(screen.getByText('Register')); + + const errorMessage = await screen.findByText('Passwords do not match'); + + expect(errorMessage).toBeInTheDocument(); + }); + + it('calls the backend API on successful registration', async () => { + axios.post = jest.fn().mockResolvedValue({ data: { token: 'mockToken' } }); + + render(); + + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'user' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password123' } }); + + fireEvent.click(screen.getByText('Register')); + + await screen.findByText(/Register for Chat/i); // Wait for any re-render + + expect(axios.post).toHaveBeenCalledWith('/api/auth/register', { + username: 'user', + password: 'password123', + }); + + expect(localStorage.getItem('token')).toBe('mockToken'); + expect(onLoginMock).toHaveBeenCalledTimes(1); + }); + +}); \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/frontend/__tests__/token_test.jsx b/Deploy/Containerization/docker_compose/frontend/__tests__/token_test.jsx new file mode 100644 index 0000000..a9cbb9a --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/__tests__/token_test.jsx @@ -0,0 +1,50 @@ +import {render, screen, fireEvent} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import App from '../src/App'; +import { jest } from '@jest/globals'; +import {BrowserRouter} from "react-router-dom"; + +const renderWithRouter = (ui, { route = '/' } = {}) => { + window.history.pushState({}, 'Test Page', route); + return render({ui}); +}; + +describe('Token management tests', () => { + + beforeEach(() => { + localStorage.clear(); // Clear localStorage to avoid persistence issues across tests + }); + + it('App verifies token presence in localStorage and updates authentication state', () => { + // Simulate token existence in localStorage + localStorage.setItem('token', 'mockToken'); + + renderWithRouter(, { route: '/login' }); + + expect(localStorage.getItem('token')).toBe('mockToken'); + expect(screen.getByText('Chat will be here.')).toBeInTheDocument(); + }); + + it('App handles absence of token: updates authentication state to false', () => { + + renderWithRouter(, { route: '/login' }); + + expect(localStorage.getItem('token')).toBe(null); + expect(screen.getByText('Login to Chat')).toBeInTheDocument(); + }); + + it('Chat removes token from localStorage upon logout', () => { + const removeItemSpy = jest.spyOn(localStorage, 'removeItem'); + + // Simulate token existence in localStorage + localStorage.setItem('token', 'mockToken'); + + renderWithRouter(, { route: '/chat' }); + + const logoutButton = screen.getByText('Logout'); + fireEvent.click(logoutButton); // Simulate logout + + expect(localStorage.getItem('token')).toBe(null); // Verify token is removed + expect(screen.getByText('Login to Chat')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/frontend/index.html b/Deploy/Containerization/docker_compose/frontend/index.html new file mode 100644 index 0000000..0ab318b --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Real-time Chat + + +
+ + + \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/frontend/nginx.conf b/Deploy/Containerization/docker_compose/frontend/nginx.conf new file mode 100644 index 0000000..75544fe --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/nginx.conf @@ -0,0 +1,53 @@ +server { + listen 3000; + + # Root directory where the built React app is located + root /usr/share/nginx/html; + index index.html; + + # Gzip compression for better performance + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Cache static assets + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ { + expires 1y; + add_header Cache-Control "public, max-age=31536000"; + } + + # Special location for /src/assets/ requests + location /src/assets/ { + alias /usr/share/nginx/html/assets/; + expires 1y; + add_header Cache-Control "public, max-age=31536000"; + } + + # Handle React routing - direct all requests to index.html + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /socket.io/ { + proxy_pass http://backend:8000/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # Error pages + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/frontend/package.json b/Deploy/Containerization/docker_compose/frontend/package.json new file mode 100644 index 0000000..966afb9 --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "vite_config-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^6.14.1", + "socket.io-client": "^4.7.1", + "axios": "^1.6.2" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.21.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "vite": "^6.2.0" + } +} \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/frontend/public/assets/academy.svg b/Deploy/Containerization/docker_compose/frontend/public/assets/academy.svg new file mode 100644 index 0000000..c5ee497 --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/public/assets/academy.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Deploy/Containerization/docker_compose/frontend/public/assets/delete.svg b/Deploy/Containerization/docker_compose/frontend/public/assets/delete.svg new file mode 100644 index 0000000..484034d --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/public/assets/delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Deploy/Containerization/docker_compose/frontend/src/App.jsx b/Deploy/Containerization/docker_compose/frontend/src/App.jsx new file mode 100644 index 0000000..e42c54a --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/src/App.jsx @@ -0,0 +1,55 @@ +import {Routes, Route, Navigate} from 'react-router-dom'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Chat from './pages/Chat'; +import { useState, useEffect } from "react"; + +function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (token) + setIsAuthenticated(true); + }, []); + + return ( +
+ + setIsAuthenticated(true)} /> + ) : ( + + ) + } + /> + setIsAuthenticated(true)} /> + ) : ( + + ) + } + /> + setIsAuthenticated(false)} /> + ) : ( + + ) + } + /> + } /> + +
+ ); +} + +export default App; diff --git a/Deploy/Containerization/docker_compose/frontend/src/index.css b/Deploy/Containerization/docker_compose/frontend/src/index.css new file mode 100644 index 0000000..a679ea2 --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/src/index.css @@ -0,0 +1,243 @@ +:root { + --primary-color: #646cff; + --primary-hover: #535bf2; + --background-color: #242424; + --text-color: rgba(255, 255, 255, 0.87); + --border-color: #3f3f3f; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color: var(--text-color); + background-color: var(--background-color); +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +input { + border-radius: 4px; + border: 1px solid var(--border-color); + padding: 0.6em 1.2em; + font-size: 1em; + font-family: inherit; + background-color: transparent; + color: var(--text-color); +} + +input:focus { + outline: none; + border-color: var(--primary-color); +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Disable number input arrows for Firefox */ +input[type="number"] { + -moz-appearance: textfield; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: var(--primary-color); + color: white; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + filter: brightness(0.85); +} + +button:disabled { + background-color: #666; + cursor: not-allowed; + opacity: 0.7; +} + +button:disabled:hover { + background-color: #666; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + width: 100%; +} + +/* Login styles */ +.login-form { + max-width: 400px; + margin: 2rem auto; + padding: 2rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: rgba(255, 255, 255, 0.05); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.login-form h2 { + margin-bottom: 1.5rem; + text-align: center; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-color); +} + +.form-footer { + margin-top: 1.5rem; + text-align: center; + font-size: 0.9em; +} + +.form-footer a { + color: var(--primary-color); + text-decoration: none; + margin-left: 0.5rem; +} + +.form-footer a:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +.error { + color: #ff4444; + margin-bottom: 1rem; + text-align: center; + padding: 0.5rem; + border-radius: 4px; + background-color: rgba(255, 68, 68, 0.1); + border: 1px solid rgba(255, 68, 68, 0.2); +} + +input:invalid { + border-color: #ff4444; +} + +input:valid { + border-color: var(--border-color); +} + +input:focus:invalid { + border-color: #ff4444; + box-shadow: 0 0 0 2px rgba(255, 68, 68, 0.2); +} + +input:focus:valid { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.2); +} + +.validation-errors { + list-style: none; + padding: 0.5rem; + margin: 0.5rem 0; + font-size: 0.85em; + color: #ff4444; + background-color: rgba(255, 68, 68, 0.1); + border-radius: 4px; +} + +.validation-errors li { + margin: 0.25rem 0; + padding-left: 1.5rem; + position: relative; +} + +.validation-errors li::before { + content: "•"; + position: absolute; + left: 0.5rem; + color: #ff4444; +} + +.validation-error { + color: #ff4444; + font-size: 0.85em; + margin-top: 0.5rem; + padding-left: 0.5rem; +} + +/* Chat styles */ +.chat-container { + max-width: 800px; + margin: 0 auto; + height: 100vh; + display: flex; + flex-direction: column; + padding: 1rem; +} + +.chat-header { + display: flex; + justify-content: flex-end; +} + +.logout-button { + background-color: #652d2b; + margin-bottom: 10px; +} + +.messages-container { + flex: 1; + overflow-y: auto; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 1rem; +} + +.message { + margin-bottom: 0.5rem; + padding: 0.5rem; + border-radius: 4px; +} + +.message:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.delete-button { + margin-left: 10px; + cursor: pointer; + width: 16px; + height: 16px; +} + +.message-form { + display: flex; + gap: 1rem; +} + +.message-form input { + flex: 1; +} \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/frontend/src/main.jsx b/Deploy/Containerization/docker_compose/frontend/src/main.jsx new file mode 100644 index 0000000..ac95f8e --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/src/main.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import {BrowserRouter} from 'react-router-dom' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/frontend/src/pages/Chat.jsx b/Deploy/Containerization/docker_compose/frontend/src/pages/Chat.jsx new file mode 100644 index 0000000..a306a5d --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/src/pages/Chat.jsx @@ -0,0 +1,114 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import io from 'socket.io-client'; + +function Chat({ onLogout }) { + + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [socket, setSocket] = useState(null); + + const handleLogout = () => { + socket?.close(); + localStorage.removeItem('token'); + onLogout(); + }; + + useEffect(() => { + const fetchMessages = async () => { + try { + const token = localStorage.getItem('token'); + const response = await axios.get('/api/messages', { + headers: { Authorization: `Bearer ${token}` } + }); + setMessages(response.data); + } catch (error) { + console.error('Failed to fetch messages:', error); + } + }; + + if(! socket){ + const newSocket = io('/', { + auth: { + token: localStorage.getItem('token') + } + }); + + newSocket.on('error', (error) => { + console.error('Socket error:', error); + }); + + newSocket.on('message', (message) => { + setMessages(prev => [...prev, message]); + }); + + newSocket.on('messageDeleted', (data) => { + setMessages(prev => prev.filter(message => message.id !== data.messageId)); + }); + + setSocket(newSocket); + } + + fetchMessages().then(() => console.log('Successfully fetched messages!')); + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + + // does not allow sending empty messages: + if (!newMessage.trim()) return; + + try { + socket?.emit('message', { content: newMessage }); + setNewMessage(''); + } catch (error) { + console.error('Failed to send message:', error); + } + }; + + const handleDelete = async (messageId) => { + try { + socket?.emit('deleteMessage', { messageId }); + } catch (error) { + console.error('Failed to delete message:', error); + } + }; + + return ( +
+
+ +
+
+ {messages.map((message) => ( +
+ {message.username}: + {message.content} + Delete handleDelete(message.id)} + className="delete-button" + /> +
+ ))} +
+
+ setNewMessage(e.target.value)} + placeholder="Type a message..." + /> + +
+
+ ); +} + +export default Chat; diff --git a/Deploy/Containerization/docker_compose/frontend/src/pages/Login.jsx b/Deploy/Containerization/docker_compose/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..d228f20 --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/src/pages/Login.jsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import axios from 'axios'; + +function Login({ onLogin }) { + const [formData, setFormData] = useState({ + username: '', + password: '', + }); + const [error, setError] = useState(''); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + try { + const response = await axios.post('/api/auth/login', formData); + localStorage.setItem('token', response.data.token); + onLogin(); + } catch (err) { + setError(err.response?.data?.message || 'Login failed'); + } + }; + + return ( +
+
+

Login to Chat

+ {error &&
{error}
} +
+
+ + +
+
+ + +
+ +
+
+
+ ); +} + +export default Login; diff --git a/Deploy/Containerization/docker_compose/frontend/src/pages/Register.jsx b/Deploy/Containerization/docker_compose/frontend/src/pages/Register.jsx new file mode 100644 index 0000000..49bc2d9 --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/src/pages/Register.jsx @@ -0,0 +1,89 @@ +import {useState} from "react"; +import axios from 'axios'; + +function Register({onLogin}) { + const [error, setError] = useState(''); + const [formData, setFormData] = useState({ + username: '', + password: '', + confirmPassword: '' + }); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); // prevents a default form submission behavior + setError(''); // clear the error message + + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match'); + return; + } + + try { + const response = await axios.post('/api/auth/register', { + username: formData.username, + password: formData.password + }); + + localStorage.setItem('token', response.data.token); + + onLogin(); + } catch (err) { + setError(err.response?.data?.message || 'Registration failed'); + } + }; + + return (
+
+

Register for Chat

+ {error &&
{error}
} +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
); +} + +export default Register; diff --git a/Deploy/Containerization/docker_compose/frontend/vite.config.js b/Deploy/Containerization/docker_compose/frontend/vite.config.js new file mode 100644 index 0000000..24965cc --- /dev/null +++ b/Deploy/Containerization/docker_compose/frontend/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// Get backend URL from the environment variable or use default for local run +const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + strictPort: true, + host: '0.0.0.0', // Allow connections from outside the container + proxy: { + '/api': { + target: backendUrl, + changeOrigin: true, + }, + '/socket.io': { + target: backendUrl, + changeOrigin: true, + ws: true, + }, + }, + }, +}) \ No newline at end of file diff --git a/Deploy/Containerization/docker_compose/images/compose_up.png b/Deploy/Containerization/docker_compose/images/compose_up.png new file mode 100644 index 0000000..1c461e5 Binary files /dev/null and b/Deploy/Containerization/docker_compose/images/compose_up.png differ diff --git a/Deploy/Containerization/docker_compose/images/compose_up_dark.png b/Deploy/Containerization/docker_compose/images/compose_up_dark.png new file mode 100644 index 0000000..b7545fd Binary files /dev/null and b/Deploy/Containerization/docker_compose/images/compose_up_dark.png differ diff --git a/Deploy/Containerization/docker_compose/images/runAll.svg b/Deploy/Containerization/docker_compose/images/runAll.svg new file mode 100644 index 0000000..cc8299d --- /dev/null +++ b/Deploy/Containerization/docker_compose/images/runAll.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Deploy/Containerization/docker_compose/images/runAll_dark.svg b/Deploy/Containerization/docker_compose/images/runAll_dark.svg new file mode 100644 index 0000000..03616d3 --- /dev/null +++ b/Deploy/Containerization/docker_compose/images/runAll_dark.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Deploy/Containerization/docker_compose/task-info.yaml b/Deploy/Containerization/docker_compose/task-info.yaml new file mode 100644 index 0000000..8045fd2 --- /dev/null +++ b/Deploy/Containerization/docker_compose/task-info.yaml @@ -0,0 +1,92 @@ +type: theory +custom_name: Docker Compose +files: + - name: docker-compose.yaml + visible: true + - name: backend/src/data/dbConfig.js + visible: true + - name: backend/src/data/dataServices.js + visible: true + - name: backend/src/routes/auth.js + visible: true + - name: backend/src/routes/messages.js + visible: true + - name: backend/src/index.js + visible: true + - name: backend/src/socket.js + visible: true + - name: backend/src/middleware/auth.js + visible: true + - name: backend/data/database.sqlite + visible: true + is_binary: true + - name: backend/scripts/generateSecret.js + visible: true + - name: backend/__tests__/auth.test.js + visible: true + - name: backend/__tests__/socket.test.js + visible: true + - name: backend/__tests__/messages.test.js + visible: true + - name: backend/__tests__/dataServices.test.js + visible: true + - name: backend/Dockerfile + visible: true + - name: backend/package.json + visible: true + - name: backend/jest.setup.js + visible: true + - name: frontend/src/pages/Chat.jsx + visible: true + - name: frontend/src/pages/Login.jsx + visible: true + - name: frontend/src/pages/Register.jsx + visible: true + - name: frontend/src/App.jsx + visible: true + - name: frontend/src/main.jsx + visible: true + - name: frontend/src/index.css + visible: true + - name: frontend/dist/assets/main-W8GGl4oE.js + visible: true + - name: frontend/dist/assets/main-CMKn4ETQ.css + visible: true + - name: frontend/dist/assets/academy-D_4kBv2-.svg + visible: true + - name: frontend/dist/index.html + visible: true + - name: frontend/public/assets/delete.svg + visible: true + - name: frontend/public/assets/academy.svg + visible: true + - name: frontend/__tests__/chat_test.jsx + visible: true + - name: frontend/__tests__/login_test.jsx + visible: true + - name: frontend/__tests__/token_test.jsx + visible: true + - name: frontend/__tests__/register_test.jsx + visible: true + - name: frontend/Dockerfile + visible: true + - name: frontend/index.html + visible: true + - name: frontend/nginx.conf + visible: true + - name: frontend/package.json + visible: true + - name: frontend/vite.config.js + visible: true + - name: .env + visible: true + - name: images/runAll.svg + visible: false + - name: images/runAll_dark.svg + visible: false + - name: images/compose_up.png + visible: false + is_binary: true + - name: images/compose_up_dark.png + visible: false + is_binary: true diff --git a/Deploy/Containerization/docker_compose/task.md b/Deploy/Containerization/docker_compose/task.md new file mode 100644 index 0000000..f220c06 --- /dev/null +++ b/Deploy/Containerization/docker_compose/task.md @@ -0,0 +1,56 @@ +So, we are all set to run both containers with our services. +To simplify their management, we will use [Docker Compose](https://docs.docker.com/compose/). + +**Docker Compose** is a tool that allows us to define the deployment of multiple containers. +It also enables targeting each container by its service name. +All we need to do is create a [docker-compose.yaml][docker-compose.yaml] file in our main directory. + +### docker-compose.yaml +This file defines two services, with each service having its own container to be built and run. +Let’s look at the most important sections used to describe the services. + +- `build`: Specifies the path and name of the Dockerfile used to build the container's image. +- `environment`: Lists the environment variables that should be set inside the container. +- `env_file`: Allows environment variables to be loaded from a file without specifying their values here. +> **Use this approach for secret keys like `JWT_SECRET`.** +> **Also, remember to generate a unique `JWT_SECRET` using the `backend/scripts/generateSecret.js` script.** +- `volumes`: Allows creating persistent storage where data is retained even if the container restarts. + For example, the volume `chat-db-data` will store the contents of the `'/app/data'` directory inside the backend container. +- `healthcheck`: Defines a command that verifies the health of the service by checking its successful execution. +- `networks`: Ensures that our containers are connected via their own private internal network. + To access one container from another within this network, you only need to use the service name. + That’s why we can use a URL like http://backend:8000 inside the frontend container. +- `ports`: Maps a port on your machine to a port inside the container in the format `Host_Port:Container_Port`. + +Note that our application is now only accessible via the frontend. +The frontend-backend communication takes place through the internal network. + +You can find more information about Docker Compose in the [official documentation](https://docs.docker.com/compose/). + +### Launch the application +To launch both services from the terminal, use the following command: +```shell +docker compose up -d +``` +To stop the services, run: +```shell +docker compose down +``` + +To launch the services in your IDE, click the ![](images/runAll.svg) button +next to `services` in the `docker-compose.yaml` file. +You can view all relevant information about the launched services in the [Services](tool_window://Services) tool window: + +
+Services tool window +
+ +Now you can access the application at http://localhost:3000. + + + +[docker-compose.yaml]: course://Deploy/Containerization/docker_compose/docker-compose.yaml diff --git a/Deploy/Containerization/docker_intro/images/add.svg b/Deploy/Containerization/docker_intro/images/add.svg new file mode 100644 index 0000000..57eaaf5 --- /dev/null +++ b/Deploy/Containerization/docker_intro/images/add.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Deploy/Containerization/docker_intro/images/add_dark.svg b/Deploy/Containerization/docker_intro/images/add_dark.svg new file mode 100644 index 0000000..397e932 --- /dev/null +++ b/Deploy/Containerization/docker_intro/images/add_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Deploy/Containerization/docker_intro/images/docker.png b/Deploy/Containerization/docker_intro/images/docker.png new file mode 100644 index 0000000..9b9610c Binary files /dev/null and b/Deploy/Containerization/docker_intro/images/docker.png differ diff --git a/Deploy/Containerization/docker_intro/images/edit.svg b/Deploy/Containerization/docker_intro/images/edit.svg new file mode 100644 index 0000000..bd3c0a3 --- /dev/null +++ b/Deploy/Containerization/docker_intro/images/edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Deploy/Containerization/docker_intro/images/edit_dark.svg b/Deploy/Containerization/docker_intro/images/edit_dark.svg new file mode 100644 index 0000000..e75126d --- /dev/null +++ b/Deploy/Containerization/docker_intro/images/edit_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Deploy/Containerization/docker_intro/images/run.svg b/Deploy/Containerization/docker_intro/images/run.svg new file mode 100644 index 0000000..dd11f50 --- /dev/null +++ b/Deploy/Containerization/docker_intro/images/run.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Deploy/Containerization/docker_intro/images/run_dark.svg b/Deploy/Containerization/docker_intro/images/run_dark.svg new file mode 100644 index 0000000..0c199c7 --- /dev/null +++ b/Deploy/Containerization/docker_intro/images/run_dark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Deploy/Containerization/docker_intro/images/services.png b/Deploy/Containerization/docker_intro/images/services.png new file mode 100644 index 0000000..e2e5335 Binary files /dev/null and b/Deploy/Containerization/docker_intro/images/services.png differ diff --git a/Deploy/Containerization/docker_intro/images/services_dark.png b/Deploy/Containerization/docker_intro/images/services_dark.png new file mode 100644 index 0000000..b127b94 Binary files /dev/null and b/Deploy/Containerization/docker_intro/images/services_dark.png differ diff --git a/Deploy/Containerization/docker_intro/images/settings_docker.png b/Deploy/Containerization/docker_intro/images/settings_docker.png new file mode 100644 index 0000000..1410567 Binary files /dev/null and b/Deploy/Containerization/docker_intro/images/settings_docker.png differ diff --git a/Deploy/Containerization/docker_intro/images/settings_docker_dark.png b/Deploy/Containerization/docker_intro/images/settings_docker_dark.png new file mode 100644 index 0000000..90920a7 Binary files /dev/null and b/Deploy/Containerization/docker_intro/images/settings_docker_dark.png differ diff --git a/Deploy/Containerization/docker_intro/task-info.yaml b/Deploy/Containerization/docker_intro/task-info.yaml new file mode 100644 index 0000000..df46f6b --- /dev/null +++ b/Deploy/Containerization/docker_intro/task-info.yaml @@ -0,0 +1,32 @@ +type: theory +custom_name: Docker intro +files: + - name: task.js + visible: true + - name: images/run.svg + visible: false + - name: images/edit_dark.svg + visible: false + - name: images/add_dark.svg + visible: false + - name: images/services.png + visible: false + is_binary: true + - name: images/run_dark.svg + visible: false + - name: images/docker.png + visible: false + is_binary: true + - name: images/services_dark.png + visible: false + is_binary: true + - name: images/edit.svg + visible: false + - name: images/settings_docker_dark.png + visible: false + is_binary: true + - name: images/settings_docker.png + visible: false + is_binary: true + - name: images/add.svg + visible: false diff --git a/Deploy/Containerization/docker_intro/task.js b/Deploy/Containerization/docker_intro/task.js new file mode 100644 index 0000000..e69de29 diff --git a/Deploy/Containerization/docker_intro/task.md b/Deploy/Containerization/docker_intro/task.md new file mode 100644 index 0000000..19508d2 --- /dev/null +++ b/Deploy/Containerization/docker_intro/task.md @@ -0,0 +1,38 @@ +Docker containers provide code isolation, independence, and portability. Docker is essentially needed +if you intend to deploy your application to production. Docker containers are created with fully prescribed +dependencies with which they can be created. These dependencies that are stored, along with the application code, +in the container’s **image**. + +A **Dockerfile** is essentially a set of instructions for building a container image, which serves as a blueprint from which your container runs. + +In our project, we will use separate containers for the backend and frontend code. + +You can read more about Docker in the [Docker documentation](https://docs.docker.com/). + +### Docker support in JetBrains IDEs +JetBrains IDEs allow you to manage Docker containers using a graphical interface with just a few simple steps to set up. + +### 1. Install and run Docker +For detailed instructions, refer to the [Docker installation guide](https://docs.docker.com/engine/install/) for your specific operating system. + +### 2. Configure the Docker daemon connection settings + +1. Open the IDE settings (you can use the shortcut &shortcut:ShowSettings;) and select **Build, Execution, Deployment | Docker**. +2. Click ![](images/add.svg) to add a Docker configuration and specify how to connect to the Docker daemon. + The connection settings will depend on your Docker version and operating system. For more information, see the [Docker configuration guide](https://www.jetbrains.com/help/pycharm/settings-docker.html). + If everything is set up correctly, you should see the **Connection successful** message at the bottom of the dialog. + +![](images/settings_docker.png) + +### 3. Connect to the Docker daemon +The configured Docker connection will appear in the Services tool window (**View | Tool Windows | Services** or use the &shortcut:ActivateServicesToolWindow;). +Select the Docker node ![](images/docker.png) and click ![](images/run.svg), or select **Connect** from the context menu. Once connected, it should look like this: +
Run MyRunConfig
+ +To edit the Docker connection settings, select the Docker node and click ![](images/edit.svg) on the toolbar, or select **Edit Configuration** from the context menu. + + diff --git a/Deploy/Containerization/frontend_dockerfile/.env b/Deploy/Containerization/frontend_dockerfile/.env new file mode 100644 index 0000000..ba848e2 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/.env @@ -0,0 +1 @@ +JWT_SECRET=testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestte== \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/Dockerfile b/Deploy/Containerization/frontend_dockerfile/backend/Dockerfile new file mode 100644 index 0000000..dc46996 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +EXPOSE 8000 + +CMD ["npm", "start"] diff --git a/Deploy/Containerization/frontend_dockerfile/backend/__tests__/auth.test.js b/Deploy/Containerization/frontend_dockerfile/backend/__tests__/auth.test.js new file mode 100644 index 0000000..ac99e2e --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/__tests__/auth.test.js @@ -0,0 +1,107 @@ +import request from 'supertest'; +import { httpServer } from '../src/index.js'; +import {dbReset} from "../src/data/dataServices.js"; + +describe('Authentication API', () => { + + afterAll((done) => { + httpServer.close(done); + }); + + describe('POST /api/auth/register', () => { + beforeEach(async() => { + await dbReset(); + + }); + + it('should register a new user successfully', async () => { + const response = await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty('username', 'testuser'); + }); + + it('should not allow duplicate usernames', async () => { + // Register first user + await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + // Try to register the same username + const response = await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'differentpassword' + }) + .timeout(4000); + + expect(response.status).toBe(409); + expect(response.body).toHaveProperty('message', 'Username already exists'); + }); + }); + + describe('POST /api/auth/login', () => { + beforeAll(async () => { + await dbReset(); + // Create a test user before login test + await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + }); + + it('should log in successfully with correct credentials', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('username', 'testuser'); + expect(response.body).toHaveProperty('token'); + }); + + it('should fail with incorrect password', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'wrongpassword' + }) + .timeout(4000); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', 'Invalid username or password'); + }); + + it('should fail with non-existent username', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'nonexistent', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', 'Invalid username or password'); + }); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/__tests__/dataServices.test.js b/Deploy/Containerization/frontend_dockerfile/backend/__tests__/dataServices.test.js new file mode 100644 index 0000000..569ed5b --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/__tests__/dataServices.test.js @@ -0,0 +1,80 @@ +import {dbReset, userService, messageService} from '../src/data/dataServices.js'; + +describe('Test database-backed data layer', () => { + beforeEach(async () => { + await dbReset(); + }); + + describe('Test userService', () => { + it('Test new user creation', async () => { + const newUser = await userService.createUser('Tom', 'password123'); + expect(newUser.username).toBe('Tom'); + }); + + it('Test existing user creation', async () => { + await userService.createUser('John', '1234567890'); + + await expect(userService.createUser('John', 'newPassword')) + .rejects + .toThrow('Username already exists'); + }); + + it('Test get existing user', async () => { + const existingUser = await userService.createUser('Alice', 'mypassword'); + const fetchedUser = await userService.getUser('Alice'); + expect(fetchedUser.username).toEqual(existingUser.username); + }); + + it('Test get non-existing user', async () => { + const nonExistentUser = await userService.getUser('NonExistentUser'); + expect(nonExistentUser).toBeUndefined(); + }); + }); + + describe('Test messageService', () => { + it('Test add new message', async () => { + const message = await messageService.addMessage('Alice', 'Test message'); + expect(message.username).toBe('Alice'); + expect(message.content).toBe('Test message'); + expect(message.id).toBeDefined(); + }); + + it('Test get messages', async () => { + await messageService.addMessage('Tom', 'Message 1'); + await messageService.addMessage('John', 'Message 2'); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(2); + expect(messages[0].content).toBe('Message 1'); + expect(messages[1].content).toBe('Message 2'); + }); + + xit('Test delete the only message', async () => { + const newMessage = await messageService.addMessage('Alice', 'Single message'); + const result = await messageService.deleteMessage(newMessage.id); + expect(result).toBe(true); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(0); + }); + + xit('Test delete message with others', async () => { + await messageService.addMessage('Alice', 'Message A'); + const messageToDelete = await messageService.addMessage('Alice', 'Message B'); + await messageService.addMessage('Alice', 'Message C'); + + const result = await messageService.deleteMessage(messageToDelete.id); + expect(result).toBe(true); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(2); + expect(messages[0].content).toBe('Message A'); + expect(messages[1].content).toBe('Message C'); + }); + + xit('Test delete non-existing message', async () => { + const result = await messageService.deleteMessage(999999); + expect(result).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/__tests__/messages.test.js b/Deploy/Containerization/frontend_dockerfile/backend/__tests__/messages.test.js new file mode 100644 index 0000000..996fd14 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/__tests__/messages.test.js @@ -0,0 +1,157 @@ +import request from 'supertest'; +import {httpServer} from '../src/index.js'; +import {dbReset} from '../src/data/dataServices.js'; + +describe('Messages API', () => { + let authToken; + const testUser = { + username: 'User', + password: '1234' + }; + beforeAll(() => { + }); + + afterAll((done) => { + httpServer.close(done); + }); + + beforeEach(async () => { + // Clear data + await dbReset(); + + // Register and login test user + const registerResponse = await request(httpServer) + .post('/api/auth/register') + .send(testUser); + + authToken = registerResponse.body.token; + }); + + describe('Messages API - Unauthorized Access', () => { + + it('should return 401 for POST /api/messages without a token', async () => { + const response = await request(httpServer) + .post('/api/messages') + .send({ content: 'Unauthorized message' }); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + + it('should return 401 for GET /api/messages without a token', async () => { + const response = await request(httpServer) + .get('/api/messages'); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + + it('should return 401 for DELETE /api/messages/:id without a token', async () => { + const response = await request(httpServer) + .delete('/api/messages/1'); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + }); + + + describe('POST /api/messages', () => { + it('should create a new message with correct structure', async () => { + const response = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: 'Hello, World!' }) + .timeout(4000); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + content: 'Hello, World!', + username: 'User', + id: expect.any(Number), + }); + }); + + it('should fail without content', async () => { + const response = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send() + .timeout(4000); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('message', 'Message content is required'); + }); + }); + + describe('GET /api/messages', () => { + it('should get empty message list initially', async () => { + const response = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + + it('should get messages', async () => { + // Add test messages + for (let i = 0; i < 3; i++) { + await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: `Test message ${i}` }) + .timeout(4000); + } + + const response = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(3); + expect(response.body[2].content).toBe('Test message 2'); + }); + }); + + describe('DELETE /api/messages/:id', () => { + xit('should delete an existing message', async () => { + // Create a message + const createResponse = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: 'Test message' }) + .timeout(4000); + + const messageId = createResponse.body.id; + + // Delete the message + const deleteResponse = await request(httpServer) + .delete(`/api/messages/${messageId}`) + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(deleteResponse.status).toBe(204); + + // Verify the message is deleted + const getResponse = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(getResponse.body).toHaveLength(0); + }); + + xit('should return 404 for non-existent message', async () => { + const response = await request(httpServer) + .delete('/api/messages/999999') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(404); + }); + + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/__tests__/socket.test.js b/Deploy/Containerization/frontend_dockerfile/backend/__tests__/socket.test.js new file mode 100644 index 0000000..c70dcbe --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/__tests__/socket.test.js @@ -0,0 +1,122 @@ +import {io as Client} from 'socket.io-client'; +import {httpServer} from '../src/index.js'; +import {messageService} from '../src/data/dataServices.js'; +import request from "supertest"; +import {dbReset} from "../src/data/dataServices.js"; + +describe('Socket.IO Chat', () => { + let clientSocket; + let authToken; + const testUser = { + username: 'User', + password: '1234' + }; + + beforeAll(async () => { + // Register and login test user + await dbReset(); + const registerResponse = await request(httpServer) + .post('/api/auth/register') + .send(testUser); + + authToken = registerResponse.body.token; + }) + + beforeEach((done) => { + clientSocket = new Client(`http://localhost:${httpServer.address().port}`, { + reconnection: false, + auth: { + token: authToken + } + }); + clientSocket.on('connect', done); + }); + + afterEach(() => { + clientSocket.close(); + }); + + afterAll((done) => { + httpServer.close(done); + }); + + + describe('Messaging', () => { + it('Should not allow unauthorized access', (done) => { + const noTokenSocket = new Client(`http://localhost:${httpServer.address().port}`, { + auth: {} + }); + + noTokenSocket.on('connect_error', (error) => { + try { + expect(error.message).toBe('Authentication token required'); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } finally { + clientSocket.close(); + } + }); + }); + + it('should broadcast messages to all clients', (done) => { + const testMessage = {username: 'User', content: 'Hello, WebSocket!'}; + + clientSocket.on('message', (message) => { + try { + expect(message).toMatchObject({ + content: 'Hello, WebSocket!', + username: 'User', + id: expect.any(Number), + }); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } + }); + + clientSocket.emit('message', testMessage); + }); + + it('should store message in database', (done) => { + const testMessage = {username: 'User', content: 'Test message for storage'}; + clientSocket.on('message', async (message) => { + try { + const storedMessages = await messageService.getMessages(); + expect(storedMessages.length).not.toBe(0); + expect(storedMessages.at(-1).content).toBe(testMessage.content); + expect(storedMessages.at(-1).username).toBe(testMessage.username); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } + }); + clientSocket.emit('message', testMessage); + }); + + xit('should delete message and broadcast deletion to all clients', (done) => { + const testMessage = {username: 'User', content: 'Temporary message'}; + + messageService.addMessage(testMessage.username, testMessage.content).then(async (addedMessage) => { + const messageIdToDelete = addedMessage.id; + clientSocket.on('messageDeleted', async (data) => { + try { + expect(data).toMatchObject({ + messageId: messageIdToDelete, + }); + // Check that the message no longer exists in the store + const messages = await messageService.getMessages(); + expect(messages.find(msg => msg.id === messageIdToDelete)).toBeUndefined(); + done(); // Mark test as successful if assertions pass + } catch (err) { + done(err); // Mark test as failed if assertions fail + } + }); + // Emit deleteMessage event + clientSocket.emit('deleteMessage', {messageId: messageIdToDelete}); + }); + + }); + + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/data/database.sqlite b/Deploy/Containerization/frontend_dockerfile/backend/data/database.sqlite new file mode 100644 index 0000000..17f59da Binary files /dev/null and b/Deploy/Containerization/frontend_dockerfile/backend/data/database.sqlite differ diff --git a/Deploy/Containerization/frontend_dockerfile/backend/jest.setup.js b/Deploy/Containerization/frontend_dockerfile/backend/jest.setup.js new file mode 100644 index 0000000..765cb5b --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/jest.setup.js @@ -0,0 +1,5 @@ +// Jest setup file +// This file sets up the test environment before running tests + +// Set NODE_ENV to 'test' to use the in-memory database +process.env.NODE_ENV = 'test'; \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/package.json b/Deploy/Containerization/frontend_dockerfile/backend/package.json new file mode 100644 index 0000000..2d15d0d --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "vite_config-backend", + "version": "1.0.0", + "main": "src/index.js", + "type": "module", + "scripts": { + "start": "node src/index.js", + "generate-secret": "node scripts/generateSecret.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "morgan": "^1.10.0", + "bcryptjs": "^2.4.3", + "socket.io": "^4.7.1", + "dotenv": "^16.3.1", + "jsonwebtoken": "^9.0.1", + "sequelize": "^6.32.1", + "sqlite3": "^5.1.6" + }, + "devDependencies": { + "jest": "^29.3.1", + "@types/jest": "^29.2.5", + "supertest": "^6.3.3", + "@types/supertest": "^6.0.3" + }, + "jest": { + "testEnvironment": "node", + "testMatch": ["/__tests__/**/*.test.js"], + "setupFiles": ["/jest.setup.js"] + } +} diff --git a/Deploy/Containerization/frontend_dockerfile/backend/scripts/generateSecret.js b/Deploy/Containerization/frontend_dockerfile/backend/scripts/generateSecret.js new file mode 100644 index 0000000..4b407ca --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/scripts/generateSecret.js @@ -0,0 +1,8 @@ +import crypto from 'crypto'; + +// Generate a secure random string of 64 bytes and convert it to base64 +const secret = crypto.randomBytes(64).toString('base64'); + +console.log('Generated JWT_SECRET: '); +console.log(secret); +console.log('\nMake sure to update the .env file with this value'); \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/src/data/dataServices.js b/Deploy/Containerization/frontend_dockerfile/backend/src/data/dataServices.js new file mode 100644 index 0000000..e4479b0 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/src/data/dataServices.js @@ -0,0 +1,84 @@ +import { DataTypes } from 'sequelize'; +import sequelize from './dbConfig.js'; + +const { Users, Messages } = await (async () => { + + // Define a Users table model + const Users = sequelize.define('Users', { + username: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + password: { + type: DataTypes.STRING, + allowNull: false + } + }); + + // Define a Messages table model + const Messages = sequelize.define('Messages', { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + allowNull: false + }, + content: { + type: DataTypes.TEXT, + allowNull: false + } + }); + + await sequelize.sync(); + return { Users, Messages }; +})(); + +export const userService = { + createUser: async (username, hashedPassword) => { + + if (await Users.findByPk(username)) { + throw new Error('Username already exists'); + } + await Users.create({ username, password: hashedPassword }); + return {username}; + }, + + getUser: async (username) => { + const user = await Users.findByPk(username); + return user ? user.get({ plain: true }) : undefined; + }, + +}; + +export const messageService = { + addMessage: async (username, content) => { + const message = await Messages.create({ + username, + content + }); + return message.get({ plain: true }); + }, + + getMessages: async () => { + return await Messages.findAll({raw: true}); + }, + + // Optional task + deleteMessage: async (messageId) => { + const deleted = await Messages.destroy({ + where: { id: messageId } + }); + return deleted > 0; + } +}; + +// It is used only for testing purposes: +export const dbReset = async () => { + // Delete all records in Messages and Users tables + await Messages.destroy({ where: {}, truncate: true }); + await Users.destroy({ where: {}, truncate: true }); +}; diff --git a/Deploy/Containerization/frontend_dockerfile/backend/src/data/dbConfig.js b/Deploy/Containerization/frontend_dockerfile/backend/src/data/dbConfig.js new file mode 100644 index 0000000..bc895be --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/src/data/dbConfig.js @@ -0,0 +1,22 @@ +// Database configuration +// This file provides configuration for both real and test databases + +import { Sequelize } from 'sequelize'; + +// Determine if we're in test mode +const isTest = process.env.NODE_ENV === 'test'; + +// Create the appropriate Sequelize instance based on the environment +const sequelize = isTest + ? new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', // Use in-memory SQLite for tests + logging: false + }) + : new Sequelize({ + dialect: 'sqlite', + storage: './data/database.sqlite', // Use file-based SQLite for production + logging: false + }); + +export default sequelize; \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/src/index.js b/Deploy/Containerization/frontend_dockerfile/backend/src/index.js new file mode 100644 index 0000000..3d420be --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/src/index.js @@ -0,0 +1,60 @@ +import express from 'express'; +import http from 'http'; +import cors from 'cors'; +import morgan from "morgan"; +import messageRoutes from './routes/messages.js'; +import authRoutes from './routes/auth.js'; +import { Server as SocketIO } from 'socket.io'; +import { initializeSocketIO } from './socket.js'; +import { authenticateRoute } from './middleware/auth.js'; + +const app = express(); +const httpServer = http.createServer(app); +const io = new SocketIO(httpServer); +initializeSocketIO(io); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Logging middleware +app.use(morgan('tiny')); + +// Adding message router +app.use('/api/messages', authenticateRoute, messageRoutes); +//app.use('/api/messages', messageRoutes); + +// Adding auth router +app.use('/api/auth', authRoutes); + +app.get('/health', (req, res) => { + const healthcheck = { + uptime: process.uptime(), // Server uptime in seconds + message: 'OK', + timestamp: Date.now() + }; + try { + res.status(200).json(healthcheck); // Respond with 200 OK and health data + } catch (error) { + healthcheck.message = error.message; + res.status(503).json(healthcheck); // Respond with 503 Service Unavailable on error + } +}); + +app.use((req, res) => { + res.status(404).type('text/plain').send('Page Not Found'); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ message: 'Something went wrong!' }); +}); + +const PORT = 8000; + +httpServer.listen(PORT, () => { + console.log(`Server is running at http://localhost:${PORT}/`); +}); + +export { httpServer, app }; diff --git a/Deploy/Containerization/frontend_dockerfile/backend/src/middleware/auth.js b/Deploy/Containerization/frontend_dockerfile/backend/src/middleware/auth.js new file mode 100644 index 0000000..1134b37 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/src/middleware/auth.js @@ -0,0 +1,62 @@ +import dotenv from 'dotenv'; +import jwt from 'jsonwebtoken'; +import { userService } from '../data/dataServices.js'; + +dotenv.config(); +const { JWT_SECRET } = process.env; + +if (!JWT_SECRET) { + console.error('JWT_SECRET is not defined in environment variables.'); + process.exit(1); +} + +export const generateToken = (username) => { + return jwt.sign({ username }, JWT_SECRET); +}; + +export const authenticateRoute = async (req, res, next) => { + const authHeader = req.headers['authorization']; + + // Typically, the `authorization` header has the format `"Bearer "` + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ message: 'Authentication token required' }); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await userService.getUser(decoded.username); + + if (!user) { + return res.status(401).json({ message: 'User not found' }); + } + + req.username = decoded.username; + next(); + } catch (err) { + return res.status(401).json({ message: 'Invalid token' }); + } +}; + +export const authenticateSocket = async (socket, next) => { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error('Authentication token required')); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await userService.getUser(decoded.username); + + if (!user) { + return next(new Error('User not found')); + } + + socket.username = decoded.username; + next(); + } catch (err) { + next(new Error('Invalid token')); + } +}; \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/src/routes/auth.js b/Deploy/Containerization/frontend_dockerfile/backend/src/routes/auth.js new file mode 100644 index 0000000..2ecd995 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/src/routes/auth.js @@ -0,0 +1,63 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import { userService } from '../data/dataServices.js'; +import { generateToken } from '../middleware/auth.js'; + +const router = express.Router(); + +// Register new user +router.post('/register', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await userService.createUser(username, hashedPassword); + + const token = generateToken(username); + + res.status(201).json({ token, username: user.username }); + } catch (error) { + if (error.message === 'Username already exists') { + return res.status(409).json({ message: error.message }); + } + return res.status(500).json({ message: 'Error creating user: ' + error.message }); + } +}); + +// Login user +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + const user = await userService.getUser(username); + + if (!user) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + // Compare password + const isValidPassword = await bcrypt.compare(password, user.password); + + if (!isValidPassword) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + const token = generateToken(username); + + res.json({ token, username: user.username }); + } catch (error) { + return res.status(500).json({ message: 'Error during login' }); + } +}); + +export default router; \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/src/routes/messages.js b/Deploy/Containerization/frontend_dockerfile/backend/src/routes/messages.js new file mode 100644 index 0000000..327a665 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/src/routes/messages.js @@ -0,0 +1,54 @@ +import express from 'express'; +import { messageService } from '../data/dataServices.js'; + +const router = express.Router(); + +// Get all messages +router.get('/', async (req, res) => { + try { + const messages = await messageService.getMessages(); + return res.json(messages); + } catch (error) { + return res.status(500).json({ message: 'Error fetching messages' }); + } +}); + +// Create a new message +router.post('/', async (req, res) => { + try { + /**/ + const content = req.body.content; + + /**/ + + if (!content) { + return res.status(400).json({ message: 'Message content is required' }); + } + + const message = await messageService.addMessage(req.username, content); + return res.status(201).json(message); + + } catch (error) { + return res.status(500).json({ message: 'Error creating message' }); + } +}); + +// Optional task +// Delete a message +router.delete('/:id', async (req, res) => { + try { + const messageId = req.params.id; + // Now messageId should be converted to the number + const deleted = await messageService.deleteMessage(Number(messageId)); + + if (!deleted) { + return res.status(404).json({ message: 'Message not found' }); + } + + return res.status(204).send(); + } catch (error) { + return res.status(500).json({ message: 'Error deleting message' }); + } +}); + +export default router; \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/backend/src/socket.js b/Deploy/Containerization/frontend_dockerfile/backend/src/socket.js new file mode 100644 index 0000000..accf35c --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/backend/src/socket.js @@ -0,0 +1,54 @@ +import { messageService } from './data/dataServices.js'; +import {authenticateSocket} from "./middleware/auth.js"; + +export const initializeSocketIO = (io) => { + + io.use(authenticateSocket); + + // Setup handlers for a new socket connection + io.on('connection', (socket) => { + console.log('User connected'); + + // Handle new messages + socket.on('message', async (data) => { + try { + const message = await messageService.addMessage(socket.username, data.content); + + io.emit('message', message); + } catch (error) { + socket.emit('error', { message: 'Error sending message' }); + } + }); + + // Handle message deletion + socket.on('deleteMessage', async (data) => { + try { + const deleted = await messageService.deleteMessage(data.messageId); + + if (!deleted) { + socket.emit('error', { message: 'Message not found' }); + return; + } + + io.emit('messageDeleted', { messageId: data.messageId }); + } catch (error) { + socket.emit('error', { message: 'Error deleting message' }); + } + }); + + // Handle user disconnection + socket.on('disconnect', () => { + console.log('User disconnected'); + }); + + // Handle errors + socket.on('error', (error) => { + console.error('Socket error:', error); + }); + }); + + // Handle server-side errors + io.on('error', (error) => { + console.error('Socket.IO error:', error); + }); +}; \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/Dockerfile b/Deploy/Containerization/frontend_dockerfile/frontend/Dockerfile new file mode 100644 index 0000000..53fe6f4 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +RUN npm run build + +# Production stage with Nginx +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 3000 + +# Nginx starts automatically in the foreground when the container starts diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/chat_test.jsx b/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/chat_test.jsx new file mode 100644 index 0000000..420fe9e --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/chat_test.jsx @@ -0,0 +1,155 @@ +import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import { jest } from '@jest/globals'; +import {act} from "react"; + +const socketMock = { + connect: jest.fn(), + disconnect: jest.fn(), + emit: jest.fn(), + on: jest.fn(), +}; + +jest.unstable_mockModule("socket.io-client", () => ({ + __esModule: true, + default: (...args) => { + socketMock.params.url = args[0] ?? null; + socketMock.params.opts = args[1] ?? null; + return socketMock; + }, +})); + +const chat_module = await import('../src/pages/Chat.jsx'); +const { default: Chat } = chat_module; + +describe('Chat tests', () => { + let onLogoutMock= jest.fn(); + + beforeEach(() => { + onLogoutMock = jest.fn(); + socketMock.connect = jest.fn(); + socketMock.disconnect = jest.fn(); + socketMock.emit = jest.fn(); + socketMock.on = jest.fn(); + socketMock.params = {}; + jest.clearAllMocks(); + localStorage.clear(); + localStorage.setItem('token', 'mockToken'); + }); + + it('creating a new WebSocket connection', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + render(); + await waitFor(() => { + expect(socketMock.params.url).toBe('/'); + expect(socketMock.params.opts).toStrictEqual({ auth: { token: "mockToken" } }); + }, { timeout: 1000 }); // Timeout of 1 second + + }) + + it('sending a new message', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + + render(); + + const inputField = screen.getByPlaceholderText('Type a message...'); + fireEvent.change(inputField, { target: { value: 'New test message' } }); + + const sendButton = screen.getByText('Send'); + fireEvent.click(sendButton); + + await waitFor(() => { + expect(socketMock.emit).toHaveBeenCalledWith('message', + { content: 'New test message' }, + ); + }, { timeout: 1000 }); // Timeout of 1 second + + expect(inputField.value).toBe(''); + }); + + it('displays new messages received via WebSocket', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' }, + ]; + + // Mock Axios `get` response for initial messages + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + + render(); + + // Check that initial mock messages are displayed + await screen.findByText('First message'); + expect(screen.getByText('Second message')).toBeInTheDocument(); + + // Simulate receiving a new "message" event via the WebSocket + const newMessage = { id: 3, username: 'User3', content: 'New incoming message' }; + const messageCallback = socketMock.on.mock.calls.find(call => call[0] === 'message')[1]; + act(() => { + messageCallback(newMessage); + }); + + expect(await screen.getByText('User3:')).toBeInTheDocument(); + expect(screen.getByText('New incoming message')).toBeInTheDocument(); + }); + + it('deletes a message when delete button is clicked', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + + render(); + + // Wait until the mock messages are displayed + await screen.findByText('First message'); + expect(screen.getByText('Second message')).toBeInTheDocument(); + + // Locate and click the delete button for the first message + const deleteButton = screen.getAllByAltText('Delete')[0]; + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(socketMock.emit).toHaveBeenCalledWith('deleteMessage', { messageId: 1 }); + }); + }); + + it('removes a message from the list when messageDeleted event is received', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + render(); + + // Wait until the mock messages are displayed + await screen.findByText('First message'); + expect(screen.getByText('Second message')).toBeInTheDocument(); + + // Simulate receiving a "messageDeleted" event + const messageDeletedCallback = socketMock.on.mock.calls.find(call => call[0] === 'messageDeleted')[1]; + act(() => { + messageDeletedCallback({ messageId: 1 }); + }); + + // Verify that the first message is removed + expect(screen.queryByText('First message')).not.toBeInTheDocument(); + expect(screen.getByText('Second message')).toBeInTheDocument(); + }); + + +}); diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/login_test.jsx b/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/login_test.jsx new file mode 100644 index 0000000..3cb8e1f --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/login_test.jsx @@ -0,0 +1,42 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import Login from '../src/pages/Login'; +import { jest } from '@jest/globals'; + +describe('Login component tests', () => { + let onLoginMock= jest.fn(); + + beforeEach(() => { + onLoginMock = jest.fn(); + jest.clearAllMocks(); + }); + + it('calls the backend API and logs in successfully', async () => { + axios.post = jest.fn().mockResolvedValue({ + data: { token: 'mockToken' }, + }); + + let {container} = render(); + + fireEvent.change(screen.getByLabelText('Username'), { + target: { value: 'user' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'password123' }, + }); + + console.log(container.innerHTML) + + fireEvent.click(screen.getByText('Login')); + + expect(axios.post).toHaveBeenCalledWith('/api/auth/login', { + username: 'user', + password: 'password123', + }); + await screen.findByText('Login to Chat'); // Wait for any potential render update + + expect(localStorage.getItem('token')).toBe('mockToken'); + expect(onLoginMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/register_test.jsx b/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/register_test.jsx new file mode 100644 index 0000000..81622c5 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/register_test.jsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import Register from '../src/pages/Register'; +import { jest } from '@jest/globals'; + + +describe('Register component tests', () => { + let onLoginMock= jest.fn(); + + beforeEach(() => { + onLoginMock = jest.fn(); + }); + + it('displays an error when passwords do not match', async () => { + render(); + + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'user' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password456' } }); + + fireEvent.click(screen.getByText('Register')); + + const errorMessage = await screen.findByText('Passwords do not match'); + + expect(errorMessage).toBeInTheDocument(); + }); + + it('calls the backend API on successful registration', async () => { + axios.post = jest.fn().mockResolvedValue({ data: { token: 'mockToken' } }); + + render(); + + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'user' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password123' } }); + + fireEvent.click(screen.getByText('Register')); + + await screen.findByText(/Register for Chat/i); // Wait for any re-render + + expect(axios.post).toHaveBeenCalledWith('/api/auth/register', { + username: 'user', + password: 'password123', + }); + + expect(localStorage.getItem('token')).toBe('mockToken'); + expect(onLoginMock).toHaveBeenCalledTimes(1); + }); + +}); \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/token_test.jsx b/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/token_test.jsx new file mode 100644 index 0000000..a9cbb9a --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/__tests__/token_test.jsx @@ -0,0 +1,50 @@ +import {render, screen, fireEvent} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import App from '../src/App'; +import { jest } from '@jest/globals'; +import {BrowserRouter} from "react-router-dom"; + +const renderWithRouter = (ui, { route = '/' } = {}) => { + window.history.pushState({}, 'Test Page', route); + return render({ui}); +}; + +describe('Token management tests', () => { + + beforeEach(() => { + localStorage.clear(); // Clear localStorage to avoid persistence issues across tests + }); + + it('App verifies token presence in localStorage and updates authentication state', () => { + // Simulate token existence in localStorage + localStorage.setItem('token', 'mockToken'); + + renderWithRouter(, { route: '/login' }); + + expect(localStorage.getItem('token')).toBe('mockToken'); + expect(screen.getByText('Chat will be here.')).toBeInTheDocument(); + }); + + it('App handles absence of token: updates authentication state to false', () => { + + renderWithRouter(, { route: '/login' }); + + expect(localStorage.getItem('token')).toBe(null); + expect(screen.getByText('Login to Chat')).toBeInTheDocument(); + }); + + it('Chat removes token from localStorage upon logout', () => { + const removeItemSpy = jest.spyOn(localStorage, 'removeItem'); + + // Simulate token existence in localStorage + localStorage.setItem('token', 'mockToken'); + + renderWithRouter(, { route: '/chat' }); + + const logoutButton = screen.getByText('Logout'); + fireEvent.click(logoutButton); // Simulate logout + + expect(localStorage.getItem('token')).toBe(null); // Verify token is removed + expect(screen.getByText('Login to Chat')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/index.html b/Deploy/Containerization/frontend_dockerfile/frontend/index.html new file mode 100644 index 0000000..0ab318b --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Real-time Chat + + +
+ + + \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/nginx.conf b/Deploy/Containerization/frontend_dockerfile/frontend/nginx.conf new file mode 100644 index 0000000..75544fe --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/nginx.conf @@ -0,0 +1,53 @@ +server { + listen 3000; + + # Root directory where the built React app is located + root /usr/share/nginx/html; + index index.html; + + # Gzip compression for better performance + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Cache static assets + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ { + expires 1y; + add_header Cache-Control "public, max-age=31536000"; + } + + # Special location for /src/assets/ requests + location /src/assets/ { + alias /usr/share/nginx/html/assets/; + expires 1y; + add_header Cache-Control "public, max-age=31536000"; + } + + # Handle React routing - direct all requests to index.html + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /socket.io/ { + proxy_pass http://backend:8000/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # Error pages + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/package.json b/Deploy/Containerization/frontend_dockerfile/frontend/package.json new file mode 100644 index 0000000..966afb9 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "vite_config-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^6.14.1", + "socket.io-client": "^4.7.1", + "axios": "^1.6.2" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.21.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "vite": "^6.2.0" + } +} \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/public/assets/academy.svg b/Deploy/Containerization/frontend_dockerfile/frontend/public/assets/academy.svg new file mode 100644 index 0000000..c5ee497 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/public/assets/academy.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/public/assets/delete.svg b/Deploy/Containerization/frontend_dockerfile/frontend/public/assets/delete.svg new file mode 100644 index 0000000..484034d --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/public/assets/delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/src/App.jsx b/Deploy/Containerization/frontend_dockerfile/frontend/src/App.jsx new file mode 100644 index 0000000..e42c54a --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/src/App.jsx @@ -0,0 +1,55 @@ +import {Routes, Route, Navigate} from 'react-router-dom'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Chat from './pages/Chat'; +import { useState, useEffect } from "react"; + +function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (token) + setIsAuthenticated(true); + }, []); + + return ( +
+ + setIsAuthenticated(true)} /> + ) : ( + + ) + } + /> + setIsAuthenticated(true)} /> + ) : ( + + ) + } + /> + setIsAuthenticated(false)} /> + ) : ( + + ) + } + /> + } /> + +
+ ); +} + +export default App; diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/src/index.css b/Deploy/Containerization/frontend_dockerfile/frontend/src/index.css new file mode 100644 index 0000000..a679ea2 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/src/index.css @@ -0,0 +1,243 @@ +:root { + --primary-color: #646cff; + --primary-hover: #535bf2; + --background-color: #242424; + --text-color: rgba(255, 255, 255, 0.87); + --border-color: #3f3f3f; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color: var(--text-color); + background-color: var(--background-color); +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +input { + border-radius: 4px; + border: 1px solid var(--border-color); + padding: 0.6em 1.2em; + font-size: 1em; + font-family: inherit; + background-color: transparent; + color: var(--text-color); +} + +input:focus { + outline: none; + border-color: var(--primary-color); +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Disable number input arrows for Firefox */ +input[type="number"] { + -moz-appearance: textfield; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: var(--primary-color); + color: white; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + filter: brightness(0.85); +} + +button:disabled { + background-color: #666; + cursor: not-allowed; + opacity: 0.7; +} + +button:disabled:hover { + background-color: #666; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + width: 100%; +} + +/* Login styles */ +.login-form { + max-width: 400px; + margin: 2rem auto; + padding: 2rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: rgba(255, 255, 255, 0.05); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.login-form h2 { + margin-bottom: 1.5rem; + text-align: center; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-color); +} + +.form-footer { + margin-top: 1.5rem; + text-align: center; + font-size: 0.9em; +} + +.form-footer a { + color: var(--primary-color); + text-decoration: none; + margin-left: 0.5rem; +} + +.form-footer a:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +.error { + color: #ff4444; + margin-bottom: 1rem; + text-align: center; + padding: 0.5rem; + border-radius: 4px; + background-color: rgba(255, 68, 68, 0.1); + border: 1px solid rgba(255, 68, 68, 0.2); +} + +input:invalid { + border-color: #ff4444; +} + +input:valid { + border-color: var(--border-color); +} + +input:focus:invalid { + border-color: #ff4444; + box-shadow: 0 0 0 2px rgba(255, 68, 68, 0.2); +} + +input:focus:valid { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.2); +} + +.validation-errors { + list-style: none; + padding: 0.5rem; + margin: 0.5rem 0; + font-size: 0.85em; + color: #ff4444; + background-color: rgba(255, 68, 68, 0.1); + border-radius: 4px; +} + +.validation-errors li { + margin: 0.25rem 0; + padding-left: 1.5rem; + position: relative; +} + +.validation-errors li::before { + content: "•"; + position: absolute; + left: 0.5rem; + color: #ff4444; +} + +.validation-error { + color: #ff4444; + font-size: 0.85em; + margin-top: 0.5rem; + padding-left: 0.5rem; +} + +/* Chat styles */ +.chat-container { + max-width: 800px; + margin: 0 auto; + height: 100vh; + display: flex; + flex-direction: column; + padding: 1rem; +} + +.chat-header { + display: flex; + justify-content: flex-end; +} + +.logout-button { + background-color: #652d2b; + margin-bottom: 10px; +} + +.messages-container { + flex: 1; + overflow-y: auto; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 1rem; +} + +.message { + margin-bottom: 0.5rem; + padding: 0.5rem; + border-radius: 4px; +} + +.message:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.delete-button { + margin-left: 10px; + cursor: pointer; + width: 16px; + height: 16px; +} + +.message-form { + display: flex; + gap: 1rem; +} + +.message-form input { + flex: 1; +} \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/src/main.jsx b/Deploy/Containerization/frontend_dockerfile/frontend/src/main.jsx new file mode 100644 index 0000000..ac95f8e --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/src/main.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import {BrowserRouter} from 'react-router-dom' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Chat.jsx b/Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Chat.jsx new file mode 100644 index 0000000..a306a5d --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Chat.jsx @@ -0,0 +1,114 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import io from 'socket.io-client'; + +function Chat({ onLogout }) { + + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [socket, setSocket] = useState(null); + + const handleLogout = () => { + socket?.close(); + localStorage.removeItem('token'); + onLogout(); + }; + + useEffect(() => { + const fetchMessages = async () => { + try { + const token = localStorage.getItem('token'); + const response = await axios.get('/api/messages', { + headers: { Authorization: `Bearer ${token}` } + }); + setMessages(response.data); + } catch (error) { + console.error('Failed to fetch messages:', error); + } + }; + + if(! socket){ + const newSocket = io('/', { + auth: { + token: localStorage.getItem('token') + } + }); + + newSocket.on('error', (error) => { + console.error('Socket error:', error); + }); + + newSocket.on('message', (message) => { + setMessages(prev => [...prev, message]); + }); + + newSocket.on('messageDeleted', (data) => { + setMessages(prev => prev.filter(message => message.id !== data.messageId)); + }); + + setSocket(newSocket); + } + + fetchMessages().then(() => console.log('Successfully fetched messages!')); + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + + // does not allow sending empty messages: + if (!newMessage.trim()) return; + + try { + socket?.emit('message', { content: newMessage }); + setNewMessage(''); + } catch (error) { + console.error('Failed to send message:', error); + } + }; + + const handleDelete = async (messageId) => { + try { + socket?.emit('deleteMessage', { messageId }); + } catch (error) { + console.error('Failed to delete message:', error); + } + }; + + return ( +
+
+ +
+
+ {messages.map((message) => ( +
+ {message.username}: + {message.content} + Delete handleDelete(message.id)} + className="delete-button" + /> +
+ ))} +
+
+ setNewMessage(e.target.value)} + placeholder="Type a message..." + /> + +
+
+ ); +} + +export default Chat; diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Login.jsx b/Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..d228f20 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Login.jsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import axios from 'axios'; + +function Login({ onLogin }) { + const [formData, setFormData] = useState({ + username: '', + password: '', + }); + const [error, setError] = useState(''); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + try { + const response = await axios.post('/api/auth/login', formData); + localStorage.setItem('token', response.data.token); + onLogin(); + } catch (err) { + setError(err.response?.data?.message || 'Login failed'); + } + }; + + return ( +
+
+

Login to Chat

+ {error &&
{error}
} +
+
+ + +
+
+ + +
+ +
+
+
+ ); +} + +export default Login; diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Register.jsx b/Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Register.jsx new file mode 100644 index 0000000..49bc2d9 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Register.jsx @@ -0,0 +1,89 @@ +import {useState} from "react"; +import axios from 'axios'; + +function Register({onLogin}) { + const [error, setError] = useState(''); + const [formData, setFormData] = useState({ + username: '', + password: '', + confirmPassword: '' + }); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); // prevents a default form submission behavior + setError(''); // clear the error message + + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match'); + return; + } + + try { + const response = await axios.post('/api/auth/register', { + username: formData.username, + password: formData.password + }); + + localStorage.setItem('token', response.data.token); + + onLogin(); + } catch (err) { + setError(err.response?.data?.message || 'Registration failed'); + } + }; + + return (
+
+

Register for Chat

+ {error &&
{error}
} +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
); +} + +export default Register; diff --git a/Deploy/Containerization/frontend_dockerfile/frontend/vite.config.js b/Deploy/Containerization/frontend_dockerfile/frontend/vite.config.js new file mode 100644 index 0000000..24965cc --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/frontend/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// Get backend URL from the environment variable or use default for local run +const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + strictPort: true, + host: '0.0.0.0', // Allow connections from outside the container + proxy: { + '/api': { + target: backendUrl, + changeOrigin: true, + }, + '/socket.io': { + target: backendUrl, + changeOrigin: true, + ws: true, + }, + }, + }, +}) \ No newline at end of file diff --git a/Deploy/Containerization/frontend_dockerfile/task-info.yaml b/Deploy/Containerization/frontend_dockerfile/task-info.yaml new file mode 100644 index 0000000..0254c83 --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/task-info.yaml @@ -0,0 +1,80 @@ +type: theory +custom_name: Frontend Dockerfile +files: + - name: frontend/src/pages/Chat.jsx + visible: true + - name: backend/src/data/dbConfig.js + visible: true + - name: backend/src/data/dataServices.js + visible: true + - name: backend/src/routes/auth.js + visible: true + - name: backend/src/routes/messages.js + visible: true + - name: backend/src/index.js + visible: true + - name: backend/src/socket.js + visible: true + - name: backend/src/middleware/auth.js + visible: true + - name: backend/data/database.sqlite + visible: true + is_binary: true + - name: backend/scripts/generateSecret.js + visible: true + - name: backend/__tests__/auth.test.js + visible: true + - name: backend/__tests__/socket.test.js + visible: true + - name: backend/__tests__/messages.test.js + visible: true + - name: backend/__tests__/dataServices.test.js + visible: true + - name: backend/Dockerfile + visible: true + - name: backend/package.json + visible: true + - name: backend/jest.setup.js + visible: true + - name: .env + visible: true + - name: frontend/src/pages/Login.jsx + visible: true + - name: frontend/src/pages/Register.jsx + visible: true + - name: frontend/public/assets/delete.svg + visible: true + - name: frontend/public/assets/academy.svg + visible: true + - name: frontend/src/App.jsx + visible: true + - name: frontend/src/main.jsx + visible: true + - name: frontend/src/index.css + visible: true + - name: frontend/dist/assets/main-W8GGl4oE.js + visible: true + - name: frontend/dist/assets/main-CMKn4ETQ.css + visible: true + - name: frontend/dist/assets/academy-D_4kBv2-.svg + visible: true + - name: frontend/dist/index.html + visible: true + - name: frontend/__tests__/chat_test.jsx + visible: true + - name: frontend/__tests__/login_test.jsx + visible: true + - name: frontend/__tests__/token_test.jsx + visible: true + - name: frontend/__tests__/register_test.jsx + visible: true + - name: frontend/index.html + visible: true + - name: frontend/package.json + visible: true + - name: frontend/vite.config.js + visible: true + - name: frontend/Dockerfile + visible: true + - name: frontend/nginx.conf + visible: true diff --git a/Deploy/Containerization/frontend_dockerfile/task.md b/Deploy/Containerization/frontend_dockerfile/task.md new file mode 100644 index 0000000..2307abd --- /dev/null +++ b/Deploy/Containerization/frontend_dockerfile/task.md @@ -0,0 +1,56 @@ +Before we write the Dockerfile for the frontend, we have made a few changes +to the project to simplify our work later on. Please review them below. + +### Frontend changes +- Images (`academy.svg` and `delete.svg`) were moved to the `public/assets` folder. + This is the default resources folder used by a Vite project. +- Paths to the images in the [Chat.jsx][Chat] and [index.html][index] files were updated. +- A `build` script was added to the [package.json][package] file. +- Updates were made to [vite.config.js][vite.config] to retrieve `backendUrl` from the environment variables. +- A new [nginx.conf][nginx] file was created. This is the default configuration file for Nginx, where changes were made, such as specifying the frontend port and backend URLs. + +The built-in Vite web server is not designed for production use. +However, it can be used to build the project, which can then be hosted on +a full-featured web server like [Nginx](https://nginx.org/). + +You can read more about Nginx configuration in the [official documentation](https://nginx.org/en/docs/beginners_guide.html#conf_structure). + +### Dockerfile +The frontend [Dockerfile][Dockerfile] is slightly more complex than the backend one because it includes two stages: + +```dockerfile +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +RUN npm run build + +# Production stage with Nginx +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 3000 +``` + +- The build stage is similar to the backend process, but its build artifacts are used in the next stage. +- For the production stage, we use `nginx:alpine`, a base image with Nginx already installed. +- Then, we copy the built frontend from the build stage into the Nginx directory: `COPY --from=build /app/dist /usr/share/nginx/html`. +- Next, we copy the configuration file into the image: `COPY nginx.conf /etc/nginx/conf.d/default.conf`. +- Finally, we specify that the container will listen on port `3000` at runtime. + +You may have noticed that in [nginx.conf][nginx], URLs such as `http://backend:8000/api/` are used without explicitly defining what "backend" refers to. +In the next task, we’ll explain why this works and how to run both the backend and frontend together. + +[Chat]: course://Deploy/Containerization/frontend_dockerfile/frontend/src/pages/Chat.jsx +[index]: course://Deploy/Containerization/frontend_dockerfile/frontend/index.html +[package]: course://Deploy/Containerization/frontend_dockerfile/frontend/package.json +[vite.config]: course://Deploy/Containerization/frontend_dockerfile/frontend/vite.config.js +[nginx]: course://Deploy/Containerization/frontend_dockerfile/frontend/nginx.conf +[Dockerfile]: course://Deploy/Containerization/frontend_dockerfile/frontend/Dockerfile diff --git a/Deploy/Containerization/introduction/task-info.yaml b/Deploy/Containerization/introduction/task-info.yaml new file mode 100644 index 0000000..0cc460d --- /dev/null +++ b/Deploy/Containerization/introduction/task-info.yaml @@ -0,0 +1,5 @@ +type: theory +custom_name: Introduction +files: + - name: task.js + visible: true diff --git a/Deploy/Containerization/introduction/task.js b/Deploy/Containerization/introduction/task.js new file mode 100644 index 0000000..e69de29 diff --git a/Deploy/Containerization/introduction/task.md b/Deploy/Containerization/introduction/task.md new file mode 100644 index 0000000..3ae18a4 --- /dev/null +++ b/Deploy/Containerization/introduction/task.md @@ -0,0 +1,15 @@ +So far, we have been running our project locally, which has been sufficient for testing. + +In this lesson, we’ll discuss how to adapt our application +before deploying it to a server and making it accessible to users. + +While you could manually copy the application to a server, install all dependencies, +and run it there, this approach is unreliable. +For example, migrating the application to a different hosting service might require significant time. + +We can avoid these issues by deploying and running the application in a **Docker container**. + +From this lesson, you will learn how to: +- Write a Dockerfile. +- Create a Docker Compose file for the project. +- Run your application in Docker containers with a single command. diff --git a/Deploy/Containerization/lesson-info.yaml b/Deploy/Containerization/lesson-info.yaml new file mode 100644 index 0000000..2e8be32 --- /dev/null +++ b/Deploy/Containerization/lesson-info.yaml @@ -0,0 +1,7 @@ +content: + - introduction + - docker_intro + - backend_dockerfile + - frontend_dockerfile + - docker_compose + - summary diff --git a/Deploy/Containerization/summary/.env b/Deploy/Containerization/summary/.env new file mode 100644 index 0000000..ba848e2 --- /dev/null +++ b/Deploy/Containerization/summary/.env @@ -0,0 +1 @@ +JWT_SECRET=testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttesttestte== \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/Dockerfile b/Deploy/Containerization/summary/backend/Dockerfile new file mode 100644 index 0000000..dc46996 --- /dev/null +++ b/Deploy/Containerization/summary/backend/Dockerfile @@ -0,0 +1,11 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +EXPOSE 8000 + +CMD ["npm", "start"] diff --git a/Deploy/Containerization/summary/backend/__tests__/auth.test.js b/Deploy/Containerization/summary/backend/__tests__/auth.test.js new file mode 100644 index 0000000..ac99e2e --- /dev/null +++ b/Deploy/Containerization/summary/backend/__tests__/auth.test.js @@ -0,0 +1,107 @@ +import request from 'supertest'; +import { httpServer } from '../src/index.js'; +import {dbReset} from "../src/data/dataServices.js"; + +describe('Authentication API', () => { + + afterAll((done) => { + httpServer.close(done); + }); + + describe('POST /api/auth/register', () => { + beforeEach(async() => { + await dbReset(); + + }); + + it('should register a new user successfully', async () => { + const response = await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(201); + expect(response.body).toHaveProperty('username', 'testuser'); + }); + + it('should not allow duplicate usernames', async () => { + // Register first user + await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + // Try to register the same username + const response = await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'differentpassword' + }) + .timeout(4000); + + expect(response.status).toBe(409); + expect(response.body).toHaveProperty('message', 'Username already exists'); + }); + }); + + describe('POST /api/auth/login', () => { + beforeAll(async () => { + await dbReset(); + // Create a test user before login test + await request(httpServer) + .post('/api/auth/register') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + }); + + it('should log in successfully with correct credentials', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toHaveProperty('username', 'testuser'); + expect(response.body).toHaveProperty('token'); + }); + + it('should fail with incorrect password', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'testuser', + password: 'wrongpassword' + }) + .timeout(4000); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', 'Invalid username or password'); + }); + + it('should fail with non-existent username', async () => { + const response = await request(httpServer) + .post('/api/auth/login') + .send({ + username: 'nonexistent', + password: 'password123' + }) + .timeout(4000); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message', 'Invalid username or password'); + }); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/__tests__/dataServices.test.js b/Deploy/Containerization/summary/backend/__tests__/dataServices.test.js new file mode 100644 index 0000000..569ed5b --- /dev/null +++ b/Deploy/Containerization/summary/backend/__tests__/dataServices.test.js @@ -0,0 +1,80 @@ +import {dbReset, userService, messageService} from '../src/data/dataServices.js'; + +describe('Test database-backed data layer', () => { + beforeEach(async () => { + await dbReset(); + }); + + describe('Test userService', () => { + it('Test new user creation', async () => { + const newUser = await userService.createUser('Tom', 'password123'); + expect(newUser.username).toBe('Tom'); + }); + + it('Test existing user creation', async () => { + await userService.createUser('John', '1234567890'); + + await expect(userService.createUser('John', 'newPassword')) + .rejects + .toThrow('Username already exists'); + }); + + it('Test get existing user', async () => { + const existingUser = await userService.createUser('Alice', 'mypassword'); + const fetchedUser = await userService.getUser('Alice'); + expect(fetchedUser.username).toEqual(existingUser.username); + }); + + it('Test get non-existing user', async () => { + const nonExistentUser = await userService.getUser('NonExistentUser'); + expect(nonExistentUser).toBeUndefined(); + }); + }); + + describe('Test messageService', () => { + it('Test add new message', async () => { + const message = await messageService.addMessage('Alice', 'Test message'); + expect(message.username).toBe('Alice'); + expect(message.content).toBe('Test message'); + expect(message.id).toBeDefined(); + }); + + it('Test get messages', async () => { + await messageService.addMessage('Tom', 'Message 1'); + await messageService.addMessage('John', 'Message 2'); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(2); + expect(messages[0].content).toBe('Message 1'); + expect(messages[1].content).toBe('Message 2'); + }); + + xit('Test delete the only message', async () => { + const newMessage = await messageService.addMessage('Alice', 'Single message'); + const result = await messageService.deleteMessage(newMessage.id); + expect(result).toBe(true); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(0); + }); + + xit('Test delete message with others', async () => { + await messageService.addMessage('Alice', 'Message A'); + const messageToDelete = await messageService.addMessage('Alice', 'Message B'); + await messageService.addMessage('Alice', 'Message C'); + + const result = await messageService.deleteMessage(messageToDelete.id); + expect(result).toBe(true); + + const messages = await messageService.getMessages(); + expect(messages.length).toBe(2); + expect(messages[0].content).toBe('Message A'); + expect(messages[1].content).toBe('Message C'); + }); + + xit('Test delete non-existing message', async () => { + const result = await messageService.deleteMessage(999999); + expect(result).toBe(false); + }); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/__tests__/messages.test.js b/Deploy/Containerization/summary/backend/__tests__/messages.test.js new file mode 100644 index 0000000..996fd14 --- /dev/null +++ b/Deploy/Containerization/summary/backend/__tests__/messages.test.js @@ -0,0 +1,157 @@ +import request from 'supertest'; +import {httpServer} from '../src/index.js'; +import {dbReset} from '../src/data/dataServices.js'; + +describe('Messages API', () => { + let authToken; + const testUser = { + username: 'User', + password: '1234' + }; + beforeAll(() => { + }); + + afterAll((done) => { + httpServer.close(done); + }); + + beforeEach(async () => { + // Clear data + await dbReset(); + + // Register and login test user + const registerResponse = await request(httpServer) + .post('/api/auth/register') + .send(testUser); + + authToken = registerResponse.body.token; + }); + + describe('Messages API - Unauthorized Access', () => { + + it('should return 401 for POST /api/messages without a token', async () => { + const response = await request(httpServer) + .post('/api/messages') + .send({ content: 'Unauthorized message' }); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + + it('should return 401 for GET /api/messages without a token', async () => { + const response = await request(httpServer) + .get('/api/messages'); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + + it('should return 401 for DELETE /api/messages/:id without a token', async () => { + const response = await request(httpServer) + .delete('/api/messages/1'); + + expect(response.status).toBe(401); + expect(response.body).toHaveProperty('message'); + }); + }); + + + describe('POST /api/messages', () => { + it('should create a new message with correct structure', async () => { + const response = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: 'Hello, World!' }) + .timeout(4000); + + expect(response.status).toBe(201); + expect(response.body).toMatchObject({ + content: 'Hello, World!', + username: 'User', + id: expect.any(Number), + }); + }); + + it('should fail without content', async () => { + const response = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send() + .timeout(4000); + + expect(response.status).toBe(400); + expect(response.body).toHaveProperty('message', 'Message content is required'); + }); + }); + + describe('GET /api/messages', () => { + it('should get empty message list initially', async () => { + const response = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); + }); + + it('should get messages', async () => { + // Add test messages + for (let i = 0; i < 3; i++) { + await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: `Test message ${i}` }) + .timeout(4000); + } + + const response = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(3); + expect(response.body[2].content).toBe('Test message 2'); + }); + }); + + describe('DELETE /api/messages/:id', () => { + xit('should delete an existing message', async () => { + // Create a message + const createResponse = await request(httpServer) + .post('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .send({ content: 'Test message' }) + .timeout(4000); + + const messageId = createResponse.body.id; + + // Delete the message + const deleteResponse = await request(httpServer) + .delete(`/api/messages/${messageId}`) + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(deleteResponse.status).toBe(204); + + // Verify the message is deleted + const getResponse = await request(httpServer) + .get('/api/messages') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(getResponse.body).toHaveLength(0); + }); + + xit('should return 404 for non-existent message', async () => { + const response = await request(httpServer) + .delete('/api/messages/999999') + .set('Authorization', `Bearer ${authToken}`) + .timeout(4000); + + expect(response.status).toBe(404); + }); + + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/__tests__/socket.test.js b/Deploy/Containerization/summary/backend/__tests__/socket.test.js new file mode 100644 index 0000000..c70dcbe --- /dev/null +++ b/Deploy/Containerization/summary/backend/__tests__/socket.test.js @@ -0,0 +1,122 @@ +import {io as Client} from 'socket.io-client'; +import {httpServer} from '../src/index.js'; +import {messageService} from '../src/data/dataServices.js'; +import request from "supertest"; +import {dbReset} from "../src/data/dataServices.js"; + +describe('Socket.IO Chat', () => { + let clientSocket; + let authToken; + const testUser = { + username: 'User', + password: '1234' + }; + + beforeAll(async () => { + // Register and login test user + await dbReset(); + const registerResponse = await request(httpServer) + .post('/api/auth/register') + .send(testUser); + + authToken = registerResponse.body.token; + }) + + beforeEach((done) => { + clientSocket = new Client(`http://localhost:${httpServer.address().port}`, { + reconnection: false, + auth: { + token: authToken + } + }); + clientSocket.on('connect', done); + }); + + afterEach(() => { + clientSocket.close(); + }); + + afterAll((done) => { + httpServer.close(done); + }); + + + describe('Messaging', () => { + it('Should not allow unauthorized access', (done) => { + const noTokenSocket = new Client(`http://localhost:${httpServer.address().port}`, { + auth: {} + }); + + noTokenSocket.on('connect_error', (error) => { + try { + expect(error.message).toBe('Authentication token required'); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } finally { + clientSocket.close(); + } + }); + }); + + it('should broadcast messages to all clients', (done) => { + const testMessage = {username: 'User', content: 'Hello, WebSocket!'}; + + clientSocket.on('message', (message) => { + try { + expect(message).toMatchObject({ + content: 'Hello, WebSocket!', + username: 'User', + id: expect.any(Number), + }); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } + }); + + clientSocket.emit('message', testMessage); + }); + + it('should store message in database', (done) => { + const testMessage = {username: 'User', content: 'Test message for storage'}; + clientSocket.on('message', async (message) => { + try { + const storedMessages = await messageService.getMessages(); + expect(storedMessages.length).not.toBe(0); + expect(storedMessages.at(-1).content).toBe(testMessage.content); + expect(storedMessages.at(-1).username).toBe(testMessage.username); + done(); // Called if the assertion passes + } catch (err) { + done(err); // Mark the test as failed if the assertion fails + } + }); + clientSocket.emit('message', testMessage); + }); + + xit('should delete message and broadcast deletion to all clients', (done) => { + const testMessage = {username: 'User', content: 'Temporary message'}; + + messageService.addMessage(testMessage.username, testMessage.content).then(async (addedMessage) => { + const messageIdToDelete = addedMessage.id; + clientSocket.on('messageDeleted', async (data) => { + try { + expect(data).toMatchObject({ + messageId: messageIdToDelete, + }); + // Check that the message no longer exists in the store + const messages = await messageService.getMessages(); + expect(messages.find(msg => msg.id === messageIdToDelete)).toBeUndefined(); + done(); // Mark test as successful if assertions pass + } catch (err) { + done(err); // Mark test as failed if assertions fail + } + }); + // Emit deleteMessage event + clientSocket.emit('deleteMessage', {messageId: messageIdToDelete}); + }); + + }); + + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/data/database.sqlite b/Deploy/Containerization/summary/backend/data/database.sqlite new file mode 100644 index 0000000..17f59da Binary files /dev/null and b/Deploy/Containerization/summary/backend/data/database.sqlite differ diff --git a/Deploy/Containerization/summary/backend/jest.setup.js b/Deploy/Containerization/summary/backend/jest.setup.js new file mode 100644 index 0000000..765cb5b --- /dev/null +++ b/Deploy/Containerization/summary/backend/jest.setup.js @@ -0,0 +1,5 @@ +// Jest setup file +// This file sets up the test environment before running tests + +// Set NODE_ENV to 'test' to use the in-memory database +process.env.NODE_ENV = 'test'; \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/package.json b/Deploy/Containerization/summary/backend/package.json new file mode 100644 index 0000000..2d15d0d --- /dev/null +++ b/Deploy/Containerization/summary/backend/package.json @@ -0,0 +1,32 @@ +{ + "name": "vite_config-backend", + "version": "1.0.0", + "main": "src/index.js", + "type": "module", + "scripts": { + "start": "node src/index.js", + "generate-secret": "node scripts/generateSecret.js" + }, + "dependencies": { + "express": "^4.18.2", + "cors": "^2.8.5", + "morgan": "^1.10.0", + "bcryptjs": "^2.4.3", + "socket.io": "^4.7.1", + "dotenv": "^16.3.1", + "jsonwebtoken": "^9.0.1", + "sequelize": "^6.32.1", + "sqlite3": "^5.1.6" + }, + "devDependencies": { + "jest": "^29.3.1", + "@types/jest": "^29.2.5", + "supertest": "^6.3.3", + "@types/supertest": "^6.0.3" + }, + "jest": { + "testEnvironment": "node", + "testMatch": ["/__tests__/**/*.test.js"], + "setupFiles": ["/jest.setup.js"] + } +} diff --git a/Deploy/Containerization/summary/backend/scripts/generateSecret.js b/Deploy/Containerization/summary/backend/scripts/generateSecret.js new file mode 100644 index 0000000..4b407ca --- /dev/null +++ b/Deploy/Containerization/summary/backend/scripts/generateSecret.js @@ -0,0 +1,8 @@ +import crypto from 'crypto'; + +// Generate a secure random string of 64 bytes and convert it to base64 +const secret = crypto.randomBytes(64).toString('base64'); + +console.log('Generated JWT_SECRET: '); +console.log(secret); +console.log('\nMake sure to update the .env file with this value'); \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/src/data/dataServices.js b/Deploy/Containerization/summary/backend/src/data/dataServices.js new file mode 100644 index 0000000..e4479b0 --- /dev/null +++ b/Deploy/Containerization/summary/backend/src/data/dataServices.js @@ -0,0 +1,84 @@ +import { DataTypes } from 'sequelize'; +import sequelize from './dbConfig.js'; + +const { Users, Messages } = await (async () => { + + // Define a Users table model + const Users = sequelize.define('Users', { + username: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false + }, + password: { + type: DataTypes.STRING, + allowNull: false + } + }); + + // Define a Messages table model + const Messages = sequelize.define('Messages', { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true + }, + username: { + type: DataTypes.STRING, + allowNull: false + }, + content: { + type: DataTypes.TEXT, + allowNull: false + } + }); + + await sequelize.sync(); + return { Users, Messages }; +})(); + +export const userService = { + createUser: async (username, hashedPassword) => { + + if (await Users.findByPk(username)) { + throw new Error('Username already exists'); + } + await Users.create({ username, password: hashedPassword }); + return {username}; + }, + + getUser: async (username) => { + const user = await Users.findByPk(username); + return user ? user.get({ plain: true }) : undefined; + }, + +}; + +export const messageService = { + addMessage: async (username, content) => { + const message = await Messages.create({ + username, + content + }); + return message.get({ plain: true }); + }, + + getMessages: async () => { + return await Messages.findAll({raw: true}); + }, + + // Optional task + deleteMessage: async (messageId) => { + const deleted = await Messages.destroy({ + where: { id: messageId } + }); + return deleted > 0; + } +}; + +// It is used only for testing purposes: +export const dbReset = async () => { + // Delete all records in Messages and Users tables + await Messages.destroy({ where: {}, truncate: true }); + await Users.destroy({ where: {}, truncate: true }); +}; diff --git a/Deploy/Containerization/summary/backend/src/data/dbConfig.js b/Deploy/Containerization/summary/backend/src/data/dbConfig.js new file mode 100644 index 0000000..bc895be --- /dev/null +++ b/Deploy/Containerization/summary/backend/src/data/dbConfig.js @@ -0,0 +1,22 @@ +// Database configuration +// This file provides configuration for both real and test databases + +import { Sequelize } from 'sequelize'; + +// Determine if we're in test mode +const isTest = process.env.NODE_ENV === 'test'; + +// Create the appropriate Sequelize instance based on the environment +const sequelize = isTest + ? new Sequelize({ + dialect: 'sqlite', + storage: ':memory:', // Use in-memory SQLite for tests + logging: false + }) + : new Sequelize({ + dialect: 'sqlite', + storage: './data/database.sqlite', // Use file-based SQLite for production + logging: false + }); + +export default sequelize; \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/src/index.js b/Deploy/Containerization/summary/backend/src/index.js new file mode 100644 index 0000000..3d420be --- /dev/null +++ b/Deploy/Containerization/summary/backend/src/index.js @@ -0,0 +1,60 @@ +import express from 'express'; +import http from 'http'; +import cors from 'cors'; +import morgan from "morgan"; +import messageRoutes from './routes/messages.js'; +import authRoutes from './routes/auth.js'; +import { Server as SocketIO } from 'socket.io'; +import { initializeSocketIO } from './socket.js'; +import { authenticateRoute } from './middleware/auth.js'; + +const app = express(); +const httpServer = http.createServer(app); +const io = new SocketIO(httpServer); +initializeSocketIO(io); + +// Middleware +app.use(cors()); +app.use(express.json()); + +// Logging middleware +app.use(morgan('tiny')); + +// Adding message router +app.use('/api/messages', authenticateRoute, messageRoutes); +//app.use('/api/messages', messageRoutes); + +// Adding auth router +app.use('/api/auth', authRoutes); + +app.get('/health', (req, res) => { + const healthcheck = { + uptime: process.uptime(), // Server uptime in seconds + message: 'OK', + timestamp: Date.now() + }; + try { + res.status(200).json(healthcheck); // Respond with 200 OK and health data + } catch (error) { + healthcheck.message = error.message; + res.status(503).json(healthcheck); // Respond with 503 Service Unavailable on error + } +}); + +app.use((req, res) => { + res.status(404).type('text/plain').send('Page Not Found'); +}); + +// Error handling middleware +app.use((err, req, res, next) => { + console.error(err.stack); + res.status(500).json({ message: 'Something went wrong!' }); +}); + +const PORT = 8000; + +httpServer.listen(PORT, () => { + console.log(`Server is running at http://localhost:${PORT}/`); +}); + +export { httpServer, app }; diff --git a/Deploy/Containerization/summary/backend/src/middleware/auth.js b/Deploy/Containerization/summary/backend/src/middleware/auth.js new file mode 100644 index 0000000..1134b37 --- /dev/null +++ b/Deploy/Containerization/summary/backend/src/middleware/auth.js @@ -0,0 +1,62 @@ +import dotenv from 'dotenv'; +import jwt from 'jsonwebtoken'; +import { userService } from '../data/dataServices.js'; + +dotenv.config(); +const { JWT_SECRET } = process.env; + +if (!JWT_SECRET) { + console.error('JWT_SECRET is not defined in environment variables.'); + process.exit(1); +} + +export const generateToken = (username) => { + return jwt.sign({ username }, JWT_SECRET); +}; + +export const authenticateRoute = async (req, res, next) => { + const authHeader = req.headers['authorization']; + + // Typically, the `authorization` header has the format `"Bearer "` + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ message: 'Authentication token required' }); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await userService.getUser(decoded.username); + + if (!user) { + return res.status(401).json({ message: 'User not found' }); + } + + req.username = decoded.username; + next(); + } catch (err) { + return res.status(401).json({ message: 'Invalid token' }); + } +}; + +export const authenticateSocket = async (socket, next) => { + const token = socket.handshake.auth.token; + + if (!token) { + return next(new Error('Authentication token required')); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + const user = await userService.getUser(decoded.username); + + if (!user) { + return next(new Error('User not found')); + } + + socket.username = decoded.username; + next(); + } catch (err) { + next(new Error('Invalid token')); + } +}; \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/src/routes/auth.js b/Deploy/Containerization/summary/backend/src/routes/auth.js new file mode 100644 index 0000000..2ecd995 --- /dev/null +++ b/Deploy/Containerization/summary/backend/src/routes/auth.js @@ -0,0 +1,63 @@ +import express from 'express'; +import bcrypt from 'bcryptjs'; +import { userService } from '../data/dataServices.js'; +import { generateToken } from '../middleware/auth.js'; + +const router = express.Router(); + +// Register new user +router.post('/register', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + // Hash password + const hashedPassword = await bcrypt.hash(password, 10); + + const user = await userService.createUser(username, hashedPassword); + + const token = generateToken(username); + + res.status(201).json({ token, username: user.username }); + } catch (error) { + if (error.message === 'Username already exists') { + return res.status(409).json({ message: error.message }); + } + return res.status(500).json({ message: 'Error creating user: ' + error.message }); + } +}); + +// Login user +router.post('/login', async (req, res) => { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Username and password are required' }); + } + + const user = await userService.getUser(username); + + if (!user) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + // Compare password + const isValidPassword = await bcrypt.compare(password, user.password); + + if (!isValidPassword) { + return res.status(401).json({ message: 'Invalid username or password' }); + } + + const token = generateToken(username); + + res.json({ token, username: user.username }); + } catch (error) { + return res.status(500).json({ message: 'Error during login' }); + } +}); + +export default router; \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/src/routes/messages.js b/Deploy/Containerization/summary/backend/src/routes/messages.js new file mode 100644 index 0000000..327a665 --- /dev/null +++ b/Deploy/Containerization/summary/backend/src/routes/messages.js @@ -0,0 +1,54 @@ +import express from 'express'; +import { messageService } from '../data/dataServices.js'; + +const router = express.Router(); + +// Get all messages +router.get('/', async (req, res) => { + try { + const messages = await messageService.getMessages(); + return res.json(messages); + } catch (error) { + return res.status(500).json({ message: 'Error fetching messages' }); + } +}); + +// Create a new message +router.post('/', async (req, res) => { + try { + /**/ + const content = req.body.content; + + /**/ + + if (!content) { + return res.status(400).json({ message: 'Message content is required' }); + } + + const message = await messageService.addMessage(req.username, content); + return res.status(201).json(message); + + } catch (error) { + return res.status(500).json({ message: 'Error creating message' }); + } +}); + +// Optional task +// Delete a message +router.delete('/:id', async (req, res) => { + try { + const messageId = req.params.id; + // Now messageId should be converted to the number + const deleted = await messageService.deleteMessage(Number(messageId)); + + if (!deleted) { + return res.status(404).json({ message: 'Message not found' }); + } + + return res.status(204).send(); + } catch (error) { + return res.status(500).json({ message: 'Error deleting message' }); + } +}); + +export default router; \ No newline at end of file diff --git a/Deploy/Containerization/summary/backend/src/socket.js b/Deploy/Containerization/summary/backend/src/socket.js new file mode 100644 index 0000000..accf35c --- /dev/null +++ b/Deploy/Containerization/summary/backend/src/socket.js @@ -0,0 +1,54 @@ +import { messageService } from './data/dataServices.js'; +import {authenticateSocket} from "./middleware/auth.js"; + +export const initializeSocketIO = (io) => { + + io.use(authenticateSocket); + + // Setup handlers for a new socket connection + io.on('connection', (socket) => { + console.log('User connected'); + + // Handle new messages + socket.on('message', async (data) => { + try { + const message = await messageService.addMessage(socket.username, data.content); + + io.emit('message', message); + } catch (error) { + socket.emit('error', { message: 'Error sending message' }); + } + }); + + // Handle message deletion + socket.on('deleteMessage', async (data) => { + try { + const deleted = await messageService.deleteMessage(data.messageId); + + if (!deleted) { + socket.emit('error', { message: 'Message not found' }); + return; + } + + io.emit('messageDeleted', { messageId: data.messageId }); + } catch (error) { + socket.emit('error', { message: 'Error deleting message' }); + } + }); + + // Handle user disconnection + socket.on('disconnect', () => { + console.log('User disconnected'); + }); + + // Handle errors + socket.on('error', (error) => { + console.error('Socket error:', error); + }); + }); + + // Handle server-side errors + io.on('error', (error) => { + console.error('Socket.IO error:', error); + }); +}; \ No newline at end of file diff --git a/Deploy/Containerization/summary/docker-compose.yaml b/Deploy/Containerization/summary/docker-compose.yaml new file mode 100644 index 0000000..df1e4f9 --- /dev/null +++ b/Deploy/Containerization/summary/docker-compose.yaml @@ -0,0 +1,52 @@ +version: '3.8' +name: chat-app +services: + backend: + build: + context: ./backend + dockerfile: Dockerfile + environment: + - NODE_ENV=production + env_file: + - ./.env + volumes: + - chat-db-data:/app/data + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + networks: + - chat-network + restart: unless-stopped + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 5s + environment: + - NODE_ENV=production + - BACKEND_URL=http://backend:8000 + networks: + - chat-network + restart: unless-stopped + +volumes: + chat-db-data: + driver: local + +networks: + chat-network: + driver: bridge \ No newline at end of file diff --git a/Deploy/Containerization/summary/frontend/Dockerfile b/Deploy/Containerization/summary/frontend/Dockerfile new file mode 100644 index 0000000..53fe6f4 --- /dev/null +++ b/Deploy/Containerization/summary/frontend/Dockerfile @@ -0,0 +1,20 @@ +# Build stage +FROM node:20-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm install +COPY . . + +RUN npm run build + +# Production stage with Nginx +FROM nginx:alpine + +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 3000 + +# Nginx starts automatically in the foreground when the container starts diff --git a/Deploy/Containerization/summary/frontend/__tests__/chat_test.jsx b/Deploy/Containerization/summary/frontend/__tests__/chat_test.jsx new file mode 100644 index 0000000..420fe9e --- /dev/null +++ b/Deploy/Containerization/summary/frontend/__tests__/chat_test.jsx @@ -0,0 +1,155 @@ +import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import { jest } from '@jest/globals'; +import {act} from "react"; + +const socketMock = { + connect: jest.fn(), + disconnect: jest.fn(), + emit: jest.fn(), + on: jest.fn(), +}; + +jest.unstable_mockModule("socket.io-client", () => ({ + __esModule: true, + default: (...args) => { + socketMock.params.url = args[0] ?? null; + socketMock.params.opts = args[1] ?? null; + return socketMock; + }, +})); + +const chat_module = await import('../src/pages/Chat.jsx'); +const { default: Chat } = chat_module; + +describe('Chat tests', () => { + let onLogoutMock= jest.fn(); + + beforeEach(() => { + onLogoutMock = jest.fn(); + socketMock.connect = jest.fn(); + socketMock.disconnect = jest.fn(); + socketMock.emit = jest.fn(); + socketMock.on = jest.fn(); + socketMock.params = {}; + jest.clearAllMocks(); + localStorage.clear(); + localStorage.setItem('token', 'mockToken'); + }); + + it('creating a new WebSocket connection', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + render(); + await waitFor(() => { + expect(socketMock.params.url).toBe('/'); + expect(socketMock.params.opts).toStrictEqual({ auth: { token: "mockToken" } }); + }, { timeout: 1000 }); // Timeout of 1 second + + }) + + it('sending a new message', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + + render(); + + const inputField = screen.getByPlaceholderText('Type a message...'); + fireEvent.change(inputField, { target: { value: 'New test message' } }); + + const sendButton = screen.getByText('Send'); + fireEvent.click(sendButton); + + await waitFor(() => { + expect(socketMock.emit).toHaveBeenCalledWith('message', + { content: 'New test message' }, + ); + }, { timeout: 1000 }); // Timeout of 1 second + + expect(inputField.value).toBe(''); + }); + + it('displays new messages received via WebSocket', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' }, + ]; + + // Mock Axios `get` response for initial messages + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + + render(); + + // Check that initial mock messages are displayed + await screen.findByText('First message'); + expect(screen.getByText('Second message')).toBeInTheDocument(); + + // Simulate receiving a new "message" event via the WebSocket + const newMessage = { id: 3, username: 'User3', content: 'New incoming message' }; + const messageCallback = socketMock.on.mock.calls.find(call => call[0] === 'message')[1]; + act(() => { + messageCallback(newMessage); + }); + + expect(await screen.getByText('User3:')).toBeInTheDocument(); + expect(screen.getByText('New incoming message')).toBeInTheDocument(); + }); + + it('deletes a message when delete button is clicked', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + + render(); + + // Wait until the mock messages are displayed + await screen.findByText('First message'); + expect(screen.getByText('Second message')).toBeInTheDocument(); + + // Locate and click the delete button for the first message + const deleteButton = screen.getAllByAltText('Delete')[0]; + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(socketMock.emit).toHaveBeenCalledWith('deleteMessage', { messageId: 1 }); + }); + }); + + it('removes a message from the list when messageDeleted event is received', async () => { + const mockMessages = [ + { id: 1, username: 'User1', content: 'First message' }, + { id: 2, username: 'User2', content: 'Second message' } + ]; + + axios.get = jest.fn().mockResolvedValue({ data: mockMessages }); + render(); + + // Wait until the mock messages are displayed + await screen.findByText('First message'); + expect(screen.getByText('Second message')).toBeInTheDocument(); + + // Simulate receiving a "messageDeleted" event + const messageDeletedCallback = socketMock.on.mock.calls.find(call => call[0] === 'messageDeleted')[1]; + act(() => { + messageDeletedCallback({ messageId: 1 }); + }); + + // Verify that the first message is removed + expect(screen.queryByText('First message')).not.toBeInTheDocument(); + expect(screen.getByText('Second message')).toBeInTheDocument(); + }); + + +}); diff --git a/Deploy/Containerization/summary/frontend/__tests__/login_test.jsx b/Deploy/Containerization/summary/frontend/__tests__/login_test.jsx new file mode 100644 index 0000000..3cb8e1f --- /dev/null +++ b/Deploy/Containerization/summary/frontend/__tests__/login_test.jsx @@ -0,0 +1,42 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import Login from '../src/pages/Login'; +import { jest } from '@jest/globals'; + +describe('Login component tests', () => { + let onLoginMock= jest.fn(); + + beforeEach(() => { + onLoginMock = jest.fn(); + jest.clearAllMocks(); + }); + + it('calls the backend API and logs in successfully', async () => { + axios.post = jest.fn().mockResolvedValue({ + data: { token: 'mockToken' }, + }); + + let {container} = render(); + + fireEvent.change(screen.getByLabelText('Username'), { + target: { value: 'user' }, + }); + fireEvent.change(screen.getByLabelText('Password'), { + target: { value: 'password123' }, + }); + + console.log(container.innerHTML) + + fireEvent.click(screen.getByText('Login')); + + expect(axios.post).toHaveBeenCalledWith('/api/auth/login', { + username: 'user', + password: 'password123', + }); + await screen.findByText('Login to Chat'); // Wait for any potential render update + + expect(localStorage.getItem('token')).toBe('mockToken'); + expect(onLoginMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/Deploy/Containerization/summary/frontend/__tests__/register_test.jsx b/Deploy/Containerization/summary/frontend/__tests__/register_test.jsx new file mode 100644 index 0000000..81622c5 --- /dev/null +++ b/Deploy/Containerization/summary/frontend/__tests__/register_test.jsx @@ -0,0 +1,51 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import axios from 'axios'; +import Register from '../src/pages/Register'; +import { jest } from '@jest/globals'; + + +describe('Register component tests', () => { + let onLoginMock= jest.fn(); + + beforeEach(() => { + onLoginMock = jest.fn(); + }); + + it('displays an error when passwords do not match', async () => { + render(); + + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'user' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password456' } }); + + fireEvent.click(screen.getByText('Register')); + + const errorMessage = await screen.findByText('Passwords do not match'); + + expect(errorMessage).toBeInTheDocument(); + }); + + it('calls the backend API on successful registration', async () => { + axios.post = jest.fn().mockResolvedValue({ data: { token: 'mockToken' } }); + + render(); + + fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'user' } }); + fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password123' } }); + fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password123' } }); + + fireEvent.click(screen.getByText('Register')); + + await screen.findByText(/Register for Chat/i); // Wait for any re-render + + expect(axios.post).toHaveBeenCalledWith('/api/auth/register', { + username: 'user', + password: 'password123', + }); + + expect(localStorage.getItem('token')).toBe('mockToken'); + expect(onLoginMock).toHaveBeenCalledTimes(1); + }); + +}); \ No newline at end of file diff --git a/Deploy/Containerization/summary/frontend/__tests__/token_test.jsx b/Deploy/Containerization/summary/frontend/__tests__/token_test.jsx new file mode 100644 index 0000000..a9cbb9a --- /dev/null +++ b/Deploy/Containerization/summary/frontend/__tests__/token_test.jsx @@ -0,0 +1,50 @@ +import {render, screen, fireEvent} from '@testing-library/react'; +import '@testing-library/jest-dom'; +import App from '../src/App'; +import { jest } from '@jest/globals'; +import {BrowserRouter} from "react-router-dom"; + +const renderWithRouter = (ui, { route = '/' } = {}) => { + window.history.pushState({}, 'Test Page', route); + return render({ui}); +}; + +describe('Token management tests', () => { + + beforeEach(() => { + localStorage.clear(); // Clear localStorage to avoid persistence issues across tests + }); + + it('App verifies token presence in localStorage and updates authentication state', () => { + // Simulate token existence in localStorage + localStorage.setItem('token', 'mockToken'); + + renderWithRouter(, { route: '/login' }); + + expect(localStorage.getItem('token')).toBe('mockToken'); + expect(screen.getByText('Chat will be here.')).toBeInTheDocument(); + }); + + it('App handles absence of token: updates authentication state to false', () => { + + renderWithRouter(, { route: '/login' }); + + expect(localStorage.getItem('token')).toBe(null); + expect(screen.getByText('Login to Chat')).toBeInTheDocument(); + }); + + it('Chat removes token from localStorage upon logout', () => { + const removeItemSpy = jest.spyOn(localStorage, 'removeItem'); + + // Simulate token existence in localStorage + localStorage.setItem('token', 'mockToken'); + + renderWithRouter(, { route: '/chat' }); + + const logoutButton = screen.getByText('Logout'); + fireEvent.click(logoutButton); // Simulate logout + + expect(localStorage.getItem('token')).toBe(null); // Verify token is removed + expect(screen.getByText('Login to Chat')).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/Deploy/Containerization/summary/frontend/index.html b/Deploy/Containerization/summary/frontend/index.html new file mode 100644 index 0000000..0ab318b --- /dev/null +++ b/Deploy/Containerization/summary/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Real-time Chat + + +
+ + + \ No newline at end of file diff --git a/Deploy/Containerization/summary/frontend/nginx.conf b/Deploy/Containerization/summary/frontend/nginx.conf new file mode 100644 index 0000000..75544fe --- /dev/null +++ b/Deploy/Containerization/summary/frontend/nginx.conf @@ -0,0 +1,53 @@ +server { + listen 3000; + + # Root directory where the built React app is located + root /usr/share/nginx/html; + index index.html; + + # Gzip compression for better performance + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + # Cache static assets + location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg)$ { + expires 1y; + add_header Cache-Control "public, max-age=31536000"; + } + + # Special location for /src/assets/ requests + location /src/assets/ { + alias /usr/share/nginx/html/assets/; + expires 1y; + add_header Cache-Control "public, max-age=31536000"; + } + + # Handle React routing - direct all requests to index.html + location / { + try_files $uri $uri/ /index.html; + } + + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + location /socket.io/ { + proxy_pass http://backend:8000/socket.io/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + + # Error pages + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /usr/share/nginx/html; + } +} \ No newline at end of file diff --git a/Deploy/Containerization/summary/frontend/package.json b/Deploy/Containerization/summary/frontend/package.json new file mode 100644 index 0000000..966afb9 --- /dev/null +++ b/Deploy/Containerization/summary/frontend/package.json @@ -0,0 +1,27 @@ +{ + "name": "vite_config-frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "start": "vite", + "build": "vite build" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^6.14.1", + "socket.io-client": "^4.7.1", + "axios": "^1.6.2" + }, + "devDependencies": { + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.21.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "vite": "^6.2.0" + } +} \ No newline at end of file diff --git a/Deploy/Containerization/summary/frontend/public/assets/academy.svg b/Deploy/Containerization/summary/frontend/public/assets/academy.svg new file mode 100644 index 0000000..c5ee497 --- /dev/null +++ b/Deploy/Containerization/summary/frontend/public/assets/academy.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Deploy/Containerization/summary/frontend/public/assets/delete.svg b/Deploy/Containerization/summary/frontend/public/assets/delete.svg new file mode 100644 index 0000000..484034d --- /dev/null +++ b/Deploy/Containerization/summary/frontend/public/assets/delete.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Deploy/Containerization/summary/frontend/src/App.jsx b/Deploy/Containerization/summary/frontend/src/App.jsx new file mode 100644 index 0000000..e42c54a --- /dev/null +++ b/Deploy/Containerization/summary/frontend/src/App.jsx @@ -0,0 +1,55 @@ +import {Routes, Route, Navigate} from 'react-router-dom'; +import Login from './pages/Login'; +import Register from './pages/Register'; +import Chat from './pages/Chat'; +import { useState, useEffect } from "react"; + +function App() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + + useEffect(() => { + const token = localStorage.getItem('token'); + if (token) + setIsAuthenticated(true); + }, []); + + return ( +
+ + setIsAuthenticated(true)} /> + ) : ( + + ) + } + /> + setIsAuthenticated(true)} /> + ) : ( + + ) + } + /> + setIsAuthenticated(false)} /> + ) : ( + + ) + } + /> + } /> + +
+ ); +} + +export default App; diff --git a/Deploy/Containerization/summary/frontend/src/index.css b/Deploy/Containerization/summary/frontend/src/index.css new file mode 100644 index 0000000..a679ea2 --- /dev/null +++ b/Deploy/Containerization/summary/frontend/src/index.css @@ -0,0 +1,243 @@ +:root { + --primary-color: #646cff; + --primary-hover: #535bf2; + --background-color: #242424; + --text-color: rgba(255, 255, 255, 0.87); + --border-color: #3f3f3f; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + color: var(--text-color); + background-color: var(--background-color); +} + +.app { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +input { + border-radius: 4px; + border: 1px solid var(--border-color); + padding: 0.6em 1.2em; + font-size: 1em; + font-family: inherit; + background-color: transparent; + color: var(--text-color); +} + +input:focus { + outline: none; + border-color: var(--primary-color); +} + +input[type="number"]::-webkit-inner-spin-button, +input[type="number"]::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +/* Disable number input arrows for Firefox */ +input[type="number"] { + -moz-appearance: textfield; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: var(--primary-color); + color: white; + cursor: pointer; + transition: border-color 0.25s; +} + +button:hover { + filter: brightness(0.85); +} + +button:disabled { + background-color: #666; + cursor: not-allowed; + opacity: 0.7; +} + +button:disabled:hover { + background-color: #666; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; + width: 100%; +} + +/* Login styles */ +.login-form { + max-width: 400px; + margin: 2rem auto; + padding: 2rem; + border: 1px solid var(--border-color); + border-radius: 8px; + background-color: rgba(255, 255, 255, 0.05); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); +} + +.login-form h2 { + margin-bottom: 1.5rem; + text-align: center; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--text-color); +} + +.form-footer { + margin-top: 1.5rem; + text-align: center; + font-size: 0.9em; +} + +.form-footer a { + color: var(--primary-color); + text-decoration: none; + margin-left: 0.5rem; +} + +.form-footer a:hover { + color: var(--primary-hover); + text-decoration: underline; +} + +.error { + color: #ff4444; + margin-bottom: 1rem; + text-align: center; + padding: 0.5rem; + border-radius: 4px; + background-color: rgba(255, 68, 68, 0.1); + border: 1px solid rgba(255, 68, 68, 0.2); +} + +input:invalid { + border-color: #ff4444; +} + +input:valid { + border-color: var(--border-color); +} + +input:focus:invalid { + border-color: #ff4444; + box-shadow: 0 0 0 2px rgba(255, 68, 68, 0.2); +} + +input:focus:valid { + border-color: var(--primary-color); + box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.2); +} + +.validation-errors { + list-style: none; + padding: 0.5rem; + margin: 0.5rem 0; + font-size: 0.85em; + color: #ff4444; + background-color: rgba(255, 68, 68, 0.1); + border-radius: 4px; +} + +.validation-errors li { + margin: 0.25rem 0; + padding-left: 1.5rem; + position: relative; +} + +.validation-errors li::before { + content: "•"; + position: absolute; + left: 0.5rem; + color: #ff4444; +} + +.validation-error { + color: #ff4444; + font-size: 0.85em; + margin-top: 0.5rem; + padding-left: 0.5rem; +} + +/* Chat styles */ +.chat-container { + max-width: 800px; + margin: 0 auto; + height: 100vh; + display: flex; + flex-direction: column; + padding: 1rem; +} + +.chat-header { + display: flex; + justify-content: flex-end; +} + +.logout-button { + background-color: #652d2b; + margin-bottom: 10px; +} + +.messages-container { + flex: 1; + overflow-y: auto; + padding: 1rem; + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 1rem; +} + +.message { + margin-bottom: 0.5rem; + padding: 0.5rem; + border-radius: 4px; +} + +.message:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.delete-button { + margin-left: 10px; + cursor: pointer; + width: 16px; + height: 16px; +} + +.message-form { + display: flex; + gap: 1rem; +} + +.message-form input { + flex: 1; +} \ No newline at end of file diff --git a/Deploy/Containerization/summary/frontend/src/main.jsx b/Deploy/Containerization/summary/frontend/src/main.jsx new file mode 100644 index 0000000..ac95f8e --- /dev/null +++ b/Deploy/Containerization/summary/frontend/src/main.jsx @@ -0,0 +1,11 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import {BrowserRouter} from 'react-router-dom' +import App from './App.jsx' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +) \ No newline at end of file diff --git a/Deploy/Containerization/summary/frontend/src/pages/Chat.jsx b/Deploy/Containerization/summary/frontend/src/pages/Chat.jsx new file mode 100644 index 0000000..a306a5d --- /dev/null +++ b/Deploy/Containerization/summary/frontend/src/pages/Chat.jsx @@ -0,0 +1,114 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import io from 'socket.io-client'; + +function Chat({ onLogout }) { + + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [socket, setSocket] = useState(null); + + const handleLogout = () => { + socket?.close(); + localStorage.removeItem('token'); + onLogout(); + }; + + useEffect(() => { + const fetchMessages = async () => { + try { + const token = localStorage.getItem('token'); + const response = await axios.get('/api/messages', { + headers: { Authorization: `Bearer ${token}` } + }); + setMessages(response.data); + } catch (error) { + console.error('Failed to fetch messages:', error); + } + }; + + if(! socket){ + const newSocket = io('/', { + auth: { + token: localStorage.getItem('token') + } + }); + + newSocket.on('error', (error) => { + console.error('Socket error:', error); + }); + + newSocket.on('message', (message) => { + setMessages(prev => [...prev, message]); + }); + + newSocket.on('messageDeleted', (data) => { + setMessages(prev => prev.filter(message => message.id !== data.messageId)); + }); + + setSocket(newSocket); + } + + fetchMessages().then(() => console.log('Successfully fetched messages!')); + }, []); + + const handleSubmit = async (e) => { + e.preventDefault(); + + // does not allow sending empty messages: + if (!newMessage.trim()) return; + + try { + socket?.emit('message', { content: newMessage }); + setNewMessage(''); + } catch (error) { + console.error('Failed to send message:', error); + } + }; + + const handleDelete = async (messageId) => { + try { + socket?.emit('deleteMessage', { messageId }); + } catch (error) { + console.error('Failed to delete message:', error); + } + }; + + return ( +
+
+ +
+
+ {messages.map((message) => ( +
+ {message.username}: + {message.content} + Delete handleDelete(message.id)} + className="delete-button" + /> +
+ ))} +
+
+ setNewMessage(e.target.value)} + placeholder="Type a message..." + /> + +
+
+ ); +} + +export default Chat; diff --git a/Deploy/Containerization/summary/frontend/src/pages/Login.jsx b/Deploy/Containerization/summary/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..d228f20 --- /dev/null +++ b/Deploy/Containerization/summary/frontend/src/pages/Login.jsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import axios from 'axios'; + +function Login({ onLogin }) { + const [formData, setFormData] = useState({ + username: '', + password: '', + }); + const [error, setError] = useState(''); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + + try { + const response = await axios.post('/api/auth/login', formData); + localStorage.setItem('token', response.data.token); + onLogin(); + } catch (err) { + setError(err.response?.data?.message || 'Login failed'); + } + }; + + return ( +
+
+

Login to Chat

+ {error &&
{error}
} +
+
+ + +
+
+ + +
+ +
+
+
+ ); +} + +export default Login; diff --git a/Deploy/Containerization/summary/frontend/src/pages/Register.jsx b/Deploy/Containerization/summary/frontend/src/pages/Register.jsx new file mode 100644 index 0000000..49bc2d9 --- /dev/null +++ b/Deploy/Containerization/summary/frontend/src/pages/Register.jsx @@ -0,0 +1,89 @@ +import {useState} from "react"; +import axios from 'axios'; + +function Register({onLogin}) { + const [error, setError] = useState(''); + const [formData, setFormData] = useState({ + username: '', + password: '', + confirmPassword: '' + }); + + const handleChange = (e) => { + const { name, value } = e.target; + setFormData(prev => ({ + ...prev, + [name]: value + })); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); // prevents a default form submission behavior + setError(''); // clear the error message + + if (formData.password !== formData.confirmPassword) { + setError('Passwords do not match'); + return; + } + + try { + const response = await axios.post('/api/auth/register', { + username: formData.username, + password: formData.password + }); + + localStorage.setItem('token', response.data.token); + + onLogin(); + } catch (err) { + setError(err.response?.data?.message || 'Registration failed'); + } + }; + + return (
+
+

Register for Chat

+ {error &&
{error}
} +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
); +} + +export default Register; diff --git a/Deploy/Containerization/summary/frontend/vite.config.js b/Deploy/Containerization/summary/frontend/vite.config.js new file mode 100644 index 0000000..24965cc --- /dev/null +++ b/Deploy/Containerization/summary/frontend/vite.config.js @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// Get backend URL from the environment variable or use default for local run +const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'; + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + strictPort: true, + host: '0.0.0.0', // Allow connections from outside the container + proxy: { + '/api': { + target: backendUrl, + changeOrigin: true, + }, + '/socket.io': { + target: backendUrl, + changeOrigin: true, + ws: true, + }, + }, + }, +}) \ No newline at end of file diff --git a/Deploy/Containerization/summary/task-info.yaml b/Deploy/Containerization/summary/task-info.yaml new file mode 100644 index 0000000..c341659 --- /dev/null +++ b/Deploy/Containerization/summary/task-info.yaml @@ -0,0 +1,82 @@ +type: theory +custom_name: Summary +files: + - name: docker-compose.yaml + visible: true + - name: frontend/src/pages/Chat.jsx + visible: true + - name: frontend/src/pages/Login.jsx + visible: true + - name: frontend/src/pages/Register.jsx + visible: true + - name: frontend/src/App.jsx + visible: true + - name: frontend/src/main.jsx + visible: true + - name: frontend/src/index.css + visible: true + - name: frontend/dist/assets/main-W8GGl4oE.js + visible: true + - name: frontend/dist/assets/main-CMKn4ETQ.css + visible: true + - name: frontend/dist/assets/academy-D_4kBv2-.svg + visible: true + - name: frontend/dist/index.html + visible: true + - name: frontend/public/assets/delete.svg + visible: true + - name: frontend/public/assets/academy.svg + visible: true + - name: frontend/__tests__/chat_test.jsx + visible: true + - name: frontend/__tests__/login_test.jsx + visible: true + - name: frontend/__tests__/token_test.jsx + visible: true + - name: frontend/__tests__/register_test.jsx + visible: true + - name: frontend/Dockerfile + visible: true + - name: frontend/index.html + visible: true + - name: frontend/nginx.conf + visible: true + - name: frontend/package.json + visible: true + - name: frontend/vite.config.js + visible: true + - name: backend/src/data/dbConfig.js + visible: true + - name: backend/src/data/dataServices.js + visible: true + - name: backend/src/routes/auth.js + visible: true + - name: backend/src/routes/messages.js + visible: true + - name: backend/src/index.js + visible: true + - name: backend/src/socket.js + visible: true + - name: backend/src/middleware/auth.js + visible: true + - name: backend/data/database.sqlite + visible: true + is_binary: true + - name: backend/scripts/generateSecret.js + visible: true + - name: backend/__tests__/auth.test.js + visible: true + - name: backend/__tests__/socket.test.js + visible: true + - name: backend/__tests__/messages.test.js + visible: true + - name: backend/__tests__/dataServices.test.js + visible: true + - name: backend/Dockerfile + visible: true + - name: backend/package.json + visible: true + - name: backend/jest.setup.js + visible: true + - name: .env + visible: true diff --git a/Deploy/Containerization/summary/task.md b/Deploy/Containerization/summary/task.md new file mode 100644 index 0000000..f97cf5e --- /dev/null +++ b/Deploy/Containerization/summary/task.md @@ -0,0 +1,9 @@ +Let’s summarize briefly. To deploy your application using Docker, you need: + +1. A Dockerfile where you define the environment for your service, install dependencies, and configure the application build process. +2. A Compose file where you describe how your services should run, what data should be stored in persistent storage, which ports to expose, and other configurations. +3. A `.env` file to store secret keys. Avoid committing this file to the repository, and never set sensitive environment variables directly in the Dockerfile or Compose file. + +Now you can deploy your application to any server with almost a single command. + +Good luck! diff --git a/Deploy/section-info.yaml b/Deploy/section-info.yaml new file mode 100644 index 0000000..865aa78 --- /dev/null +++ b/Deploy/section-info.yaml @@ -0,0 +1,2 @@ +content: + - Containerization diff --git a/course-info.yaml b/course-info.yaml index d1ab4fd..9a31f3d 100644 --- a/course-info.yaml +++ b/course-info.yaml @@ -13,6 +13,7 @@ content: - Introduction - Backend - Frontend + - Deploy - Feedback additional_files: - name: package.json