diff --git a/server/cmd/server/main.go b/server/cmd/server/main.go index 47fcdc8..929a769 100644 --- a/server/cmd/server/main.go +++ b/server/cmd/server/main.go @@ -5,21 +5,22 @@ import ( "log" "novamd/internal/app" - "novamd/internal/config" ) func main() { // Load configuration - cfg, err := config.Load() + cfg, err := app.LoadConfig() if err != nil { log.Fatal("Failed to load configuration:", err) } // Initialize and start server - server, err := app.NewServer(cfg) + options, err := app.DefaultOptions(cfg) if err != nil { - log.Fatal("Failed to initialize server:", err) + log.Fatal("Failed to initialize server options:", err) } + + server := app.NewServer(options) defer func() { if err := server.Close(); err != nil { log.Println("Error closing server:", err) diff --git a/server/documentation.md b/server/documentation.md new file mode 100644 index 0000000..76e9edb --- /dev/null +++ b/server/documentation.md @@ -0,0 +1,832 @@ +# NovaMD Package Documentation + +Generated documentation for all packages in the NovaMD project. + +## Table of Contents + +- [cmd/server](#cmd-server) +- [internal/app](#internal-app) +- [internal/auth](#internal-auth) +- [internal/config](#internal-config) +- [internal/context](#internal-context) +- [internal/db](#internal-db) +- [internal/git](#internal-git) +- [internal/handlers](#internal-handlers) +- [internal/models](#internal-models) +- [internal/secrets](#internal-secrets) +- [internal/storage](#internal-storage) + +## cmd/server + +```go +Package main provides the entry point for the application. It loads the +configuration, initializes the server, and starts the server. +``` + +## internal/app + +```go +package app // import "novamd/internal/app" + +Package app provides application-level functionality for initializing and +running the server + +FUNCTIONS + +func SetupRoutes(r chi.Router, db db.Database, s storage.Manager, authMiddleware *auth.Middleware, sessionService *auth.SessionService) + SetupRoutes configures the API routes + + +TYPES + +type Server struct { + // Has unexported fields. +} + Server represents the HTTP server and its dependencies + +func NewServer(cfg *config.Config) (*Server, error) + NewServer initializes a new server instance with all dependencies + +func (s *Server) Close() error + Close handles graceful shutdown of server dependencies + +func (s *Server) Start() error + Start configures and starts the HTTP server + +``` + +## internal/auth + +```go +package auth // import "novamd/internal/auth" + +Package auth provides JWT token generation and validation + +TYPES + +type Claims struct { + jwt.RegisteredClaims // Embedded standard JWT claims + UserID int `json:"uid"` // User identifier + Role string `json:"role"` // User role (admin, editor, viewer) + Type TokenType `json:"type"` // Token type (access or refresh) +} + Claims represents the custom claims we store in JWT tokens + +type JWTConfig struct { + SigningKey string // Secret key used to sign tokens + AccessTokenExpiry time.Duration // How long access tokens are valid + RefreshTokenExpiry time.Duration // How long refresh tokens are valid +} + JWTConfig holds the configuration for the JWT service + +type JWTManager interface { + GenerateAccessToken(userID int, role string) (string, error) + GenerateRefreshToken(userID int, role string) (string, error) + ValidateToken(tokenString string) (*Claims, error) + RefreshAccessToken(refreshToken string) (string, error) +} + JWTManager defines the interface for managing JWT tokens + +func NewJWTService(config JWTConfig) (JWTManager, error) + NewJWTService creates a new JWT service with the provided configuration + Returns an error if the signing key is missing + +type Middleware struct { + // Has unexported fields. +} + Middleware handles JWT authentication for protected routes + +func NewMiddleware(jwtManager JWTManager) *Middleware + NewMiddleware creates a new authentication middleware + +func (m *Middleware) Authenticate(next http.Handler) http.Handler + Authenticate middleware validates JWT tokens and sets user information in + context + +func (m *Middleware) RequireRole(role string) func(http.Handler) http.Handler + RequireRole returns a middleware that ensures the user has the required role + +func (m *Middleware) RequireWorkspaceAccess(next http.Handler) http.Handler + RequireWorkspaceAccess returns a middleware that ensures the user has access + to the workspace + +type SessionService struct { + // Has unexported fields. +} + SessionService manages user sessions in the database + +func NewSessionService(db db.SessionStore, jwtManager JWTManager) *SessionService + NewSessionService creates a new session service with the given database and + JWT manager + +func (s *SessionService) CleanExpiredSessions() error + CleanExpiredSessions removes all expired sessions from the database + +func (s *SessionService) CreateSession(userID int, role string) (*models.Session, string, error) + CreateSession creates a new user session for a user with the given userID + and role + +func (s *SessionService) InvalidateSession(sessionID string) error + InvalidateSession removes a session with the given sessionID from the + database + +func (s *SessionService) RefreshSession(refreshToken string) (string, error) + RefreshSession creates a new access token using a refreshToken + +type TokenType string + TokenType represents the type of JWT token (access or refresh) + +const ( + AccessToken TokenType = "access" // AccessToken - Short-lived token for API access + RefreshToken TokenType = "refresh" // RefreshToken - Long-lived token for obtaining new access tokens +) +``` + +## internal/config + +```go +package config // import "novamd/internal/config" + +Package config provides the configuration for the application + +TYPES + +type Config struct { + DBPath string + WorkDir string + StaticPath string + Port string + AppURL string + CORSOrigins []string + AdminEmail string + AdminPassword string + EncryptionKey string + JWTSigningKey string + RateLimitRequests int + RateLimitWindow time.Duration + IsDevelopment bool +} + Config holds the configuration for the application + +func DefaultConfig() *Config + DefaultConfig returns a new Config instance with default values + +func Load() (*Config, error) + Load creates a new Config instance with values from environment variables + +func (c *Config) Validate() error + Validate checks if the configuration is valid + +``` + +## internal/context + +```go +package context // import "novamd/internal/context" + +Package context provides functions for managing request context + +CONSTANTS + +const ( + // HandlerContextKey is the key used to store handler context in the request context + HandlerContextKey contextKey = "handlerContext" +) + +FUNCTIONS + +func WithHandlerContext(r *http.Request, hctx *HandlerContext) *http.Request + WithHandlerContext adds handler context to the request + +func WithUserContextMiddleware(next http.Handler) http.Handler + WithUserContextMiddleware extracts user information from JWT claims and adds + it to the request context + +func WithWorkspaceContextMiddleware(db db.WorkspaceReader) func(http.Handler) http.Handler + WithWorkspaceContextMiddleware adds workspace information to the request + context + + +TYPES + +type HandlerContext struct { + UserID int + UserRole string + Workspace *models.Workspace // Optional, only set for workspace routes +} + HandlerContext holds the request-specific data available to all handlers + +func GetRequestContext(w http.ResponseWriter, r *http.Request) (*HandlerContext, bool) + GetRequestContext retrieves the handler context from the request + +type UserClaims struct { + UserID int + Role string +} + UserClaims represents user information from authentication + +func GetUserFromContext(ctx context.Context) (*UserClaims, error) + GetUserFromContext retrieves user claims from the context + +``` + +## internal/db + +```go +package db // import "novamd/internal/db" + +Package db provides the database access layer for the application. It contains +methods for interacting with the database, such as creating, updating, and +deleting records. + +CONSTANTS + +const ( + // JWTSecretKey is the key for the JWT secret in the system settings + JWTSecretKey = "jwt_secret" +) + +TYPES + +type Database interface { + UserStore + WorkspaceStore + SessionStore + SystemStore + Begin() (*sql.Tx, error) + Close() error + Migrate() error +} + Database defines the methods for interacting with the database + +func Init(dbPath string, secretsService secrets.Service) (Database, error) + Init initializes the database connection + +type Migration struct { + Version int + SQL string +} + Migration represents a database migration + +type SessionStore interface { + CreateSession(session *models.Session) error + GetSessionByRefreshToken(refreshToken string) (*models.Session, error) + DeleteSession(sessionID string) error + CleanExpiredSessions() error +} + SessionStore defines the methods for interacting with jwt sessions in the + database + +type SystemStore interface { + GetSystemStats() (*UserStats, error) + EnsureJWTSecret() (string, error) + GetSystemSetting(key string) (string, error) + SetSystemSetting(key, value string) error +} + SystemStore defines the methods for interacting with system settings and + stats in the database + +type UserStats struct { + TotalUsers int `json:"totalUsers"` + TotalWorkspaces int `json:"totalWorkspaces"` + ActiveUsers int `json:"activeUsers"` // Users with activity in last 30 days +} + UserStats represents system-wide statistics + +type UserStore interface { + CreateUser(user *models.User) (*models.User, error) + GetUserByEmail(email string) (*models.User, error) + GetUserByID(userID int) (*models.User, error) + GetAllUsers() ([]*models.User, error) + UpdateUser(user *models.User) error + DeleteUser(userID int) error + UpdateLastWorkspace(userID int, workspaceName string) error + GetLastWorkspaceName(userID int) (string, error) + CountAdminUsers() (int, error) +} + UserStore defines the methods for interacting with user data in the database + +type WorkspaceReader interface { + GetWorkspaceByID(workspaceID int) (*models.Workspace, error) + GetWorkspaceByName(userID int, workspaceName string) (*models.Workspace, error) + GetWorkspacesByUserID(userID int) ([]*models.Workspace, error) + GetAllWorkspaces() ([]*models.Workspace, error) +} + WorkspaceReader defines the methods for reading workspace data from the + database + +type WorkspaceStore interface { + WorkspaceReader + WorkspaceWriter +} + WorkspaceStore defines the methods for interacting with workspace data in + the database + +type WorkspaceWriter interface { + CreateWorkspace(workspace *models.Workspace) error + UpdateWorkspace(workspace *models.Workspace) error + DeleteWorkspace(workspaceID int) error + UpdateWorkspaceSettings(workspace *models.Workspace) error + DeleteWorkspaceTx(tx *sql.Tx, workspaceID int) error + UpdateLastWorkspaceTx(tx *sql.Tx, userID, workspaceID int) error + UpdateLastOpenedFile(workspaceID int, filePath string) error + GetLastOpenedFile(workspaceID int) (string, error) +} + WorkspaceWriter defines the methods for writing workspace data to the + database + +``` + +## internal/git + +```go +package git // import "novamd/internal/git" + +Package git provides functionalities to interact with Git repositories, +including cloning, pulling, committing, and pushing changes. + +TYPES + +type Client interface { + Clone() error + Pull() error + Commit(message string) error + Push() error + EnsureRepo() error +} + Client defines the interface for Git operations + +func New(url, username, token, workDir, commitName, commitEmail string) Client + New creates a new git Client instance + +type Config struct { + URL string + Username string + Token string + WorkDir string + CommitName string + CommitEmail string +} + Config holds the configuration for a Git client + +``` + +## internal/handlers + +```go +package handlers // import "novamd/internal/handlers" + +Package handlers contains the request handlers for the api routes. + +TYPES + +type CreateUserRequest struct { + Email string `json:"email"` + DisplayName string `json:"displayName"` + Password string `json:"password"` + Role models.UserRole `json:"role"` +} + CreateUserRequest holds the request fields for creating a new user + +type DeleteAccountRequest struct { + Password string `json:"password"` +} + DeleteAccountRequest represents a user account deletion request + +type Handler struct { + DB db.Database + Storage storage.Manager +} + Handler provides common functionality for all handlers + +func NewHandler(db db.Database, s storage.Manager) *Handler + NewHandler creates a new handler with the given dependencies + +func (h *Handler) AdminCreateUser() http.HandlerFunc + AdminCreateUser creates a new user + +func (h *Handler) AdminDeleteUser() http.HandlerFunc + AdminDeleteUser deletes a specific user + +func (h *Handler) AdminGetSystemStats() http.HandlerFunc + AdminGetSystemStats returns system-wide statistics for admins + +func (h *Handler) AdminGetUser() http.HandlerFunc + AdminGetUser gets a specific user by ID + +func (h *Handler) AdminListUsers() http.HandlerFunc + AdminListUsers returns a list of all users + +func (h *Handler) AdminListWorkspaces() http.HandlerFunc + AdminListWorkspaces returns a list of all workspaces and their stats + +func (h *Handler) AdminUpdateUser() http.HandlerFunc + AdminUpdateUser updates a specific user + +func (h *Handler) CreateWorkspace() http.HandlerFunc + CreateWorkspace creates a new workspace + +func (h *Handler) DeleteAccount() http.HandlerFunc + DeleteAccount handles user account deletion + +func (h *Handler) DeleteFile() http.HandlerFunc + DeleteFile deletes a file + +func (h *Handler) DeleteWorkspace() http.HandlerFunc + DeleteWorkspace deletes the current workspace + +func (h *Handler) GetCurrentUser() http.HandlerFunc + GetCurrentUser returns the currently authenticated user + +func (h *Handler) GetFileContent() http.HandlerFunc + GetFileContent returns the content of a file + +func (h *Handler) GetLastOpenedFile() http.HandlerFunc + GetLastOpenedFile returns the last opened file in the workspace + +func (h *Handler) GetLastWorkspaceName() http.HandlerFunc + GetLastWorkspaceName returns the name of the last opened workspace + +func (h *Handler) GetWorkspace() http.HandlerFunc + GetWorkspace returns the current workspace + +func (h *Handler) ListFiles() http.HandlerFunc + ListFiles returns a list of all files in the workspace + +func (h *Handler) ListWorkspaces() http.HandlerFunc + ListWorkspaces returns a list of all workspaces for the current user + +func (h *Handler) Login(authService *auth.SessionService) http.HandlerFunc + Login handles user authentication and returns JWT tokens + +func (h *Handler) Logout(authService *auth.SessionService) http.HandlerFunc + Logout invalidates the user's session + +func (h *Handler) LookupFileByName() http.HandlerFunc + LookupFileByName returns the paths of files with the given name + +func (h *Handler) PullChanges() http.HandlerFunc + PullChanges pulls changes from the remote repository + +func (h *Handler) RefreshToken(authService *auth.SessionService) http.HandlerFunc + RefreshToken generates a new access token using a refresh token + +func (h *Handler) SaveFile() http.HandlerFunc + SaveFile saves the content of a file + +func (h *Handler) StageCommitAndPush() http.HandlerFunc + StageCommitAndPush stages, commits, and pushes changes to the remote + repository + +func (h *Handler) UpdateLastOpenedFile() http.HandlerFunc + UpdateLastOpenedFile updates the last opened file in the workspace + +func (h *Handler) UpdateLastWorkspaceName() http.HandlerFunc + UpdateLastWorkspaceName updates the name of the last opened workspace + +func (h *Handler) UpdateProfile() http.HandlerFunc + UpdateProfile updates the current user's profile + +func (h *Handler) UpdateWorkspace() http.HandlerFunc + UpdateWorkspace updates the current workspace + +type LoginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + LoginRequest represents a user login request + +type LoginResponse struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + User *models.User `json:"user"` + Session *models.Session `json:"session"` +} + LoginResponse represents a user login response + +type RefreshRequest struct { + RefreshToken string `json:"refreshToken"` +} + RefreshRequest represents a refresh token request + +type RefreshResponse struct { + AccessToken string `json:"accessToken"` +} + RefreshResponse represents a refresh token response + +type StaticHandler struct { + // Has unexported fields. +} + StaticHandler serves static files with support for SPA routing and + pre-compressed files + +func NewStaticHandler(staticPath string) *StaticHandler + NewStaticHandler creates a new StaticHandler with the given static path + +func (h *StaticHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) + ServeHTTP serves the static files + +type SystemStats struct { + *db.UserStats + *storage.FileCountStats +} + SystemStats holds system-wide statistics + +type UpdateProfileRequest struct { + DisplayName string `json:"displayName"` + Email string `json:"email"` + CurrentPassword string `json:"currentPassword"` + NewPassword string `json:"newPassword"` +} + UpdateProfileRequest represents a user profile update request + +type UpdateUserRequest struct { + Email string `json:"email,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Password string `json:"password,omitempty"` + Role models.UserRole `json:"role,omitempty"` +} + UpdateUserRequest holds the request fields for updating a user + +type WorkspaceStats struct { + UserID int `json:"userID"` + UserEmail string `json:"userEmail"` + WorkspaceID int `json:"workspaceID"` + WorkspaceName string `json:"workspaceName"` + WorkspaceCreatedAt time.Time `json:"workspaceCreatedAt"` + *storage.FileCountStats +} + WorkspaceStats holds workspace statistics + +``` + +## internal/models + +```go +package models // import "novamd/internal/models" + +Package models contains the data models used throughout the application. +These models are used to represent data in the database, as well as to validate +and serialize data in the application. + +TYPES + +type Session struct { + ID string // Unique session identifier + UserID int // ID of the user this session belongs to + RefreshToken string // The refresh token associated with this session + ExpiresAt time.Time // When this session expires + CreatedAt time.Time // When this session was created +} + Session represents a user session in the database + +type User struct { + ID int `json:"id" validate:"required,min=1"` + Email string `json:"email" validate:"required,email"` + DisplayName string `json:"displayName"` + PasswordHash string `json:"-"` + Role UserRole `json:"role" validate:"required,oneof=admin editor viewer"` + CreatedAt time.Time `json:"createdAt"` + LastWorkspaceID int `json:"lastWorkspaceId"` +} + User represents a user in the system + +func (u *User) Validate() error + Validate validates the user struct + +type UserRole string + UserRole represents the role of a user in the system + +const ( + RoleAdmin UserRole = "admin" + RoleEditor UserRole = "editor" + RoleViewer UserRole = "viewer" +) + User roles + +type Workspace struct { + ID int `json:"id" validate:"required,min=1"` + UserID int `json:"userId" validate:"required,min=1"` + Name string `json:"name" validate:"required"` + CreatedAt time.Time `json:"createdAt"` + LastOpenedFilePath string `json:"lastOpenedFilePath"` + + // Integrated settings + Theme string `json:"theme" validate:"oneof=light dark"` + AutoSave bool `json:"autoSave"` + ShowHiddenFiles bool `json:"showHiddenFiles"` + GitEnabled bool `json:"gitEnabled"` + GitURL string `json:"gitUrl" validate:"required_if=GitEnabled true"` + GitUser string `json:"gitUser" validate:"required_if=GitEnabled true"` + GitToken string `json:"gitToken" validate:"required_if=GitEnabled true"` + GitAutoCommit bool `json:"gitAutoCommit"` + GitCommitMsgTemplate string `json:"gitCommitMsgTemplate"` + GitCommitName string `json:"gitCommitName"` + GitCommitEmail string `json:"gitCommitEmail" validate:"omitempty,required_if=GitEnabled true,email"` +} + Workspace represents a user's workspace in the system + +func (w *Workspace) SetDefaultSettings() + SetDefaultSettings sets the default settings for the workspace + +func (w *Workspace) Validate() error + Validate validates the workspace struct + +func (w *Workspace) ValidateGitSettings() error + ValidateGitSettings validates the git settings if git is enabled + +``` + +## internal/secrets + +```go +package secrets // import "novamd/internal/secrets" + +Package secrets provides an Encryptor interface for encrypting and decrypting +strings using AES-256-GCM. + +FUNCTIONS + +func ValidateKey(key string) error + ValidateKey checks if the provided base64-encoded key is suitable for + AES-256 + + +TYPES + +type Service interface { + Encrypt(plaintext string) (string, error) + Decrypt(ciphertext string) (string, error) +} + Service is an interface for encrypting and decrypting strings + +func NewService(key string) (Service, error) + NewService creates a new Encryptor instance with the provided base64-encoded + key + +``` + +## internal/storage + +```go +package storage // import "novamd/internal/storage" + +Package storage provides functionalities to interact with the file system, +including listing files, finding files by name, getting file content, saving +files, and deleting files. + +FUNCTIONS + +func IsPathValidationError(err error) bool + IsPathValidationError checks if the error is a PathValidationError + + +TYPES + +type FileCountStats struct { + TotalFiles int `json:"totalFiles"` + TotalSize int64 `json:"totalSize"` +} + FileCountStats holds statistics about files in a workspace + +type FileManager interface { + ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) + FindFileByName(userID, workspaceID int, filename string) ([]string, error) + GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) + SaveFile(userID, workspaceID int, filePath string, content []byte) error + DeleteFile(userID, workspaceID int, filePath string) error + GetFileStats(userID, workspaceID int) (*FileCountStats, error) + GetTotalFileStats() (*FileCountStats, error) +} + FileManager provides functionalities to interact with files in the storage. + +type FileNode struct { + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Children []FileNode `json:"children,omitempty"` +} + FileNode represents a file or directory in the storage. + +type Manager interface { + FileManager + WorkspaceManager + RepositoryManager +} + Manager interface combines all storage interfaces. + +type Options struct { + Fs fileSystem + NewGitClient func(url, user, token, path, commitName, commitEmail string) git.Client +} + Options represents the options for the storage service. + +type PathValidationError struct { + Path string + Message string +} + PathValidationError represents a path validation error (e.g., path traversal + attempt) + +func (e *PathValidationError) Error() string + +type RepositoryManager interface { + SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error + DisableGitRepo(userID, workspaceID int) + StageCommitAndPush(userID, workspaceID int, message string) error + Pull(userID, workspaceID int) error +} + RepositoryManager defines the interface for managing Git repositories. + +type Service struct { + RootDir string + GitRepos map[int]map[int]git.Client // map[userID]map[workspaceID]*git.Client + // Has unexported fields. +} + Service represents the file system structure. + +func NewService(rootDir string) *Service + NewService creates a new Storage instance with the default options and the + given rootDir root directory. + +func NewServiceWithOptions(rootDir string, options Options) *Service + NewServiceWithOptions creates a new Storage instance with the given options + and the given rootDir root directory. + +func (s *Service) DeleteFile(userID, workspaceID int, filePath string) error + DeleteFile deletes the file at the given filePath. Path must be a relative + path within the workspace directory given by userID and workspaceID. + +func (s *Service) DeleteUserWorkspace(userID, workspaceID int) error + DeleteUserWorkspace deletes the workspace directory for the given userID and + workspaceID. + +func (s *Service) DisableGitRepo(userID, workspaceID int) + DisableGitRepo disables the Git repository for the given userID and + workspaceID. + +func (s *Service) FindFileByName(userID, workspaceID int, filename string) ([]string, error) + FindFileByName returns a list of file paths that match the given filename. + Files are searched recursively in the workspace directory and its + subdirectories. Workspace is identified by the given userID and workspaceID. + +func (s *Service) GetFileContent(userID, workspaceID int, filePath string) ([]byte, error) + GetFileContent returns the content of the file at the given filePath. + Path must be a relative path within the workspace directory given by userID + and workspaceID. + +func (s *Service) GetFileStats(userID, workspaceID int) (*FileCountStats, error) + GetFileStats returns the total number of files and related statistics in a + workspace Workspace is identified by the given userID and workspaceID + +func (s *Service) GetTotalFileStats() (*FileCountStats, error) + GetTotalFileStats returns the total file statistics for the storage. + +func (s *Service) GetWorkspacePath(userID, workspaceID int) string + GetWorkspacePath returns the path to the workspace directory for the given + userID and workspaceID. + +func (s *Service) InitializeUserWorkspace(userID, workspaceID int) error + InitializeUserWorkspace creates the workspace directory for the given userID + and workspaceID. + +func (s *Service) ListFilesRecursively(userID, workspaceID int) ([]FileNode, error) + ListFilesRecursively returns a list of all files in the workspace directory + and its subdirectories. Workspace is identified by the given userID and + workspaceID. + +func (s *Service) Pull(userID, workspaceID int) error + Pull pulls the changes from the remote Git repository. The git repository + belongs to the given userID and is associated with the given workspaceID. + +func (s *Service) SaveFile(userID, workspaceID int, filePath string, content []byte) error + SaveFile writes the content to the file at the given filePath. Path must + be a relative path within the workspace directory given by userID and + workspaceID. + +func (s *Service) SetupGitRepo(userID, workspaceID int, gitURL, gitUser, gitToken, commitName, commitEmail string) error + SetupGitRepo sets up a Git repository for the given userID and workspaceID. + The repository is cloned from the given gitURL using the given gitUser and + gitToken. + +func (s *Service) StageCommitAndPush(userID, workspaceID int, message string) error + StageCommitAndPush stages, commit with the message, and pushes the changes + to the Git repository. The git repository belongs to the given userID and is + associated with the given workspaceID. + +func (s *Service) ValidatePath(userID, workspaceID int, path string) (string, error) + ValidatePath validates the if the given path is valid within the workspace + directory. Workspace directory is defined as the directory for the given + userID and workspaceID. + +type WorkspaceManager interface { + ValidatePath(userID, workspaceID int, path string) (string, error) + GetWorkspacePath(userID, workspaceID int) string + InitializeUserWorkspace(userID, workspaceID int) error + DeleteUserWorkspace(userID, workspaceID int) error +} + WorkspaceManager provides functionalities to interact with workspaces in the + storage. + +``` + diff --git a/server/internal/api/routes.go b/server/internal/api/routes.go deleted file mode 100644 index 1409ddc..0000000 --- a/server/internal/api/routes.go +++ /dev/null @@ -1,97 +0,0 @@ -// Package api contains the API routes for the application. It sets up the routes for the public and protected endpoints, as well as the admin-only routes. -package api - -import ( - "novamd/internal/auth" - "novamd/internal/context" - "novamd/internal/db" - "novamd/internal/handlers" - "novamd/internal/storage" - - "github.com/go-chi/chi/v5" -) - -// SetupRoutes configures the API routes -func SetupRoutes(r chi.Router, db db.Database, s storage.Manager, authMiddleware *auth.Middleware, sessionService *auth.SessionService) { - - handler := &handlers.Handler{ - DB: db, - Storage: s, - } - - // Public routes (no authentication required) - r.Group(func(r chi.Router) { - r.Post("/auth/login", handler.Login(sessionService)) - r.Post("/auth/refresh", handler.RefreshToken(sessionService)) - }) - - // Protected routes (authentication required) - r.Group(func(r chi.Router) { - // Apply authentication middleware to all routes in this group - r.Use(authMiddleware.Authenticate) - r.Use(context.WithUserContextMiddleware) - - // Auth routes - r.Post("/auth/logout", handler.Logout(sessionService)) - r.Get("/auth/me", handler.GetCurrentUser()) - - // User profile routes - r.Put("/profile", handler.UpdateProfile()) - r.Delete("/profile", handler.DeleteAccount()) - - // Admin-only routes - r.Route("/admin", func(r chi.Router) { - r.Use(authMiddleware.RequireRole("admin")) - // User management - r.Route("/users", func(r chi.Router) { - r.Get("/", handler.AdminListUsers()) - r.Post("/", handler.AdminCreateUser()) - r.Get("/{userId}", handler.AdminGetUser()) - r.Put("/{userId}", handler.AdminUpdateUser()) - r.Delete("/{userId}", handler.AdminDeleteUser()) - }) - // Workspace management - r.Route("/workspaces", func(r chi.Router) { - r.Get("/", handler.AdminListWorkspaces()) - }) - // System stats - r.Get("/stats", handler.AdminGetSystemStats()) - }) - - // Workspace routes - r.Route("/workspaces", func(r chi.Router) { - r.Get("/", handler.ListWorkspaces()) - r.Post("/", handler.CreateWorkspace()) - r.Get("/last", handler.GetLastWorkspaceName()) - r.Put("/last", handler.UpdateLastWorkspaceName()) - - // Single workspace routes - r.Route("/{workspaceName}", func(r chi.Router) { - r.Use(context.WithWorkspaceContextMiddleware(db)) - r.Use(authMiddleware.RequireWorkspaceAccess) - - r.Get("/", handler.GetWorkspace()) - r.Put("/", handler.UpdateWorkspace()) - r.Delete("/", handler.DeleteWorkspace()) - - // File routes - r.Route("/files", func(r chi.Router) { - r.Get("/", handler.ListFiles()) - r.Get("/last", handler.GetLastOpenedFile()) - r.Put("/last", handler.UpdateLastOpenedFile()) - r.Get("/lookup", handler.LookupFileByName()) - - r.Post("/*", handler.SaveFile()) - r.Get("/*", handler.GetFileContent()) - r.Delete("/*", handler.DeleteFile()) - }) - - // Git routes - r.Route("/git", func(r chi.Router) { - r.Post("/commit", handler.StageCommitAndPush()) - r.Post("/pull", handler.PullChanges()) - }) - }) - }) - }) -} diff --git a/server/internal/app/app.go b/server/internal/app/app.go deleted file mode 100644 index 75f1c04..0000000 --- a/server/internal/app/app.go +++ /dev/null @@ -1,223 +0,0 @@ -// Package app provides application-level functionality for initializing and running the server -package app - -import ( - "database/sql" - "fmt" - "log" - "net/http" - "time" - - "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/cors" - "github.com/go-chi/httprate" - "github.com/unrolled/secure" - "golang.org/x/crypto/bcrypt" - - "novamd/internal/api" - "novamd/internal/auth" - "novamd/internal/config" - "novamd/internal/db" - "novamd/internal/handlers" - "novamd/internal/models" - "novamd/internal/secrets" - "novamd/internal/storage" -) - -// Server represents the HTTP server and its dependencies -type Server struct { - router *chi.Mux - config *config.Config - db db.Database - storage storage.Manager -} - -// NewServer initializes a new server instance with all dependencies -func NewServer(cfg *config.Config) (*Server, error) { - // Initialize secrets service - secretsService, err := secrets.NewService(cfg.EncryptionKey) - if err != nil { - return nil, fmt.Errorf("failed to initialize secrets service: %w", err) - } - - // Initialize database - database, err := initDatabase(cfg, secretsService) - if err != nil { - return nil, fmt.Errorf("failed to initialize database: %w", err) - } - - // Initialize filesystem - storageManager := storage.NewService(cfg.WorkDir) - - // Setup admin user - err = setupAdminUser(database, storageManager, cfg) - if err != nil { - return nil, fmt.Errorf("failed to setup admin user: %w", err) - } - - // Initialize router - router := initRouter(cfg) - - return &Server{ - router: router, - config: cfg, - db: database, - storage: storageManager, - }, nil -} - -// Start configures and starts the HTTP server -func (s *Server) Start() error { - // Set up authentication - jwtManager, sessionService, err := s.setupAuth() - if err != nil { - return fmt.Errorf("failed to setup authentication: %w", err) - } - - // Set up routes - s.setupRoutes(jwtManager, sessionService) - - // Start server - addr := ":" + s.config.Port - log.Printf("Server starting on port %s", s.config.Port) - return http.ListenAndServe(addr, s.router) -} - -// Close handles graceful shutdown of server dependencies -func (s *Server) Close() error { - return s.db.Close() -} - -// initDatabase initializes and migrates the database -func initDatabase(cfg *config.Config, secretsService secrets.Service) (db.Database, error) { - database, err := db.Init(cfg.DBPath, secretsService) - if err != nil { - return nil, fmt.Errorf("failed to initialize database: %w", err) - } - - if err := database.Migrate(); err != nil { - return nil, fmt.Errorf("failed to apply database migrations: %w", err) - } - - return database, nil -} - -// initRouter creates and configures the chi router with middleware -func initRouter(cfg *config.Config) *chi.Mux { - r := chi.NewRouter() - - // Basic middleware - r.Use(middleware.Logger) - r.Use(middleware.Recoverer) - r.Use(middleware.RequestID) - r.Use(middleware.RealIP) - r.Use(middleware.Timeout(30 * time.Second)) - - // Security headers - r.Use(secure.New(secure.Options{ - SSLRedirect: false, // Let proxy handle HTTPS - SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"}, - IsDevelopment: cfg.IsDevelopment, - }).Handler) - - // CORS if origins are configured - if len(cfg.CORSOrigins) > 0 { - r.Use(cors.Handler(cors.Options{ - AllowedOrigins: cfg.CORSOrigins, - AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"}, - AllowCredentials: true, - MaxAge: 300, - })) - } - - return r -} - -// setupAuth initializes JWT and session services -func (s *Server) setupAuth() (auth.JWTManager, *auth.SessionService, error) { - // Get or generate JWT signing key - signingKey := s.config.JWTSigningKey - if signingKey == "" { - var err error - signingKey, err = s.db.EnsureJWTSecret() - if err != nil { - return nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err) - } - } - - // Initialize JWT service - jwtManager, err := auth.NewJWTService(auth.JWTConfig{ - SigningKey: signingKey, - AccessTokenExpiry: 15 * time.Minute, - RefreshTokenExpiry: 7 * 24 * time.Hour, - }) - if err != nil { - return nil, nil, fmt.Errorf("failed to initialize JWT service: %w", err) - } - - // Initialize session service - sessionService := auth.NewSessionService(s.db, jwtManager) - - return jwtManager, sessionService, nil -} - -// setupRoutes configures all application routes -func (s *Server) setupRoutes(jwtManager auth.JWTManager, sessionService *auth.SessionService) { - // Initialize auth middleware - authMiddleware := auth.NewMiddleware(jwtManager) - - // Set up API routes - s.router.Route("/api/v1", func(r chi.Router) { - r.Use(httprate.LimitByIP(s.config.RateLimitRequests, s.config.RateLimitWindow)) - api.SetupRoutes(r, s.db, s.storage, authMiddleware, sessionService) - }) - - // Handle all other routes with static file server - s.router.Get("/*", handlers.NewStaticHandler(s.config.StaticPath).ServeHTTP) -} - -func setupAdminUser(db db.Database, w storage.WorkspaceManager, cfg *config.Config) error { - - adminEmail := cfg.AdminEmail - adminPassword := cfg.AdminPassword - - // Check if admin user exists - adminUser, err := db.GetUserByEmail(adminEmail) - if adminUser != nil { - return nil // Admin user already exists - } else if err != sql.ErrNoRows { - return err - } - - // Hash the password - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("failed to hash password: %w", err) - } - - // Create admin user - adminUser = &models.User{ - Email: adminEmail, - DisplayName: "Admin", - PasswordHash: string(hashedPassword), - Role: models.RoleAdmin, - } - - createdUser, err := db.CreateUser(adminUser) - if err != nil { - return fmt.Errorf("failed to create admin user: %w", err) - } - - // Initialize workspace directory - err = w.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID) - if err != nil { - return fmt.Errorf("failed to initialize admin workspace: %w", err) - } - - log.Printf("Created admin user with ID: %d and default workspace with ID: %d", createdUser.ID, createdUser.LastWorkspaceID) - - return nil - -} diff --git a/server/internal/config/config.go b/server/internal/app/config.go similarity index 88% rename from server/internal/config/config.go rename to server/internal/app/config.go index 41c21a6..fc34487 100644 --- a/server/internal/config/config.go +++ b/server/internal/app/config.go @@ -1,5 +1,4 @@ -// Package config provides the configuration for the application -package config +package app import ( "fmt" @@ -40,8 +39,8 @@ func DefaultConfig() *Config { } } -// Validate checks if the configuration is valid -func (c *Config) Validate() error { +// validate checks if the configuration is valid +func (c *Config) validate() error { if c.AdminEmail == "" || c.AdminPassword == "" { return fmt.Errorf("NOVAMD_ADMIN_EMAIL and NOVAMD_ADMIN_PASSWORD must be set") } @@ -54,8 +53,8 @@ func (c *Config) Validate() error { return nil } -// Load creates a new Config instance with values from environment variables -func Load() (*Config, error) { +// LoadConfig creates a new Config instance with values from environment variables +func LoadConfig() (*Config, error) { config := DefaultConfig() if env := os.Getenv("NOVAMD_ENV"); env != "" { @@ -105,7 +104,7 @@ func Load() (*Config, error) { } // Validate all settings - if err := config.Validate(); err != nil { + if err := config.validate(); err != nil { return nil, err } diff --git a/server/internal/config/config_test.go b/server/internal/app/config_test.go similarity index 97% rename from server/internal/config/config_test.go rename to server/internal/app/config_test.go index 51aef69..383f73f 100644 --- a/server/internal/config/config_test.go +++ b/server/internal/app/config_test.go @@ -1,15 +1,14 @@ -package config_test +package app_test import ( + "novamd/internal/app" "os" "testing" "time" - - "novamd/internal/config" ) func TestDefaultConfig(t *testing.T) { - cfg := config.DefaultConfig() + cfg := app.DefaultConfig() tests := []struct { name string @@ -75,7 +74,7 @@ func TestLoad(t *testing.T) { setEnv(t, "NOVAMD_ADMIN_PASSWORD", "password123") setEnv(t, "NOVAMD_ENCRYPTION_KEY", "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=") // 32 bytes base64 encoded - cfg, err := config.Load() + cfg, err := app.LoadConfig() if err != nil { t.Fatalf("Load() error = %v", err) } @@ -110,7 +109,7 @@ func TestLoad(t *testing.T) { setEnv(t, k, v) } - cfg, err := config.Load() + cfg, err := app.LoadConfig() if err != nil { t.Fatalf("Load() error = %v", err) } @@ -201,7 +200,7 @@ func TestLoad(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { tc.setupEnv(t) - _, err := config.Load() + _, err := app.LoadConfig() if err == nil { t.Error("expected error, got nil") return diff --git a/server/internal/app/init.go b/server/internal/app/init.go new file mode 100644 index 0000000..eec83b4 --- /dev/null +++ b/server/internal/app/init.go @@ -0,0 +1,111 @@ +// Package app provides application-level functionality for initializing and running the server +package app + +import ( + "database/sql" + "fmt" + "log" + "time" + + "golang.org/x/crypto/bcrypt" + + "novamd/internal/auth" + "novamd/internal/db" + "novamd/internal/models" + "novamd/internal/secrets" + "novamd/internal/storage" +) + +// initSecretsService initializes the secrets service +func initSecretsService(cfg *Config) (secrets.Service, error) { + secretsService, err := secrets.NewService(cfg.EncryptionKey) + if err != nil { + return nil, fmt.Errorf("failed to initialize secrets service: %w", err) + } + return secretsService, nil +} + +// initDatabase initializes and migrates the database +func initDatabase(cfg *Config, secretsService secrets.Service) (db.Database, error) { + database, err := db.Init(cfg.DBPath, secretsService) + if err != nil { + return nil, fmt.Errorf("failed to initialize database: %w", err) + } + + if err := database.Migrate(); err != nil { + return nil, fmt.Errorf("failed to apply database migrations: %w", err) + } + + return database, nil +} + +// initAuth initializes JWT and session services +func initAuth(cfg *Config, database db.Database) (auth.JWTManager, *auth.SessionService, error) { + // Get or generate JWT signing key + signingKey := cfg.JWTSigningKey + if signingKey == "" { + var err error + signingKey, err = database.EnsureJWTSecret() + if err != nil { + return nil, nil, fmt.Errorf("failed to ensure JWT secret: %w", err) + } + } + + // Initialize JWT service + jwtManager, err := auth.NewJWTService(auth.JWTConfig{ + SigningKey: signingKey, + AccessTokenExpiry: 15 * time.Minute, + RefreshTokenExpiry: 7 * 24 * time.Hour, + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to initialize JWT service: %w", err) + } + + // Initialize session service + sessionService := auth.NewSessionService(database, jwtManager) + + return jwtManager, sessionService, nil +} + +// setupAdminUser creates the admin user if it doesn't exist +func setupAdminUser(database db.Database, storageManager storage.Manager, cfg *Config) error { + adminEmail := cfg.AdminEmail + adminPassword := cfg.AdminPassword + + // Check if admin user exists + adminUser, err := database.GetUserByEmail(adminEmail) + if adminUser != nil { + return nil // Admin user already exists + } else if err != sql.ErrNoRows { + return err + } + + // Hash the password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("failed to hash password: %w", err) + } + + // Create admin user + adminUser = &models.User{ + Email: adminEmail, + DisplayName: "Admin", + PasswordHash: string(hashedPassword), + Role: models.RoleAdmin, + } + + createdUser, err := database.CreateUser(adminUser) + if err != nil { + return fmt.Errorf("failed to create admin user: %w", err) + } + + // Initialize workspace directory + err = storageManager.InitializeUserWorkspace(createdUser.ID, createdUser.LastWorkspaceID) + if err != nil { + return fmt.Errorf("failed to initialize admin workspace: %w", err) + } + + log.Printf("Created admin user with ID: %d and default workspace with ID: %d", createdUser.ID, createdUser.LastWorkspaceID) + + return nil +} diff --git a/server/internal/app/options.go b/server/internal/app/options.go new file mode 100644 index 0000000..9e8b2fb --- /dev/null +++ b/server/internal/app/options.go @@ -0,0 +1,53 @@ +package app + +import ( + "novamd/internal/auth" + "novamd/internal/db" + "novamd/internal/storage" +) + +// Options holds all dependencies and configuration for the server +type Options struct { + Config *Config + Database db.Database + Storage storage.Manager + JWTManager auth.JWTManager + SessionService *auth.SessionService +} + +// DefaultOptions creates server options with default configuration +func DefaultOptions(cfg *Config) (*Options, error) { + // Initialize secrets service + secretsService, err := initSecretsService(cfg) + if err != nil { + return nil, err + } + + // Initialize database + database, err := initDatabase(cfg, secretsService) + if err != nil { + return nil, err + } + + // Initialize storage + storageManager := storage.NewService(cfg.WorkDir) + + // Initialize auth services + jwtManager, sessionService, err := initAuth(cfg, database) + if err != nil { + return nil, err + } + + // Setup admin user + if err := setupAdminUser(database, storageManager, cfg); err != nil { + return nil, err + } + + return &Options{ + Config: cfg, + Database: database, + Storage: storageManager, + JWTManager: jwtManager, + SessionService: sessionService, + }, nil +} diff --git a/server/internal/app/routes.go b/server/internal/app/routes.go new file mode 100644 index 0000000..3b02d02 --- /dev/null +++ b/server/internal/app/routes.go @@ -0,0 +1,142 @@ +package app + +import ( + "novamd/internal/auth" + "novamd/internal/context" + "novamd/internal/handlers" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/cors" + "github.com/go-chi/httprate" + "github.com/unrolled/secure" +) + +// setupRouter creates and configures the chi router with middleware and routes +func setupRouter(o Options) *chi.Mux { + r := chi.NewRouter() + + // Basic middleware + r.Use(middleware.Logger) + r.Use(middleware.Recoverer) + r.Use(middleware.RequestID) + r.Use(middleware.RealIP) + r.Use(middleware.Timeout(30 * time.Second)) + + // Security headers + r.Use(secure.New(secure.Options{ + SSLRedirect: false, + SSLProxyHeaders: map[string]string{"X-Forwarded-Proto": "https"}, + IsDevelopment: o.Config.IsDevelopment, + }).Handler) + + // CORS if origins are configured + if len(o.Config.CORSOrigins) > 0 { + r.Use(cors.Handler(cors.Options{ + AllowedOrigins: o.Config.CORSOrigins, + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"}, + AllowCredentials: true, + MaxAge: 300, + })) + } + + // Initialize auth middleware and handler + authMiddleware := auth.NewMiddleware(o.JWTManager) + handler := &handlers.Handler{ + DB: o.Database, + Storage: o.Storage, + } + + // API routes + r.Route("/api/v1", func(r chi.Router) { + // Rate limiting for API routes + if o.Config.RateLimitRequests > 0 { + r.Use(httprate.LimitByIP( + o.Config.RateLimitRequests, + o.Config.RateLimitWindow, + )) + } + + // Public routes (no authentication required) + r.Group(func(r chi.Router) { + r.Post("/auth/login", handler.Login(o.SessionService)) + r.Post("/auth/refresh", handler.RefreshToken(o.SessionService)) + }) + + // Protected routes (authentication required) + r.Group(func(r chi.Router) { + r.Use(authMiddleware.Authenticate) + r.Use(context.WithUserContextMiddleware) + + // Auth routes + r.Post("/auth/logout", handler.Logout(o.SessionService)) + r.Get("/auth/me", handler.GetCurrentUser()) + + // User profile routes + r.Put("/profile", handler.UpdateProfile()) + r.Delete("/profile", handler.DeleteAccount()) + + // Admin-only routes + r.Route("/admin", func(r chi.Router) { + r.Use(authMiddleware.RequireRole("admin")) + // User management + r.Route("/users", func(r chi.Router) { + r.Get("/", handler.AdminListUsers()) + r.Post("/", handler.AdminCreateUser()) + r.Get("/{userId}", handler.AdminGetUser()) + r.Put("/{userId}", handler.AdminUpdateUser()) + r.Delete("/{userId}", handler.AdminDeleteUser()) + }) + // Workspace management + r.Route("/workspaces", func(r chi.Router) { + r.Get("/", handler.AdminListWorkspaces()) + }) + // System stats + r.Get("/stats", handler.AdminGetSystemStats()) + }) + + // Workspace routes + r.Route("/workspaces", func(r chi.Router) { + r.Get("/", handler.ListWorkspaces()) + r.Post("/", handler.CreateWorkspace()) + r.Get("/last", handler.GetLastWorkspaceName()) + r.Put("/last", handler.UpdateLastWorkspaceName()) + + // Single workspace routes + r.Route("/{workspaceName}", func(r chi.Router) { + r.Use(context.WithWorkspaceContextMiddleware(o.Database)) + r.Use(authMiddleware.RequireWorkspaceAccess) + + r.Get("/", handler.GetWorkspace()) + r.Put("/", handler.UpdateWorkspace()) + r.Delete("/", handler.DeleteWorkspace()) + + // File routes + r.Route("/files", func(r chi.Router) { + r.Get("/", handler.ListFiles()) + r.Get("/last", handler.GetLastOpenedFile()) + r.Put("/last", handler.UpdateLastOpenedFile()) + r.Get("/lookup", handler.LookupFileByName()) + + r.Post("/*", handler.SaveFile()) + r.Get("/*", handler.GetFileContent()) + r.Delete("/*", handler.DeleteFile()) + }) + + // Git routes + r.Route("/git", func(r chi.Router) { + r.Post("/commit", handler.StageCommitAndPush()) + r.Post("/pull", handler.PullChanges()) + }) + }) + }) + }) + }) + + // Handle all other routes with static file server + r.Get("/*", handlers.NewStaticHandler(o.Config.StaticPath).ServeHTTP) + + return r +} diff --git a/server/internal/app/server.go b/server/internal/app/server.go new file mode 100644 index 0000000..adb381e --- /dev/null +++ b/server/internal/app/server.go @@ -0,0 +1,40 @@ +package app + +import ( + "log" + "net/http" + + "github.com/go-chi/chi/v5" +) + +// Server represents the HTTP server and its dependencies +type Server struct { + router *chi.Mux + options *Options +} + +// NewServer creates a new server instance with the given options +func NewServer(options *Options) *Server { + return &Server{ + router: setupRouter(*options), + options: options, + } +} + +// Start configures and starts the HTTP server +func (s *Server) Start() error { + // Start server + addr := ":" + s.options.Config.Port + log.Printf("Server starting on port %s", s.options.Config.Port) + return http.ListenAndServe(addr, s.router) +} + +// Close handles graceful shutdown of server dependencies +func (s *Server) Close() error { + return s.options.Database.Close() +} + +// Router returns the chi router for testing +func (s *Server) Router() chi.Router { + return s.router +} diff --git a/server/internal/handlers/integration_test.go b/server/internal/handlers/integration_test.go index 9b380e9..047966f 100644 --- a/server/internal/handlers/integration_test.go +++ b/server/internal/handlers/integration_test.go @@ -11,14 +11,12 @@ import ( "testing" "time" - "github.com/go-chi/chi/v5" "golang.org/x/crypto/bcrypt" - "novamd/internal/api" + "novamd/internal/app" "novamd/internal/auth" "novamd/internal/db" "novamd/internal/git" - "novamd/internal/handlers" "novamd/internal/models" "novamd/internal/secrets" "novamd/internal/storage" @@ -26,10 +24,9 @@ import ( // testHarness encapsulates all the dependencies needed for testing type testHarness struct { + Server *app.Server DB db.TestDatabase Storage storage.Manager - Router *chi.Mux - Handler *handlers.Handler JWTManager auth.JWTManager SessionSvc *auth.SessionService AdminUser *models.User @@ -89,24 +86,34 @@ func setupTestHarness(t *testing.T) *testHarness { // Initialize session service sessionSvc := auth.NewSessionService(database, jwtSvc) - // Create handler - handler := &handlers.Handler{ - DB: database, - Storage: storageSvc, + // Create test config + testConfig := &app.Config{ + DBPath: ":memory:", + WorkDir: tempDir, + StaticPath: "../testdata", + Port: "8081", + AdminEmail: "admin@test.com", + AdminPassword: "admin123", + EncryptionKey: "YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXoxMjM0NTY=", + IsDevelopment: true, } - // Set up router with middlewares - router := chi.NewRouter() - authMiddleware := auth.NewMiddleware(jwtSvc) - router.Route("/api/v1", func(r chi.Router) { - api.SetupRoutes(r, database, storageSvc, authMiddleware, sessionSvc) - }) + // Create server options + serverOpts := &app.Options{ + Config: testConfig, + Database: database, + Storage: storageSvc, + JWTManager: jwtSvc, + SessionService: sessionSvc, + } + + // Create server + srv := app.NewServer(serverOpts) h := &testHarness{ + Server: srv, DB: database, Storage: storageSvc, - Router: router, - Handler: handler, JWTManager: jwtSvc, SessionSvc: sessionSvc, TempDirectory: tempDir, @@ -114,8 +121,8 @@ func setupTestHarness(t *testing.T) *testHarness { } // Create test users - adminUser, adminToken := h.createTestUser(t, database, sessionSvc, "admin@test.com", "admin123", models.RoleAdmin) - regularUser, regularToken := h.createTestUser(t, database, sessionSvc, "user@test.com", "user123", models.RoleEditor) + adminUser, adminToken := h.createTestUser(t, "admin@test.com", "admin123", models.RoleAdmin) + regularUser, regularToken := h.createTestUser(t, "user@test.com", "user123", models.RoleEditor) h.AdminUser = adminUser h.AdminToken = adminToken @@ -139,7 +146,7 @@ func (h *testHarness) teardown(t *testing.T) { } // createTestUser creates a test user and returns the user and access token -func (h *testHarness) createTestUser(t *testing.T, db db.Database, sessionSvc *auth.SessionService, email, password string, role models.UserRole) (*models.User, string) { +func (h *testHarness) createTestUser(t *testing.T, email, password string, role models.UserRole) (*models.User, string) { t.Helper() hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) @@ -154,7 +161,7 @@ func (h *testHarness) createTestUser(t *testing.T, db db.Database, sessionSvc *a Role: role, } - user, err = db.CreateUser(user) + user, err = h.DB.CreateUser(user) if err != nil { t.Fatalf("Failed to create user: %v", err) } @@ -165,7 +172,7 @@ func (h *testHarness) createTestUser(t *testing.T, db db.Database, sessionSvc *a t.Fatalf("Failed to initialize user workspace: %v", err) } - session, accessToken, err := sessionSvc.CreateSession(user.ID, string(user.Role)) + session, accessToken, err := h.SessionSvc.CreateSession(user.ID, string(user.Role)) if err != nil { t.Fatalf("Failed to create session: %v", err) } @@ -203,7 +210,7 @@ func (h *testHarness) makeRequest(t *testing.T, method, path string, body interf } rr := httptest.NewRecorder() - h.Router.ServeHTTP(rr, req) + h.Server.Router().ServeHTTP(rr, req) return rr } @@ -223,7 +230,7 @@ func (h *testHarness) makeRequestRaw(t *testing.T, method, path string, body io. } rr := httptest.NewRecorder() - h.Router.ServeHTTP(rr, req) + h.Server.Router().ServeHTTP(rr, req) return rr }