Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions api/sql/migrations/002_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Migration 002: Add users table for account management

CREATE TABLE users (
user_id INTEGER PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
CHECK (role IN ('user', 'admin'))
);

-- Index for fast email lookup (used during login)
CREATE INDEX idx_users_email ON users(email);
5 changes: 5 additions & 0 deletions api/sql/seed/005_users.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- Seed data: default user accounts
-- Passwords: admin user = 'admin123', regular user = 'user1234'
INSERT INTO users (email, password_hash, name, role) VALUES
('admin@github.com', '2a55ec229a234af5674125fc39868abc:aa19bd0c8ffd4c6ba927cfe08885678d4356d996e360db78207855ebcf63364b9d67354b2e24a837675ae100ea9f06313466c8a9afc15e1c7154397178525683', 'Admin User', 'admin'),
('user@example.com', '189d0438941db1deb15302f68139be65:09d4e4ba164546fd11e77e6ddfbf5b77b82427d74a901df9e8e9ca784628337480353f35e73c85cd4d5096daafef21e464d6d6a45e3c1e30b08bb1c57cbdf6f5', 'Regular User', 'user');
2 changes: 2 additions & 0 deletions api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import orderRoutes from './routes/order';
import branchRoutes from './routes/branch';
import headquartersRoutes from './routes/headquarters';
import supplierRoutes from './routes/supplier';
import userRoutes from './routes/user';
import { initializeDatabase } from './init-db';
import { errorHandler } from './utils/errors';

Expand Down Expand Up @@ -78,6 +79,7 @@ app.use('/api/orders', orderRoutes);
app.use('/api/branches', branchRoutes);
app.use('/api/headquarters', headquartersRoutes);
app.use('/api/suppliers', supplierRoutes);
app.use('/api/users', userRoutes);

