Agent Host changes for agents/nodejs-express-rest-api-orm-swagger
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
NODE_ENV=development
|
||||
PORT=8765
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_NAME=my_app
|
||||
DB_USER=my_app
|
||||
DB_PASSWORD=
|
||||
@@ -48,6 +48,9 @@ nbproject/*
|
||||
.sass-cache/
|
||||
# node
|
||||
/node_modules/*
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
# yarn
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = {
|
||||
config: path.resolve('node/config/database.cjs'),
|
||||
'migrations-path': path.resolve('node/migrations'),
|
||||
};
|
||||
@@ -1,73 +1,68 @@
|
||||
# Test REST API (CakePHP 5)
|
||||
# REST API: Express, Sequelize i MySQL
|
||||
|
||||
Testowy serwis REST API: klienci (`customers`), ich auta (`cars`) i użytkownicy (`users`),
|
||||
uwierzytelnianie tokenem JWT, dokumentacja Swagger UI.
|
||||
Serwis REST dla użytkowników, klientów i samochodów. Aplikacja Node.js korzysta z istniejących tabel `users`, `customers` i `cars`; aplikacja nie tworzy ani nie synchronizuje ich automatycznie przy uruchomieniu.
|
||||
|
||||
## Uruchomienie
|
||||
## Wymagania i uruchomienie
|
||||
|
||||
- Node.js 20 lub nowszy
|
||||
- MySQL z tabelami używanymi przez dotychczasową aplikację
|
||||
|
||||
```bash
|
||||
composer install
|
||||
bin/cake migrations migrate # tworzy tabele users, customers, cars
|
||||
bin/cake seeds run DatabaseSeed # 2 konta, 100 klientów, 1-5 aut na klienta (czyści tabele!)
|
||||
bin/cake server -p 8765
|
||||
npm install
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Dokumentacja i konsola testowa: **http://localhost:8765/docs** (specyfikacja: `webroot/openapi.yaml`).
|
||||
Ustaw w `.env` połączenie do tej samej bazy MySQL co dotychczasowa aplikacja. Następnie wykonaj migrację dodającą unikalne pole `login` do tabeli `users` (dotychczasowym użytkownikom ustawi `login = email`):
|
||||
|
||||
Konfiguracja bazy i sekretu JWT: `config/app_local.php` (`Datasources.default`, `Jwt.secret`, `Jwt.ttl`).
|
||||
Sekret można też podać zmienną środowiskową `JWT_SECRET`.
|
||||
```bash
|
||||
npm run db:migrate
|
||||
npm start
|
||||
```
|
||||
|
||||
## Konta testowe
|
||||
API działa domyślnie na `http://localhost:8765`, dokumentacja Swagger UI na `http://localhost:8765/docs`, a specyfikacja OpenAPI na `http://localhost:8765/openapi.yaml`. Port można zmienić przez `PORT`.
|
||||
|
||||
| Rola | E-mail | Hasło |
|
||||
|-------|-------------------|-----------|
|
||||
| admin | admin@example.com | Admin123! |
|
||||
| user | user@example.com | User1234! |
|
||||
Migrację można cofnąć przez `npx sequelize-cli db:migrate:undo`. Start serwera nigdy nie uruchamia migracji ani `sequelize.sync()`.
|
||||
|
||||
* **admin** – wszystko, w tym CRUD użytkowników.
|
||||
* **user** – logowanie, własny profil, CRUD klientów i aut.
|
||||
## Uwierzytelnianie i uprawnienia
|
||||
|
||||
API używa HTTP Basic Auth: podaj `login` i hasło w nagłówku `Authorization: Basic ...` albo skorzystaj z przycisku **Authorize** w Swagger UI. Loginem jest pole `users.login`; użytkownicy istniejący przed migracją zachowają dotychczasowe dane logowania, bo ich login zostanie ustawiony na adres e-mail.
|
||||
|
||||
HTTP Basic przesyła hasło w każdym żądaniu; w środowisku produkcyjnym wystawiaj API wyłącznie przez HTTPS.
|
||||
|
||||
Konta dodawane przez dotychczasowy seeder, o ile istnieją w bazie:
|
||||
|
||||
| Rola | Login po migracji | Hasło |
|
||||
|---|---|---|
|
||||
| admin | `admin@example.com` | `Admin123!` |
|
||||
| user | `user@example.com` | `User1234!` |
|
||||
|
||||
Hasła nadal są przechowywane jako bcrypt. Nieaktywne konta nie mogą się uwierzytelniać. Dostęp do `/api/users` wymaga roli `admin`; klienci i samochody są dostępne dla każdego uwierzytelnionego użytkownika.
|
||||
|
||||
```bash
|
||||
curl -u 'admin@example.com:Admin123!' http://localhost:8765/api/auth/me
|
||||
curl -u 'user@example.com:User1234!' 'http://localhost:8765/api/customers?q=nowak&with_cars=1'
|
||||
```
|
||||
|
||||
## Endpointy
|
||||
|
||||
| Metoda | Ścieżka | Opis |
|
||||
|----------------------|--------------------------------------|---------------------------------------|
|
||||
| POST | `/api/auth/login` | logowanie, zwraca token JWT |
|
||||
| GET / PUT / PATCH | `/api/auth/me` | własny profil |
|
||||
| GET / POST | `/api/customers` | lista (filtry, paginacja) / dodanie |
|
||||
| GET / PUT / PATCH / DELETE | `/api/customers/{id}` | klient (GET zwraca też auta) |
|
||||
| GET / POST | `/api/customers/{id}/cars` | auta klienta / dodanie auta klientowi |
|
||||
| GET / POST | `/api/cars` | lista aut / dodanie |
|
||||
| GET / PUT / PATCH / DELETE | `/api/cars/{id}` | auto (GET zwraca też właściciela) |
|
||||
| GET / POST | `/api/users` | użytkownicy (tylko admin) |
|
||||
| GET / PUT / PATCH / DELETE | `/api/users/{id}` | użytkownik (tylko admin) |
|
||||
| Metody | Ścieżka | Opis |
|
||||
|---|---|---|
|
||||
| POST | `/api/auth/login` | Potwierdzenie logowania Basic Auth i aktualizacja `last_login` |
|
||||
| GET / PUT / PATCH | `/api/auth/me` | Odczyt i edycja własnego profilu |
|
||||
| GET / POST | `/api/users` | Lista i dodawanie użytkowników (admin) |
|
||||
| GET / PUT / PATCH / DELETE | `/api/users/{id}` | Odczyt, edycja i usuwanie użytkownika (admin) |
|
||||
| GET / POST | `/api/customers` | Lista klientów i dodawanie |
|
||||
| GET / PUT / PATCH / DELETE | `/api/customers/{id}` | Szczegóły klienta z samochodami, edycja i usuwanie |
|
||||
| GET / POST | `/api/customers/{customer_id}/cars` | Samochody klienta i dodawanie samochodu |
|
||||
| GET / POST | `/api/cars` | Lista samochodów i dodawanie |
|
||||
| GET / PUT / PATCH / DELETE | `/api/cars/{id}` | Szczegóły samochodu z właścicielem, edycja i usuwanie |
|
||||
|
||||
Przykład:
|
||||
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8765/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"email":"user@example.com","password":"User1234!"}' | jq -r .data.token)
|
||||
|
||||
curl -s "http://localhost:8765/api/customers?q=nowak&with_cars=1" -H "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## Pola
|
||||
|
||||
**Klient** – wymagane: `first_name`, `last_name`, `email` (unikalny), `phone`;
|
||||
opcjonalne: `type` (`individual`/`company`, dla `company` wymagane `company_name`), `company_name`,
|
||||
`tax_id` (NIP, 10 cyfr), `street`, `city`, `postal_code` (`00-000`), `country` (ISO, domyślnie `PL`),
|
||||
`birth_date` (`YYYY-MM-DD`), `notes`.
|
||||
|
||||
**Auto** – wymagane: `customer_id`, `make`, `model`, `production_year`, `vin` (17 znaków, unikalny),
|
||||
`registration_number` (unikalny), `fuel_type` (`petrol`/`diesel`/`lpg`/`hybrid`/`electric`);
|
||||
opcjonalne: `color`, `engine_capacity` (cm³), `power_hp`, `transmission` (`manual`/`automatic`),
|
||||
`mileage` (km), `notes`.
|
||||
|
||||
**Użytkownik** – wymagane: `email` (unikalny), `password` (min. 8 znaków), `first_name`, `last_name`;
|
||||
opcjonalne: `role` (`admin`/`user`, domyślnie `user`), `active` (nieaktywny nie może się zalogować).
|
||||
Listy obsługują `page`, `limit` (domyślnie 20, maksymalnie 100), `sort` i `direction` (`asc`/`desc`). Endpointy obsługują wyszukiwanie i filtry opisane w Swagger UI; liczba wyników jest zwracana w `X-Total-Count`.
|
||||
|
||||
## Format odpowiedzi
|
||||
|
||||
* sukces: `{"success": true, "data": ..., "meta": {...paginacja}}`
|
||||
* błąd: `{"success": false, "error": {"code": 422, "message": "Validation failed.", "fields": {...}}}`
|
||||
* kody: `200`, `201` (+ nagłówek `Location`), `204` (DELETE), `400`, `401`, `403`, `404`, `405`, `422`
|
||||
- Sukces: `{"success": true, "data": ...}`; odpowiedź z listą zawiera także `meta`.
|
||||
- Błąd walidacji: `{"success": false, "error": {"code": 422, "message": "Validation failed.", "fields": {...}}}`.
|
||||
- Usunięcie: `204 No Content`.
|
||||
|
||||
Migracja nie modyfikuje plików PHP; można je usunąć osobno po przełączeniu i sprawdzeniu wdrożenia Node.js.
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const express = require('express');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const YAML = require('yamljs');
|
||||
const apiRouter = require('./routes/api');
|
||||
|
||||
const app = express();
|
||||
const openApiPath = path.resolve(__dirname, '../webroot/openapi.yaml');
|
||||
const openApiDocument = YAML.parse(fs.readFileSync(openApiPath, 'utf8'));
|
||||
|
||||
app.disable('x-powered-by');
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.get('/openapi.yaml', (req, res) => res.sendFile(openApiPath));
|
||||
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiDocument));
|
||||
app.get('/', (req, res) => res.redirect('/docs'));
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
app.use((req, res) => res.status(404).json({
|
||||
success: false,
|
||||
error: { code: 404, message: 'Not found.' },
|
||||
}));
|
||||
|
||||
app.use((error, req, res, next) => {
|
||||
if (res.headersSent) return next(error);
|
||||
if (error instanceof SyntaxError && error.status === 400 && 'body' in error) {
|
||||
return res.status(400).json({ success: false, error: { code: 400, message: 'Invalid JSON body.' } });
|
||||
}
|
||||
console.error(error);
|
||||
return res.status(500).json({ success: false, error: { code: 500, message: 'Internal server error.' } });
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,17 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const config = {
|
||||
username: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
dialect: 'mysql',
|
||||
logging: false,
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
development: config,
|
||||
test: config,
|
||||
production: config,
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME,
|
||||
process.env.DB_USER,
|
||||
process.env.DB_PASSWORD,
|
||||
{
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
dialect: 'mysql',
|
||||
logging: false,
|
||||
timezone: '+00:00',
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = sequelize;
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
const { Op, ValidationError, UniqueConstraintError, ForeignKeyConstraintError } = require('sequelize');
|
||||
|
||||
const USER_FIELDS = ['login', 'email', 'password', 'first_name', 'last_name', 'role', 'active'];
|
||||
const PROFILE_FIELDS = ['email', 'password', 'first_name', 'last_name'];
|
||||
const CUSTOMER_FIELDS = [
|
||||
'type', 'first_name', 'last_name', 'email', 'phone', 'company_name', 'tax_id',
|
||||
'street', 'city', 'postal_code', 'country', 'birth_date', 'notes',
|
||||
];
|
||||
const CAR_FIELDS = [
|
||||
'customer_id', 'make', 'model', 'production_year', 'vin', 'registration_number',
|
||||
'fuel_type', 'color', 'engine_capacity', 'power_hp', 'transmission', 'mileage', 'notes',
|
||||
];
|
||||
|
||||
function pick(source, fields) {
|
||||
source ||= {};
|
||||
return Object.fromEntries(fields.filter((field) => source[field] !== undefined)
|
||||
.map((field) => [field, source[field]]));
|
||||
}
|
||||
|
||||
function cleanUser(user) {
|
||||
const data = user?.toJSON ? user.toJSON() : { ...user };
|
||||
delete data.password;
|
||||
return data;
|
||||
}
|
||||
|
||||
function response(res, data, status = 200, extra = {}) {
|
||||
if (status === 204) return res.status(status).end();
|
||||
return res.status(status).json({ success: status < 400, data, ...extra });
|
||||
}
|
||||
|
||||
function validationResponse(res, fields) {
|
||||
return res.status(422).json({
|
||||
success: false,
|
||||
error: { code: 422, message: 'Validation failed.', fields },
|
||||
});
|
||||
}
|
||||
|
||||
function validationFields(error) {
|
||||
if (error instanceof ValidationError || error instanceof UniqueConstraintError) {
|
||||
return Object.fromEntries(error.errors.map((item) => [item.path, item.message]));
|
||||
}
|
||||
if (error instanceof ForeignKeyConstraintError) {
|
||||
return { customer_id: 'The referenced record does not exist.' };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fail(res, error) {
|
||||
const fields = validationFields(error);
|
||||
if (fields) return validationResponse(res, fields);
|
||||
throw error;
|
||||
}
|
||||
|
||||
function requireFields(data, names) {
|
||||
const errors = {};
|
||||
for (const name of names) {
|
||||
if (data[name] === undefined || data[name] === null || data[name] === '') {
|
||||
errors[name] = 'This field is required.';
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateUser(data, creating) {
|
||||
const errors = requireFields(data, creating ? ['login', 'email', 'password', 'first_name', 'last_name'] : []);
|
||||
if (data.login !== undefined && (typeof data.login !== 'string' || !data.login.trim() || data.login.length > 255)) errors.login = 'Login is required and must be at most 255 characters.';
|
||||
if (data.email !== undefined && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email) || data.email.length > 255)) errors.email = 'Enter a valid email address.';
|
||||
if (data.password !== undefined && (String(data.password).length < 8 || String(data.password).length > 72)) errors.password = 'Password must be between 8 and 72 characters.';
|
||||
for (const field of ['first_name', 'last_name']) {
|
||||
if (data[field] !== undefined && (typeof data[field] !== 'string' || !data[field].trim() || data[field].length > 100)) errors[field] = 'This field is required and must be at most 100 characters.';
|
||||
}
|
||||
if (data.role !== undefined && !['admin', 'user'].includes(data.role)) errors.role = 'Role must be admin or user.';
|
||||
if (data.active !== undefined && typeof data.active !== 'boolean') errors.active = 'Active must be a boolean.';
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateCustomer(data, creating) {
|
||||
const errors = requireFields(data, creating ? ['first_name', 'last_name', 'email', 'phone'] : []);
|
||||
if (data.type !== undefined && !['individual', 'company'].includes(data.type)) errors.type = 'Type must be individual or company.';
|
||||
if (data.email !== undefined && (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(data.email) || data.email.length > 255)) errors.email = 'Enter a valid email address.';
|
||||
for (const [field, max] of Object.entries({ first_name: 100, last_name: 100, phone: 30, company_name: 255, street: 255, city: 100, notes: 5000 })) {
|
||||
const required = ['first_name', 'last_name', 'phone'].includes(field);
|
||||
if (data[field] !== undefined && ((data[field] === null && required) || (data[field] !== null && (typeof data[field] !== 'string' || data[field].length > max)))) {
|
||||
errors[field] = `Must be a string of at most ${max} characters.`;
|
||||
}
|
||||
}
|
||||
if (data.phone !== undefined && !/^\+?[0-9 ()-]{6,30}$/.test(data.phone)) errors.phone = 'Invalid phone number.';
|
||||
if (data.type === 'company' && (creating || data.company_name !== undefined)
|
||||
&& (typeof data.company_name !== 'string' || !data.company_name.trim())) {
|
||||
errors.company_name = 'Company name is required for company customers.';
|
||||
}
|
||||
if (data.tax_id != null && (typeof data.tax_id !== 'string' || !/^[0-9]{10}$/.test(data.tax_id))) errors.tax_id = 'Tax ID (NIP) must consist of 10 digits.';
|
||||
if (data.postal_code != null && !/^[0-9]{2}-[0-9]{3}$/.test(data.postal_code)) errors.postal_code = 'Postal code must be in format 00-000.';
|
||||
if (data.country !== undefined && !/^[A-Z]{2}$/.test(data.country)) errors.country = 'Country must be a two-letter uppercase code.';
|
||||
if (data.birth_date != null) {
|
||||
const date = typeof data.birth_date === 'string' && /^(\d{4})-(\d{2})-(\d{2})$/.exec(data.birth_date);
|
||||
const parsedDate = date
|
||||
? new Date(Date.UTC(Number(date[1]), Number(date[2]) - 1, Number(date[3])))
|
||||
: null;
|
||||
if (!parsedDate || parsedDate.toISOString().slice(0, 10) !== data.birth_date) {
|
||||
errors.birth_date = 'Birth date must be a valid date in YYYY-MM-DD format.';
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function validateCar(data, creating) {
|
||||
const errors = requireFields(data, creating
|
||||
? ['customer_id', 'make', 'model', 'production_year', 'vin', 'registration_number', 'fuel_type']
|
||||
: []);
|
||||
for (const field of ['make', 'model']) {
|
||||
if (data[field] !== undefined && (typeof data[field] !== 'string' || !data[field].trim() || data[field].length > 50)) errors[field] = 'This field is required and must be at most 50 characters.';
|
||||
}
|
||||
for (const field of ['customer_id', 'production_year', 'engine_capacity', 'power_hp', 'mileage']) {
|
||||
if (data[field] !== undefined && data[field] !== null && !Number.isInteger(Number(data[field]))) errors[field] = 'Must be an integer.';
|
||||
}
|
||||
if (data.production_year !== undefined && (Number(data.production_year) < 1900 || Number(data.production_year) > new Date().getFullYear() + 1)) errors.production_year = 'Production year is out of range.';
|
||||
if (data.customer_id !== undefined && Number(data.customer_id) < 1) errors.customer_id = 'Customer ID must be positive.';
|
||||
if (data.vin !== undefined && !/^[A-HJ-NPR-Z0-9]{17}$/i.test(data.vin)) errors.vin = 'VIN must have 17 characters (letters I, O, Q are not allowed).';
|
||||
if (data.registration_number !== undefined && !/^[A-Z0-9 ]{4,10}$/i.test(data.registration_number)) errors.registration_number = 'Registration number must have 4-10 letters/digits.';
|
||||
if (data.fuel_type !== undefined && !['petrol', 'diesel', 'lpg', 'hybrid', 'electric'].includes(data.fuel_type)) errors.fuel_type = 'Invalid fuel type.';
|
||||
if (data.transmission != null && !['manual', 'automatic'].includes(data.transmission)) errors.transmission = 'Transmission must be manual or automatic.';
|
||||
if (data.engine_capacity != null && (Number(data.engine_capacity) < 0 || Number(data.engine_capacity) > 10000)) errors.engine_capacity = 'Engine capacity must be between 0 and 10000.';
|
||||
if (data.power_hp != null && (Number(data.power_hp) < 1 || Number(data.power_hp) > 2000)) errors.power_hp = 'Power must be between 1 and 2000.';
|
||||
if (data.mileage != null && Number(data.mileage) < 0) errors.mileage = 'Mileage must not be negative.';
|
||||
if (data.color != null && (typeof data.color !== 'string' || data.color.length > 30)) errors.color = 'Color must be at most 30 characters.';
|
||||
if (data.notes != null && (typeof data.notes !== 'string' || data.notes.length > 5000)) errors.notes = 'Notes must be at most 5000 characters.';
|
||||
if (typeof data.vin === 'string') data.vin = data.vin.toUpperCase().trim();
|
||||
if (typeof data.registration_number === 'string') data.registration_number = data.registration_number.toUpperCase().replaceAll(' ', '');
|
||||
return errors;
|
||||
}
|
||||
|
||||
function pageOptions(query, sortable, defaultSort = 'id') {
|
||||
const page = Math.max(1, Number.parseInt(query.page, 10) || 1);
|
||||
const limit = Math.min(100, Math.max(1, Number.parseInt(query.limit, 10) || 20));
|
||||
const sort = sortable.includes(query.sort) ? query.sort : defaultSort;
|
||||
const direction = String(query.direction).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
|
||||
return { page, limit, sort, direction, offset: (page - 1) * limit };
|
||||
}
|
||||
|
||||
async function list(res, model, options, include) {
|
||||
const { page, limit, sort, direction, offset, ...query } = options;
|
||||
const result = await model.findAndCountAll({
|
||||
...query, include, distinct: true, limit, offset, order: [[sort, direction]],
|
||||
});
|
||||
const total = result.count;
|
||||
res.set('X-Total-Count', String(total));
|
||||
return response(res, result.rows, 200, {
|
||||
meta: {
|
||||
page, limit, total, pages: Math.ceil(total / limit),
|
||||
has_next_page: page * limit < total, has_prev_page: page > 1,
|
||||
sort, direction: direction.toLowerCase(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function notFound(res, resource) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: { code: 404, message: `${resource} not found.` },
|
||||
});
|
||||
}
|
||||
|
||||
function asyncRoute(handler) {
|
||||
return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
USER_FIELDS, PROFILE_FIELDS, CUSTOMER_FIELDS, CAR_FIELDS, pick, cleanUser,
|
||||
response, validationResponse, validationFields, fail, requireFields,
|
||||
validateUser, validateCustomer, validateCar, pageOptions, list, notFound, asyncRoute, Op,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { User } = require('../models');
|
||||
|
||||
function unauthorized(res) {
|
||||
res.set('WWW-Authenticate', 'Basic realm="REST API", charset="UTF-8"');
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: { code: 401, message: 'Invalid login or password.' },
|
||||
});
|
||||
}
|
||||
|
||||
async function authenticate(req, res, next) {
|
||||
const header = req.get('authorization') || '';
|
||||
const match = /^Basic\s+([A-Za-z0-9+/]+={0,2})$/i.exec(header);
|
||||
if (!match) return unauthorized(res);
|
||||
|
||||
const decoded = Buffer.from(match[1], 'base64').toString('utf8');
|
||||
const separator = decoded.indexOf(':');
|
||||
if (separator < 1) return unauthorized(res);
|
||||
|
||||
const login = decoded.slice(0, separator);
|
||||
const password = decoded.slice(separator + 1);
|
||||
const user = await User.unscoped().findOne({
|
||||
attributes: ['id', 'login', 'password', 'role', 'active', 'email', 'first_name', 'last_name', 'last_login', 'created', 'modified'],
|
||||
where: { login },
|
||||
});
|
||||
if (!user || !user.active) return unauthorized(res);
|
||||
|
||||
const compatibleHash = user.password.replace(/^\$2y\$/, '$2b$');
|
||||
if (!(await bcrypt.compare(password, compatibleHash))) return unauthorized(res);
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: { code: 403, message: 'This action requires the admin role.' },
|
||||
});
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = { authenticate, requireAdmin };
|
||||
@@ -0,0 +1,38 @@
|
||||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
const columns = await queryInterface.describeTable('users');
|
||||
if (!columns.login) {
|
||||
await queryInterface.addColumn('users', 'login', {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true,
|
||||
});
|
||||
}
|
||||
|
||||
await queryInterface.sequelize.query(
|
||||
"UPDATE `users` SET `login` = `email` WHERE `login` IS NULL OR `login` = ''",
|
||||
);
|
||||
await queryInterface.changeColumn('users', 'login', {
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
});
|
||||
|
||||
const indexes = await queryInterface.showIndex('users');
|
||||
if (!indexes.some((index) => index.name === 'users_login_unique')) {
|
||||
await queryInterface.addIndex('users', ['login'], {
|
||||
name: 'users_login_unique',
|
||||
unique: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
const indexes = await queryInterface.showIndex('users');
|
||||
if (indexes.some((index) => index.name === 'users_login_unique')) {
|
||||
await queryInterface.removeIndex('users', 'users_login_unique');
|
||||
}
|
||||
const columns = await queryInterface.describeTable('users');
|
||||
if (columns.login) await queryInterface.removeColumn('users', 'login');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('../config/sequelize');
|
||||
|
||||
const timestamps = {
|
||||
timestamps: true,
|
||||
createdAt: 'created',
|
||||
updatedAt: 'modified',
|
||||
};
|
||||
|
||||
const User = sequelize.define('User', {
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||
login: { type: DataTypes.STRING(255), allowNull: false, unique: true },
|
||||
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, validate: { isEmail: true } },
|
||||
password: { type: DataTypes.STRING(255), allowNull: false },
|
||||
first_name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
last_name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
role: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'user', validate: { isIn: [['admin', 'user']] } },
|
||||
active: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true },
|
||||
last_login: { type: DataTypes.DATE, allowNull: true },
|
||||
}, {
|
||||
...timestamps,
|
||||
tableName: 'users',
|
||||
defaultScope: { attributes: { exclude: ['password'] } },
|
||||
scopes: { withPassword: { attributes: { include: ['password'] } } },
|
||||
hooks: {
|
||||
beforeSave: async (user) => {
|
||||
if (user.changed('password')) {
|
||||
user.password = await bcrypt.hash(user.password, 10);
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const Customer = sequelize.define('Customer', {
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||
type: { type: DataTypes.STRING(20), allowNull: false, defaultValue: 'individual', validate: { isIn: [['individual', 'company']] } },
|
||||
first_name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
last_name: { type: DataTypes.STRING(100), allowNull: false },
|
||||
email: { type: DataTypes.STRING(255), allowNull: false, unique: true, validate: { isEmail: true } },
|
||||
phone: { type: DataTypes.STRING(30), allowNull: false },
|
||||
company_name: { type: DataTypes.STRING(255), allowNull: true },
|
||||
tax_id: { type: DataTypes.STRING(20), allowNull: true },
|
||||
street: { type: DataTypes.STRING(255), allowNull: true },
|
||||
city: { type: DataTypes.STRING(100), allowNull: true },
|
||||
postal_code: { type: DataTypes.STRING(10), allowNull: true },
|
||||
country: { type: DataTypes.STRING(2), allowNull: false, defaultValue: 'PL' },
|
||||
birth_date: { type: DataTypes.DATEONLY, allowNull: true },
|
||||
notes: { type: DataTypes.TEXT, allowNull: true },
|
||||
created_by: { type: DataTypes.INTEGER, allowNull: true },
|
||||
}, { ...timestamps, tableName: 'customers' });
|
||||
|
||||
const Car = sequelize.define('Car', {
|
||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||
customer_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||
make: { type: DataTypes.STRING(50), allowNull: false },
|
||||
model: { type: DataTypes.STRING(50), allowNull: false },
|
||||
production_year: { type: DataTypes.INTEGER, allowNull: false },
|
||||
vin: { type: DataTypes.STRING(17), allowNull: false, unique: true },
|
||||
registration_number: { type: DataTypes.STRING(10), allowNull: false, unique: true },
|
||||
fuel_type: { type: DataTypes.STRING(20), allowNull: false, validate: { isIn: [['petrol', 'diesel', 'lpg', 'hybrid', 'electric']] } },
|
||||
color: { type: DataTypes.STRING(30), allowNull: true },
|
||||
engine_capacity: { type: DataTypes.INTEGER, allowNull: true },
|
||||
power_hp: { type: DataTypes.INTEGER, allowNull: true },
|
||||
transmission: { type: DataTypes.STRING(20), allowNull: true, validate: { isIn: [['manual', 'automatic']] } },
|
||||
mileage: { type: DataTypes.INTEGER, allowNull: true },
|
||||
notes: { type: DataTypes.TEXT, allowNull: true },
|
||||
}, { ...timestamps, tableName: 'cars' });
|
||||
|
||||
User.hasMany(Customer, { foreignKey: 'created_by', as: 'createdCustomers' });
|
||||
Customer.belongsTo(User, { foreignKey: 'created_by', as: 'creator' });
|
||||
Customer.hasMany(Car, { foreignKey: 'customer_id', as: 'cars', onDelete: 'CASCADE' });
|
||||
Car.belongsTo(Customer, { foreignKey: 'customer_id', as: 'customer' });
|
||||
|
||||
module.exports = { sequelize, User, Customer, Car };
|
||||
@@ -0,0 +1,202 @@
|
||||
const express = require('express');
|
||||
const { Op } = require('sequelize');
|
||||
const { User, Customer, Car } = require('../models');
|
||||
const { authenticate, requireAdmin } = require('../middleware/auth');
|
||||
const api = require('../lib/api');
|
||||
|
||||
const router = express.Router();
|
||||
const route = api.asyncRoute;
|
||||
router.use(route(authenticate));
|
||||
|
||||
router.post('/auth/login', route(async (req, res) => {
|
||||
await req.user.update({ last_login: new Date() });
|
||||
return api.response(res, api.cleanUser(req.user));
|
||||
}));
|
||||
|
||||
router.route('/auth/me')
|
||||
.get((req, res) => api.response(res, api.cleanUser(req.user)))
|
||||
.put(route((req, res) => updateProfile(req, res)))
|
||||
.patch(route((req, res) => updateProfile(req, res)));
|
||||
|
||||
async function updateProfile(req, res) {
|
||||
const data = api.pick(req.body, api.PROFILE_FIELDS);
|
||||
const errors = api.validateUser(data, false);
|
||||
if (Object.keys(errors).length) return api.validationResponse(res, errors);
|
||||
await req.user.update(data);
|
||||
return api.response(res, api.cleanUser(req.user));
|
||||
}
|
||||
|
||||
router.route('/users')
|
||||
.all(requireAdmin)
|
||||
.get(route(async (req, res) => {
|
||||
const options = api.pageOptions(req.query, ['id', ...api.USER_FIELDS.filter((field) => field !== 'password'), 'last_login', 'created', 'modified']);
|
||||
const where = {};
|
||||
if (req.query.q) where[Op.or] = ['login', 'email', 'first_name', 'last_name'].map((field) => ({ [field]: { [Op.like]: `%${req.query.q}%` } }));
|
||||
if (req.query.role) where.role = req.query.role;
|
||||
if (req.query.active !== undefined) where.active = req.query.active === 'true' || req.query.active === '1';
|
||||
const result = await User.findAndCountAll({ where, limit: options.limit, offset: options.offset, order: [[options.sort, options.direction]] });
|
||||
const total = result.count;
|
||||
res.set('X-Total-Count', String(total));
|
||||
return api.response(res, result.rows.map(api.cleanUser), 200, {
|
||||
meta: { page: options.page, limit: options.limit, total, pages: Math.ceil(total / options.limit), has_next_page: options.page * options.limit < total, has_prev_page: options.page > 1, sort: options.sort, direction: options.direction.toLowerCase() },
|
||||
});
|
||||
}))
|
||||
.post(route(async (req, res) => {
|
||||
const data = api.pick(req.body, api.USER_FIELDS);
|
||||
if (data.active === undefined) data.active = true;
|
||||
if (data.role === undefined) data.role = 'user';
|
||||
const errors = api.validateUser(data, true);
|
||||
if (Object.keys(errors).length) return api.validationResponse(res, errors);
|
||||
try {
|
||||
const user = await User.create(data);
|
||||
res.location(`/api/users/${user.id}`);
|
||||
return api.response(res, api.cleanUser(user), 201);
|
||||
} catch (error) { return api.fail(res, error); }
|
||||
}));
|
||||
|
||||
router.route('/users/:id')
|
||||
.all(requireAdmin)
|
||||
.get(route(async (req, res) => {
|
||||
const user = await User.findByPk(req.params.id);
|
||||
return user ? api.response(res, api.cleanUser(user)) : api.notFound(res, 'User');
|
||||
}))
|
||||
.put(route((req, res) => updateUser(req, res)))
|
||||
.patch(route((req, res) => updateUser(req, res)))
|
||||
.delete(route(async (req, res) => {
|
||||
const user = await User.findByPk(req.params.id);
|
||||
if (!user) return api.notFound(res, 'User');
|
||||
if (user.id === req.user.id) return res.status(400).json({ success: false, error: { code: 400, message: 'You cannot delete your own account.' } });
|
||||
await user.destroy();
|
||||
return api.response(res, null, 204);
|
||||
}));
|
||||
|
||||
async function updateUser(req, res) {
|
||||
const user = await User.findByPk(req.params.id);
|
||||
if (!user) return api.notFound(res, 'User');
|
||||
const data = api.pick(req.body, api.USER_FIELDS);
|
||||
if (user.id === req.user.id) {
|
||||
if (data.role !== undefined && data.role !== user.role) return res.status(400).json({ success: false, error: { code: 400, message: 'You cannot change your own role.' } });
|
||||
if (data.active === false) return res.status(400).json({ success: false, error: { code: 400, message: 'You cannot deactivate your own account.' } });
|
||||
}
|
||||
const errors = api.validateUser(data, false);
|
||||
if (Object.keys(errors).length) return api.validationResponse(res, errors);
|
||||
try {
|
||||
await user.update(data);
|
||||
return api.response(res, api.cleanUser(user));
|
||||
} catch (error) { return api.fail(res, error); }
|
||||
}
|
||||
|
||||
router.route('/customers')
|
||||
.get(route(async (req, res) => {
|
||||
const options = api.pageOptions(req.query, ['id', 'type', 'first_name', 'last_name', 'email', 'company_name', 'city', 'country', 'created', 'modified']);
|
||||
const where = {};
|
||||
if (req.query.q) where[Op.or] = ['first_name', 'last_name', 'email', 'phone', 'company_name', 'city', 'tax_id'].map((field) => ({ [field]: { [Op.like]: `%${req.query.q}%` } }));
|
||||
for (const field of ['type', 'city', 'country']) if (req.query[field]) where[field] = req.query[field];
|
||||
const include = String(req.query.with_cars).toLowerCase() === 'true' || req.query.with_cars === '1' ? [{ model: Car, as: 'cars' }] : undefined;
|
||||
return api.list(res, Customer, { ...options, where }, include);
|
||||
}))
|
||||
.post(route(async (req, res) => {
|
||||
const data = api.pick(req.body, api.CUSTOMER_FIELDS);
|
||||
if (data.type === undefined) data.type = 'individual';
|
||||
if (data.country === undefined) data.country = 'PL';
|
||||
const errors = api.validateCustomer(data, true);
|
||||
if (Object.keys(errors).length) return api.validationResponse(res, errors);
|
||||
try {
|
||||
const customer = await Customer.create({ ...data, created_by: req.user.id });
|
||||
res.location(`/api/customers/${customer.id}`);
|
||||
return api.response(res, customer, 201);
|
||||
} catch (error) { return api.fail(res, error); }
|
||||
}));
|
||||
|
||||
router.route('/customers/:id')
|
||||
.get(route(async (req, res) => {
|
||||
const customer = await Customer.findByPk(req.params.id, { include: [{ model: Car, as: 'cars' }] });
|
||||
return customer ? api.response(res, customer) : api.notFound(res, 'Customer');
|
||||
}))
|
||||
.put(route((req, res) => updateCustomer(req, res)))
|
||||
.patch(route((req, res) => updateCustomer(req, res)))
|
||||
.delete(route(async (req, res) => {
|
||||
const customer = await Customer.findByPk(req.params.id);
|
||||
if (!customer) return api.notFound(res, 'Customer');
|
||||
await customer.destroy();
|
||||
return api.response(res, null, 204);
|
||||
}));
|
||||
|
||||
async function updateCustomer(req, res) {
|
||||
const customer = await Customer.findByPk(req.params.id);
|
||||
if (!customer) return api.notFound(res, 'Customer');
|
||||
const data = api.pick(req.body, api.CUSTOMER_FIELDS);
|
||||
const errors = api.validateCustomer(data, false);
|
||||
if (Object.keys(errors).length) return api.validationResponse(res, errors);
|
||||
try {
|
||||
await customer.update(data);
|
||||
return api.response(res, customer);
|
||||
} catch (error) { return api.fail(res, error); }
|
||||
}
|
||||
|
||||
router.route('/customers/:customer_id/cars')
|
||||
.get(route(async (req, res) => {
|
||||
const customer = await Customer.findByPk(req.params.customer_id);
|
||||
if (!customer) return api.notFound(res, 'Customer');
|
||||
const options = api.pageOptions(req.query, ['id', 'customer_id', 'make', 'model', 'production_year', 'registration_number', 'fuel_type', 'mileage', 'power_hp', 'created', 'modified']);
|
||||
return api.list(res, Car, { ...options, where: { customer_id: customer.id } });
|
||||
}))
|
||||
.post(route(async (req, res) => {
|
||||
const customer = await Customer.findByPk(req.params.customer_id);
|
||||
if (!customer) return api.notFound(res, 'Customer');
|
||||
return createCar(req, res, customer.id);
|
||||
}));
|
||||
|
||||
router.route('/cars')
|
||||
.get(route(async (req, res) => {
|
||||
const options = api.pageOptions(req.query, ['id', 'customer_id', 'make', 'model', 'production_year', 'registration_number', 'fuel_type', 'mileage', 'power_hp', 'created', 'modified']);
|
||||
const where = {};
|
||||
if (req.query.customer_id) where.customer_id = req.query.customer_id;
|
||||
if (req.query.q) where[Op.or] = ['make', 'model', 'vin', 'registration_number'].map((field) => ({ [field]: { [Op.like]: `%${req.query.q}%` } }));
|
||||
for (const field of ['make', 'fuel_type', 'transmission']) if (req.query[field]) where[field] = req.query[field];
|
||||
if (req.query.year_from) where.production_year = { ...(where.production_year || {}), [Op.gte]: Number(req.query.year_from) };
|
||||
if (req.query.year_to) where.production_year = { ...(where.production_year || {}), [Op.lte]: Number(req.query.year_to) };
|
||||
const include = String(req.query.with_customer).toLowerCase() === 'true' || req.query.with_customer === '1' ? [{ model: Customer, as: 'customer' }] : undefined;
|
||||
return api.list(res, Car, { ...options, where }, include);
|
||||
}))
|
||||
.post(route((req, res) => createCar(req, res)));
|
||||
|
||||
async function createCar(req, res, customerId) {
|
||||
const data = api.pick(req.body, api.CAR_FIELDS);
|
||||
if (customerId !== undefined) data.customer_id = customerId;
|
||||
const errors = api.validateCar(data, true);
|
||||
if (Object.keys(errors).length) return api.validationResponse(res, errors);
|
||||
try {
|
||||
const car = await Car.create(data);
|
||||
res.location(`/api/cars/${car.id}`);
|
||||
return api.response(res, car, 201);
|
||||
} catch (error) { return api.fail(res, error); }
|
||||
}
|
||||
|
||||
router.route('/cars/:id')
|
||||
.get(route(async (req, res) => {
|
||||
const car = await Car.findByPk(req.params.id, { include: [{ model: Customer, as: 'customer' }] });
|
||||
return car ? api.response(res, car) : api.notFound(res, 'Car');
|
||||
}))
|
||||
.put(route((req, res) => updateCar(req, res)))
|
||||
.patch(route((req, res) => updateCar(req, res)))
|
||||
.delete(route(async (req, res) => {
|
||||
const car = await Car.findByPk(req.params.id);
|
||||
if (!car) return api.notFound(res, 'Car');
|
||||
await car.destroy();
|
||||
return api.response(res, null, 204);
|
||||
}));
|
||||
|
||||
async function updateCar(req, res) {
|
||||
const car = await Car.findByPk(req.params.id);
|
||||
if (!car) return api.notFound(res, 'Car');
|
||||
const data = api.pick(req.body, api.CAR_FIELDS);
|
||||
const errors = api.validateCar(data, false);
|
||||
if (Object.keys(errors).length) return api.validationResponse(res, errors);
|
||||
try {
|
||||
await car.update(data);
|
||||
return api.response(res, car);
|
||||
} catch (error) { return api.fail(res, error); }
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,27 @@
|
||||
require('dotenv').config();
|
||||
|
||||
const app = require('./app');
|
||||
const { sequelize } = require('./models');
|
||||
|
||||
const port = Number(process.env.PORT || 8765);
|
||||
|
||||
async function start() {
|
||||
await sequelize.authenticate();
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`REST API listening on http://localhost:${port}`);
|
||||
});
|
||||
|
||||
const shutdown = async () => {
|
||||
server.close(async () => {
|
||||
await sequelize.close();
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
start().catch((error) => {
|
||||
console.error('Unable to connect to the database:', error.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const test = require('node:test');
|
||||
const loginMigration = require('../migrations/20261027000000-add-login-to-users');
|
||||
const {
|
||||
validateUser,
|
||||
validateCustomer,
|
||||
validateCar,
|
||||
} = require('../lib/api');
|
||||
const app = require('../app');
|
||||
|
||||
test('user validation requires a login and an eight-character password', () => {
|
||||
assert.deepEqual(validateUser({
|
||||
login: 'jan',
|
||||
email: 'jan@example.com',
|
||||
password: 'Haslo123!',
|
||||
first_name: 'Jan',
|
||||
last_name: 'Kowalski',
|
||||
}, true), {});
|
||||
assert.equal(validateUser({ password: 'short' }, false).password, 'Password must be between 8 and 72 characters.');
|
||||
});
|
||||
|
||||
test('customer validation enforces company name, phone and Polish postal code formats', () => {
|
||||
const errors = validateCustomer({
|
||||
type: 'company',
|
||||
first_name: 'Jan',
|
||||
last_name: 'Kowalski',
|
||||
email: 'jan@example.com',
|
||||
phone: '123',
|
||||
postal_code: '12345',
|
||||
birth_date: '2024-99-99',
|
||||
}, true);
|
||||
assert.ok(errors.company_name);
|
||||
assert.ok(errors.phone);
|
||||
assert.ok(errors.postal_code);
|
||||
assert.ok(errors.birth_date);
|
||||
assert.equal(validateCustomer({ first_name: null }, false).first_name, 'Must be a string of at most 100 characters.');
|
||||
});
|
||||
|
||||
test('car validation normalizes identifiers and rejects invalid VINs', () => {
|
||||
const data = {
|
||||
customer_id: '1',
|
||||
make: 'Toyota',
|
||||
model: 'Corolla',
|
||||
production_year: 2020,
|
||||
vin: '1HGCM82633A004352',
|
||||
registration_number: 'wa 12345',
|
||||
fuel_type: 'petrol',
|
||||
};
|
||||
assert.deepEqual(validateCar(data, true), {});
|
||||
assert.equal(data.vin, '1HGCM82633A004352');
|
||||
assert.equal(data.registration_number, 'WA12345');
|
||||
assert.ok(validateCar({ vin: 'INVALID' }, false).vin);
|
||||
});
|
||||
|
||||
test('login migration backfills existing users and adds a unique login index', async () => {
|
||||
const calls = [];
|
||||
const queryInterface = {
|
||||
describeTable: async () => ({ email: {} }),
|
||||
addColumn: async (...args) => calls.push(['addColumn', ...args]),
|
||||
sequelize: { query: async (...args) => calls.push(['query', ...args]) },
|
||||
changeColumn: async (...args) => calls.push(['changeColumn', ...args]),
|
||||
showIndex: async () => [],
|
||||
addIndex: async (...args) => calls.push(['addIndex', ...args]),
|
||||
};
|
||||
await loginMigration.up(queryInterface, { STRING: (length) => `STRING(${length})` });
|
||||
assert.deepEqual(calls.map(([name]) => name), ['addColumn', 'query', 'changeColumn', 'addIndex']);
|
||||
assert.match(calls[1][1], /SET `login` = `email`/);
|
||||
assert.equal(calls[3][3].unique, true);
|
||||
});
|
||||
|
||||
test('HTTP app exposes OpenAPI docs and challenges unauthenticated API requests', async (t) => {
|
||||
const server = http.createServer(app);
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
t.after(() => new Promise((resolve, reject) => {
|
||||
server.close((error) => error ? reject(error) : resolve());
|
||||
}));
|
||||
const baseUrl = `http://127.0.0.1:${server.address().port}`;
|
||||
|
||||
const docs = await fetch(`${baseUrl}/docs`);
|
||||
assert.equal(docs.status, 200);
|
||||
assert.match(await docs.text(), /swagger-ui/);
|
||||
|
||||
const spec = await fetch(`${baseUrl}/openapi.yaml`);
|
||||
assert.equal(spec.status, 200);
|
||||
assert.match(await spec.text(), /scheme: basic/);
|
||||
|
||||
const protectedResponse = await fetch(`${baseUrl}/api/auth/me`);
|
||||
assert.equal(protectedResponse.status, 401);
|
||||
assert.match(protectedResponse.headers.get('www-authenticate'), /^Basic/);
|
||||
});
|
||||
Generated
+2354
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "test-rest-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "REST API for users, customers and cars",
|
||||
"main": "node/server.js",
|
||||
"scripts": {
|
||||
"start": "node node/server.js",
|
||||
"dev": "node --watch node/server.js",
|
||||
"db:migrate": "sequelize-cli db:migrate --config node/config/database.cjs --migrations-path node/migrations",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"mysql2": "^3.14.0",
|
||||
"sequelize": "^6.37.6",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
"yamljs": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"sequelize-cli": "^6.6.2"
|
||||
},
|
||||
"overrides": {
|
||||
"uuid": "^11.1.1"
|
||||
}
|
||||
}
|
||||
+380
-570
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user