From 76618af58a29afd54ef4bbfb456e4bc2e42e58e9 Mon Sep 17 00:00:00 2001 From: Alex V Date: Sat, 25 Apr 2026 13:02:42 +0200 Subject: [PATCH] registration + account sync --- .env | 8 +- CLAUDE.md | 90 +++++++++ README.md | 79 ++++++++ api/package.json | 4 +- api/src/controllers/auth.controller.ts | 142 ++++++++++++- api/src/routes/auth.routes.ts | 5 +- docker-compose.yml | 5 + react-client/package.json | 4 +- react-client/src/App.tsx | 7 + react-client/src/pages/DashboardPage.tsx | 77 +++++-- react-client/src/pages/GameServerPage.tsx | 233 ++++++++++++++++++++++ react-client/src/pages/RegisterPage.tsx | 6 + test/smoke-test.sh | 121 +++++++++++ 13 files changed, 744 insertions(+), 37 deletions(-) create mode 100644 CLAUDE.md create mode 100644 react-client/src/pages/GameServerPage.tsx create mode 100755 test/smoke-test.sh diff --git a/.env b/.env index de8c991..fa3f9fc 100644 --- a/.env +++ b/.env @@ -8,17 +8,17 @@ JWT_EXPIRES_IN=8h DATABASE_URL=file:./dev.db # Game Server Database (MSSQL) -GAME_SERVER_HOST=192.168.1.100 +GAME_SERVER_HOST=10.23.19.205 GAME_SERVER_PORT=1433 GAME_SERVER_USER=sa -GAME_SERVER_PASSWORD=your_game_server_password +GAME_SERVER_PASSWORD=q159753Q GAME_SERVER_DATABASE=lin2db # Game World Database (MSSQL) -GAME_WORLD_HOST=192.168.1.100 +GAME_WORLD_HOST=10.23.19.205 GAME_WORLD_PORT=1433 GAME_WORLD_USER=sa -GAME_WORLD_PASSWORD=your_game_server_password +GAME_WORLD_PASSWORD=q159753Q GAME_WORLD_DATABASE=lin2world # Server Info diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c49ad62 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,90 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +LA2 Eternal Portal is a web portal for a Lineage 2 private server. It consists of an Express API and a React SPA that manage website user accounts, link them to existing game server accounts, and display character data. + +## Architecture + +This is a monorepo with two packages: + +- **`api/`** — Express.js API server (TypeScript, Node 18+) +- **`react-client/`** — React SPA (Vite, TypeScript) + +### Dual Database Architecture + +The API talks to two completely different databases: + +1. **Portal Database (SQLite)** — Stores website users, game account links, and cached character metadata. Accessed via Prisma ORM (`api/prisma/schema.prisma`). File path controlled by `DATABASE_URL` env var. This is the core database and is available in all environments. +2. **Game Server Databases (MSSQL)** — External production-only database containing live Lineage 2 game server data. **Not available in local development.** Accessed via raw SQL through the `mssql` driver (`api/src/services/gameServerService.ts`) only when env vars are configured. Two connections: + - `lin2db` — account and character tables + - `lin2world` — builder_account table + +Game server connection config lives in `api/src/config/gameServer.ts` and is read from env vars (`GAME_SERVER_*`, `GAME_WORLD_*`). Code that touches MSSQL should gracefully handle connection failures because the game server is not reachable outside production. + +### Auth Flow + +- Portal uses JWT (8h expiry). Token is generated on login, stored in `localStorage`, and sent via `Authorization: Bearer` header. +- The API has two middleware functions in `api/src/middleware/auth.ts`: `protectRoute` (validates JWT) and `adminOnly` (checks `role === 'ADMIN'`). +- React auth state is managed by Zustand (`react-client/src/hooks/useAuth.ts`). + +### API Routing Structure + +| Route | File | Notes | +|-------|------|-------| +| `/api/auth` | `api/src/routes/auth.routes.ts` | login, register, me, link game account | +| `/api/users` | `api/src/routes/user.routes.ts` | user/character CRUD (Prisma/SQLite) | +| `/api/admin` | `api/src/routes/admin.routes.ts` | admin-only user management | +| `/api/characters` | `api/src/routes/characters.routes.ts` | character routes (mostly stub) | + +Business logic for game server DB operations is in `api/src/services/gameServerService.ts`. Business logic for portal DB operations is split between `api/src/controllers/auth.controller.ts` and `api/src/controllers/user.controller.ts`. + +## Commands + +All commands are run from the respective package directory (`api/` or `react-client/`). + +### API (`cd api/`) + +```bash +npm run dev # Start dev server with tsx watch (port 3001) +npm run build # Compile TypeScript to ./dist +npm run start # Run compiled JS (production) +npm run migrate # Run Prisma migrations +``` + +### React Client (`cd react-client/`) + +```bash +npm run dev # Start Vite dev server (port 5173) +npm run build # Type-check and build for production +npm run preview # Preview production build locally +``` + +Note: `npm run lint` is defined in `react-client/package.json` but ESLint is not actually installed/configured in the current lockfile. + +### Docker (from repo root) + +```bash +docker compose build # Build API and frontend images +docker compose up -d # Start both services +docker compose logs -f api # Tail API logs +docker compose logs -f react # Tail frontend logs +``` + +The API container runs `tsx watch` in development. The React container is a multi-stage build that compiles the SPA and serves it via nginx. + +## Important Files + +- `api/prisma/schema.prisma` — Portal DB schema (SQLite). Models: `WebsiteAccount`, `GameAccount`, `GameCharacter`. +- `api/src/config/gameServer.ts` — MSSQL connection config and server metadata. +- `react-client/vite.config.ts` — Vite config with path alias `@/` pointing to `src/` and a dev proxy for `/api` to `localhost:3001`. +- `.env.example` — Required environment variables for local development. + +## Code Patterns + +- Controllers are inconsistent in style: some export plain async functions (`auth.controller.ts`), others export a controller object (`user.controller.ts`). Prefer plain async functions for new code. +- MSSQL queries use template strings with parameterized inputs via `.input()`. Never interpolate user values directly into query strings. +- Passwords for the game server are stored as plain binary (`Buffer.from(password)`) in the `user_auth` table, not hashed. +- The `gameServerService.ts` connection pools are lazily initialized module-level singletons. They reconnect automatically if the connection drops. diff --git a/README.md b/README.md index 3cfa9dd..ce52502 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,85 @@ sudo systemctl enable la2eternal.service sudo systemctl start la2eternal.service ``` +## Testing (Docker) + +### Smoke Test Script + +A `test/smoke-test.sh` script is included to verify the Docker deployment works end-to-end. + +**Run the smoke test after starting Docker:** + +```bash +# Make sure containers are running first +sudo docker compose up -d + +# Run the smoke test +chmod +x test/smoke-test.sh +./test/smoke-test.sh +``` + +**What the smoke test verifies:** +1. API container is reachable on port 3001 +2. Frontend container is reachable on port 5173 +3. API `/auth/register` accepts valid registration (alphanumeric username only) +4. API `/auth/login` returns a JWT token +5. API `/auth/me` returns the authenticated user profile +6. API `/auth/game-account` returns game account status for the authenticated user + +### Manual Docker Verification + +```bash +# 1. Build and start all services +sudo docker compose build +sudo docker compose up -d + +# 2. Wait for API to be ready +sleep 5 + +# 3. Verify API is up +curl -s http://localhost:3001/health || echo "API health check not configured — try registering a test user instead" + +# 4. Test registration (username must be alphanumeric) +curl -s -X POST http://localhost:3001/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"username":"testuser123","email":"test@example.com","password":"testpass123"}' + +# 5. Test login +curl -s -X POST http://localhost:3001/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","password":"testpass123"}' + +# 6. Verify frontend is serving +curl -s -o /dev/null -w "%{http_code}" http://localhost:5173 +# Expected: 200 +``` + +### Running Tests in Docker (API) + +```bash +# Access the API container shell +sudo docker exec -it la2_portal_api sh + +# Inside the container, run type checks +npx tsc --noEmit + +# Exit the container shell +exit +``` + +### Running Tests in Docker (Frontend) + +```bash +# Access the React container shell (if using dev image) +sudo docker exec -it la2_portal_fe sh + +# Inside the container, run the build to verify no TypeScript errors +npm run build + +# Exit the container shell +exit +``` + ## Maintenance Commands ```bash diff --git a/api/package.json b/api/package.json index 705fd4e..2202957 100644 --- a/api/package.json +++ b/api/package.json @@ -6,7 +6,9 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "start": "npx tsx src/index.ts", - "migrate": "npx prisma migrate dev" + "migrate": "npx prisma migrate dev", + "test": "echo 'No unit tests configured. Use ../test/smoke-test.sh for Docker smoke testing.'", + "typecheck": "tsc --noEmit" }, "dependencies": { "@prisma/client": "^5.6.0", diff --git a/api/src/controllers/auth.controller.ts b/api/src/controllers/auth.controller.ts index 68e5b53..0daf45d 100644 --- a/api/src/controllers/auth.controller.ts +++ b/api/src/controllers/auth.controller.ts @@ -25,6 +25,8 @@ async function getWorldPool(): Promise { return worldPool; } +const USERNAME_REGEX = /^[a-zA-Z0-9]+$/; + interface AuthRequest extends Request { user?: { id: number; @@ -35,18 +37,18 @@ interface AuthRequest extends Request { // Login endpoint export const login = async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const { username, password } = req.body; + const { email, password } = req.body; - if (!username || !password) { - return res.status(400).json({ error: 'Username and password are required' }); + if (!email || !password) { + return res.status(400).json({ error: 'Email and password are required' }); } // Find user by username or email const user = await prisma.websiteAccount.findFirst({ where: { OR: [ - { username: username }, - { email: username } + { username: email }, + { email: email } ] } }); @@ -77,12 +79,16 @@ export const login = async (req: AuthRequest, res: Response, next: NextFunction) // Register endpoint - creates both website account and game server account export const register = async (req: AuthRequest, res: Response, next: NextFunction) => { try { - const { username, email, password, gameAccountName } = req.body; + const { username, email, password } = req.body; if (!username || !email || !password) { return res.status(400).json({ error: 'Username, email, and password are required' }); } + if (!USERNAME_REGEX.test(username)) { + return res.status(400).json({ error: 'Username must contain only English letters and numbers' }); + } + // Check if username exists const existingUser = await prisma.websiteAccount.findUnique({ where: { username } @@ -121,14 +127,11 @@ export const register = async (req: AuthRequest, res: Response, next: NextFuncti } }); - // Determine game account name (can be same as username or custom) - const accountName = gameAccountName || username; - let gameAccount = null; // Try to create game server account if configured try { - gameAccount = await createGameServerAccount(accountName, password, email, websiteUser.id); + gameAccount = await createGameServerAccount(username, password, email, websiteUser.id); } catch (gameError) { console.warn('Failed to create game server account:', gameError); // Don't fail registration if game server is unavailable @@ -366,3 +369,122 @@ export const linkGameAccount = async (req: AuthRequest, res: Response, next: Nex next(error); } }; + +// Get current user's game account status +export const getGameAccountStatus = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = (req as any).user?.id; + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + const gameAccount = await prisma.gameAccount.findFirst({ + where: { websiteUserId: userId } + }); + + if (!gameAccount) { + return res.json({ hasGameAccount: false, accountName: null, existsOnServer: false }); + } + + try { + const pool = await getDbPool(); + const result = await pool.request() + .input('account', mssql.VarChar, gameAccount.accountName) + .query('SELECT COUNT(*) as count FROM lin2db.dbo.user_account WHERE account = @account'); + + const existsOnServer = result.recordset[0].count > 0; + res.json({ + hasGameAccount: true, + accountName: gameAccount.accountName, + existsOnServer + }); + } catch { + res.json({ + hasGameAccount: true, + accountName: gameAccount.accountName, + existsOnServer: false + }); + } + } catch (error) { + next(error); + } +}; + +// Create a game server account for the current user +export const createMyGameAccount = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = (req as any).user?.id; + const { password } = req.body; + + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + if (!password) { + return res.status(400).json({ error: 'Password is required' }); + } + + // Enforce one game account per user + const existingLink = await prisma.gameAccount.findFirst({ + where: { websiteUserId: userId } + }); + + if (existingLink) { + return res.status(400).json({ error: 'You already have a game server account' }); + } + + const user = await prisma.websiteAccount.findUnique({ + where: { id: userId } + }); + + if (!user) { + return res.status(404).json({ error: 'User not found' }); + } + + const gameAccount = await createGameServerAccount(user.username, password, user.email, userId); + + res.status(201).json({ + message: 'Game account created successfully', + gameAccount: { + accountName: gameAccount.accountName, + ssn: gameAccount.ssn + } + }); + } catch (error) { + next(error); + } +}; + +// Update game server password for the current user +export const updateGamePassword = async (req: AuthRequest, res: Response, next: NextFunction) => { + try { + const userId = (req as any).user?.id; + const { password } = req.body; + + if (!userId) { + return res.status(401).json({ error: 'Not authenticated' }); + } + + if (!password) { + return res.status(400).json({ error: 'Password is required' }); + } + + const gameAccount = await prisma.gameAccount.findFirst({ + where: { websiteUserId: userId } + }); + + if (!gameAccount) { + return res.status(404).json({ error: 'Game account not linked' }); + } + + const pool = await getDbPool(); + await pool.request() + .input('account', mssql.VarChar, gameAccount.accountName) + .input('password', mssql.Binary, Buffer.from(password)) + .query('UPDATE lin2db.dbo.user_auth SET password = @password WHERE account = @account'); + + res.json({ message: 'Password updated successfully' }); + } catch (error) { + next(error); + } +}; diff --git a/api/src/routes/auth.routes.ts b/api/src/routes/auth.routes.ts index 29df6c1..712c7fa 100644 --- a/api/src/routes/auth.routes.ts +++ b/api/src/routes/auth.routes.ts @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { register, login, getMe, linkGameAccount } from '../controllers/auth.controller'; +import { register, login, getMe, linkGameAccount, getGameAccountStatus, createMyGameAccount, updateGamePassword } from '../controllers/auth.controller'; import { protectRoute } from '../middleware/auth'; const router = Router(); @@ -11,5 +11,8 @@ router.post('/login', login); // Protected routes router.get('/me', protectRoute, getMe); router.post('/link-game-account', protectRoute, linkGameAccount); +router.get('/game-account', protectRoute, getGameAccountStatus); +router.post('/game-account', protectRoute, createMyGameAccount); +router.put('/game-account/password', protectRoute, updateGamePassword); export default router; diff --git a/docker-compose.yml b/docker-compose.yml index fdf979e..924123c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,11 @@ services: GAME_SERVER_USER: ${GAME_SERVER_USER:-sa} GAME_SERVER_PASSWORD: ${GAME_SERVER_PASSWORD:-} GAME_SERVER_DB: ${GAME_SERVER_DB:-lin2db} + GAME_WORLD_HOST: ${GAME_WORLD_HOST:-127.0.0.1} + GAME_WORLD_PORT: ${GAME_WORLD_PORT:-1433} + GAME_WORLD_USER: ${GAME_WORLD_USER:-sa} + GAME_WORLD_PASSWORD: ${GAME_WORLD_PASSWORD:-} + GAME_WORLD_DB: ${GAME_WORLD_DB:-lin2world} ports: - "3001:3001" volumes: diff --git a/react-client/package.json b/react-client/package.json index 7f735ee..1ff1e86 100644 --- a/react-client/package.json +++ b/react-client/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", - "preview": "vite preview" + "preview": "vite preview", + "test": "echo 'No unit tests configured. Use ../test/smoke-test.sh for Docker smoke testing.'", + "typecheck": "tsc --noEmit" }, "dependencies": { "react": "^18.3.1", diff --git a/react-client/src/App.tsx b/react-client/src/App.tsx index 6def2ee..4569bb4 100644 --- a/react-client/src/App.tsx +++ b/react-client/src/App.tsx @@ -4,6 +4,7 @@ import LoginPage from './pages/LoginPage'; import RegisterPage from './pages/RegisterPage'; import DashboardPage from './pages/DashboardPage'; import AddCharacterPage from './pages/AddCharacterPage'; +import GameServerPage from './pages/GameServerPage'; import { useAuth } from './hooks/useAuth'; function App() { @@ -29,6 +30,12 @@ function App() { isAuthenticated ? : } /> + : + } + /> ); diff --git a/react-client/src/pages/DashboardPage.tsx b/react-client/src/pages/DashboardPage.tsx index 441f40a..a31333c 100644 --- a/react-client/src/pages/DashboardPage.tsx +++ b/react-client/src/pages/DashboardPage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import api from '../utils/api'; import { useCharacters } from '../hooks/useCharacters'; @@ -24,16 +24,39 @@ interface GameCharacter { createdAt: string; } +interface GameAccountInfo { + hasGameAccount: boolean; + accountName: string | null; + existsOnServer: boolean; +} + function DashboardPage() { const navigate = useNavigate(); const { characters, loading, fetchCharacters } = useCharacters(); const [showCreateForm, setShowCreateForm] = useState(false); const [createError, setCreateError] = useState(''); - const [userId, setUserId] = useState(''); + const [gameAccount, setGameAccount] = useState(null); + const [accountLoading, setAccountLoading] = useState(true); + + useEffect(() => { + const fetchGameAccount = async () => { + try { + setAccountLoading(true); + const response = await api.get('/auth/game-account'); + setGameAccount(response.data); + } catch { + setGameAccount({ hasGameAccount: false, accountName: null, existsOnServer: false }); + } finally { + setAccountLoading(false); + } + }; + fetchGameAccount(); + }, []); const handleDelete = async (charName: string) => { if (!confirm(`Are you sure you want to delete character "${charName}"?`)) return; try { + const userId = gameAccount?.accountName || ''; await api.delete(`/users/me/characters/${charName}`, { data: { userId } }); fetchCharacters(); } catch { @@ -52,13 +75,13 @@ function DashboardPage() { return; } - if (!userId) { - setCreateError('User account is required'); + if (!gameAccount?.accountName) { + setCreateError('Game server account is required'); return; } try { - await api.post('/users/me/characters', { name, userId }); + await api.post('/users/me/characters', { name, userId: gameAccount.accountName }); fetchCharacters(); setShowCreateForm(false); } catch { @@ -79,6 +102,9 @@ function DashboardPage() { + @@ -86,23 +112,34 @@ function DashboardPage() {
- {/* User Account Input */} + {/* Game Server Account Info */}
- -
- setUserId(e.target.value)} - placeholder="Enter your game server account name" - style={{ flex: 1 }} - /> - -
+ + {accountLoading ? ( +
Loading account status...
+ ) : gameAccount?.hasGameAccount && gameAccount.existsOnServer ? ( +
+ + +
+ ) : ( +
+ + No game server account linked. + + +
+ )}
diff --git a/react-client/src/pages/GameServerPage.tsx b/react-client/src/pages/GameServerPage.tsx new file mode 100644 index 0000000..218c650 --- /dev/null +++ b/react-client/src/pages/GameServerPage.tsx @@ -0,0 +1,233 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import api from '../utils/api'; + +interface GameAccountStatus { + hasGameAccount: boolean; + accountName: string | null; + existsOnServer: boolean; +} + +function GameServerPage() { + const navigate = useNavigate(); + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const [createPassword, setCreatePassword] = useState(''); + const [createConfirm, setCreateConfirm] = useState(''); + + const [newPassword, setNewPassword] = useState(''); + const [newConfirm, setNewConfirm] = useState(''); + + const fetchStatus = async () => { + try { + setLoading(true); + const response = await api.get('/auth/game-account'); + setStatus(response.data); + setError(''); + } catch { + setError('Failed to load game account status'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchStatus(); + }, []); + + const handleCreate = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + + if (createPassword !== createConfirm) { + setError('Passwords do not match'); + return; + } + + if (createPassword.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + try { + await api.post('/auth/game-account', { password: createPassword }); + setCreatePassword(''); + setCreateConfirm(''); + setSuccess('Game account created successfully!'); + fetchStatus(); + } catch (err: any) { + setError(err.response?.data?.error || 'Failed to create game account'); + } + }; + + const handleUpdatePassword = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setSuccess(''); + + if (newPassword !== newConfirm) { + setError('Passwords do not match'); + return; + } + + if (newPassword.length < 6) { + setError('Password must be at least 6 characters'); + return; + } + + try { + await api.put('/auth/game-account/password', { password: newPassword }); + setNewPassword(''); + setNewConfirm(''); + setSuccess('Password updated successfully!'); + } catch (err: any) { + setError(err.response?.data?.error || 'Failed to update password'); + } + }; + + const renderContent = () => { + if (loading) { + return
Loading game account status...
; + } + + if (!status?.hasGameAccount || !status?.existsOnServer) { + return ( + <> +
+

+ Your game server account will be created with the same username as your web account. +

+

Account name: {status?.accountName || 'Your username'}

+
+ +
+
+ + setCreatePassword(e.target.value)} + placeholder="Minimum 6 characters" + required + minLength={6} + /> +
+
+ + setCreateConfirm(e.target.value)} + placeholder="Re-enter your password" + required + minLength={6} + /> +
+ +
+ + ); + } + + return ( + <> +
+

+ Game account: {status.accountName} +

+

Account is active on the game server.

+
+ +
+ +

Update Game Password

+
+
+ + setNewPassword(e.target.value)} + placeholder="Minimum 6 characters" + required + minLength={6} + /> +
+
+ + setNewConfirm(e.target.value)} + placeholder="Re-enter new password" + required + minLength={6} + /> +
+ +
+ + ); + }; + + return ( +
+
+
+

Game Server

+

+ Manage your Lineage II game server account +

+
+
+ +
+
+ +
+
+ {(error || success) && ( +
+ {error || success} +
+ )} + {renderContent()} +
+
+
+ ); +} + +export default GameServerPage; diff --git a/react-client/src/pages/RegisterPage.tsx b/react-client/src/pages/RegisterPage.tsx index 590dd5e..75cbb15 100644 --- a/react-client/src/pages/RegisterPage.tsx +++ b/react-client/src/pages/RegisterPage.tsx @@ -15,6 +15,12 @@ function RegisterPage() { e.preventDefault(); setError(''); + const USERNAME_REGEX = /^[a-zA-Z0-9]+$/; + if (!USERNAME_REGEX.test(username)) { + setError('Username must contain only English letters and numbers'); + return; + } + if (password !== confirmPassword) { setError('Passwords do not match'); return; diff --git a/test/smoke-test.sh b/test/smoke-test.sh new file mode 100755 index 0000000..1d97955 --- /dev/null +++ b/test/smoke-test.sh @@ -0,0 +1,121 @@ +#!/bin/bash +set -e + +API_URL="http://localhost:3001" +FE_URL="http://localhost:5173" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +passed=0 +failed=0 + +log_pass() { + echo -e "${GREEN}PASS${NC}: $1" + ((passed++)) || true +} + +log_fail() { + echo -e "${RED}FAIL${NC}: $1" + ((failed++)) || true +} + +log_info() { + echo -e "${YELLOW}INFO${NC}: $1" +} + +echo "========================================" +echo " LA2 Eternal Portal - Docker Smoke Test" +echo "========================================" +echo "" + +# 1. Check API container is running +log_info "Checking API container..." +if curl -s -o /dev/null -w "%{http_code}" "$API_URL/api/auth/register" | grep -q "400\|404\|405\|200"; then + log_pass "API is reachable on port 3001" +else + log_fail "API is not reachable on port 3001" +fi + +# 2. Check Frontend container is running +log_info "Checking Frontend container..." +FE_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$FE_URL" || echo "000") +if [ "$FE_CODE" = "200" ] || [ "$FE_CODE" = "301" ]; then + log_pass "Frontend is reachable on port 5173 (HTTP $FE_CODE)" +else + log_fail "Frontend is not reachable on port 5173 (HTTP $FE_CODE)" +fi + +# 3. Test registration with invalid username (should fail) +log_info "Testing registration validation (invalid username)..." +REG_INVALID=$(curl -s -w "\n%{http_code}" -X POST "$API_URL/api/auth/register" \ + -H "Content-Type: application/json" \ + -d '{"username":"bad@user!","email":"bad@example.com","password":"testpass123"}' || echo "000") +REG_INVALID_CODE=$(echo "$REG_INVALID" | tail -n1) +if [ "$REG_INVALID_CODE" = "400" ]; then + log_pass "Registration rejects invalid username (alphanumeric validation)" +else + log_fail "Registration did not reject invalid username (HTTP $REG_INVALID_CODE)" +fi + +# 4. Test registration with valid username +UNIQUE_USER="testuser$(date +%s)" +log_info "Testing registration with valid username: $UNIQUE_USER..." +REG_RESULT=$(curl -s -w "\n%{http_code}" -X POST "$API_URL/api/auth/register" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"$UNIQUE_USER\",\"email\":\"$UNIQUE_USER@example.com\",\"password\":\"testpass123\"}" || echo "000") +REG_CODE=$(echo "$REG_RESULT" | tail -n1) +if [ "$REG_CODE" = "201" ]; then + log_pass "Registration succeeds with valid username" +else + log_fail "Registration failed (HTTP $REG_CODE) — user may already exist" +fi + +# 5. Test login +log_info "Testing login..." +LOGIN_RESULT=$(curl -s -w "\n%{http_code}" -X POST "$API_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"email\":\"$UNIQUE_USER@example.com\",\"password\":\"testpass123\"}" || echo "000") +LOGIN_CODE=$(echo "$LOGIN_RESULT" | tail -n1) +TOKEN=$(echo "$LOGIN_RESULT" | sed '$d' | grep -o '"token":"[^"]*"' | cut -d'"' -f4) + +if [ "$LOGIN_CODE" = "200" ] && [ -n "$TOKEN" ]; then + log_pass "Login returns JWT token" +else + log_fail "Login failed (HTTP $LOGIN_CODE)" +fi + +# 6. Test /auth/me with token +if [ -n "$TOKEN" ]; then + log_info "Testing /auth/me..." + ME_RESULT=$(curl -s -w "\n%{http_code}" "$API_URL/api/auth/me" \ + -H "Authorization: Bearer $TOKEN" || echo "000") + ME_CODE=$(echo "$ME_RESULT" | tail -n1) + if [ "$ME_CODE" = "200" ]; then + log_pass "/auth/me returns authenticated user profile" + else + log_fail "/auth/me failed (HTTP $ME_CODE)" + fi + + # 7. Test /auth/game-account with token + log_info "Testing /auth/game-account..." + GA_RESULT=$(curl -s -w "\n%{http_code}" "$API_URL/api/auth/game-account" \ + -H "Authorization: Bearer $TOKEN" || echo "000") + GA_CODE=$(echo "$GA_RESULT" | tail -n1) + if [ "$GA_CODE" = "200" ]; then + log_pass "/auth/game-account returns game account status" + else + log_fail "/auth/game-account failed (HTTP $GA_CODE)" + fi +fi + +echo "" +echo "========================================" +echo " Results: $passed passed, $failed failed" +echo "========================================" + +if [ "$failed" -gt 0 ]; then + exit 1 +fi