Add coordinate validation and ESLint integration

- Add explicit latitude/longitude validation in location submissions
- Implement ESLint with TypeScript support and flat config
- Auto-fix 621 formatting issues across codebase
- Add comprehensive tests for coordinate validation
- Update documentation with lint scripts and validation rules
- Maintain 128 passing tests with enhanced security

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Claude Code 2025-07-05 22:12:37 -04:00
parent 5176636f6d
commit 30fdd72cc5
20 changed files with 2171 additions and 599 deletions

View file

@ -58,9 +58,18 @@ npm run build-css:dev
npm run watch-css npm run watch-css
``` ```
### Code Quality
```bash
# Run ESLint to check code style and quality
npm run lint
# Auto-fix ESLint issues where possible
npm run lint:fix
```
### Testing ### Testing
```bash ```bash
# Run all tests (125+ tests with TypeScript) # Run all tests (128+ tests with TypeScript)
npm test npm test
# Run tests with coverage report (76% overall coverage) # Run tests with coverage report (76% overall coverage)

View file

@ -6,10 +6,11 @@ A community-driven web application for tracking winter road conditions and icy h
- 🗺️ **Interactive Map** - Real-time location tracking centered on Grand Rapids - 🗺️ **Interactive Map** - Real-time location tracking centered on Grand Rapids
- ⚡ **Fast Geocoding** - Lightning-fast address lookup with MapBox API - ⚡ **Fast Geocoding** - Lightning-fast address lookup with MapBox API
- 🔄 **Auto-Expiration** - Reports automatically removed after 24 hours - 🔄 **Auto-Expiration** - Reports automatically removed after 48 hours
- 👨‍💼 **Admin Panel** - Manage and moderate location reports - 👨‍💼 **Admin Panel** - Manage and moderate location reports
- 📱 **Responsive Design** - Works on desktop and mobile devices - 📱 **Responsive Design** - Works on desktop and mobile devices
- 🔒 **Privacy-Focused** - No user tracking, community safety oriented - 🔒 **Privacy-Focused** - No user tracking, community safety oriented
- 🛡️ **Enhanced Security** - Coordinate validation, rate limiting, and content filtering
## Quick Start ## Quick Start
@ -39,11 +40,31 @@ A community-driven web application for tracking winter road conditions and icy h
4. **Start the server:** 4. **Start the server:**
```bash ```bash
npm start # Production mode npm start # Production mode
npm run dev # Development mode npm run dev # Development mode
npm run dev-with-css # Development with CSS watching npm run dev-with-css # Development with CSS watching
``` ```
## Development Commands
### Code Quality
```bash
# Run ESLint to check code style and quality
npm run lint
# Auto-fix ESLint issues where possible
npm run lint:fix
```
### Testing
```bash
# Run all tests (128+ tests with TypeScript)
npm test
# Run tests with coverage report (76% overall coverage)
npm run test:coverage
```
5. **Visit the application:** 5. **Visit the application:**
``` ```
http://localhost:3000 http://localhost:3000
@ -139,7 +160,7 @@ Interactive API documentation available at `/api-docs` when running the server.
- **Frontend:** Vanilla JavaScript, Leaflet.js - **Frontend:** Vanilla JavaScript, Leaflet.js
- **Geocoding:** MapBox API (with Nominatim fallback) - **Geocoding:** MapBox API (with Nominatim fallback)
- **Security:** Rate limiting, input validation, authentication - **Security:** Rate limiting, input validation, authentication
- **Testing:** Jest, TypeScript, 125+ tests with 76% coverage - **Testing:** Jest, TypeScript, 128+ tests with 76% coverage
- **Reverse Proxy:** Caddy (automatic HTTPS) - **Reverse Proxy:** Caddy (automatic HTTPS)
- **Database:** SQLite (lightweight, serverless) - **Database:** SQLite (lightweight, serverless)
@ -165,6 +186,8 @@ Interactive API documentation available at `/api-docs` when running the server.
### Input Limits ### Input Limits
- **Address:** Maximum 500 characters - **Address:** Maximum 500 characters
- **Description:** Maximum 1000 characters - **Description:** Maximum 1000 characters
- **Latitude:** Must be between -90 and 90 degrees
- **Longitude:** Must be between -180 and 180 degrees
- **Submissions:** 10 per 15 minutes per IP address - **Submissions:** 10 per 15 minutes per IP address
## License ## License

77
eslint.config.mjs Normal file
View file

@ -0,0 +1,77 @@
import js from '@eslint/js';
import typescript from '@typescript-eslint/eslint-plugin';
import typescriptParser from '@typescript-eslint/parser';
import globals from 'globals';
export default [
{
ignores: ['node_modules/', 'dist/', 'public/*.css', '*.db', '*.scss', '*.md']
},
js.configs.recommended,
{
files: ['**/*.ts'],
languageOptions: {
parser: typescriptParser,
parserOptions: {
ecmaVersion: 2020,
sourceType: 'module'
},
globals: {
...globals.node
}
},
plugins: {
'@typescript-eslint': typescript
},
rules: {
// TypeScript rules
'@typescript-eslint/no-unused-vars': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
// General rules
'no-console': 'off', // Allow console.log for server logging
'no-var': 'error',
'prefer-const': 'error',
'eqeqeq': 'error',
'no-unused-vars': 'off', // Use TypeScript version instead
// Style rules
'indent': ['error', 2],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'comma-dangle': ['error', 'never'],
'no-trailing-spaces': 'error'
}
},
{
files: ['**/*.js'],
languageOptions: {
globals: {
...globals.node
}
},
rules: {
'no-console': 'off',
'no-var': 'error',
'prefer-const': 'error',
'eqeqeq': 'error',
'indent': ['error', 2],
'quotes': ['error', 'single'],
'semi': ['error', 'always'],
'comma-dangle': ['error', 'never'],
'no-trailing-spaces': 'error'
}
},
{
files: ['tests/**/*.ts'],
languageOptions: {
globals: {
...globals.node,
...globals.jest
}
},
rules: {
'@typescript-eslint/no-explicit-any': 'off' // Allow any in tests for mocks
}
}
];

