2026-06-26 17:48:24 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* Users API Tests – List / Get / Update / Delete (password_hash stripping)
|
|
|
|
|
|
*/
|
|
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
|
|
|
|
import type { FastifyInstance } from 'fastify';
|
|
|
|
|
|
import { SqliteAdapter } from '../src/database/SqliteAdapter.js';
|
|
|
|
|
|
import { createServer } from '../src/server.js';
|
|
|
|
|
|
|
|
|
|
|
|
describe('Users API', () => {
|
|
|
|
|
|
let app: FastifyInstance;
|
|
|
|
|
|
let db: SqliteAdapter;
|
|
|
|
|
|
let testUserId: string;
|
2026-07-04 16:43:55 +02:00
|
|
|
|
let authToken: string;
|
2026-06-26 17:48:24 +02:00
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
|
db = new SqliteAdapter(':memory:');
|
|
|
|
|
|
await db.init();
|
|
|
|
|
|
app = await createServer({ db, port: 0 });
|
|
|
|
|
|
await app.ready();
|
|
|
|
|
|
|
|
|
|
|
|
// Create a test user directly via DB (bypassing AuthService)
|
|
|
|
|
|
const user = db.createUser({
|
|
|
|
|
|
email: 'test-admin@example.com',
|
|
|
|
|
|
password_hash: 'fake-hash-for-testing',
|
|
|
|
|
|
name: 'Test Admin',
|
|
|
|
|
|
role: 'admin',
|
|
|
|
|
|
});
|
|
|
|
|
|
testUserId = user.id;
|
2026-07-04 16:43:55 +02:00
|
|
|
|
|
|
|
|
|
|
// Register an admin user via API to get a valid auth token
|
|
|
|
|
|
const regRes = await app.inject({
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
url: '/api/auth/register',
|
|
|
|
|
|
payload: { email: 'admin-auth@example.com', password: 'Password123!', name: 'Admin Auth', role: 'admin' },
|
|
|
|
|
|
});
|
|
|
|
|
|
const regBody = JSON.parse(regRes.body);
|
|
|
|
|
|
authToken = regBody.session.token;
|
2026-06-26 17:48:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
|
await app.close();
|
|
|
|
|
|
db.close();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── List ───────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe('GET /api/users', () => {
|
2026-07-04 16:43:55 +02:00
|
|
|
|
it('should return 401 without authorization header', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
url: '/api/users',
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(401);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-26 17:48:24 +02:00
|
|
|
|
it('should list all users', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
url: '/api/users',
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
|
|
const body = JSON.parse(response.body);
|
|
|
|
|
|
expect(Array.isArray(body)).toBe(true);
|
|
|
|
|
|
expect(body.length).toBeGreaterThanOrEqual(2); // default user + test user
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should strip password_hash from list responses', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
url: '/api/users',
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
const body = JSON.parse(response.body);
|
|
|
|
|
|
for (const user of body) {
|
|
|
|
|
|
expect(user.password_hash).toBeUndefined();
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Get ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe('GET /api/users/:id', () => {
|
|
|
|
|
|
it('should get a single user', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
url: `/api/users/${testUserId}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
|
|
const body = JSON.parse(response.body);
|
|
|
|
|
|
expect(body.id).toBe(testUserId);
|
|
|
|
|
|
expect(body.email).toBe('test-admin@example.com');
|
|
|
|
|
|
expect(body.name).toBe('Test Admin');
|
|
|
|
|
|
expect(body.role).toBe('admin');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should strip password_hash from single user response', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
url: `/api/users/${testUserId}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
const body = JSON.parse(response.body);
|
|
|
|
|
|
expect(body.password_hash).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should return 404 for non-existent user', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
url: '/api/users/non-existent-id',
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Update ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe('PATCH /api/users/:id', () => {
|
|
|
|
|
|
it('should update user name', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
|
url: `/api/users/${testUserId}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
payload: { name: 'Updated Name' },
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
|
|
const body = JSON.parse(response.body);
|
|
|
|
|
|
expect(body.name).toBe('Updated Name');
|
|
|
|
|
|
expect(body.id).toBe(testUserId);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should update user role', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
|
url: `/api/users/${testUserId}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
payload: { role: 'planer' },
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
|
|
const body = JSON.parse(response.body);
|
|
|
|
|
|
expect(body.role).toBe('planer');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should strip password_hash from update response', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
|
url: `/api/users/${testUserId}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
payload: { name: 'Another Name' },
|
|
|
|
|
|
});
|
|
|
|
|
|
const body = JSON.parse(response.body);
|
|
|
|
|
|
expect(body.password_hash).toBeUndefined();
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should not update password_hash via PATCH endpoint', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
|
url: `/api/users/${testUserId}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
payload: { password_hash: 'hacked-hash' },
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
|
|
|
|
// Verify the password_hash was NOT changed in DB
|
|
|
|
|
|
const dbUser = db.getUser(testUserId);
|
|
|
|
|
|
expect(dbUser!.password_hash).toBe('fake-hash-for-testing');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should return 404 for non-existent user update', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
|
url: '/api/users/non-existent-id',
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
payload: { name: 'New Name' },
|
|
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// ─── Delete ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
describe('DELETE /api/users/:id', () => {
|
|
|
|
|
|
it('should delete a user', async () => {
|
|
|
|
|
|
// Create a user to delete
|
|
|
|
|
|
const user = db.createUser({
|
|
|
|
|
|
email: 'delete-me@example.com',
|
|
|
|
|
|
password_hash: 'temp-hash',
|
|
|
|
|
|
name: 'Delete Me',
|
|
|
|
|
|
role: 'planer',
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
url: `/api/users/${user.id}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(204);
|
|
|
|
|
|
|
|
|
|
|
|
// Verify it's gone
|
|
|
|
|
|
const getRes = await app.inject({
|
|
|
|
|
|
method: 'GET',
|
|
|
|
|
|
url: `/api/users/${user.id}`,
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
expect(getRes.statusCode).toBe(404);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('should return 404 for non-existent user delete', async () => {
|
|
|
|
|
|
const response = await app.inject({
|
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
|
url: '/api/users/non-existent-id',
|
2026-07-04 16:43:55 +02:00
|
|
|
|
headers: { authorization: `Bearer ${authToken}` },
|
2026-06-26 17:48:24 +02:00
|
|
|
|
});
|
|
|
|
|
|
expect(response.statusCode).toBe(404);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|