commit 986745d8c8d02fbce4ee32877f2b460b49986fb2 Author: 0x1d Date: Tue Oct 21 14:27:21 2025 +0200 feat: basic firmware registry diff --git a/.cursor/rules/cleancode.mdc b/.cursor/rules/cleancode.mdc new file mode 100644 index 0000000..8b41201 --- /dev/null +++ b/.cursor/rules/cleancode.mdc @@ -0,0 +1,56 @@ +--- +description: Guidelines for writing clean, maintainable, and human-readable code. Apply these rules when writing or reviewing code to ensure consistency and quality. +globs: +alwaysApply: true +--- +# Clean Code Guidelines + +## Constants Over Magic Numbers +- Replace hard-coded values with named constants +- Use descriptive constant names that explain the value's purpose +- Keep constants at the top of the file or in a dedicated constants file + +## Meaningful Names +- Variables, functions, and classes should reveal their purpose +- Names should explain why something exists and how it's used +- Avoid abbreviations unless they're universally understood + +## Smart Comments +- Don't comment on what the code does - make the code self-documenting +- Use comments to explain why something is done a certain way +- Document APIs, complex algorithms, and non-obvious side effects + +## Single Responsibility +- Each function should do exactly one thing +- Functions should be small and focused +- If a function needs a comment to explain what it does, it should be split + +## DRY (Don't Repeat Yourself) +- Extract repeated code into reusable functions +- Share common logic through proper abstraction +- Maintain single sources of truth + +## Clean Structure +- Keep related code together +- Organize code in a logical hierarchy +- Use consistent file and folder naming conventions + +## Encapsulation +- Hide implementation details +- Expose clear interfaces +- Move nested conditionals into well-named functions + +## Code Quality Maintenance +- Refactor continuously +- Fix technical debt early +- Leave code cleaner than you found it + +## Testing +- Write tests before fixing bugs +- Keep tests readable and maintainable +- Test edge cases and error conditions + +## Version Control +- Write clear commit messages +- Make small, focused commits +- Use meaningful branch names \ No newline at end of file diff --git a/.cursor/rules/gitflow.mdc b/.cursor/rules/gitflow.mdc new file mode 100644 index 0000000..d52c71b --- /dev/null +++ b/.cursor/rules/gitflow.mdc @@ -0,0 +1,111 @@ +--- +description: Gitflow Workflow Rules. These rules should be applied when performing git operations. +--- +# Gitflow Workflow Rules + +## Main Branches + +### main (or master) +- Contains production-ready code +- Never commit directly to main +- Only accepts merges from: + - hotfix/* branches + - release/* branches +- Must be tagged with version number after each merge + +### develop +- Main development branch +- Contains latest delivered development changes +- Source branch for feature branches +- Never commit directly to develop + +## Supporting Branches + +### feature/* +- Branch from: develop +- Merge back into: develop +- Naming convention: feature/[issue-id]-descriptive-name +- Example: feature/123-user-authentication +- Must be up-to-date with develop before creating PR +- Delete after merge + +### release/* +- Branch from: develop +- Merge back into: + - main + - develop +- Naming convention: release/vX.Y.Z +- Example: release/v1.2.0 +- Only bug fixes, documentation, and release-oriented tasks +- No new features +- Delete after merge + +### hotfix/* +- Branch from: main +- Merge back into: + - main + - develop +- Naming convention: hotfix/vX.Y.Z +- Example: hotfix/v1.2.1 +- Only for urgent production fixes +- Delete after merge + +## Commit Messages + +- Format: `type(scope): description` +- Types: + - feat: New feature + - fix: Bug fix + - docs: Documentation changes + - style: Formatting, missing semicolons, etc. + - refactor: Code refactoring + - test: Adding tests + - chore: Maintenance tasks + +## Version Control + +### Semantic Versioning +- MAJOR version for incompatible API changes +- MINOR version for backwards-compatible functionality +- PATCH version for backwards-compatible bug fixes + +## Pull Request Rules + +1. All changes must go through Pull Requests +2. Required approvals: minimum 1 +3. CI checks must pass +4. No direct commits to protected branches (main, develop) +5. Branch must be up to date before merging +6. Delete branch after merge + +## Branch Protection Rules + +### main & develop +- Require pull request reviews +- Require status checks to pass +- Require branches to be up to date +- Include administrators in restrictions +- No force pushes +- No deletions + +## Release Process + +1. Create release branch from develop +2. Bump version numbers +3. Fix any release-specific issues +4. Create PR to main +5. After merge to main: + - Tag release + - Merge back to develop + - Delete release branch + +## Hotfix Process + +1. Create hotfix branch from main +2. Fix the issue +3. Bump patch version +4. Create PR to main +5. After merge to main: + - Tag release + - Merge back to develop + - Delete hotfix branch \ No newline at end of file diff --git a/.cursor/rules/golang.mdc b/.cursor/rules/golang.mdc new file mode 100644 index 0000000..2b1ef5d --- /dev/null +++ b/.cursor/rules/golang.mdc @@ -0,0 +1,35 @@ +--- +description: +globs: *.go +alwaysApply: true +--- + + You are an expert AI programming assistant specializing in building APIs with Go, using the standard library's net/http package and the new ServeMux introduced in Go 1.22. + + Always use the latest stable version of Go (1.22 or newer) and be familiar with RESTful API design principles, best practices, and Go idioms. + + - Follow the user's requirements carefully & to the letter. + - First think step-by-step - describe your plan for the API structure, endpoints, and data flow in pseudocode, written out in great detail. + - Confirm the plan, then write code! + - Write correct, up-to-date, bug-free, fully functional, secure, and efficient Go code for APIs. + - Use the standard library's net/http package for API development: + - Utilize the new ServeMux introduced in Go 1.22 for routing + - Implement proper handling of different HTTP methods (GET, POST, PUT, DELETE, etc.) + - Use method handlers with appropriate signatures (e.g., func(w http.ResponseWriter, r *http.Request)) + - Leverage new features like wildcard matching and regex support in routes + - Implement proper error handling, including custom error types when beneficial. + - Use appropriate status codes and format JSON responses correctly. + - Implement input validation for API endpoints. + - Utilize Go's built-in concurrency features when beneficial for API performance. + - Follow RESTful API design principles and best practices. + - Include necessary imports, package declarations, and any required setup code. + - Implement proper logging using the standard library's log package or a simple custom logger. + - Consider implementing middleware for cross-cutting concerns (e.g., logging, authentication). + - Implement rate limiting and authentication/authorization when appropriate, using standard library features or simple custom implementations. + - Leave NO todos, placeholders, or missing pieces in the API implementation. + - Be concise in explanations, but provide brief comments for complex logic or Go-specific idioms. + - If unsure about a best practice or implementation detail, say so instead of guessing. + - Offer suggestions for testing the API endpoints using Go's testing package. + + Always prioritize security, scalability, and maintainability in your API designs and implementations. Leverage the power and simplicity of Go's standard library to create efficient and idiomatic APIs. + \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac37db5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +registry.db +spore-registry +registry diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3493685 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +.PHONY: build run test clean + +# Build the application +build: + go build -o spore-registry main.go + +# Run the application +run: + go run main.go + +# Run tests +test: + go test -v ./... + +# Clean build artifacts +clean: + rm -f spore-registry + rm -rf registry/ + rm -f registry.db + +# Run tests with coverage +test-coverage: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +# Format code +fmt: + go fmt ./... + +# Lint code (requires golangci-lint) +lint: + golangci-lint run + +# Install dependencies +deps: + go mod download + go mod tidy diff --git a/README.md b/README.md new file mode 100644 index 0000000..5d7fdd0 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# SPORE Registry + +The SPORE registry is a storage backend and update server for managing versioned firmware binaries in the SPORE embedded system ecosystem. + +## Overview + +The registry provides: +- **Hierarchical Storage**: Firmware binaries stored as `registry/{name}/{version}/firmware.bin` +- **SQLite Database**: Metadata storage with labels for flexible querying +- **REST API**: Simple HTTP endpoints for upload, listing, and download +- **Version Management**: Semantic versioning support + +## Quick Start + +### Prerequisites +- Go 1.22 or later + +### Installation + +1. Clone the repository: +```bash +git clone +cd spore-registry +``` + +2. Install dependencies: +```bash +go mod download +``` + +3. Run the server: +```bash +go run main.go +``` + +The server will start on port 8080 by default (or use `PORT` environment variable to change it). + +## API Endpoints + +### Upload Firmware +```bash +curl -X POST http://localhost:8080/firmware \ + -F "metadata={\"name\":\"base\",\"version\":\"1.0.0\",\"labels\":{\"app\":\"base\"}}" \ + -F "firmware=@firmware.bin" +``` + +### List Firmware +```bash +# Get all firmware +curl http://localhost:8080/firmware + +# Filter by name +curl "http://localhost:8080/firmware?name=base" + +# Filter by version +curl "http://localhost:8080/firmware?version=1.0.0" +``` + +### Download Firmware +```bash +curl http://localhost:8080/firmware/base/1.0.0 -o firmware.bin +``` + +### Health Check +```bash +curl http://localhost:8080/health +``` + +## Storage Structure + +Firmware binaries are stored hierarchically: +``` +registry/ +├── base/ +│ ├── 1.0.0/ +│ │ └── firmware.bin +│ └── 1.1.0/ +│ └── firmware.bin +└── sensor/ + └── 2.0.0/ + └── firmware.bin +``` + +## Configuration + +The registry uses the following environment variables: +- `PORT`: Server port (default: 8080) + +## Database Schema + +The SQLite database contains a `firmware` table: +```sql +CREATE TABLE firmware ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + version TEXT NOT NULL, + size INTEGER NOT NULL, + labels TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(name, version) +); +``` + +## API Documentation + +Full OpenAPI specification is available in `api/openapi.yaml`. + +## Development + +### Building +```bash +go build -o spore-registry main.go +``` + +### Testing +```bash +go test ./... +``` + +### Code Structure + +- `main.go` - Main application with HTTP server and API handlers +- `api/openapi.yaml` - OpenAPI specification +- `registry/` - Firmware storage directory (created automatically) +- `registry.db` - SQLite database (created automatically) + +## License + +MIT License - see LICENSE file for details. \ No newline at end of file diff --git a/api/openapi.yaml b/api/openapi.yaml new file mode 100644 index 0000000..4e09433 --- /dev/null +++ b/api/openapi.yaml @@ -0,0 +1,275 @@ +openapi: 3.0.3 +info: + title: SPORE Registry API + description: | + RESTful API for managing firmware binaries in the SPORE embedded system. + + The SPORE registry provides storage and distribution of versioned firmware binaries + with metadata management and hierarchical file organization. + + ## Features + - **Firmware Upload**: Store firmware binaries with metadata + - **Firmware Listing**: Query firmware by name, version, or labels + - **Firmware Download**: Retrieve specific firmware versions + - **Version Management**: Semantic versioning support with hierarchical storage + + ## Storage Structure + Firmware binaries are stored hierarchically as: + ``` + registry/{name}/{version}/firmware.bin + ``` + + Metadata is stored in SQLite database with labels for flexible querying. + version: 1.0.0 + contact: + name: SPORE Development Team + url: https://git.dcentral.systems/iot/spore + license: + name: MIT + url: https://opensource.org/licenses/MIT + +servers: + - url: http://localhost:8080 + description: Local SPORE registry server + +paths: + /firmware: + post: + summary: Upload firmware binary + description: | + Upload a firmware binary with metadata. The firmware is stored in the + hierarchical file structure and metadata is saved to the database. + operationId: uploadFirmware + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - metadata + - firmware + properties: + metadata: + type: string + description: JSON metadata for the firmware + example: | + { + "name": "base", + "version": "1.0.0", + "labels": { + "app": "base", + "platform": "esp32" + } + } + firmware: + type: string + format: binary + description: Firmware binary file + responses: + '201': + description: Firmware uploaded successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + example: true + name: + type: string + example: "base" + version: + type: string + example: "1.0.0" + size: + type: integer + format: int64 + example: 245760 + '400': + description: Bad request - missing or invalid parameters + content: + text/plain: + schema: + type: string + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + + get: + summary: List firmware records + description: | + Retrieve firmware records with optional filtering by name, version, or labels. + Returns metadata for each firmware including download URLs. + operationId: listFirmware + parameters: + - name: name + in: query + description: Filter by firmware name + required: false + schema: + type: string + example: "base" + - name: version + in: query + description: Filter by firmware version + required: false + schema: + type: string + example: "1.0.0" + responses: + '200': + description: List of firmware records + content: + application/json: + schema: + type: array + items: + type: object + properties: + name: + type: string + example: "base" + version: + type: string + example: "1.0.0" + size: + type: integer + format: int64 + example: 245760 + labels: + type: object + additionalProperties: + type: string + example: + app: "base" + platform: "esp32" + download_url: + type: string + example: "/firmware/base/1.0.0" + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + + /firmware/{name}/{version}: + get: + summary: Download firmware binary + description: | + Download the firmware binary for the specified name and version. + Returns the binary file directly. + operationId: downloadFirmware + parameters: + - name: name + in: path + description: Firmware name + required: true + schema: + type: string + example: "base" + - name: version + in: path + description: Firmware version (semantic versioning) + required: true + schema: + type: string + example: "1.0.0" + responses: + '200': + description: Firmware binary file + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Firmware not found + content: + text/plain: + schema: + type: string + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + + /health: + get: + summary: Health check + description: Simple health check endpoint + operationId: healthCheck + responses: + '200': + description: Service is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: "healthy" + +components: + schemas: + FirmwareMetadata: + type: object + required: + - name + - version + properties: + name: + type: string + description: Firmware name + example: "base" + version: + type: string + description: Firmware version (semantic versioning) + example: "1.0.0" + labels: + type: object + additionalProperties: + type: string + description: Key-value labels for categorizing firmware + example: + app: "base" + platform: "esp32" + + FirmwareRecord: + type: object + properties: + name: + type: string + example: "base" + version: + type: string + example: "1.0.0" + size: + type: integer + format: int64 + example: 245760 + labels: + type: object + additionalProperties: + type: string + download_url: + type: string + example: "/firmware/base/1.0.0" + + Error: + type: object + properties: + error: + type: string + description: Error message + code: + type: integer + description: HTTP status code diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8079d39 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,85 @@ +# Architecture + +## Overview + +The refactored code follows a clean architecture pattern with clear separation of concerns: + +### 1. **Models** (`internal/models/`) +- Contains all data structures and types +- `FirmwareMetadata`, `FirmwareRecord`, `GroupedFirmware`, etc. +- Pure data structures with JSON tags + +### 2. **Config** (`internal/config/`) +- Configuration management +- Environment variable handling +- Default values and validation + +### 3. **Database** (`internal/database/`) +- Database connection management +- Table creation and schema management +- Connection lifecycle handling + +### 4. **Repository** (`internal/repository/`) +- Data access layer +- Database operations (CRUD) +- SQL query management +- Data mapping between database and models + +### 5. **Storage** (`internal/storage/`) +- File system operations +- Firmware binary storage +- File path management +- File existence checks + +### 6. **Service** (`internal/service/`) +- Business logic layer +- Orchestrates repository and storage operations +- Data validation and processing +- Version comparison logic + +### 7. **Handlers** (`internal/handlers/`) +- HTTP request handling +- Request/response processing +- Error handling and status codes +- Input validation + +### 8. **App** (`internal/app/`) +- Application initialization +- Dependency injection +- Route setup +- Server configuration + +### 9. **Main** (`main.go`) +- Entry point +- Minimal and focused +- Error handling and graceful shutdown + +## Environment Variables + +- `PORT`: Server port (default: 3002) +- `DB_PATH`: Database file path (default: ./registry.db) +- `REGISTRY_PATH`: Firmware storage directory (default: registry) +- `MAX_UPLOAD_SIZE`: Maximum upload size in bytes (default: 32MB) + +## API Endpoints + +All API endpoints remain the same: + +- `POST /firmware` - Upload firmware +- `GET /firmware` - List firmware (with optional filters) +- `GET /firmware/{name}/{version}` - Download firmware +- `PUT /firmware/{name}/{version}` - Update firmware metadata +- `GET /health` - Health check + +## Testing + +```bash +# Run all tests +go test ./... + +# Run tests with coverage +go test -cover ./... + +# Run specific test +go test -run TestStoreFirmwareMetadata +``` \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a988829 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module spore-registry + +go 1.22 + +require ( + github.com/mattn/go-sqlite3 v1.14.22 + github.com/rs/cors v1.11.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..13f5d4d --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..d8597b6 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,109 @@ +package app + +import ( + "fmt" + "log" + "net/http" + "os" + + "github.com/rs/cors" + + "spore-registry/internal/config" + "spore-registry/internal/database" + "spore-registry/internal/handlers" + "spore-registry/internal/repository" + "spore-registry/internal/service" + "spore-registry/internal/storage" +) + +// App holds the application dependencies +type App struct { + config *config.Config + db *database.DB + handler *handlers.FirmwareHandler +} + +// NewApp creates a new application instance +func NewApp() (*App, error) { + // Load configuration + cfg := config.LoadConfig() + + // Create registry directory if it doesn't exist + if err := os.MkdirAll(cfg.Registry, 0755); err != nil { + return nil, fmt.Errorf("failed to create registry directory: %w", err) + } + + // Initialize database + db, err := database.NewDB(cfg.DBPath) + if err != nil { + return nil, fmt.Errorf("failed to initialize database: %w", err) + } + + // Create repository + repo := repository.NewFirmwareRepository(db.GetConnection()) + + // Create storage + fileStorage := storage.NewFileStorage(cfg.Registry) + + // Create service + firmwareService := service.NewFirmwareService(repo, fileStorage) + + // Create handler + handler := handlers.NewFirmwareHandler(firmwareService) + + return &App{ + config: cfg, + db: db, + handler: handler, + }, nil +} + +// Close closes the database connection +func (a *App) Close() error { + return a.db.Close() +} + +// SetupRoutes sets up HTTP routes +func (a *App) SetupRoutes() *http.ServeMux { + mux := http.NewServeMux() + + // API endpoints + mux.HandleFunc("POST /firmware", a.handler.UploadFirmware) + mux.HandleFunc("PUT /firmware/{name}/{version}", a.handler.UpdateFirmwareMetadata) + mux.HandleFunc("GET /firmware", a.handler.ListFirmware) + mux.HandleFunc("GET /firmware/{name}/{version}", a.handler.DownloadFirmware) + + // Health check endpoint + mux.HandleFunc("GET /health", a.handler.HealthCheck) + + return mux +} + +// Start starts the HTTP server +func (a *App) Start() error { + mux := a.SetupRoutes() + + log.Printf("Starting SPORE registry server on port %s", a.config.Port) + log.Printf("Server will be accessible from any host on port %s", a.config.Port) + log.Printf("CORS enabled for all origins to support mobile access") + + // Add CORS middleware with dynamic origin handling + c := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, // Allow all origins for mobile access + AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowedHeaders: []string{"*"}, + AllowCredentials: false, // Set to false when using wildcard origins + AllowOriginFunc: func(origin string) bool { + // Allow all origins for mobile/remote access + return true + }, + }) + + // Add request logging middleware + loggedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) + c.Handler(mux).ServeHTTP(w, r) + }) + + return http.ListenAndServe(":"+a.config.Port, loggedHandler) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..f5abd2e --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,47 @@ +package config + +import ( + "os" + "strconv" +) + +// Config holds the application configuration +type Config struct { + Port string + DBPath string + Registry string + MaxSize int64 +} + +// LoadConfig loads configuration from environment variables with defaults +func LoadConfig() *Config { + port := os.Getenv("PORT") + if port == "" { + port = "3002" + } + + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "./registry.db" + } + + registry := os.Getenv("REGISTRY_PATH") + if registry == "" { + registry = "registry" + } + + maxSizeStr := os.Getenv("MAX_UPLOAD_SIZE") + maxSize := int64(32 << 20) // 32MB default + if maxSizeStr != "" { + if parsed, err := strconv.ParseInt(maxSizeStr, 10, 64); err == nil { + maxSize = parsed + } + } + + return &Config{ + Port: port, + DBPath: dbPath, + Registry: registry, + MaxSize: maxSize, + } +} diff --git a/internal/database/database.go b/internal/database/database.go new file mode 100644 index 0000000..39bb2f2 --- /dev/null +++ b/internal/database/database.go @@ -0,0 +1,61 @@ +package database + +import ( + "database/sql" + "fmt" + + _ "github.com/mattn/go-sqlite3" +) + +// DB wraps the database connection and provides methods for database operations +type DB struct { + conn *sql.DB +} + +// NewDB creates a new database connection +func NewDB(dbPath string) (*DB, error) { + conn, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + db := &DB{conn: conn} + + // Create tables + if err := db.createTables(); err != nil { + conn.Close() + return nil, fmt.Errorf("failed to create tables: %w", err) + } + + return db, nil +} + +// Close closes the database connection +func (db *DB) Close() error { + return db.conn.Close() +} + +// GetConnection returns the underlying database connection +func (db *DB) GetConnection() *sql.DB { + return db.conn +} + +// createTables creates the necessary database tables +func (db *DB) createTables() error { + query := ` + CREATE TABLE IF NOT EXISTS firmware ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + version TEXT NOT NULL, + size INTEGER NOT NULL, + labels TEXT NOT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(name, version) + );` + + if _, err := db.conn.Exec(query); err != nil { + return err + } + + return nil +} diff --git a/internal/handlers/firmware.go b/internal/handlers/firmware.go new file mode 100644 index 0000000..f4862cb --- /dev/null +++ b/internal/handlers/firmware.go @@ -0,0 +1,207 @@ +package handlers + +import ( + "encoding/json" + "log" + "net/http" + "strings" + + "spore-registry/internal/models" + "spore-registry/internal/service" +) + +// FirmwareHandler handles HTTP requests for firmware operations +type FirmwareHandler struct { + service *service.FirmwareService +} + +// NewFirmwareHandler creates a new firmware handler +func NewFirmwareHandler(service *service.FirmwareService) *FirmwareHandler { + return &FirmwareHandler{service: service} +} + +// UploadFirmware handles POST /firmware endpoint +func (h *FirmwareHandler) UploadFirmware(w http.ResponseWriter, r *http.Request) { + log.Printf("POST /firmware - %s", r.RemoteAddr) + + if r.Method != http.MethodPost { + log.Printf("Invalid method %s for /firmware", r.Method) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse multipart form + err := r.ParseMultipartForm(32 << 20) // 32MB max + if err != nil { + log.Printf("Failed to parse multipart form: %v", err) + http.Error(w, "Failed to parse form: "+err.Error(), http.StatusBadRequest) + return + } + + // Get metadata from form + metadataJSON := r.FormValue("metadata") + if metadataJSON == "" { + log.Printf("Missing metadata field in firmware upload") + http.Error(w, "Missing metadata field", http.StatusBadRequest) + return + } + + var metadata models.FirmwareMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + log.Printf("Invalid metadata JSON: %v", err) + http.Error(w, "Invalid metadata JSON: "+err.Error(), http.StatusBadRequest) + return + } + + // Get firmware file + file, _, err := r.FormFile("firmware") + if err != nil { + log.Printf("Missing firmware file in upload: %v", err) + http.Error(w, "Missing firmware file: "+err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + log.Printf("Uploading firmware: %s/%s", metadata.Name, metadata.Version) + + // Upload firmware using service + response, err := h.service.UploadFirmware(metadata, file) + if err != nil { + log.Printf("Failed to upload firmware: %v", err) + http.Error(w, "Failed to upload firmware: "+err.Error(), http.StatusInternalServerError) + return + } + + log.Printf("Successfully uploaded firmware: %s/%s (size: %d bytes)", metadata.Name, metadata.Version, response.Size) + + // Return success response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(response) +} + +// ListFirmware handles GET /firmware endpoint with optional query parameters +func (h *FirmwareHandler) ListFirmware(w http.ResponseWriter, r *http.Request) { + log.Printf("GET /firmware - %s (name=%s, version=%s)", + r.RemoteAddr, r.URL.Query().Get("name"), r.URL.Query().Get("version")) + + if r.Method != http.MethodGet { + log.Printf("Invalid method %s for /firmware", r.Method) + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse query parameters + name := r.URL.Query().Get("name") + version := r.URL.Query().Get("version") + + // Get firmware records using service + grouped, err := h.service.ListFirmware(name, version) + if err != nil { + log.Printf("Failed to retrieve firmware list: %v", err) + http.Error(w, "Failed to retrieve firmware list: "+err.Error(), http.StatusInternalServerError) + return + } + + log.Printf("Returning %d firmware groups", len(grouped)) + + // Return grouped firmware list + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(grouped) +} + +// UpdateFirmwareMetadata handles PUT /firmware/{name}/{version} endpoint for metadata-only updates +func (h *FirmwareHandler) UpdateFirmwareMetadata(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract path parameters + path := strings.TrimPrefix(r.URL.Path, "/firmware/") + parts := strings.Split(path, "/") + if len(parts) != 2 { + log.Printf("Invalid firmware path for update: %s", r.URL.Path) + http.Error(w, "Invalid firmware path", http.StatusBadRequest) + return + } + + name := parts[0] + version := parts[1] + + log.Printf("PUT /firmware/%s/%s - %s", name, version, r.RemoteAddr) + + // Parse JSON body + var metadata models.FirmwareMetadata + if err := json.NewDecoder(r.Body).Decode(&metadata); err != nil { + log.Printf("Invalid JSON in metadata update: %v", err) + http.Error(w, "Invalid JSON: "+err.Error(), http.StatusBadRequest) + return + } + + // Update metadata using service + response, err := h.service.UpdateFirmwareMetadata(name, version, metadata) + if err != nil { + if strings.Contains(err.Error(), "not found") { + log.Printf("Firmware not found for update: %s/%s", name, version) + http.Error(w, "Firmware not found", http.StatusNotFound) + } else { + log.Printf("Failed to update firmware metadata: %v", err) + http.Error(w, "Failed to update firmware metadata: "+err.Error(), http.StatusInternalServerError) + } + return + } + + log.Printf("Successfully updated firmware metadata: %s/%s", name, version) + + // Return success response + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) +} + +// DownloadFirmware handles GET /firmware/{name}/{version} endpoint +func (h *FirmwareHandler) DownloadFirmware(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Extract path parameters + path := strings.TrimPrefix(r.URL.Path, "/firmware/") + parts := strings.Split(path, "/") + if len(parts) != 2 { + log.Printf("Invalid firmware path: %s", r.URL.Path) + http.Error(w, "Invalid firmware path", http.StatusBadRequest) + return + } + + name := parts[0] + version := parts[1] + + log.Printf("GET /firmware/%s/%s - %s", name, version, r.RemoteAddr) + + // Get firmware file path using service + filePath, err := h.service.GetFirmwarePath(name, version) + if err != nil { + if strings.Contains(err.Error(), "not found") { + log.Printf("Firmware not found: %s/%s", name, version) + http.Error(w, "Firmware not found", http.StatusNotFound) + } else { + log.Printf("Failed to get firmware path: %v", err) + http.Error(w, "Failed to get firmware: "+err.Error(), http.StatusInternalServerError) + } + return + } + + // Serve the file + log.Printf("Serving firmware file: %s", filePath) + http.ServeFile(w, r, filePath) +} + +// HealthCheck provides a simple health check endpoint +func (h *FirmwareHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { + response := models.HealthResponse{Status: "healthy"} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/internal/models/firmware.go b/internal/models/firmware.go new file mode 100644 index 0000000..0f124e5 --- /dev/null +++ b/internal/models/firmware.go @@ -0,0 +1,37 @@ +package models + +// FirmwareMetadata represents the metadata structure for firmware uploads +type FirmwareMetadata struct { + Name string `json:"name"` + Version string `json:"version"` + Labels map[string]string `json:"labels"` +} + +// FirmwareRecord represents a firmware record in the database +type FirmwareRecord struct { + Name string `json:"name"` + Version string `json:"version"` + Size int64 `json:"size"` + Labels map[string]string `json:"labels"` + Path string `json:"download_url"` +} + +// GroupedFirmware represents firmware grouped by name +type GroupedFirmware struct { + Name string `json:"name"` + Firmware []FirmwareRecord `json:"firmware"` +} + +// UploadResponse represents the response for firmware upload +type UploadResponse struct { + Success bool `json:"success"` + Name string `json:"name"` + Version string `json:"version"` + Size int64 `json:"size"` + Message string `json:"message,omitempty"` +} + +// HealthResponse represents the health check response +type HealthResponse struct { + Status string `json:"status"` +} diff --git a/internal/repository/firmware.go b/internal/repository/firmware.go new file mode 100644 index 0000000..27267e9 --- /dev/null +++ b/internal/repository/firmware.go @@ -0,0 +1,82 @@ +package repository + +import ( + "database/sql" + "encoding/json" + "fmt" + + "spore-registry/internal/models" +) + +// FirmwareRepository handles database operations for firmware +type FirmwareRepository struct { + db *sql.DB +} + +// NewFirmwareRepository creates a new firmware repository +func NewFirmwareRepository(db *sql.DB) *FirmwareRepository { + return &FirmwareRepository{db: db} +} + +// StoreFirmwareMetadata stores firmware metadata in the database +func (r *FirmwareRepository) StoreFirmwareMetadata(metadata models.FirmwareMetadata, size int64) error { + labelsJSON, err := json.Marshal(metadata.Labels) + if err != nil { + return fmt.Errorf("failed to marshal labels: %w", err) + } + + query := `INSERT OR REPLACE INTO firmware (name, version, size, labels) VALUES (?, ?, ?, ?)` + _, err = r.db.Exec(query, metadata.Name, metadata.Version, size, string(labelsJSON)) + if err != nil { + return fmt.Errorf("failed to store firmware metadata: %w", err) + } + + return nil +} + +// GetFirmwareRecords retrieves firmware records with optional filtering +func (r *FirmwareRepository) GetFirmwareRecords(name, version string) ([]models.FirmwareRecord, error) { + var records []models.FirmwareRecord + + // Build query with optional filters + query := ` + SELECT name, version, size, labels + FROM firmware + WHERE 1=1` + + var args []interface{} + if name != "" { + query += " AND name = ?" + args = append(args, name) + } + if version != "" { + query += " AND version = ?" + args = append(args, version) + } + + rows, err := r.db.Query(query, args...) + if err != nil { + return nil, fmt.Errorf("failed to query firmware records: %w", err) + } + defer rows.Close() + + for rows.Next() { + var record models.FirmwareRecord + var labelsJSON string + + err := rows.Scan(&record.Name, &record.Version, &record.Size, &labelsJSON) + if err != nil { + return nil, fmt.Errorf("failed to scan firmware record: %w", err) + } + + if err := json.Unmarshal([]byte(labelsJSON), &record.Labels); err != nil { + return nil, fmt.Errorf("failed to unmarshal labels: %w", err) + } + + // Construct download path + record.Path = fmt.Sprintf("/firmware/%s/%s", record.Name, record.Version) + records = append(records, record) + } + + return records, nil +} diff --git a/internal/service/firmware.go b/internal/service/firmware.go new file mode 100644 index 0000000..a1ff408 --- /dev/null +++ b/internal/service/firmware.go @@ -0,0 +1,190 @@ +package service + +import ( + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + + "spore-registry/internal/models" + "spore-registry/internal/repository" + "spore-registry/internal/storage" +) + +// FirmwareService handles business logic for firmware operations +type FirmwareService struct { + repo *repository.FirmwareRepository + storage *storage.FileStorage +} + +// NewFirmwareService creates a new firmware service +func NewFirmwareService(repo *repository.FirmwareRepository, storage *storage.FileStorage) *FirmwareService { + return &FirmwareService{ + repo: repo, + storage: storage, + } +} + +// UploadFirmware uploads firmware binary and metadata +func (s *FirmwareService) UploadFirmware(metadata models.FirmwareMetadata, data io.Reader) (*models.UploadResponse, error) { + // Validate required fields + if metadata.Name == "" || metadata.Version == "" { + return nil, fmt.Errorf("name and version are required") + } + + // Store firmware binary + filePath, size, err := s.storage.StoreFirmwareBinary(metadata.Name, metadata.Version, data) + if err != nil { + return nil, fmt.Errorf("failed to store firmware binary: %w", err) + } + + // Store metadata in database + if err := s.repo.StoreFirmwareMetadata(metadata, size); err != nil { + // Try to clean up the file if database operation fails + os.Remove(filePath) + return nil, fmt.Errorf("failed to store firmware metadata: %w", err) + } + + return &models.UploadResponse{ + Success: true, + Name: metadata.Name, + Version: metadata.Version, + Size: size, + }, nil +} + +// ListFirmware retrieves firmware records with optional filtering +func (s *FirmwareService) ListFirmware(name, version string) ([]models.GroupedFirmware, error) { + records, err := s.repo.GetFirmwareRecords(name, version) + if err != nil { + return nil, fmt.Errorf("failed to retrieve firmware records: %w", err) + } + + grouped := s.groupFirmwareByName(records) + return grouped, nil +} + +// UpdateFirmwareMetadata updates firmware metadata +func (s *FirmwareService) UpdateFirmwareMetadata(name, version string, metadata models.FirmwareMetadata) (*models.UploadResponse, error) { + // Validate that the name and version match the URL + if metadata.Name != name || metadata.Version != version { + return nil, fmt.Errorf("name and version in URL must match metadata") + } + + // Check if firmware exists + records, err := s.repo.GetFirmwareRecords(name, version) + if err != nil { + return nil, fmt.Errorf("failed to check if firmware exists: %w", err) + } + + if len(records) == 0 { + return nil, fmt.Errorf("firmware not found") + } + + // Get existing firmware size (keep the same size since we're not updating the binary) + existingRecord := records[0] + size := existingRecord.Size + + // Update metadata in database + if err := s.repo.StoreFirmwareMetadata(metadata, size); err != nil { + return nil, fmt.Errorf("failed to update firmware metadata: %w", err) + } + + return &models.UploadResponse{ + Success: true, + Name: metadata.Name, + Version: metadata.Version, + Size: size, + Message: "Metadata updated successfully", + }, nil +} + +// GetFirmwarePath returns the file path for downloading firmware +func (s *FirmwareService) GetFirmwarePath(name, version string) (string, error) { + if !s.storage.FirmwareExists(name, version) { + return "", fmt.Errorf("firmware not found") + } + + return s.storage.GetFirmwareBinaryPath(name, version), nil +} + +// groupFirmwareByName groups firmware records by name +func (s *FirmwareService) groupFirmwareByName(records []models.FirmwareRecord) []models.GroupedFirmware { + nameMap := make(map[string][]models.FirmwareRecord) + + // Group records by name + for _, record := range records { + nameMap[record.Name] = append(nameMap[record.Name], record) + } + + // Convert to slice + var grouped []models.GroupedFirmware + for name, firmware := range nameMap { + // Sort firmware versions by version number (highest first) + sort.Slice(firmware, func(i, j int) bool { + return compareVersions(firmware[i].Version, firmware[j].Version) > 0 + }) + + grouped = append(grouped, models.GroupedFirmware{ + Name: name, + Firmware: firmware, + }) + } + + // Sort firmware groups by name (A to Z) + sort.Slice(grouped, func(i, j int) bool { + return grouped[i].Name < grouped[j].Name + }) + + return grouped +} + +// compareVersions compares two semantic version strings +// Returns: -1 if v1 < v2, 0 if v1 == v2, 1 if v1 > v2 +func compareVersions(v1, v2 string) int { + // Remove 'v' prefix if present + v1 = strings.TrimPrefix(v1, "v") + v2 = strings.TrimPrefix(v2, "v") + + parts1 := strings.Split(v1, ".") + parts2 := strings.Split(v2, ".") + + // Pad shorter version with zeros + maxLen := len(parts1) + if len(parts2) > maxLen { + maxLen = len(parts2) + } + + for len(parts1) < maxLen { + parts1 = append(parts1, "0") + } + for len(parts2) < maxLen { + parts2 = append(parts2, "0") + } + + // Compare each part + for i := 0; i < maxLen; i++ { + num1, err1 := strconv.Atoi(parts1[i]) + num2, err2 := strconv.Atoi(parts2[i]) + + // If parsing fails, do string comparison + if err1 != nil || err2 != nil { + if parts1[i] < parts2[i] { + return -1 + } else if parts1[i] > parts2[i] { + return 1 + } + continue + } + + if num1 < num2 { + return -1 + } else if num1 > num2 { + return 1 + } + } + + return 0 +} diff --git a/internal/storage/file.go b/internal/storage/file.go new file mode 100644 index 0000000..e68b571 --- /dev/null +++ b/internal/storage/file.go @@ -0,0 +1,57 @@ +package storage + +import ( + "fmt" + "io" + "os" + "path/filepath" +) + +// FileStorage handles file system operations for firmware binaries +type FileStorage struct { + registryPath string +} + +// NewFileStorage creates a new file storage instance +func NewFileStorage(registryPath string) *FileStorage { + return &FileStorage{registryPath: registryPath} +} + +// StoreFirmwareBinary stores a firmware binary in the hierarchical file structure +func (fs *FileStorage) StoreFirmwareBinary(name, version string, data io.Reader) (string, int64, error) { + // Create directory path: registry/// + dirPath := filepath.Join(fs.registryPath, name, version) + if err := os.MkdirAll(dirPath, 0755); err != nil { + return "", 0, fmt.Errorf("failed to create firmware directory: %w", err) + } + + // Create firmware file path + filePath := filepath.Join(dirPath, "firmware.bin") + + // Create the file + file, err := os.Create(filePath) + if err != nil { + return "", 0, fmt.Errorf("failed to create firmware file: %w", err) + } + defer file.Close() + + // Copy data to file and calculate size + size, err := io.Copy(file, data) + if err != nil { + return "", 0, fmt.Errorf("failed to write firmware data: %w", err) + } + + return filePath, size, nil +} + +// GetFirmwareBinaryPath returns the file path for a firmware binary +func (fs *FileStorage) GetFirmwareBinaryPath(name, version string) string { + return filepath.Join(fs.registryPath, name, version, "firmware.bin") +} + +// FirmwareExists checks if a firmware binary exists +func (fs *FileStorage) FirmwareExists(name, version string) bool { + filePath := fs.GetFirmwareBinaryPath(name, version) + _, err := os.Stat(filePath) + return !os.IsNotExist(err) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..400c9dd --- /dev/null +++ b/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "log" + + "spore-registry/internal/app" +) + +func main() { + application, err := app.NewApp() + if err != nil { + log.Fatal("Failed to initialize application:", err) + } + defer application.Close() + + if err := application.Start(); err != nil { + log.Fatal("Server failed:", err) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..ba61cf9 --- /dev/null +++ b/main_test.go @@ -0,0 +1,279 @@ +package main + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "spore-registry/internal/database" + "spore-registry/internal/handlers" + "spore-registry/internal/models" + "spore-registry/internal/repository" + "spore-registry/internal/service" + "spore-registry/internal/storage" + + _ "github.com/mattn/go-sqlite3" +) + +// setupTestDB creates a temporary database for testing +func setupTestDB(t *testing.T) (*database.DB, *storage.FileStorage, *handlers.FirmwareHandler) { + // Create temporary directory for test registry + tmpDir, err := os.MkdirTemp("", "registry_test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + // Clean up temporary directory after test + defer func() { + os.RemoveAll(tmpDir) + }() + + // Create test database + db, err := database.NewDB(":memory:") + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + // Create repository + repo := repository.NewFirmwareRepository(db.GetConnection()) + + // Create storage + fileStorage := storage.NewFileStorage(tmpDir) + + // Create service + firmwareService := service.NewFirmwareService(repo, fileStorage) + + // Create handler + handler := handlers.NewFirmwareHandler(firmwareService) + + return db, fileStorage, handler +} + +func TestStoreFirmwareMetadata(t *testing.T) { + db, _, _ := setupTestDB(t) + defer db.Close() + + metadata := models.FirmwareMetadata{ + Name: "test-firmware", + Version: "1.0.0", + Labels: map[string]string{ + "app": "test", + }, + } + + // Create a test service to test the repository directly + repo := repository.NewFirmwareRepository(db.GetConnection()) + fileStorage := storage.NewFileStorage("/tmp") + service := service.NewFirmwareService(repo, fileStorage) + + _, err := service.UploadFirmware(metadata, bytes.NewReader([]byte("test data"))) + if err != nil { + t.Fatalf("Failed to upload firmware: %v", err) + } + + // Verify the record was stored by listing firmware + grouped, err := service.ListFirmware("", "") + if err != nil { + t.Fatalf("Failed to retrieve firmware records: %v", err) + } + + if len(grouped) != 1 { + t.Fatalf("Expected 1 group, got %d", len(grouped)) + } + + if len(grouped[0].Firmware) != 1 { + t.Fatalf("Expected 1 firmware record, got %d", len(grouped[0].Firmware)) + } + + record := grouped[0].Firmware[0] + if record.Name != metadata.Name { + t.Errorf("Expected name %s, got %s", metadata.Name, record.Name) + } + if record.Version != metadata.Version { + t.Errorf("Expected version %s, got %s", metadata.Version, record.Version) + } + if record.Labels["app"] != "test" { + t.Errorf("Expected label app=test, got %s", record.Labels["app"]) + } +} + +func TestGetFirmwareRecordsWithFilters(t *testing.T) { + db, _, _ := setupTestDB(t) + defer db.Close() + + // Create a test service to test the repository directly + repo := repository.NewFirmwareRepository(db.GetConnection()) + fileStorage := storage.NewFileStorage("/tmp") + service := service.NewFirmwareService(repo, fileStorage) + + // Insert test data + testRecords := []models.FirmwareMetadata{ + {Name: "firmware1", Version: "1.0.0", Labels: map[string]string{"type": "app"}}, + {Name: "firmware1", Version: "1.1.0", Labels: map[string]string{"type": "app"}}, + {Name: "firmware2", Version: "1.0.0", Labels: map[string]string{"type": "sensor"}}, + } + + for _, record := range testRecords { + _, err := service.UploadFirmware(record, bytes.NewReader([]byte("test data"))) + if err != nil { + t.Fatalf("Failed to store test data: %v", err) + } + } + + // Test filtering by name + grouped, err := service.ListFirmware("firmware1", "") + if err != nil { + t.Fatalf("Failed to get records by name: %v", err) + } + if len(grouped) != 1 || len(grouped[0].Firmware) != 2 { + t.Errorf("Expected 1 group with 2 records for firmware1, got %d groups", len(grouped)) + } + + // Test filtering by version + grouped, err = service.ListFirmware("", "1.0.0") + if err != nil { + t.Fatalf("Failed to get records by version: %v", err) + } + if len(grouped) != 2 { + t.Errorf("Expected 2 groups for version 1.0.0, got %d", len(grouped)) + } + + // Test filtering by both name and version + grouped, err = service.ListFirmware("firmware1", "1.0.0") + if err != nil { + t.Fatalf("Failed to get records by name and version: %v", err) + } + if len(grouped) != 1 || len(grouped[0].Firmware) != 1 { + t.Errorf("Expected 1 group with 1 record for firmware1/1.0.0, got %d groups", len(grouped)) + } +} + +func TestStoreFirmwareBinary(t *testing.T) { + db, fileStorage, _ := setupTestDB(t) + defer db.Close() + + testData := []byte("test firmware binary data") + reader := bytes.NewReader(testData) + + filePath, size, err := fileStorage.StoreFirmwareBinary("test", "1.0.0", reader) + if err != nil { + t.Fatalf("Failed to store firmware binary: %v", err) + } + + if size != int64(len(testData)) { + t.Errorf("Expected size %d, got %d", len(testData), size) + } + + // Verify file was created + if _, err := os.Stat(filePath); os.IsNotExist(err) { + t.Errorf("Firmware file was not created at %s", filePath) + } + + // Verify file contents + storedData, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read stored file: %v", err) + } + + if !bytes.Equal(testData, storedData) { + t.Error("Stored file contents don't match original data") + } +} + +func TestListFirmwareAPI(t *testing.T) { + db, _, handler := setupTestDB(t) + defer db.Close() + + // Create a test service to test the repository directly + repo := repository.NewFirmwareRepository(db.GetConnection()) + fileStorage := storage.NewFileStorage("/tmp") + service := service.NewFirmwareService(repo, fileStorage) + + // Insert test data + metadata := models.FirmwareMetadata{ + Name: "test-firmware", + Version: "1.0.0", + Labels: map[string]string{"app": "test"}, + } + _, err := service.UploadFirmware(metadata, bytes.NewReader([]byte("test data"))) + if err != nil { + t.Fatalf("Failed to store test data: %v", err) + } + + // Test GET /firmware + req := httptest.NewRequest("GET", "/firmware", nil) + w := httptest.NewRecorder() + + handler.ListFirmware(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var grouped []models.GroupedFirmware + err = json.Unmarshal(w.Body.Bytes(), &grouped) + if err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if len(grouped) != 1 { + t.Errorf("Expected 1 group, got %d", len(grouped)) + } +} + +func TestHealthCheckAPI(t *testing.T) { + db, _, handler := setupTestDB(t) + defer db.Close() + + req := httptest.NewRequest("GET", "/health", nil) + w := httptest.NewRecorder() + + handler.HealthCheck(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", w.Code) + } + + var response models.HealthResponse + err := json.Unmarshal(w.Body.Bytes(), &response) + if err != nil { + t.Fatalf("Failed to parse response: %v", err) + } + + if response.Status != "healthy" { + t.Errorf("Expected status 'healthy', got '%s'", response.Status) + } +} + +func TestInvalidFirmwarePath(t *testing.T) { + db, _, handler := setupTestDB(t) + defer db.Close() + + // Test invalid path (too many parts) + req := httptest.NewRequest("GET", "/firmware/invalid/path/extra", nil) + w := httptest.NewRecorder() + + handler.DownloadFirmware(w, req) + + if w.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", w.Code) + } +} + +func TestFirmwareNotFound(t *testing.T) { + db, _, handler := setupTestDB(t) + defer db.Close() + + // Test firmware not found + req := httptest.NewRequest("GET", "/firmware/nonexistent/1.0.0", nil) + w := httptest.NewRecorder() + + handler.DownloadFirmware(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("Expected status 404, got %d", w.Code) + } +}