app.get('/', (req, res) => {
res.send('Hello, world!');
Expand Down
72 changes: 72 additions & 0 deletions api/src/models/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* @swagger
* components:
* schemas:
* User:
* type: object
* required:
* - userId
* - email
* - name
* - role
* properties:
* userId:
* type: integer
* description: The unique identifier for the user
* email:
* type: string
* format: email
* description: The user's email address (unique)
* name:
* type: string
* description: The user's display name
* role:
* type: string
* enum: [user, admin]
* description: The user's role
* createdAt:
* type: string
* format: date-time
* description: The timestamp when the account was created
* UserRegistration:
* type: object
* required:
* - email
* - password
* - name
* properties:
* email:
* type: string
* format: email
* password:
* type: string
* minLength: 8
* name:
* type: string
* role:
* type: string
* enum: [user, admin]
* default: user
* UserLogin:
* type: object
* required:
* - email
* - password
* properties:
* email:
* type: string
* format: email
* password:
* type: string
*/
export interface User {
userId: number;
email: string;
name: string;
role: 'user' | 'admin';
createdAt: string;
}

export interface UserWithPassword extends User {
passwordHash: string;
}
217 changes: 217 additions & 0 deletions api/src/repositories/usersRepo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { UsersRepository, hashPassword, verifyPassword } from '../repositories/usersRepo';
import { NotFoundError, ValidationError } from '../utils/errors';

// Mock the getDatabase function first
vi.mock('../db/sqlite', () => ({
getDatabase: vi.fn(),
}));

import { getDatabase } from '../db/sqlite';

describe('hashPassword / verifyPassword', () => {
it('should produce a hash that verifies correctly', () => {
const hash = hashPassword('secret123');
expect(verifyPassword('secret123', hash)).toBe(true);
});

it('should reject an incorrect password', () => {
const hash = hashPassword('secret123');
expect(verifyPassword('wrong', hash)).toBe(false);
});

it('should produce different hashes for the same password (random salt)', () => {
const h1 = hashPassword('secret123');
const h2 = hashPassword('secret123');
expect(h1).not.toBe(h2);
});
});

describe('UsersRepository', () => {
let repository: UsersRepository;
let mockDb: any;

const dbRow = {
user_id: 1,
email: 'alice@example.com',
name: 'Alice',
role: 'user',
created_at: '2024-01-01T00:00:00.000Z',
password_hash: hashPassword('password1'),
};

beforeEach(() => {
mockDb = {
db: {} as any,
run: vi.fn(),
get: vi.fn(),
all: vi.fn(),
close: vi.fn(),
};

(getDatabase as any).mockResolvedValue(mockDb);
repository = new UsersRepository(mockDb);
vi.clearAllMocks();
});

describe('findAll', () => {
it('should return all users without password hashes', async () => {
mockDb.all.mockResolvedValue([dbRow]);

const result = await repository.findAll();

expect(mockDb.all).toHaveBeenCalledWith(
'SELECT user_id, email, name, role, created_at FROM users ORDER BY user_id',
);
expect(result).toHaveLength(1);
expect(result[0].userId).toBe(1);
expect(result[0].email).toBe('alice@example.com');
expect((result[0] as any).passwordHash).toBeUndefined();
});

it('should return empty array when no users exist', async () => {
mockDb.all.mockResolvedValue([]);
const result = await repository.findAll();
expect(result).toEqual([]);
});
});

describe('findById', () => {
it('should return user when found', async () => {
mockDb.get.mockResolvedValue(dbRow);

const result = await repository.findById(1);

expect(result?.userId).toBe(1);
expect(result?.email).toBe('alice@example.com');
expect((result as any)?.passwordHash).toBeUndefined();
});

it('should return null when user not found', async () => {
mockDb.get.mockResolvedValue(undefined);
const result = await repository.findById(999);
expect(result).toBeNull();
});
});

describe('findByEmail', () => {
it('should return user with password hash when found', async () => {
mockDb.get.mockResolvedValue(dbRow);

const result = await repository.findByEmail('alice@example.com');

expect(result?.email).toBe('alice@example.com');
expect(result?.passwordHash).toBeDefined();
});

it('should return null when email not found', async () => {
mockDb.get.mockResolvedValue(undefined);
const result = await repository.findByEmail('nobody@example.com');
expect(result).toBeNull();
});
});

describe('create', () => {
it('should create a new user and return it without password hash', async () => {
mockDb.run.mockResolvedValue({ lastID: 2, changes: 1 });
mockDb.get.mockResolvedValue({
user_id: 2,
email: 'bob@example.com',
name: 'Bob',
role: 'user',
created_at: '2024-01-02T00:00:00.000Z',
});

const result = await repository.create({
email: 'bob@example.com',
password: 'securePass1',
name: 'Bob',
});

expect(result.userId).toBe(2);
expect(result.email).toBe('bob@example.com');
expect((result as any).passwordHash).toBeUndefined();
});

it('should throw ValidationError when password is too short', async () => {
await expect(
repository.create({ email: 'x@y.com', password: 'short', name: 'X' }),
).rejects.toThrow(ValidationError);
});

it('should throw ValidationError when required fields are missing', async () => {
await expect(
repository.create({ email: '', password: 'longenough', name: 'X' }),
).rejects.toThrow(ValidationError);
});
});

describe('update', () => {
it('should update user name and return updated user', async () => {
mockDb.run.mockResolvedValue({ changes: 1 });
mockDb.get.mockResolvedValue({ ...dbRow, name: 'Alice Updated' });

const result = await repository.update(1, { name: 'Alice Updated' });

expect(result.name).toBe('Alice Updated');
});

it('should throw NotFoundError when user does not exist', async () => {
mockDb.run.mockResolvedValue({ changes: 0 });
await expect(repository.update(999, { name: 'Nobody' })).rejects.toThrow(NotFoundError);
});
});

describe('delete', () => {
it('should delete existing user', async () => {
mockDb.run.mockResolvedValue({ changes: 1 });
await repository.delete(1);
expect(mockDb.run).toHaveBeenCalledWith('DELETE FROM users WHERE user_id = ?', [1]);
});

it('should throw NotFoundError when user does not exist', async () => {
mockDb.run.mockResolvedValue({ changes: 0 });
await expect(repository.delete(999)).rejects.toThrow(NotFoundError);
});
});

describe('validateCredentials', () => {
it('should return user when credentials are valid', async () => {
const pw = 'password1';
const hash = hashPassword(pw);
mockDb.get.mockResolvedValue({ ...dbRow, password_hash: hash });

const result = await repository.validateCredentials('alice@example.com', pw);

expect(result).not.toBeNull();
expect(result?.email).toBe('alice@example.com');
expect((result as any)?.passwordHash).toBeUndefined();
});

it('should return null when password is wrong', async () => {
mockDb.get.mockResolvedValue(dbRow);
const result = await repository.validateCredentials('alice@example.com', 'wrongpassword');
expect(result).toBeNull();
});

it('should return null when email is not found', async () => {
mockDb.get.mockResolvedValue(undefined);
const result = await repository.validateCredentials('nobody@example.com', 'pass');
expect(result).toBeNull();
});
});

describe('exists', () => {
it('should return true when user exists', async () => {
mockDb.get.mockResolvedValue({ count: 1 });
const result = await repository.exists(1);
expect(result).toBe(true);
});

it('should return false when user does not exist', async () => {
mockDb.get.mockResolvedValue({ count: 0 });
const result = await repository.exists(999);
expect(result).toBe(false);
});
});
});
Loading