Update documentation structure and enhance .gitignore
- Added generated index files and report directories to .gitignore to prevent unnecessary tracking of transient files. - Updated README links to reflect new documentation paths for better navigation. - Improved documentation organization by ensuring all links point to the correct locations, enhancing user experience and accessibility.
This commit is contained in:
2
api/.npmrc
Normal file
2
api/.npmrc
Normal file
@@ -0,0 +1,2 @@
|
||||
# Prefer pnpm, but allow npm as fallback
|
||||
package-manager-strict=false
|
||||
@@ -8,17 +8,50 @@ import { ResourceProvider } from '../../types/resource.js'
|
||||
import { logger } from '../../lib/logger.js'
|
||||
import type { ProxmoxCluster, ProxmoxVM, ProxmoxVMConfig } from './types.js'
|
||||
|
||||
/**
|
||||
* Proxmox VE Infrastructure Adapter
|
||||
*
|
||||
* Implements the InfrastructureAdapter interface for Proxmox VE infrastructure.
|
||||
* Provides resource discovery, creation, update, deletion, metrics, and health checks.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const adapter = new ProxmoxAdapter({
|
||||
* apiUrl: 'https://proxmox.example.com:8006',
|
||||
* apiToken: 'token-id=...'
|
||||
* });
|
||||
* const resources = await adapter.discoverResources();
|
||||
* ```
|
||||
*/
|
||||
export class ProxmoxAdapter implements InfrastructureAdapter {
|
||||
readonly provider: ResourceProvider = 'PROXMOX'
|
||||
|
||||
private apiUrl: string
|
||||
private apiToken: string
|
||||
|
||||
/**
|
||||
* Create a new Proxmox adapter instance
|
||||
*
|
||||
* @param config - Configuration object
|
||||
* @param config.apiUrl - Proxmox API URL (e.g., 'https://proxmox.example.com:8006')
|
||||
* @param config.apiToken - Proxmox API token in format 'token-id=...' or 'username@realm!token-id=...'
|
||||
*/
|
||||
constructor(config: { apiUrl: string; apiToken: string }) {
|
||||
this.apiUrl = config.apiUrl
|
||||
this.apiToken = config.apiToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all resources across all Proxmox nodes
|
||||
*
|
||||
* @returns Array of normalized resources (VMs) from all nodes
|
||||
* @throws {Error} If API connection fails or nodes cannot be retrieved
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resources = await adapter.discoverResources();
|
||||
* console.log(`Found ${resources.length} VMs`);
|
||||
* ```
|
||||
*/
|
||||
async discoverResources(): Promise<NormalizedResource[]> {
|
||||
try {
|
||||
const nodes = await this.getNodes()
|
||||
|
||||
151
api/src/services/auth.test.ts
Normal file
151
api/src/services/auth.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Unit tests for authentication service
|
||||
*
|
||||
* This file demonstrates testing patterns for service functions in the API.
|
||||
* See docs/TEST_EXAMPLES.md for more examples.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import jwt from 'jsonwebtoken'
|
||||
import { login } from './auth'
|
||||
import { getDb } from '../db'
|
||||
import { AppErrors } from '../lib/errors'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../db')
|
||||
vi.mock('../lib/errors')
|
||||
vi.mock('bcryptjs')
|
||||
vi.mock('jsonwebtoken')
|
||||
vi.mock('../lib/secret-validation', () => ({
|
||||
requireJWTSecret: () => 'test-secret'
|
||||
}))
|
||||
|
||||
describe('auth service', () => {
|
||||
let mockDb: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockDb = {
|
||||
query: vi.fn()
|
||||
}
|
||||
|
||||
vi.mocked(getDb).mockReturnValue(mockDb as any)
|
||||
})
|
||||
|
||||
describe('login', () => {
|
||||
it('should authenticate valid user and return token', async () => {
|
||||
// Arrange
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
password_hash: '$2a$10$hashed',
|
||||
role: 'USER',
|
||||
created_at: new Date('2024-01-01'),
|
||||
updated_at: new Date('2024-01-01'),
|
||||
}
|
||||
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: [mockUser]
|
||||
})
|
||||
|
||||
vi.mocked(bcrypt.compare).mockResolvedValue(true as never)
|
||||
vi.mocked(jwt.sign).mockReturnValue('mock-jwt-token' as any)
|
||||
|
||||
// Act
|
||||
const result = await login('user@example.com', 'password123')
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveProperty('token')
|
||||
expect(result.token).toBe('mock-jwt-token')
|
||||
expect(result.user.email).toBe('user@example.com')
|
||||
expect(result.user.name).toBe('Test User')
|
||||
expect(result.user.role).toBe('USER')
|
||||
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('SELECT'),
|
||||
['user@example.com']
|
||||
)
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith('password123', mockUser.password_hash)
|
||||
expect(jwt.sign).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw error for invalid email', async () => {
|
||||
// Arrange
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: []
|
||||
})
|
||||
|
||||
// Act & Assert
|
||||
await expect(login('invalid@example.com', 'password123')).rejects.toThrow()
|
||||
|
||||
expect(bcrypt.compare).not.toHaveBeenCalled()
|
||||
expect(jwt.sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should throw error for invalid password', async () => {
|
||||
// Arrange
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
password_hash: '$2a$10$hashed',
|
||||
role: 'USER',
|
||||
created_at: new Date('2024-01-01'),
|
||||
updated_at: new Date('2024-01-01'),
|
||||
}
|
||||
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: [mockUser]
|
||||
})
|
||||
|
||||
vi.mocked(bcrypt.compare).mockResolvedValue(false as never)
|
||||
|
||||
// Act & Assert
|
||||
await expect(login('user@example.com', 'wrongpassword')).rejects.toThrow()
|
||||
|
||||
expect(bcrypt.compare).toHaveBeenCalledWith('wrongpassword', mockUser.password_hash)
|
||||
expect(jwt.sign).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should include user role in JWT token', async () => {
|
||||
// Arrange
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
password_hash: '$2a$10$hashed',
|
||||
role: 'ADMIN',
|
||||
created_at: new Date('2024-01-01'),
|
||||
updated_at: new Date('2024-01-01'),
|
||||
}
|
||||
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: [mockUser]
|
||||
})
|
||||
|
||||
vi.mocked(bcrypt.compare).mockResolvedValue(true as never)
|
||||
vi.mocked(jwt.sign).mockReturnValue('mock-jwt-token' as any)
|
||||
|
||||
// Act
|
||||
await login('admin@example.com', 'password123')
|
||||
|
||||
// Assert
|
||||
expect(jwt.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: '1',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User',
|
||||
role: 'ADMIN',
|
||||
}),
|
||||
'test-secret',
|
||||
expect.objectContaining({
|
||||
expiresIn: expect.any(String)
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -14,6 +14,19 @@ export interface AuthPayload {
|
||||
user: User
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a user and return JWT token
|
||||
*
|
||||
* @param email - User email address
|
||||
* @param password - User password
|
||||
* @returns Authentication payload with JWT token and user information
|
||||
* @throws {AuthenticationError} If credentials are invalid
|
||||
* @example
|
||||
* ```typescript
|
||||
* const result = await login('user@example.com', 'password123');
|
||||
* console.log(result.token); // JWT token
|
||||
* ```
|
||||
*/
|
||||
export async function login(email: string, password: string): Promise<AuthPayload> {
|
||||
const db = getDb()
|
||||
const result = await db.query(
|
||||
|
||||
267
api/src/services/resource.test.ts
Normal file
267
api/src/services/resource.test.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Unit tests for resource service
|
||||
*
|
||||
* This file demonstrates testing patterns for service functions with database operations.
|
||||
* See docs/TEST_EXAMPLES.md for more examples.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { getResources, getResource, createResource } from './resource'
|
||||
import { AppErrors } from '../lib/errors'
|
||||
import { Context } from '../types/context'
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../lib/errors')
|
||||
|
||||
describe('resource service', () => {
|
||||
let mockContext: Context
|
||||
let mockDb: any
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
mockDb = {
|
||||
query: vi.fn()
|
||||
}
|
||||
|
||||
mockContext = {
|
||||
user: {
|
||||
id: '1',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User',
|
||||
role: 'USER',
|
||||
createdAt: new Date('2024-01-01'),
|
||||
updatedAt: new Date('2024-01-01'),
|
||||
},
|
||||
db: mockDb,
|
||||
tenantContext: null,
|
||||
} as Context
|
||||
})
|
||||
|
||||
describe('getResources', () => {
|
||||
it('should return resources with site information', async () => {
|
||||
// Arrange
|
||||
const mockRows = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'VM-1',
|
||||
type: 'VM',
|
||||
status: 'RUNNING',
|
||||
site_id: 'site-1',
|
||||
tenant_id: null,
|
||||
metadata: null,
|
||||
created_at: new Date('2024-01-01'),
|
||||
updated_at: new Date('2024-01-01'),
|
||||
site_id_full: 'site-1',
|
||||
site_name: 'Site 1',
|
||||
site_region: 'us-west',
|
||||
site_status: 'ACTIVE',
|
||||
site_metadata: null,
|
||||
site_created_at: new Date('2024-01-01'),
|
||||
site_updated_at: new Date('2024-01-01'),
|
||||
}
|
||||
]
|
||||
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: mockRows
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = await getResources(mockContext)
|
||||
|
||||
// Assert
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0].id).toBe('1')
|
||||
expect(result[0].name).toBe('VM-1')
|
||||
expect(result[0].site).toBeDefined()
|
||||
expect(result[0].site.name).toBe('Site 1')
|
||||
|
||||
expect(mockDb.query).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should filter resources by type', async () => {
|
||||
// Arrange
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: []
|
||||
})
|
||||
|
||||
// Act
|
||||
await getResources(mockContext, { type: 'VM' })
|
||||
|
||||
// Assert
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("r.type = $"),
|
||||
expect.arrayContaining(['VM'])
|
||||
)
|
||||
})
|
||||
|
||||
it('should filter resources by status', async () => {
|
||||
// Arrange
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: []
|
||||
})
|
||||
|
||||
// Act
|
||||
await getResources(mockContext, { status: 'RUNNING' })
|
||||
|
||||
// Assert
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("r.status = $"),
|
||||
expect.arrayContaining(['RUNNING'])
|
||||
)
|
||||
})
|
||||
|
||||
it('should enforce tenant isolation', async () => {
|
||||
// Arrange
|
||||
mockContext.tenantContext = {
|
||||
tenantId: 'tenant-1',
|
||||
isSystemAdmin: false,
|
||||
}
|
||||
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: []
|
||||
})
|
||||
|
||||
// Act
|
||||
await getResources(mockContext)
|
||||
|
||||
// Assert
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining("r.tenant_id = $"),
|
||||
expect.arrayContaining(['tenant-1'])
|
||||
)
|
||||
})
|
||||
|
||||
it('should allow system admins to see all resources', async () => {
|
||||
// Arrange
|
||||
mockContext.tenantContext = {
|
||||
tenantId: 'tenant-1',
|
||||
isSystemAdmin: true,
|
||||
}
|
||||
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: []
|
||||
})
|
||||
|
||||
// Act
|
||||
await getResources(mockContext)
|
||||
|
||||
// Assert
|
||||
const queryCall = mockDb.query.mock.calls[0][0]
|
||||
expect(queryCall).not.toContain("r.tenant_id = $")
|
||||
})
|
||||
})
|
||||
|
||||
describe('getResource', () => {
|
||||
it('should return a single resource by ID', async () => {
|
||||
// Arrange
|
||||
const mockRow = {
|
||||
id: '1',
|
||||
name: 'VM-1',
|
||||
type: 'VM',
|
||||
status: 'RUNNING',
|
||||
site_id: 'site-1',
|
||||
tenant_id: null,
|
||||
metadata: null,
|
||||
created_at: new Date('2024-01-01'),
|
||||
updated_at: new Date('2024-01-01'),
|
||||
}
|
||||
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: [mockRow]
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = await getResource(mockContext, '1')
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined()
|
||||
expect(result.id).toBe('1')
|
||||
expect(result.name).toBe('VM-1')
|
||||
|
||||
expect(mockDb.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('WHERE id = $1'),
|
||||
['1']
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw NotFoundError for non-existent resource', async () => {
|
||||
// Arrange
|
||||
mockDb.query.mockResolvedValue({
|
||||
rows: []
|
||||
})
|
||||
|
||||
// Act & Assert
|
||||
await expect(getResource(mockContext, 'nonexistent')).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createResource', () => {
|
||||
it('should create a new resource', async () => {
|
||||
// Arrange
|
||||
const mockCreatedRow = {
|
||||
id: 'new-resource-id',
|
||||
name: 'New VM',
|
||||
type: 'VM',
|
||||
status: 'PENDING',
|
||||
site_id: 'site-1',
|
||||
tenant_id: null,
|
||||
metadata: null,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
}
|
||||
|
||||
// Mock site query
|
||||
mockDb.query
|
||||
.mockResolvedValueOnce({
|
||||
rows: [{ id: 'site-1', name: 'Site 1', region: 'us-west', status: 'ACTIVE' }]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
rows: [mockCreatedRow]
|
||||
})
|
||||
|
||||
// Act
|
||||
const result = await createResource(mockContext, {
|
||||
name: 'New VM',
|
||||
type: 'VM',
|
||||
siteId: 'site-1',
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined()
|
||||
expect(result.name).toBe('New VM')
|
||||
expect(mockDb.query).toHaveBeenCalledTimes(2) // Site lookup + insert
|
||||
})
|
||||
|
||||
it('should set tenant_id from context when available', async () => {
|
||||
// Arrange
|
||||
mockContext.tenantContext = {
|
||||
tenantId: 'tenant-1',
|
||||
isSystemAdmin: false,
|
||||
}
|
||||
|
||||
mockDb.query
|
||||
.mockResolvedValueOnce({
|
||||
rows: [{ id: 'site-1', name: 'Site 1', region: 'us-west', status: 'ACTIVE' }]
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
id: 'new-resource-id',
|
||||
tenant_id: 'tenant-1',
|
||||
}]
|
||||
})
|
||||
|
||||
// Act
|
||||
await createResource(mockContext, {
|
||||
name: 'New VM',
|
||||
type: 'VM',
|
||||
siteId: 'site-1',
|
||||
})
|
||||
|
||||
// Assert
|
||||
const insertQuery = mockDb.query.mock.calls[1][0]
|
||||
expect(insertQuery).toContain('tenant_id')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,18 @@ interface SiteRow {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all resources with optional filtering
|
||||
*
|
||||
* @param context - Request context with user and database connection
|
||||
* @param filter - Optional filter criteria (type, status, siteId, tenantId)
|
||||
* @returns Array of resources with site information
|
||||
* @throws {UnauthenticatedError} If user is not authenticated
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resources = await getResources(context, { type: 'VM', status: 'RUNNING' });
|
||||
* ```
|
||||
*/
|
||||
export async function getResources(context: Context, filter?: ResourceFilter) {
|
||||
const db = context.db
|
||||
// Use LEFT JOIN to fetch resources and sites in a single query (fixes N+1 problem)
|
||||
@@ -104,6 +116,19 @@ export async function getResources(context: Context, filter?: ResourceFilter) {
|
||||
return result.rows.map((row) => mapResourceWithSite(row))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single resource by ID
|
||||
*
|
||||
* @param context - Request context with user and database connection
|
||||
* @param id - Resource ID
|
||||
* @returns Resource with site information
|
||||
* @throws {NotFoundError} If resource not found or user doesn't have access
|
||||
* @throws {UnauthenticatedError} If user is not authenticated
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resource = await getResource(context, 'resource-123');
|
||||
* ```
|
||||
*/
|
||||
export async function getResource(context: Context, id: string) {
|
||||
const db = context.db
|
||||
let query = 'SELECT * FROM resources WHERE id = $1'
|
||||
@@ -134,6 +159,23 @@ export async function getResource(context: Context, id: string) {
|
||||
return mapResource(result.rows[0], context)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new resource
|
||||
*
|
||||
* @param context - Request context with user and database connection
|
||||
* @param input - Resource creation input (name, type, siteId, metadata)
|
||||
* @returns Created resource with site information
|
||||
* @throws {UnauthenticatedError} If user is not authenticated
|
||||
* @throws {QuotaExceededError} If tenant quota limits are exceeded
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resource = await createResource(context, {
|
||||
* name: 'My VM',
|
||||
* type: 'VM',
|
||||
* siteId: 'site-123'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function createResource(context: Context, input: CreateResourceInput) {
|
||||
const db = context.db
|
||||
|
||||
@@ -252,6 +294,22 @@ export async function createResource(context: Context, input: CreateResourceInpu
|
||||
return resource
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing resource
|
||||
*
|
||||
* @param context - Request context with user and database connection
|
||||
* @param id - Resource ID
|
||||
* @param input - Resource update input (name, metadata)
|
||||
* @returns Updated resource
|
||||
* @throws {NotFoundError} If resource not found or user doesn't have access
|
||||
* @throws {UnauthenticatedError} If user is not authenticated
|
||||
* @example
|
||||
* ```typescript
|
||||
* const resource = await updateResource(context, 'resource-123', {
|
||||
* name: 'Updated Name'
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function updateResource(context: Context, id: string, input: UpdateResourceInput) {
|
||||
const db = context.db
|
||||
const updates: string[] = []
|
||||
@@ -289,6 +347,19 @@ export async function updateResource(context: Context, id: string, input: Update
|
||||
return resource
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a resource
|
||||
*
|
||||
* @param context - Request context with user and database connection
|
||||
* @param id - Resource ID
|
||||
* @returns true if deletion was successful
|
||||
* @throws {NotFoundError} If resource not found or user doesn't have access
|
||||
* @throws {UnauthenticatedError} If user is not authenticated
|
||||
* @example
|
||||
* ```typescript
|
||||
* await deleteResource(context, 'resource-123');
|
||||
* ```
|
||||
*/
|
||||
export async function deleteResource(context: Context, id: string) {
|
||||
const db = context.db
|
||||
await db.query('DELETE FROM resources WHERE id = $1', [id])
|
||||
|
||||
20
api/vitest.config.ts
Normal file
20
api/vitest.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/',
|
||||
'dist/',
|
||||
'**/*.test.ts',
|
||||
'**/*.spec.ts',
|
||||
'**/types/**',
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user