1384
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -17,6 +17,8 @@
"build:ts": "tsc", "build:ts": "tsc",
"test": "jest --runInBand --forceExit", "test": "jest --runInBand --forceExit",
"test:coverage": "jest --coverage", "test:coverage": "jest --coverage",
"lint": "eslint src/ tests/",
"lint:fix": "eslint src/ tests/ --fix",
"postinstall": "npm run build-css" "postinstall": "npm run build-css"
}, },
"dependencies": { "dependencies": {
@ -39,7 +41,11 @@
"@types/supertest": "^6.0.3", "@types/supertest": "^6.0.3",
"@types/swagger-jsdoc": "^6.0.4", "@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8", "@types/swagger-ui-express": "^4.1.8",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"concurrently": "^9.2.0", "concurrently": "^9.2.0",
"eslint": "^9.30.1",
"globals": "^16.3.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"jest-environment-node": "^30.0.4", "jest-environment-node": "^30.0.4",
"nodemon": "^3.1.10", "nodemon": "^3.1.10",

View file

@ -144,7 +144,7 @@ class Location {
) )
`, (err: Error | null) => { `, (err: Error | null) => {
if (err) return reject(err); if (err) return reject(err);
this.db.run(` this.db.run(`
ALTER TABLE locations ADD COLUMN persistent INTEGER DEFAULT 0 ALTER TABLE locations ADD COLUMN persistent INTEGER DEFAULT 0
`, (err: Error | null) => { `, (err: Error | null) => {

View file

@ -62,9 +62,9 @@ class ProfanityWord {
} }
async create( async create(
word: string, word: string,
severity: 'low' | 'medium' | 'high', severity: 'low' | 'medium' | 'high',
category: string, category: string,
createdBy: string = 'admin' createdBy: string = 'admin'
): Promise<ProfanityWordCreatedResult> { ): Promise<ProfanityWordCreatedResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -73,10 +73,10 @@ class ProfanityWord {
[word.toLowerCase(), severity, category, createdBy], [word.toLowerCase(), severity, category, createdBy],
function(this: { lastID: number }, err: Error | null) { function(this: { lastID: number }, err: Error | null) {
if (err) return reject(err); if (err) return reject(err);
resolve({ resolve({
id: this.lastID, id: this.lastID,
word: word.toLowerCase(), word: word.toLowerCase(),
severity, severity,
category category
}); });
} }
@ -85,9 +85,9 @@ class ProfanityWord {
} }
async update( async update(
id: number, id: number,
word: string, word: string,
severity: 'low' | 'medium' | 'high', severity: 'low' | 'medium' | 'high',
category: string category: string
): Promise<DatabaseResult> { ): Promise<DatabaseResult> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {

View file

@ -72,14 +72,14 @@ interface ProfanityTestRequest extends Request {
type AuthMiddleware = (req: Request, res: Response, next: NextFunction) => void; type AuthMiddleware = (req: Request, res: Response, next: NextFunction) => void;
export default ( export default (
locationModel: Location, locationModel: Location,
profanityWordModel: ProfanityWord, profanityWordModel: ProfanityWord,
profanityFilter: ProfanityFilterService | any, profanityFilter: ProfanityFilterService | any,
authenticateAdmin: AuthMiddleware authenticateAdmin: AuthMiddleware
): Router => { ): Router => {
const router = express.Router(); const router = express.Router();
/** /**
* @swagger * @swagger
* /api/admin/login: * /api/admin/login:
* post: * post:
@ -114,21 +114,21 @@ export default (
* example: * example:
* error: "Invalid password" * error: "Invalid password"
*/ */
router.post('/login', (req: AdminLoginRequest, res: Response): void => { router.post('/login', (req: AdminLoginRequest, res: Response): void => {
console.log('Admin login attempt'); console.log('Admin login attempt');
const { password } = req.body; const { password } = req.body;
const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123'; const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123';
if (password === ADMIN_PASSWORD) {
console.log('Admin login successful');
res.json({ token: ADMIN_PASSWORD, message: 'Login successful' });
} else {
console.warn('Admin login failed: invalid password');
res.status(401).json({ error: 'Invalid password' });
}
});
/** if (password === ADMIN_PASSWORD) {
console.log('Admin login successful');
res.json({ token: ADMIN_PASSWORD, message: 'Login successful' });
} else {
console.warn('Admin login failed: invalid password');
res.status(401).json({ error: 'Invalid password' });
}
});
/**
* @swagger * @swagger
* /api/admin/locations: * /api/admin/locations:
* get: * get:
@ -183,219 +183,219 @@ export default (
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
*/ */
router.get('/locations', authenticateAdmin, async (req: Request, res: Response): Promise<void> => { router.get('/locations', authenticateAdmin, async (req: Request, res: Response): Promise<void> => {
try { try {
const rows = await locationModel.getAll(); const rows = await locationModel.getAll();
// Process and clean data before sending
const locations = rows.map(row => ({
id: row.id,
address: row.address,
description: row.description || '',
latitude: row.latitude,
longitude: row.longitude,
persistent: !!row.persistent,
created_at: row.created_at,
isActive: new Date(row.created_at || '').getTime() > Date.now() - 48 * 60 * 60 * 1000
}));
res.json(locations);
} catch (err) {
console.error('Error fetching all locations:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Update a location (admin only) // Process and clean data before sending
router.put('/locations/:id', authenticateAdmin, async (req: LocationUpdateRequest, res: Response): Promise<void> => { const locations = rows.map(row => ({
const { id } = req.params; id: row.id,
const { address, latitude, longitude, description } = req.body; address: row.address,
description: row.description || '',
if (!address) { latitude: row.latitude,
res.status(400).json({ error: 'Address is required' }); longitude: row.longitude,
return; persistent: !!row.persistent,
} created_at: row.created_at,
isActive: new Date(row.created_at || '').getTime() > Date.now() - 48 * 60 * 60 * 1000
try { }));
const result = await locationModel.update(parseInt(id, 10), {
address,
latitude,
longitude,
description
});
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location updated successfully' });
} catch (err) {
console.error('Error updating location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Toggle persistent status of a location (admin only) res.json(locations);
router.patch('/locations/:id/persistent', authenticateAdmin, async (req: LocationPersistentRequest, res: Response): Promise<void> => { } catch (err) {
const { id } = req.params; console.error('Error fetching all locations:', err);
const { persistent } = req.body; res.status(500).json({ error: 'Internal server error' });
}
if (typeof persistent !== 'boolean') { });
res.status(400).json({ error: 'Persistent value must be a boolean' });
return;
}
try {
const result = await locationModel.togglePersistent(parseInt(id, 10), persistent);
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
console.log(`Location ${id} persistent status set to ${persistent}`);
res.json({ message: 'Persistent status updated successfully', persistent });
} catch (err) {
console.error('Error updating persistent status:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete a location (admin authentication required) // Update a location (admin only)
router.delete('/locations/:id', authenticateAdmin, async (req: LocationDeleteRequest, res: Response): Promise<void> => { router.put('/locations/:id', authenticateAdmin, async (req: LocationUpdateRequest, res: Response): Promise<void> => {
const { id } = req.params; const { id } = req.params;
const { address, latitude, longitude, description } = req.body;
try {
const result = await locationModel.delete(parseInt(id, 10));
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
} catch (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Profanity Management Routes if (!address) {
res.status(400).json({ error: 'Address is required' });
return;
}
// Get all custom profanity words (admin only) try {
router.get('/profanity-words', authenticateAdmin, async (req: Request, res: Response): Promise<void> => { const result = await locationModel.update(parseInt(id, 10), {
try { address,
const words = await profanityFilter.getCustomWords(); latitude,
res.json(words); longitude,
} catch (error) { description
console.error('Error fetching custom profanity words:', error); });
res.status(500).json({ error: 'Internal server error' });
}
});
// Add a custom profanity word (admin only) if (result.changes === 0) {
router.post('/profanity-words', authenticateAdmin, async (req: ProfanityWordCreateRequest, res: Response): Promise<void> => { res.status(404).json({ error: 'Location not found' });
try { return;
const { word, severity = 'medium', category = 'custom' } = req.body; }
if (!word || typeof word !== 'string' || word.trim().length === 0) {
res.status(400).json({ error: 'Word is required and must be a non-empty string' });
return;
}
if (!['low', 'medium', 'high'].includes(severity)) {
res.status(400).json({ error: 'Severity must be low, medium, or high' });
return;
}
const result = await profanityFilter.addCustomWord(word, severity, category, 'admin');
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin added custom profanity word: ${word}`);
res.json(result);
} catch (error: any) {
console.error('Error adding custom profanity word:', error);
if (error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Update a custom profanity word (admin only) res.json({ message: 'Location updated successfully' });
router.put('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordUpdateRequest, res: Response): Promise<void> => { } catch (err) {
try { console.error('Error updating location:', err);
const { id } = req.params; res.status(500).json({ error: 'Internal server error' });
const { word, severity, category } = req.body; }
});
if (!word || typeof word !== 'string' || word.trim().length === 0) {
res.status(400).json({ error: 'Word is required and must be a non-empty string' });
return;
}
if (!['low', 'medium', 'high'].includes(severity)) {
res.status(400).json({ error: 'Severity must be low, medium, or high' });
return;
}
const result = await profanityFilter.updateCustomWord(parseInt(id, 10), { word, severity, category });
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin updated custom profanity word ID ${id}`);
res.json(result);
} catch (error: any) {
console.error('Error updating custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Delete a custom profanity word (admin only) // Toggle persistent status of a location (admin only)
router.delete('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordDeleteRequest, res: Response): Promise<void> => { router.patch('/locations/:id/persistent', authenticateAdmin, async (req: LocationPersistentRequest, res: Response): Promise<void> => {
try { const { id } = req.params;
const { id } = req.params; const { persistent } = req.body;
const result = await profanityFilter.removeCustomWord(parseInt(id, 10));
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin deleted custom profanity word ID ${id}`);
res.json(result);
} catch (error: any) {
console.error('Error deleting custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Test profanity filter (admin only) - for testing purposes if (typeof persistent !== 'boolean') {
router.post('/test-profanity', authenticateAdmin, (req: ProfanityTestRequest, res: Response): void => { res.status(400).json({ error: 'Persistent value must be a boolean' });
try { return;
const { text } = req.body; }
if (!text || typeof text !== 'string') {
res.status(400).json({ error: 'Text is required for testing' });
return;
}
const analysis = profanityFilter.analyzeProfanity(text);
res.json({
original: text,
analysis: analysis,
filtered: analysis.filtered
});
} catch (error) {
console.error('Error testing profanity filter:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
return router; try {
const result = await locationModel.togglePersistent(parseInt(id, 10), persistent);
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
console.log(`Location ${id} persistent status set to ${persistent}`);
res.json({ message: 'Persistent status updated successfully', persistent });
} catch (err) {
console.error('Error updating persistent status:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Delete a location (admin authentication required)
router.delete('/locations/:id', authenticateAdmin, async (req: LocationDeleteRequest, res: Response): Promise<void> => {
const { id } = req.params;
try {
const result = await locationModel.delete(parseInt(id, 10));
if (result.changes === 0) {
res.status(404).json({ error: 'Location not found' });
return;
}
res.json({ message: 'Location deleted successfully' });
} catch (err) {
console.error('Error deleting location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// Profanity Management Routes
// Get all custom profanity words (admin only)
router.get('/profanity-words', authenticateAdmin, async (req: Request, res: Response): Promise<void> => {
try {
const words = await profanityFilter.getCustomWords();
res.json(words);
} catch (error) {
console.error('Error fetching custom profanity words:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Add a custom profanity word (admin only)
router.post('/profanity-words', authenticateAdmin, async (req: ProfanityWordCreateRequest, res: Response): Promise<void> => {
try {
const { word, severity = 'medium', category = 'custom' } = req.body;
if (!word || typeof word !== 'string' || word.trim().length === 0) {
res.status(400).json({ error: 'Word is required and must be a non-empty string' });
return;
}
if (!['low', 'medium', 'high'].includes(severity)) {
res.status(400).json({ error: 'Severity must be low, medium, or high' });
return;
}
const result = await profanityFilter.addCustomWord(word, severity, category, 'admin');
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin added custom profanity word: ${word}`);
res.json(result);
} catch (error: any) {
console.error('Error adding custom profanity word:', error);
if (error.message.includes('already exists')) {
res.status(409).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Update a custom profanity word (admin only)
router.put('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordUpdateRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const { word, severity, category } = req.body;
if (!word || typeof word !== 'string' || word.trim().length === 0) {
res.status(400).json({ error: 'Word is required and must be a non-empty string' });
return;
}
if (!['low', 'medium', 'high'].includes(severity)) {
res.status(400).json({ error: 'Severity must be low, medium, or high' });
return;
}
const result = await profanityFilter.updateCustomWord(parseInt(id, 10), { word, severity, category });
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin updated custom profanity word ID ${id}`);
res.json(result);
} catch (error: any) {
console.error('Error updating custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Delete a custom profanity word (admin only)
router.delete('/profanity-words/:id', authenticateAdmin, async (req: ProfanityWordDeleteRequest, res: Response): Promise<void> => {
try {
const { id } = req.params;
const result = await profanityFilter.removeCustomWord(parseInt(id, 10));
await profanityFilter.loadCustomWords(); // Reload to update patterns
console.log(`Admin deleted custom profanity word ID ${id}`);
res.json(result);
} catch (error: any) {
console.error('Error deleting custom profanity word:', error);
if (error.message.includes('not found')) {
res.status(404).json({ error: error.message });
} else {
res.status(500).json({ error: 'Internal server error' });
}
}
});
// Test profanity filter (admin only) - for testing purposes
router.post('/test-profanity', authenticateAdmin, (req: ProfanityTestRequest, res: Response): void => {
try {
const { text } = req.body;
if (!text || typeof text !== 'string') {
res.status(400).json({ error: 'Text is required for testing' });
return;
}
const analysis = profanityFilter.analyzeProfanity(text);
res.json({
original: text,
analysis: analysis,
filtered: analysis.filtered
});
} catch (error) {
console.error('Error testing profanity filter:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
return router;
}; };

View file

@ -1,9 +1,9 @@
import express, { Request, Response, Router } from 'express'; import express, { Request, Response, Router } from 'express';
export default (): Router => { export default (): Router => {
const router = express.Router(); const router = express.Router();
/** /**
* @swagger * @swagger
* /api/config: * /api/config:
* get: * get:
@ -36,20 +36,20 @@ export default (): Router => {
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
*/ */
router.get('/', (req: Request, res: Response): void => { router.get('/', (req: Request, res: Response): void => {
console.log('📡 API Config requested'); console.log('📡 API Config requested');
const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined; const MAPBOX_ACCESS_TOKEN: string | undefined = process.env.MAPBOX_ACCESS_TOKEN || undefined;
console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN);
console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
res.json({
// MapBox tokens are designed to be public (they have domain restrictions)
mapboxAccessToken: MAPBOX_ACCESS_TOKEN || null,
hasMapbox: !!MAPBOX_ACCESS_TOKEN
// SECURITY: Google Maps API key is kept server-side only
});
});
return router; console.log('MapBox token present:', !!MAPBOX_ACCESS_TOKEN);
console.log('MapBox token starts with pk:', MAPBOX_ACCESS_TOKEN?.startsWith('pk.'));
res.json({
// MapBox tokens are designed to be public (they have domain restrictions)
mapboxAccessToken: MAPBOX_ACCESS_TOKEN || null,
hasMapbox: !!MAPBOX_ACCESS_TOKEN
// SECURITY: Google Maps API key is kept server-side only
});
});
return router;
}; };

View file

@ -16,24 +16,24 @@ interface LocationPostRequest extends Request {
export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => { export default (locationModel: Location, profanityFilter: ProfanityFilterService | any): Router => {
const router = express.Router(); const router = express.Router();
// Rate limiting for location submissions to prevent abuse // Rate limiting for location submissions to prevent abuse
const submitLocationLimiter = rateLimit({ const submitLocationLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes windowMs: 15 * 60 * 1000, // 15 minutes
limit: 10, // Limit each IP to 10 location submissions per 15 minutes limit: 10, // Limit each IP to 10 location submissions per 15 minutes
message: { message: {
error: 'Too many location reports submitted', error: 'Too many location reports submitted',
message: 'You can submit up to 10 location reports every 15 minutes. Please wait before submitting more.', message: 'You can submit up to 10 location reports every 15 minutes. Please wait before submitting more.',
retryAfter: '15 minutes' retryAfter: '15 minutes'
}, },
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers
// Skip rate limiting in test environment // Skip rate limiting in test environment
skip: (req) => process.env.NODE_ENV === 'test' skip: (req) => process.env.NODE_ENV === 'test'
}); });
/** /**
* @swagger * @swagger
* /api/locations: * /api/locations:
* get: * get:
@ -78,20 +78,20 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
*/ */
router.get('/', async (req: Request, res: Response): Promise<void> => { router.get('/', async (req: Request, res: Response): Promise<void> => {
console.log('Fetching active locations'); console.log('Fetching active locations');
try {
const locations = await locationModel.getActive();
console.log(`Fetched ${locations.length} active locations (including persistent)`);
res.json(locations);
} catch (err) {
console.error('Error fetching locations:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/** try {
const locations = await locationModel.getActive();
console.log(`Fetched ${locations.length} active locations (including persistent)`);
res.json(locations);
} catch (err) {
console.error('Error fetching locations:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
/**
* @swagger * @swagger
* /api/locations: * /api/locations:
* post: * post:
@ -165,84 +165,98 @@ export default (locationModel: Location, profanityFilter: ProfanityFilterService
* schema: * schema:
* $ref: '#/components/schemas/Error' * $ref: '#/components/schemas/Error'
*/ */
router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise<void> => { router.post('/', submitLocationLimiter, async (req: LocationPostRequest, res: Response): Promise<void> => {
const { address, latitude, longitude } = req.body; const { address, latitude, longitude } = req.body;
let { description } = req.body; const { description } = req.body;
console.log(`Attempt to add new location: ${address}`); console.log(`Attempt to add new location: ${address}`);
// Input validation for security
if (!address) {
console.warn('Failed to add location: Address is required');
res.status(400).json({ error: 'Address is required' });
return;
}
if (typeof address !== 'string' || address.length > 500) { // Input validation for security
console.warn(`Failed to add location: Invalid address length (${address?.length || 0})`); if (!address) {
res.status(400).json({ error: 'Address must be a string with maximum 500 characters' }); console.warn('Failed to add location: Address is required');
return; res.status(400).json({ error: 'Address is required' });
} return;
}
if (description && (typeof description !== 'string' || description.length > 1000)) { if (typeof address !== 'string' || address.length > 500) {
console.warn(`Failed to add location: Invalid description length (${description?.length || 0})`); console.warn(`Failed to add location: Invalid address length (${address?.length || 0})`);
res.status(400).json({ error: 'Description must be a string with maximum 1000 characters' }); res.status(400).json({ error: 'Address must be a string with maximum 500 characters' });
return; return;
} }
// Log suspicious activity if (description && (typeof description !== 'string' || description.length > 1000)) {
if (address.length > 200 || (description && description.length > 500)) { console.warn(`Failed to add location: Invalid description length (${description?.length || 0})`);
console.warn(`Unusually long submission from IP: ${req.ip} - Address: ${address.length} chars, Description: ${description?.length || 0} chars`); res.status(400).json({ error: 'Description must be a string with maximum 1000 characters' });
} return;
}
// Check for profanity in description and reject if any is found
if (description && profanityFilter) { // Validate latitude if provided
try { if (latitude !== undefined && (typeof latitude !== 'number' || latitude < -90 || latitude > 90)) {
const analysis = profanityFilter.analyzeProfanity(description); console.warn(`Failed to add location: Invalid latitude (${latitude})`);
if (analysis.hasProfanity) { res.status(400).json({ error: 'Latitude must be a number between -90 and 90' });
console.warn(`Submission rejected due to inappropriate language (${analysis.count} word${analysis.count > 1 ? 's' : ''}, severity: ${analysis.severity}) - Original: "${req.body.description}"`); return;
}
// Reject any submission with profanity
const wordText = analysis.count === 1 ? 'word' : 'words'; // Validate longitude if provided
const detectedWords = analysis.matches.map((m: any) => m.word).join(', '); if (longitude !== undefined && (typeof longitude !== 'number' || longitude < -180 || longitude > 180)) {
console.warn(`Failed to add location: Invalid longitude (${longitude})`);
res.status(400).json({ res.status(400).json({ error: 'Longitude must be a number between -180 and 180' });
error: 'Submission rejected', return;
message: `Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.\n\nExample: "Multiple vehicles stuck, black ice present" or "Road very slippery, saw 3 accidents"`, }
details: {
severity: analysis.severity, // Log suspicious activity
wordCount: analysis.count, if (address.length > 200 || (description && description.length > 500)) {
detectedCategories: [...new Set(analysis.matches.map((m: any) => m.category))] console.warn(`Unusually long submission from IP: ${req.ip} - Address: ${address.length} chars, Description: ${description?.length || 0} chars`);
} }
});
return; // Check for profanity in description and reject if any is found
} if (description && profanityFilter) {
} catch (filterError) { try {
console.error('Error checking profanity:', filterError); const analysis = profanityFilter.analyzeProfanity(description);
// Continue with original description if filter fails if (analysis.hasProfanity) {
console.warn(`Submission rejected due to inappropriate language (${analysis.count} word${analysis.count > 1 ? 's' : ''}, severity: ${analysis.severity}) - Original: "${req.body.description}"`);
// Reject any submission with profanity
const wordText = analysis.count === 1 ? 'word' : 'words';
const detectedWords = analysis.matches.map((m: any) => m.word).join(', ');
res.status(400).json({
error: 'Submission rejected',
message: 'Your description contains inappropriate language and cannot be posted. Please revise your description to focus on road conditions and keep it professional.\n\nExample: "Multiple vehicles stuck, black ice present" or "Road very slippery, saw 3 accidents"',
details: {
severity: analysis.severity,
wordCount: analysis.count,
detectedCategories: [...new Set(analysis.matches.map((m: any) => m.category))]
} }
});
return;
} }
} catch (filterError) {
try { console.error('Error checking profanity:', filterError);
const newLocation = await locationModel.create({ // Continue with original description if filter fails
address, }
latitude, }
longitude,
description
});
console.log(`Location added successfully: ${address}`);
res.json({
...newLocation,
created_at: new Date().toISOString()
});
} catch (err) {
console.error('Error inserting location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE functionality has been moved to admin-only routes for security. try {
// Use /api/admin/locations/:id (with authentication) for location deletion. const newLocation = await locationModel.create({
address,
latitude,
longitude,
description
});
return router; console.log(`Location added successfully: ${address}`);
res.json({
...newLocation,
created_at: new Date().toISOString()
});
} catch (err) {
console.error('Error inserting location:', err);
res.status(500).json({ error: 'Internal server error' });
}
});
// DELETE functionality has been moved to admin-only routes for security.
// Use /api/admin/locations/:id (with authentication) for location deletion.
return router;
}; };

View file

@ -62,82 +62,82 @@ interface FallbackFilter {
// Create fallback filter function // Create fallback filter function
function createFallbackFilter(): FallbackFilter { function createFallbackFilter(): FallbackFilter {
return { return {
// Core profanity checking methods // Core profanity checking methods
containsProfanity: (): boolean => false, containsProfanity: (): boolean => false,
analyzeProfanity: (text: string) => ({ analyzeProfanity: (text: string) => ({
hasProfanity: false, hasProfanity: false,
matches: [], matches: [],
severity: 'none', severity: 'none',
count: 0, count: 0,
filtered: text || '' filtered: text || ''
}), }),
filterProfanity: (text: string): string => text || '', filterProfanity: (text: string): string => text || '',
// Database management methods used by admin routes // Database management methods used by admin routes
addCustomWord: async (word: string, severity: string, category: string, createdBy?: string) => ({ addCustomWord: async (word: string, severity: string, category: string, createdBy?: string) => ({
id: null, id: null,
word: word || null, word: word || null,
severity: severity || null, severity: severity || null,
category: category || null, category: category || null,
createdBy: createdBy || null, createdBy: createdBy || null,
success: false, success: false,
error: 'Profanity filter not available - please check server configuration' error: 'Profanity filter not available - please check server configuration'
}), }),
removeCustomWord: async (wordId: number) => ({ removeCustomWord: async (wordId: number) => ({
success: false, success: false,
error: 'Profanity filter not available - please check server configuration' error: 'Profanity filter not available - please check server configuration'
}), }),
updateCustomWord: async (wordId: number, updates: any) => ({ updateCustomWord: async (wordId: number, updates: any) => ({
success: false, success: false,
error: 'Profanity filter not available - please check server configuration' error: 'Profanity filter not available - please check server configuration'
}), }),
getCustomWords: async (): Promise<any[]> => [], getCustomWords: async (): Promise<any[]> => [],
loadCustomWords: async (): Promise<void> => {}, loadCustomWords: async (): Promise<void> => {},
// Utility methods // Utility methods
getAllWords: (): any[] => [], getAllWords: (): any[] => [],
getSeverity: (): string => 'none', getSeverity: (): string => 'none',
getSeverityLevel: (): number => 0, getSeverityLevel: (): number => 0,
getSeverityName: (): string => 'none', getSeverityName: (): string => 'none',
normalizeText: (text: string): string => text || '', normalizeText: (text: string): string => text || '',
buildPatterns: (): any[] => [], buildPatterns: (): any[] => [],
// Cleanup method // Cleanup method
close: (): void => {}, close: (): void => {},
// Special property to identify this as a fallback filter // Special property to identify this as a fallback filter
_isFallback: true _isFallback: true
}; };
} }
// Initialize profanity filter asynchronously // Initialize profanity filter asynchronously
async function initializeProfanityFilter(): Promise<void> { async function initializeProfanityFilter(): Promise<void> {
try { try {
const profanityWordModel = databaseService.getProfanityWordModel(); const profanityWordModel = databaseService.getProfanityWordModel();
profanityFilter = new ProfanityFilterService(profanityWordModel); profanityFilter = new ProfanityFilterService(profanityWordModel);
await profanityFilter.initialize(); await profanityFilter.initialize();
console.log('Profanity filter initialized successfully'); console.log('Profanity filter initialized successfully');
} catch (error) { } catch (error) {
console.error('WARNING: Failed to initialize profanity filter:', error); console.error('WARNING: Failed to initialize profanity filter:', error);
console.error('Creating fallback no-op profanity filter. ALL CONTENT WILL BE ALLOWED!'); console.error('Creating fallback no-op profanity filter. ALL CONTENT WILL BE ALLOWED!');
console.error('This is a security risk - please fix the profanity filter configuration.'); console.error('This is a security risk - please fix the profanity filter configuration.');
profanityFilter = createFallbackFilter(); profanityFilter = createFallbackFilter();
console.warn('⚠️ SECURITY WARNING: Profanity filtering is DISABLED due to initialization failure!'); console.warn('⚠️ SECURITY WARNING: Profanity filtering is DISABLED due to initialization failure!');
} }
} }
// Clean up expired locations (older than 48 hours, but not persistent ones) // Clean up expired locations (older than 48 hours, but not persistent ones)
const cleanupExpiredLocations = async (): Promise<void> => { const cleanupExpiredLocations = async (): Promise<void> => {
console.log('Running cleanup of expired locations'); console.log('Running cleanup of expired locations');
try { try {
const locationModel = databaseService.getLocationModel(); const locationModel = databaseService.getLocationModel();
const result = await locationModel.cleanupExpired(); const result = await locationModel.cleanupExpired();
console.log(`Cleaned up ${result.changes} expired locations (persistent reports preserved)`); console.log(`Cleaned up ${result.changes} expired locations (persistent reports preserved)`);
} catch (err) { } catch (err) {
console.error('Error cleaning up expired locations:', err); console.error('Error cleaning up expired locations:', err);
} }
}; };
// Run cleanup every hour // Run cleanup every hour
@ -149,96 +149,96 @@ const ADMIN_PASSWORD: string = process.env.ADMIN_PASSWORD || 'admin123'; // Chan
// Authentication middleware // Authentication middleware
const authenticateAdmin = (req: Request, res: Response, next: NextFunction): void => { const authenticateAdmin = (req: Request, res: Response, next: NextFunction): void => {
const authHeader = req.headers.authorization; const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) { if (!authHeader || !authHeader.startsWith('Bearer ')) {
res.status(401).json({ error: 'Unauthorized' }); res.status(401).json({ error: 'Unauthorized' });
return; return;
} }
const token = authHeader.substring(7); const token = authHeader.substring(7);
if (token !== ADMIN_PASSWORD) { if (token !== ADMIN_PASSWORD) {
res.status(401).json({ error: 'Invalid credentials' }); res.status(401).json({ error: 'Invalid credentials' });
return; return;
} }
next(); next();
}; };
// Setup routes after database and profanity filter are initialized // Setup routes after database and profanity filter are initialized
function setupRoutes(): void { function setupRoutes(): void {
const locationModel = databaseService.getLocationModel(); const locationModel = databaseService.getLocationModel();
const profanityWordModel = databaseService.getProfanityWordModel(); const profanityWordModel = databaseService.getProfanityWordModel();
// API Documentation // API Documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customCss: '.swagger-ui .topbar { display: none }', customCss: '.swagger-ui .topbar { display: none }',
customSiteTitle: 'Great Lakes Ice Report API Documentation' customSiteTitle: 'Great Lakes Ice Report API Documentation'
})); }));
// API Routes // API Routes
app.use('/api/config', configRoutes()); app.use('/api/config', configRoutes());
app.use('/api/locations', locationRoutes(locationModel, profanityFilter)); app.use('/api/locations', locationRoutes(locationModel, profanityFilter));
app.use('/api/admin', adminRoutes(locationModel, profanityWordModel, profanityFilter, authenticateAdmin)); app.use('/api/admin', adminRoutes(locationModel, profanityWordModel, profanityFilter, authenticateAdmin));
// Static page routes // Static page routes
app.get('/', (req: Request, res: Response): void => { app.get('/', (req: Request, res: Response): void => {
console.log('Serving the main page'); console.log('Serving the main page');
res.sendFile(path.join(__dirname, '../../public', 'index.html')); res.sendFile(path.join(__dirname, '../../public', 'index.html'));
}); });
app.get('/admin', (req: Request, res: Response): void => { app.get('/admin', (req: Request, res: Response): void => {
console.log('Serving the admin page'); console.log('Serving the admin page');
res.sendFile(path.join(__dirname, '../../public', 'admin.html')); res.sendFile(path.join(__dirname, '../../public', 'admin.html'));
}); });
app.get('/privacy', (req: Request, res: Response): void => { app.get('/privacy', (req: Request, res: Response): void => {
console.log('Serving the privacy policy page'); console.log('Serving the privacy policy page');
res.sendFile(path.join(__dirname, '../../public', 'privacy.html')); res.sendFile(path.join(__dirname, '../../public', 'privacy.html'));
}); });
} }
// Async server startup function // Async server startup function
async function startServer(): Promise<void> { async function startServer(): Promise<void> {
try { try {
// Initialize database service first // Initialize database service first
await databaseService.initialize(); await databaseService.initialize();
console.log('Database service initialized successfully'); console.log('Database service initialized successfully');
// Initialize profanity filter // Initialize profanity filter
await initializeProfanityFilter(); await initializeProfanityFilter();
// Validate profanity filter is properly initialized // Validate profanity filter is properly initialized
if (!profanityFilter) { if (!profanityFilter) {
console.error('CRITICAL ERROR: profanityFilter is undefined after initialization attempt.'); console.error('CRITICAL ERROR: profanityFilter is undefined after initialization attempt.');
console.error('Cannot start server without a functional profanity filter.'); console.error('Cannot start server without a functional profanity filter.');
process.exit(1); process.exit(1);
}
// Initialize routes after everything is set up
setupRoutes();
// Start server
app.listen(PORT, (): void => {
console.log('===========================================');
console.log('Great Lakes Ice Report server started');
console.log(`Listening on port ${PORT}`);
console.log(`Visit http://localhost:${PORT} to view the website`);
// Display profanity filter status
if ('_isFallback' in profanityFilter && profanityFilter._isFallback) {
console.log('🚨 PROFANITY FILTER: DISABLED (FALLBACK MODE)');
console.log('⚠️ ALL USER CONTENT WILL BE ALLOWED!');
} else {
console.log('✅ PROFANITY FILTER: ACTIVE AND FUNCTIONAL');
}
console.log('===========================================');
});
} catch (error) {
console.error('CRITICAL ERROR: Failed to start server:', error);
process.exit(1);
} }
// Initialize routes after everything is set up
setupRoutes();
// Start server
app.listen(PORT, (): void => {
console.log('===========================================');
console.log('Great Lakes Ice Report server started');
console.log(`Listening on port ${PORT}`);
console.log(`Visit http://localhost:${PORT} to view the website`);
// Display profanity filter status
if ('_isFallback' in profanityFilter && profanityFilter._isFallback) {
console.log('🚨 PROFANITY FILTER: DISABLED (FALLBACK MODE)');
console.log('⚠️ ALL USER CONTENT WILL BE ALLOWED!');
} else {
console.log('✅ PROFANITY FILTER: ACTIVE AND FUNCTIONAL');
}
console.log('===========================================');
});
} catch (error) {
console.error('CRITICAL ERROR: Failed to start server:', error);
process.exit(1);
}
} }
// Start the server // Start the server
@ -246,20 +246,20 @@ startServer();
// Graceful shutdown // Graceful shutdown
process.on('SIGINT', (): void => { process.on('SIGINT', (): void => {
console.log('\nShutting down server...'); console.log('\nShutting down server...');
// Close profanity filter database first // Close profanity filter database first
if (profanityFilter && typeof profanityFilter.close === 'function') { if (profanityFilter && typeof profanityFilter.close === 'function') {
try { try {
profanityFilter.close(); profanityFilter.close();
console.log('Profanity filter database closed.'); console.log('Profanity filter database closed.');
} catch (error) { } catch (error) {
console.error('Error closing profanity filter:', error); console.error('Error closing profanity filter:', error);
}
} }
}
// Close database service
databaseService.close(); // Close database service
console.log('Database connections closed.'); databaseService.close();
process.exit(0); console.log('Database connections closed.');
process.exit(0);
}); });

View file

@ -23,7 +23,7 @@ class DatabaseService {
return reject(err); return reject(err);
} }
console.log('Connected to main SQLite database.'); console.log('Connected to main SQLite database.');
if (!this.mainDb) { if (!this.mainDb) {
return reject(new Error('Main database connection failed')); return reject(new Error('Main database connection failed'));
} }
@ -48,7 +48,7 @@ class DatabaseService {
return reject(err); return reject(err);
} }
console.log('Connected to profanity SQLite database.'); console.log('Connected to profanity SQLite database.');
if (!this.profanityDb) { if (!this.profanityDb) {
return reject(new Error('Profanity database connection failed')); return reject(new Error('Profanity database connection failed'));
} }

View file

@ -51,42 +51,42 @@ class ProfanityFilterService {
constructor(profanityWordModel: ProfanityWord) { constructor(profanityWordModel: ProfanityWord) {
this.profanityWordModel = profanityWordModel; this.profanityWordModel = profanityWordModel;
// Base profanity words - comprehensive list // Base profanity words - comprehensive list
this.baseProfanityWords = [ this.baseProfanityWords = [
// Common profanity // Common profanity
'damn', 'hell', 'crap', 'shit', 'fuck', 'ass', 'bitch', 'bastard', 'damn', 'hell', 'crap', 'shit', 'fuck', 'ass', 'bitch', 'bastard',
'piss', 'whore', 'slut', 'retard', 'fag', 'gay', 'homo', 'tranny', 'piss', 'whore', 'slut', 'retard', 'fag', 'gay', 'homo', 'tranny',
'dickhead', 'asshole', 'motherfucker', 'cocksucker', 'twat', 'cunt', 'dickhead', 'asshole', 'motherfucker', 'cocksucker', 'twat', 'cunt',
// Racial slurs and hate speech // Racial slurs and hate speech
'nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike', 'nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike',
'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey', 'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey',
'kyke', 'jigaboo', 'coon', 'darkie', 'mammy', 'pickaninny', 'kyke', 'jigaboo', 'coon', 'darkie', 'mammy', 'pickaninny',
// Sexual content // Sexual content
'penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum', 'penis', 'vagina', 'boob', 'tit', 'cock', 'dick', 'pussy', 'cum',
'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate', 'orgasm', 'sex', 'porn', 'nude', 'naked', 'horny', 'masturbate', 'orgasm',
'blowjob', 'handjob', 'anal', 'penetration', 'erection', 'climax', 'blowjob', 'handjob', 'anal', 'penetration', 'erection', 'climax',
// Violence and threats // Violence and threats
'kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape', 'kill', 'murder', 'shoot', 'bomb', 'terrorist', 'suicide', 'rape',
'violence', 'assault', 'attack', 'threat', 'harm', 'hurt', 'pain', 'violence', 'assault', 'attack', 'threat', 'harm', 'hurt', 'pain',
'stab', 'strangle', 'torture', 'execute', 'assassinate', 'slaughter', 'stab', 'strangle', 'torture', 'execute', 'assassinate', 'slaughter',
// Drugs and substances // Drugs and substances
'weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high', 'weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high',
'stoned', 'drunk', 'alcohol', 'beer', 'liquor', 'vodka', 'whiskey', 'stoned', 'drunk', 'alcohol', 'beer', 'liquor', 'vodka', 'whiskey',
'ecstasy', 'lsd', 'crack', 'dope', 'pot', 'joint', 'bong', 'ecstasy', 'lsd', 'crack', 'dope', 'pot', 'joint', 'bong',
// Religious/cultural insults // Religious/cultural insults
'jesus christ', 'goddamn', 'christ almighty', 'holy shit', 'god damn', 'jesus christ', 'goddamn', 'christ almighty', 'holy shit', 'god damn',
'for christ sake', 'jesus fucking christ', 'holy fuck', 'for christ sake', 'jesus fucking christ', 'holy fuck',
// Body parts (inappropriate context) // Body parts (inappropriate context)
'testicles', 'balls', 'scrotum', 'clitoris', 'labia', 'anus', 'testicles', 'balls', 'scrotum', 'clitoris', 'labia', 'anus',
'rectum', 'butthole', 'nipples', 'breasts', 'rectum', 'butthole', 'nipples', 'breasts',
// Misc inappropriate // Misc inappropriate
'wtf', 'omfg', 'stfu', 'gtfo', 'milf', 'dilf', 'thot', 'simp', 'wtf', 'omfg', 'stfu', 'gtfo', 'milf', 'dilf', 'thot', 'simp',
'incel', 'chad', 'beta', 'alpha male', 'mansplain', 'karen' 'incel', 'chad', 'beta', 'alpha male', 'mansplain', 'karen'
@ -99,7 +99,7 @@ class ProfanityFilterService {
'%': 'a', '(': 'c', ')': 'c', '&': 'a', '#': 'h', '|': 'l', '\\': '/' '%': 'a', '(': 'c', ')': 'c', '&': 'a', '#': 'h', '|': 'l', '\\': '/'
}; };
} }
/** /**
* Initialize the filter by loading custom words * Initialize the filter by loading custom words
*/ */
@ -107,7 +107,7 @@ class ProfanityFilterService {
if (this.isInitialized) { if (this.isInitialized) {
return; return;
} }
try { try {
await this.loadCustomWords(); await this.loadCustomWords();
this.isInitialized = true; this.isInitialized = true;
@ -124,13 +124,13 @@ class ProfanityFilterService {
async loadCustomWords(): Promise<void> { async loadCustomWords(): Promise<void> {
try { try {
const rows = await this.profanityWordModel.loadWords(); const rows = await this.profanityWordModel.loadWords();
this.customWords = rows.map(row => ({ this.customWords = rows.map(row => ({
word: row.word.toLowerCase(), word: row.word.toLowerCase(),
severity: row.severity, severity: row.severity,
category: row.category category: row.category
})); }));
console.log(`Loaded ${this.customWords.length} custom profanity words`); console.log(`Loaded ${this.customWords.length} custom profanity words`);
this.patterns = this.buildPatterns(); // Rebuild patterns with custom words this.patterns = this.buildPatterns(); // Rebuild patterns with custom words
} catch (err) { } catch (err) {
@ -144,10 +144,10 @@ class ProfanityFilterService {
*/ */
buildPatterns(): ProfanityPattern[] { buildPatterns(): ProfanityPattern[] {
const allWords = [...this.baseProfanityWords, ...this.customWords.map(w => w.word)]; const allWords = [...this.baseProfanityWords, ...this.customWords.map(w => w.word)];
// Sort by length (longest first) to catch longer variations before shorter ones // Sort by length (longest first) to catch longer variations before shorter ones
allWords.sort((a, b) => b.length - a.length); allWords.sort((a, b) => b.length - a.length);
// Create patterns with word boundaries and common variations // Create patterns with word boundaries and common variations
return allWords.map(word => { return allWords.map(word => {
const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@ -157,9 +157,9 @@ class ProfanityFilterService {
const leetChars = Object.entries(this.leetMap) const leetChars = Object.entries(this.leetMap)
.filter(([_, v]) => v === char.toLowerCase()) .filter(([_, v]) => v === char.toLowerCase())
.map(([k, _]) => k); .map(([k, _]) => k);
if (leetChars.length > 0) { if (leetChars.length > 0) {
const allChars = [char, ...leetChars].map(c => const allChars = [char, ...leetChars].map(c =>
c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') c.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
); );
return `[${allChars.join('')}]`; return `[${allChars.join('')}]`;
@ -167,7 +167,7 @@ class ProfanityFilterService {
return char; return char;
}) })
.join('[\\s\\-\\_\\*\\.]*'); .join('[\\s\\-\\_\\*\\.]*');
return { return {
word: word, word: word,
pattern: new RegExp(`\\b${pattern}\\b`, 'gi'), pattern: new RegExp(`\\b${pattern}\\b`, 'gi'),
@ -186,11 +186,11 @@ class ProfanityFilterService {
if (customWord) { if (customWord) {
return customWord.severity; return customWord.severity;
} }
// Categorize severity based on type // Categorize severity based on type
const highSeverity = ['nigger', 'nigga', 'cunt', 'fag', 'retard', 'kike', 'spic', 'gook', 'chink']; const highSeverity = ['nigger', 'nigga', 'cunt', 'fag', 'retard', 'kike', 'spic', 'gook', 'chink'];
const lowSeverity = ['damn', 'hell', 'crap', 'wtf', 'omfg']; const lowSeverity = ['damn', 'hell', 'crap', 'wtf', 'omfg'];
if (highSeverity.includes(word.toLowerCase())) return 'high'; if (highSeverity.includes(word.toLowerCase())) return 'high';
if (lowSeverity.includes(word.toLowerCase())) return 'low'; if (lowSeverity.includes(word.toLowerCase())) return 'low';
return 'medium'; return 'medium';
@ -205,7 +205,7 @@ class ProfanityFilterService {
if (customWord) { if (customWord) {
return customWord.category; return customWord.category;
} }
// Categorize based on type // Categorize based on type
const categories: CategoryMap = { const categories: CategoryMap = {
racial: ['nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike', 'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey'], racial: ['nigger', 'nigga', 'spic', 'wetback', 'chink', 'gook', 'kike', 'raghead', 'towelhead', 'beaner', 'cracker', 'honkey', 'whitey'],
@ -214,13 +214,13 @@ class ProfanityFilterService {
substance: ['weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high', 'stoned', 'drunk', 'alcohol'], substance: ['weed', 'marijuana', 'cocaine', 'heroin', 'meth', 'drugs', 'high', 'stoned', 'drunk', 'alcohol'],
general: ['shit', 'fuck', 'ass', 'bitch', 'bastard', 'damn', 'hell', 'crap'] general: ['shit', 'fuck', 'ass', 'bitch', 'bastard', 'damn', 'hell', 'crap']
}; };
for (const [category, words] of Object.entries(categories)) { for (const [category, words] of Object.entries(categories)) {
if (words.includes(word.toLowerCase())) { if (words.includes(word.toLowerCase())) {
return category; return category;
} }
} }
return 'general'; return 'general';
} }
@ -229,18 +229,18 @@ class ProfanityFilterService {
*/ */
normalizeText(text: string): string { normalizeText(text: string): string {
if (!text) return ''; if (!text) return '';
// Convert to lowercase and handle basic substitutions // Convert to lowercase and handle basic substitutions
let normalized = text.toLowerCase(); let normalized = text.toLowerCase();
// Replace multiple spaces/special chars with single space // Replace multiple spaces/special chars with single space
normalized = normalized.replace(/[\s\-\_\*\.]+/g, ' '); normalized = normalized.replace(/[\s\-\_\*\.]+/g, ' ');
// Apply leet speak conversions // Apply leet speak conversions
normalized = normalized.split('').map(char => normalized = normalized.split('').map(char =>
this.leetMap[char] || char this.leetMap[char] || char
).join(''); ).join('');
return normalized; return normalized;
} }
@ -249,7 +249,7 @@ class ProfanityFilterService {
*/ */
containsProfanity(text: string): boolean { containsProfanity(text: string): boolean {
if (!text || !this.patterns) return false; if (!text || !this.patterns) return false;
const normalized = this.normalizeText(text); const normalized = this.normalizeText(text);
return this.patterns.some(({ pattern }) => pattern.test(normalized)); return this.patterns.some(({ pattern }) => pattern.test(normalized));
} }
@ -267,15 +267,15 @@ class ProfanityFilterService {
filtered: text || '' filtered: text || ''
}; };
} }
const normalized = this.normalizeText(text); const normalized = this.normalizeText(text);
const matches: ProfanityMatch[] = []; const matches: ProfanityMatch[] = [];
let filteredText = text; let filteredText = text;
this.patterns.forEach(({ word, pattern, severity, category }) => { this.patterns.forEach(({ word, pattern, severity, category }) => {
const regex = new RegExp(pattern.source, 'gi'); const regex = new RegExp(pattern.source, 'gi');
let match; let match;
while ((match = regex.exec(normalized)) !== null) { while ((match = regex.exec(normalized)) !== null) {
matches.push({ matches.push({
word: word, word: word,
@ -284,15 +284,15 @@ class ProfanityFilterService {
severity: severity, severity: severity,
category: category category: category
}); });
// Replace in filtered text // Replace in filtered text
const replacement = '*'.repeat(match[0].length); const replacement = '*'.repeat(match[0].length);
filteredText = filteredText.substring(0, match.index) + filteredText = filteredText.substring(0, match.index) +
replacement + replacement +
filteredText.substring(match.index + match[0].length); filteredText.substring(match.index + match[0].length);
} }
}); });
// Determine overall severity // Determine overall severity
let overallSeverity: 'none' | 'low' | 'medium' | 'high' = 'none'; let overallSeverity: 'none' | 'low' | 'medium' | 'high' = 'none';
if (matches.length > 0) { if (matches.length > 0) {
@ -304,7 +304,7 @@ class ProfanityFilterService {
overallSeverity = 'low'; overallSeverity = 'low';
} }
} }
return { return {
hasProfanity: matches.length > 0, hasProfanity: matches.length > 0,
matches: matches, matches: matches,
@ -326,9 +326,9 @@ class ProfanityFilterService {
* Add a custom word using the model * Add a custom word using the model
*/ */
async addCustomWord( async addCustomWord(
word: string, word: string,
severity: 'low' | 'medium' | 'high' = 'medium', severity: 'low' | 'medium' | 'high' = 'medium',
category: string = 'custom', category: string = 'custom',
createdBy: string = 'admin' createdBy: string = 'admin'
): Promise<any> { ): Promise<any> {
try { try {

View file

@ -70,7 +70,7 @@ const options: swaggerJsdoc.Options = {
}, },
longitude: { longitude: {
type: 'number', type: 'number',
format: 'float', format: 'float',
description: 'Geographic longitude coordinate', description: 'Geographic longitude coordinate',
example: -85.6681, example: -85.6681,
nullable: true nullable: true
@ -116,7 +116,7 @@ const options: swaggerJsdoc.Options = {
example: 42.9634 example: 42.9634
}, },
longitude: { longitude: {
type: 'number', type: 'number',
format: 'float', format: 'float',
description: 'Geographic longitude coordinate (optional, will be geocoded if not provided)', description: 'Geographic longitude coordinate (optional, will be geocoded if not provided)',
example: -85.6681 example: -85.6681
@ -341,7 +341,7 @@ const options: swaggerJsdoc.Options = {
} }
] ]
}, },
apis: ['./src/routes/*.ts', './src/server.ts'], // Paths to files containing OpenAPI definitions apis: ['./src/routes/*.ts', './src/server.ts'] // Paths to files containing OpenAPI definitions
}; };
export const swaggerSpec = swaggerJsdoc(options); export const swaggerSpec = swaggerJsdoc(options);

View file

@ -35,12 +35,12 @@ describe('Admin API Routes', () => {
if (!authHeader) { if (!authHeader) {
return res.status(401).json({ error: 'Access denied' }); return res.status(401).json({ error: 'Access denied' });
} }
const token = authHeader.split(' ')[1]; const token = authHeader.split(' ')[1];
if (!token || !authHeader.startsWith('Bearer ')) { if (!token || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Access denied' }); return res.status(401).json({ error: 'Access denied' });
} }
// Simple token validation for testing // Simple token validation for testing
if (token === authToken) { if (token === authToken) {
next(); next();
@ -59,7 +59,7 @@ describe('Admin API Routes', () => {
const loginResponse = await request(app) const loginResponse = await request(app)
.post('/api/admin/login') .post('/api/admin/login')
.send({ password: 'test_admin_password' }); .send({ password: 'test_admin_password' });
authToken = loginResponse.body.token; authToken = loginResponse.body.token;
}); });
@ -69,7 +69,7 @@ describe('Admin API Routes', () => {
closedCount++; closedCount++;
if (closedCount === 2) done(); if (closedCount === 2) done();
}; };
db.close(checkBothClosed); db.close(checkBothClosed);
profanityDb.close(checkBothClosed); profanityDb.close(checkBothClosed);
}); });
@ -481,7 +481,7 @@ describe('Admin API Routes', () => {
// Create a new app with broken database to simulate error // Create a new app with broken database to simulate error
const brokenApp = express(); const brokenApp = express();
brokenApp.use(express.json()); brokenApp.use(express.json());
// Create a broken location model that throws errors // Create a broken location model that throws errors
const brokenLocationModel = { const brokenLocationModel = {
getAll: jest.fn().mockRejectedValue(new Error('Database error')) getAll: jest.fn().mockRejectedValue(new Error('Database error'))
@ -497,7 +497,7 @@ describe('Admin API Routes', () => {
const loginResponse = await request(brokenApp) const loginResponse = await request(brokenApp)
.post('/api/admin/login') .post('/api/admin/login')
.send({ password: 'test_admin_password' }); .send({ password: 'test_admin_password' });
const brokenAuthToken = loginResponse.body.token; const brokenAuthToken = loginResponse.body.token;
const response = await request(brokenApp) const response = await request(brokenApp)
@ -554,7 +554,7 @@ describe('Admin API Routes', () => {
it('should handle expired/tampered tokens gracefully', async () => { it('should handle expired/tampered tokens gracefully', async () => {
const tamperedToken = authToken.slice(0, -5) + 'XXXXX'; const tamperedToken = authToken.slice(0, -5) + 'XXXXX';
const response = await request(app) const response = await request(app)
.get('/api/admin/locations') .get('/api/admin/locations')
.set('Authorization', `Bearer ${tamperedToken}`) .set('Authorization', `Bearer ${tamperedToken}`)

View file

@ -121,7 +121,7 @@ describe('Public API Routes', () => {
// Create a new app with broken database to simulate error // Create a new app with broken database to simulate error
const brokenApp = express(); const brokenApp = express();
brokenApp.use(express.json()); brokenApp.use(express.json());
// Create a broken location model that throws errors // Create a broken location model that throws errors
const brokenLocationModel = { const brokenLocationModel = {
getActive: jest.fn().mockRejectedValue(new Error('Database error')) getActive: jest.fn().mockRejectedValue(new Error('Database error'))
@ -341,9 +341,9 @@ describe('Public API Routes', () => {
const response = await request(app) const response = await request(app)
.post('/api/locations') .post('/api/locations')
.send({ .send({
address: 'Test Address', address: 'Test Address',
description: longDescription description: longDescription
}) })
.expect(400); .expect(400);
@ -372,5 +372,64 @@ describe('Public API Routes', () => {
expect(response.body.address).toBe(unicodeAddress); expect(response.body.address).toBe(unicodeAddress);
}); });
it('should reject invalid latitude values', async () => {
const invalidLatitudes = [91, -91, 'invalid', null, true, []];
for (const latitude of invalidLatitudes) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: latitude,
longitude: -85.6681
})
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Latitude must be a number between -90 and 90');
}
});
it('should reject invalid longitude values', async () => {
const invalidLongitudes = [181, -181, 'invalid', null, true, []];
for (const longitude of invalidLongitudes) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: 42.9634,
longitude: longitude
})
.expect(400);
expect(response.body).toHaveProperty('error');
expect(response.body.error).toBe('Longitude must be a number between -180 and 180');
}
});
it('should accept valid latitude and longitude values', async () => {
const validCoordinates = [
{ latitude: 0, longitude: 0 },
{ latitude: 90, longitude: 180 },
{ latitude: -90, longitude: -180 },
{ latitude: 42.9634, longitude: -85.6681 }
];
for (const coords of validCoordinates) {
const response = await request(app)
.post('/api/locations')
.send({
address: 'Test Address',
latitude: coords.latitude,
longitude: coords.longitude
})
.expect(200);
expect(response.body.latitude).toBe(coords.latitude);
expect(response.body.longitude).toBe(coords.longitude);
}
});
}); });
}); });

View file

@ -16,7 +16,7 @@ export const createTestDatabase = (): Promise<Database> => {
reject(err); reject(err);
return; return;
} }
// Create locations table // Create locations table
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS locations ( CREATE TABLE IF NOT EXISTS locations (
@ -48,7 +48,7 @@ export const createTestProfanityDatabase = (): Promise<Database> => {
reject(err); reject(err);
return; return;
} }
// Create profanity_words table // Create profanity_words table
db.run(` db.run(`
CREATE TABLE IF NOT EXISTS profanity_words ( CREATE TABLE IF NOT EXISTS profanity_words (

View file

@ -138,7 +138,7 @@ describe('Location Model', () => {
// Check that we have all locations and they're ordered by created_at DESC // Check that we have all locations and they're ordered by created_at DESC
expect(allLocations).toHaveLength(3); expect(allLocations).toHaveLength(3);
// The query uses ORDER BY created_at DESC, so the most recent should be first // The query uses ORDER BY created_at DESC, so the most recent should be first
// Since they're created in the same moment, check that ordering is consistent // Since they're created in the same moment, check that ordering is consistent
expect(allLocations[0]).toHaveProperty('id'); expect(allLocations[0]).toHaveProperty('id');

View file

@ -5,7 +5,7 @@ describe('DatabaseService', () => {
beforeEach(() => { beforeEach(() => {
databaseService = new DatabaseService(); databaseService = new DatabaseService();
// Mock console methods to reduce test noise // Mock console methods to reduce test noise
jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'log').mockImplementation(() => {});
jest.spyOn(console, 'error').mockImplementation(() => {}); jest.spyOn(console, 'error').mockImplementation(() => {});
@ -88,7 +88,7 @@ describe('DatabaseService', () => {
it('should allow multiple instances', () => { it('should allow multiple instances', () => {
const service1 = new DatabaseService(); const service1 = new DatabaseService();
const service2 = new DatabaseService(); const service2 = new DatabaseService();
expect(service1).toBeInstanceOf(DatabaseService); expect(service1).toBeInstanceOf(DatabaseService);
expect(service2).toBeInstanceOf(DatabaseService); expect(service2).toBeInstanceOf(DatabaseService);
expect(service1).not.toBe(service2); expect(service1).not.toBe(service2);

View file

@ -27,10 +27,10 @@ describe('ProfanityFilterService', () => {
it('should load custom words during initialization', async () => { it('should load custom words during initialization', async () => {
await profanityWordModel.create('customword', 'high', 'test'); await profanityWordModel.create('customword', 'high', 'test');
const newFilter = new ProfanityFilterService(profanityWordModel); const newFilter = new ProfanityFilterService(profanityWordModel);
await newFilter.initialize(); await newFilter.initialize();
expect(newFilter.containsProfanity('customword')).toBe(true); expect(newFilter.containsProfanity('customword')).toBe(true);
}); });
}); });
@ -50,7 +50,7 @@ describe('ProfanityFilterService', () => {
// Test basic profanity detection - may or may not be case insensitive // Test basic profanity detection - may or may not be case insensitive
const testWord = 'damn'; const testWord = 'damn';
expect(profanityFilter.containsProfanity(testWord)).toBe(true); expect(profanityFilter.containsProfanity(testWord)).toBe(true);
// Test with sentences containing profanity // Test with sentences containing profanity
expect(profanityFilter.containsProfanity('This is DAMN cold')).toBe(true); expect(profanityFilter.containsProfanity('This is DAMN cold')).toBe(true);
expect(profanityFilter.containsProfanity('What the HELL')).toBe(true); expect(profanityFilter.containsProfanity('What the HELL')).toBe(true);
@ -104,7 +104,7 @@ describe('ProfanityFilterService', () => {
it('should determine severity levels correctly', () => { it('should determine severity levels correctly', () => {
const lowResult = profanityFilter.analyzeProfanity('damn'); const lowResult = profanityFilter.analyzeProfanity('damn');
const mediumResult = profanityFilter.analyzeProfanity('shit'); const mediumResult = profanityFilter.analyzeProfanity('shit');
expect(lowResult.severity).toBe('low'); expect(lowResult.severity).toBe('low');
expect(mediumResult.severity).toBe('medium'); expect(mediumResult.severity).toBe('medium');
}); });
@ -164,7 +164,7 @@ describe('ProfanityFilterService', () => {
it('should remove custom words', async () => { it('should remove custom words', async () => {
const added = await profanityFilter.addCustomWord('removeme', 'low', 'test'); const added = await profanityFilter.addCustomWord('removeme', 'low', 'test');
const result = await profanityFilter.removeCustomWord(added.id); const result = await profanityFilter.removeCustomWord(added.id);
expect(result.deleted).toBe(true); expect(result.deleted).toBe(true);
@ -180,7 +180,7 @@ describe('ProfanityFilterService', () => {
it('should update custom words', async () => { it('should update custom words', async () => {
const added = await profanityFilter.addCustomWord('updateme', 'low', 'test'); const added = await profanityFilter.addCustomWord('updateme', 'low', 'test');
const result = await profanityFilter.updateCustomWord(added.id, { const result = await profanityFilter.updateCustomWord(added.id, {
word: 'updated', word: 'updated',
severity: 'high', severity: 'high',
@ -208,14 +208,14 @@ describe('ProfanityFilterService', () => {
describe('text normalization', () => { describe('text normalization', () => {
it('should normalize text correctly', () => { it('should normalize text correctly', () => {
const normalized = profanityFilter.normalizeText('Hello World!!!'); const normalized = profanityFilter.normalizeText('Hello World!!!');
expect(typeof normalized).toBe('string'); expect(typeof normalized).toBe('string');
expect(normalized.length).toBeGreaterThan(0); expect(normalized.length).toBeGreaterThan(0);
}); });
it('should handle special characters', () => { it('should handle special characters', () => {
const normalized = profanityFilter.normalizeText('h3ll0 w0rld'); const normalized = profanityFilter.normalizeText('h3ll0 w0rld');
expect(normalized).toContain('hello world'); expect(normalized).toContain('hello world');
}); });
@ -228,13 +228,13 @@ describe('ProfanityFilterService', () => {
describe('severity and category helpers', () => { describe('severity and category helpers', () => {
it('should get severity for words', () => { it('should get severity for words', () => {
const severity = profanityFilter.getSeverity('damn'); const severity = profanityFilter.getSeverity('damn');
expect(['low', 'medium', 'high']).toContain(severity); expect(['low', 'medium', 'high']).toContain(severity);
}); });
it('should get category for words', () => { it('should get category for words', () => {
const category = profanityFilter.getCategory('damn'); const category = profanityFilter.getCategory('damn');
expect(typeof category).toBe('string'); expect(typeof category).toBe('string');
expect(category.length).toBeGreaterThan(0); expect(category.length).toBeGreaterThan(0);
}); });
@ -242,7 +242,7 @@ describe('ProfanityFilterService', () => {
it('should return default values for unknown words', () => { it('should return default values for unknown words', () => {
const severity = profanityFilter.getSeverity('unknownword'); const severity = profanityFilter.getSeverity('unknownword');
const category = profanityFilter.getCategory('unknownword'); const category = profanityFilter.getCategory('unknownword');
expect(['low', 'medium', 'high']).toContain(severity); expect(['low', 'medium', 'high']).toContain(severity);
expect(typeof category).toBe('string'); expect(typeof category).toBe('string');
}); });
@ -251,26 +251,26 @@ describe('ProfanityFilterService', () => {
describe('utility methods', () => { describe('utility methods', () => {
it('should get all words', () => { it('should get all words', () => {
const words = profanityFilter.getAllWords(); const words = profanityFilter.getAllWords();
expect(Array.isArray(words)).toBe(true); expect(Array.isArray(words)).toBe(true);
expect(words.length).toBeGreaterThan(0); expect(words.length).toBeGreaterThan(0);
}); });
it('should get severity level as number', () => { it('should get severity level as number', () => {
const level = profanityFilter.getSeverityLevel(); const level = profanityFilter.getSeverityLevel();
expect(typeof level).toBe('number'); expect(typeof level).toBe('number');
}); });
it('should get severity name', () => { it('should get severity name', () => {
const name = profanityFilter.getSeverityName(); const name = profanityFilter.getSeverityName();
expect(typeof name).toBe('string'); expect(typeof name).toBe('string');
}); });
it('should have close method', () => { it('should have close method', () => {
expect(typeof profanityFilter.close).toBe('function'); expect(typeof profanityFilter.close).toBe('function');
// Should not throw // Should not throw
profanityFilter.close(); profanityFilter.close();
}); });