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:
defiQUG
2025-12-12 21:18:55 -08:00
parent 664707d912
commit fe0365757a
106 changed files with 4666 additions and 2294 deletions

2
api/.npmrc Normal file
View File

@@ -0,0 +1,2 @@
# Prefer pnpm, but allow npm as fallback
package-manager-strict=false

View File

@@ -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()

View 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)
})
)
})
})
})

View File

@@ -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(

View 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')
})
})
})

View File

@@ -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
View 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/**',
],
},
},
})