docs(cors): add CORS configuration planning and tasks
Add comprehensive CORS planning documentation and task breakdown for Phase 0.5 (8 tasks: T009-T016). - Create research-cors.md with security analysis and decisions - Add FR-022a to spec.md for production CORS requirements - Update tasks.md: 94 → 102 tasks across 9 phases - Document CORS in README and plan.md Configuration approach: hybrid (configurable origins/credentials, hardcoded methods/headers) with restrictive fail-safe defaults.
This commit is contained in:
@@ -20,6 +20,7 @@ STA will provide a modern web interface for controlling Modbus-compatible relay
|
||||
- ✅ Health check and metadata API endpoints
|
||||
- ✅ OpenAPI documentation with Swagger UI
|
||||
- ✅ Rate limiting middleware
|
||||
- ✅ CORS middleware (configurable for production)
|
||||
- ✅ SQLite schema and repository for relay labels
|
||||
- ✅ Vue 3 + TypeScript frontend scaffolding with Vite
|
||||
- ✅ Type-safe API client generation from OpenAPI specs
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
**Language/Version**: Rust 1.75+
|
||||
**Primary Dependencies**:
|
||||
- tokio-modbus 0.17.0 with TCP feature only (Modbus TCP protocol)
|
||||
- Poem 3.1 + poem-openapi 5.1 (HTTP API with OpenAPI)
|
||||
- Poem 3.1 + poem-openapi 5.1 (HTTP API with OpenAPI + CORS middleware)
|
||||
- Tokio 1.48 (async runtime)
|
||||
- sqlx 0.8 (SQLite persistence with compile-time verification)
|
||||
- mockall + async-trait (testing)
|
||||
@@ -77,6 +77,7 @@ specs/001-modbus-relay-control/
|
||||
├── spec.md # Feature specification
|
||||
├── decisions.md # Architecture and technical decisions
|
||||
├── research.md # Technical research findings
|
||||
├── research-cors.md # CORS configuration research and decisions
|
||||
└── types-design.md # Type system design (TyDD)
|
||||
```
|
||||
|
||||
|
||||
465
specs/001-modbus-relay-control/research-cors.md
Normal file
465
specs/001-modbus-relay-control/research-cors.md
Normal file
@@ -0,0 +1,465 @@
|
||||
# CORS Configuration Research
|
||||
|
||||
**Date**: 2026-01-02
|
||||
**Feature**: Configurable CORS for Modbus Relay Control System
|
||||
**Research Focus**: Production-ready CORS configuration to replace permissive defaults
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Current CORS implementation uses `Cors::new()` with default settings that allow ALL origins, ALL methods, and ALL headers - acceptable for development but insecure for production. This research documents how to implement configurable CORS following the project's existing patterns for middleware configuration.
|
||||
|
||||
## Current State Analysis
|
||||
|
||||
### Implementation Location
|
||||
- **File**: `backend/src/startup.rs:86`
|
||||
- **Current Code**: `.with(Cors::new())`
|
||||
- **Status**: Hardcoded permissive CORS with no configuration
|
||||
|
||||
### Security Profile (Current)
|
||||
- ✗ Allows ALL origins (including potentially malicious sites)
|
||||
- ✗ Allows ALL HTTP methods (GET, POST, DELETE, PATCH, etc.)
|
||||
- ✗ Allows ALL request headers
|
||||
- ✓ Does NOT allow credentials (default: false)
|
||||
- ✓ 24-hour preflight cache (default max_age: 86400)
|
||||
|
||||
### Configuration Gap
|
||||
- `settings.frontend_url` exists in `backend/src/settings.rs:20` but is NOT used for CORS
|
||||
- No CORS-specific settings struct
|
||||
- No environment-aware CORS configuration
|
||||
|
||||
## Research Findings
|
||||
|
||||
### 1. Poem CORS API
|
||||
|
||||
**Core Configuration Methods**:
|
||||
```rust
|
||||
Cors::new()
|
||||
.allow_origin(origin: &str) // Single origin
|
||||
.allow_origins(origins: Vec<&str>) // Multiple origins
|
||||
.allow_origin_regex(pattern: &str) // Wildcard patterns
|
||||
.allow_method(method: Method) // HTTP method
|
||||
.allow_methods(methods: Vec<Method>) // Multiple methods
|
||||
.allow_header(header: HeaderName) // Request header
|
||||
.allow_headers(headers: Vec<HeaderName>) // Multiple headers
|
||||
.expose_header(header: HeaderName) // Response header
|
||||
.expose_headers(headers: Vec<HeaderName>) // Multiple response headers
|
||||
.allow_credentials(bool) // Cookie/auth support
|
||||
.max_age(seconds: i32) // Preflight cache duration
|
||||
```
|
||||
|
||||
**Default Behavior**:
|
||||
- Empty collections → permits ALL values (permissive)
|
||||
- `max_age`: 86400 seconds (24 hours)
|
||||
- `allow_credentials`: false
|
||||
|
||||
### 2. Production Security Best Practices
|
||||
|
||||
#### Origin Configuration
|
||||
| Approach | Security | Use Case |
|
||||
|----------|----------|----------|
|
||||
| Specific origin | High | Production (single frontend domain) |
|
||||
| Multiple specific origins | High | Multi-environment (dev + prod) |
|
||||
| Wildcard patterns | Medium | Subdomains (*.example.com) |
|
||||
| Custom validation function | Medium | Complex rules |
|
||||
| Default (empty) | Low | Development only |
|
||||
|
||||
**Recommendation**: Use specific origins from `frontend_url` setting.
|
||||
|
||||
#### Credentials
|
||||
- **false**: Public APIs without authentication
|
||||
- **true**: APIs requiring cookies/auth headers (Authelia requires this)
|
||||
|
||||
**For STA**: Set to `true` because Traefik uses Authelia authentication.
|
||||
|
||||
**Critical**: When credentials=true, wildcard `*` origin is NOT allowed by browsers. Must use specific origins.
|
||||
|
||||
#### Methods
|
||||
- **Minimal**: GET only (read-only endpoints)
|
||||
- **Standard**: GET, POST (basic REST)
|
||||
- **Full**: GET, POST, PUT, DELETE, PATCH (complete CRUD)
|
||||
|
||||
**For STA**: GET, POST, PUT (relay control requires POST/PUT for state changes)
|
||||
|
||||
#### Headers
|
||||
- **Essential**: Content-Type (for POST/PUT bodies)
|
||||
- **Authentication**: Authorization (for bearer tokens)
|
||||
- **Custom**: X-Request-Id, X-Custom-Header (application-specific)
|
||||
|
||||
**For STA**: Minimum required: `content-type`, `authorization`
|
||||
|
||||
#### Max Age
|
||||
| Duration | Implications | Security |
|
||||
|----------|--------------|----------|
|
||||
| 0 | No caching, preflight every request | High but inefficient |
|
||||
| 3600 (1 hour) | Balance security & performance | High (recommended) |
|
||||
| 86400 (24 hours) | Fewer preflights, slower updates | Medium |
|
||||
| 604800 (7 days) | Very few preflights | Low |
|
||||
|
||||
**Recommendation**: 3600 seconds (1 hour) for production.
|
||||
|
||||
### 3. Deployment Architecture Context
|
||||
|
||||
From `specs/001-modbus-relay-control/decisions.md`:
|
||||
|
||||
**Production Setup**:
|
||||
- **Frontend**: Cloudflare Pages (static hosting with global CDN)
|
||||
- **Backend**: Raspberry Pi 3B+ (local network, same as Modbus device)
|
||||
- **Reverse Proxy**: Traefik on Raspberry Pi
|
||||
- HTTPS termination (TLS certificates)
|
||||
- Authelia middleware (user authentication)
|
||||
- Routes HTTPS requests to backend HTTP service
|
||||
|
||||
**Communication Flow**:
|
||||
```
|
||||
Frontend (Cloudflare Pages)
|
||||
↓ HTTPS
|
||||
Traefik (Raspberry Pi)
|
||||
↓ Authelia authentication
|
||||
↓ HTTP (local network)
|
||||
Backend (Raspberry Pi)
|
||||
↓ Modbus TCP
|
||||
Relay Device (local network)
|
||||
```
|
||||
|
||||
**CORS Implications**:
|
||||
- Origin must be the Cloudflare Pages URL (HTTPS)
|
||||
- Credentials must be allowed (Authelia auth tokens)
|
||||
- Backend sees requests from Traefik, but CORS origin is frontend domain
|
||||
|
||||
### 4. Configuration Pattern (from Rate Limiting)
|
||||
|
||||
**Reference**: `backend/src/middleware/rate_limit.rs`
|
||||
|
||||
**Pattern Structure**:
|
||||
1. **Configuration struct** with settings
|
||||
2. **Middleware implementation** using configuration
|
||||
3. **Conditional instantiation** in `startup.rs` based on settings
|
||||
4. **YAML configuration** in `settings/*.yaml`
|
||||
5. **Environment variable overrides** via `APP__` prefix
|
||||
|
||||
**Example from Rate Limiting**:
|
||||
```rust
|
||||
// Settings struct
|
||||
pub struct RateLimitSettings {
|
||||
pub enabled: bool,
|
||||
pub burst_size: u32,
|
||||
pub per_seconds: u64,
|
||||
}
|
||||
|
||||
// Conditional instantiation in startup.rs
|
||||
let rate_limit_config = if value.settings.rate_limit.enabled {
|
||||
RateLimitConfig::new(burst_size, per_seconds)
|
||||
} else {
|
||||
RateLimitConfig::new(u32::MAX, 1) // Effectively disabled
|
||||
};
|
||||
|
||||
let app = value.app
|
||||
.with(RateLimit::new(&rate_limit_config))
|
||||
.with(Cors::new()) // ← Should follow same pattern
|
||||
.data(value.settings);
|
||||
```
|
||||
|
||||
## Recommended Approach
|
||||
|
||||
### Option A: Minimal Configuration (MVP)
|
||||
|
||||
Add simple CORS settings that cover 80% of use cases:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, serde::Deserialize, Clone)]
|
||||
pub struct CorsSettings {
|
||||
pub allowed_origins: Vec<String>,
|
||||
pub allow_credentials: bool,
|
||||
}
|
||||
|
||||
impl Default for CorsSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed_origins: vec!["*".to_string()],
|
||||
allow_credentials: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**YAML Configuration**:
|
||||
```yaml
|
||||
# development.yaml
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "http://localhost:3000"
|
||||
- "http://127.0.0.1:3000"
|
||||
allow_credentials: false
|
||||
|
||||
# production.yaml
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "https://REACTED" # Cloudflare Pages URL
|
||||
allow_credentials: true
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Simple to implement
|
||||
- Covers most critical security concerns
|
||||
- Easy to understand and maintain
|
||||
|
||||
**Cons**:
|
||||
- Limited flexibility (no method/header customization)
|
||||
- Cannot disable CORS entirely
|
||||
- No max_age configuration
|
||||
|
||||
### Option B: Full Configuration (Production-Ready)
|
||||
|
||||
Complete CORS configuration matching Poem's capabilities:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, serde::Deserialize, Clone)]
|
||||
pub struct CorsSettings {
|
||||
pub enabled: bool,
|
||||
pub allowed_origins: Vec<String>,
|
||||
pub allowed_methods: Vec<String>,
|
||||
pub allowed_headers: Vec<String>,
|
||||
pub expose_headers: Vec<String>,
|
||||
pub allow_credentials: bool,
|
||||
pub max_age_secs: i32,
|
||||
}
|
||||
|
||||
impl Default for CorsSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
allowed_origins: vec!["*".to_string()],
|
||||
allowed_methods: vec!["GET".to_string(), "POST".to_string(), "PUT".to_string()],
|
||||
allowed_headers: vec!["content-type".to_string(), "authorization".to_string()],
|
||||
expose_headers: vec![],
|
||||
allow_credentials: false,
|
||||
max_age_secs: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**YAML Configuration**:
|
||||
```yaml
|
||||
# production.yaml
|
||||
cors:
|
||||
enabled: true
|
||||
allowed_origins:
|
||||
- "https://REDACTED"
|
||||
allowed_methods:
|
||||
- "GET"
|
||||
- "POST"
|
||||
- "PUT"
|
||||
allowed_headers:
|
||||
- "content-type"
|
||||
- "authorization"
|
||||
expose_headers:
|
||||
- "x-ratelimit-remaining"
|
||||
allow_credentials: true
|
||||
max_age_secs: 3600
|
||||
```
|
||||
|
||||
**Pros**:
|
||||
- Complete control over CORS behavior
|
||||
- Can disable CORS entirely (enabled: false)
|
||||
- Production-ready security
|
||||
- Follows Poem API closely
|
||||
|
||||
**Cons**:
|
||||
- More complex configuration
|
||||
- More YAML to maintain
|
||||
- Risk of misconfiguration
|
||||
|
||||
### Option C: Hybrid Approach (Recommended)
|
||||
|
||||
Combine simplicity with essential production features:
|
||||
|
||||
```rust
|
||||
#[derive(Debug, serde::Deserialize, Clone)]
|
||||
pub struct CorsSettings {
|
||||
pub allowed_origins: Vec<String>,
|
||||
pub allow_credentials: bool,
|
||||
pub max_age_secs: i32,
|
||||
}
|
||||
|
||||
impl Default for CorsSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
allowed_origins: vec!["*".to_string()],
|
||||
allow_credentials: false,
|
||||
max_age_secs: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Methods and Headers**: Hardcoded in code based on API requirements (not configurable).
|
||||
|
||||
**Rationale**:
|
||||
- Origins and credentials are deployment-specific (need configuration)
|
||||
- Methods and headers are API-specific (shouldn't change per environment)
|
||||
- Simpler than Option B, more secure than Option A
|
||||
- Reduces configuration surface area
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Files to Modify
|
||||
|
||||
1. **`backend/src/settings.rs`**
|
||||
- Add `CorsSettings` struct (after `RateLimitSettings`)
|
||||
- Add `cors: CorsSettings` field to `Settings` struct
|
||||
- Add `#[serde(default)]` attribute for backward compatibility
|
||||
|
||||
2. **`backend/src/startup.rs`**
|
||||
- Import CORS-related types from Poem
|
||||
- Create helper method `build_cors(settings: &CorsSettings) -> Cors`
|
||||
- Replace `.with(Cors::new())` with `.with(Self::build_cors(&value.settings.cors))`
|
||||
|
||||
3. **`backend/settings/development.yaml`**
|
||||
- Add `cors:` section with permissive development settings
|
||||
- Allow localhost:3000 and 127.0.0.1:3000
|
||||
|
||||
4. **`backend/settings/production.yaml`**
|
||||
- Add `cors:` section with restrictive production settings
|
||||
- Use actual Cloudflare Pages URL
|
||||
- Set `allow_credentials: true`
|
||||
|
||||
5. **`specs/001-modbus-relay-control/plan.md`**
|
||||
- Update technical context with CORS configuration
|
||||
- Add CORS configuration task to implementation plan
|
||||
|
||||
6. **`CLAUDE.md`** (optional)
|
||||
- Document CORS configuration in project instructions
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
1. **Unit Tests**: Verify `build_cors()` creates correct Poem `Cors` instance
|
||||
2. **Integration Tests**: Verify CORS headers in HTTP responses
|
||||
3. **Manual Tests**: Test with actual frontend (Vite dev server + Cloudflare Pages)
|
||||
|
||||
## Key Files Referenced
|
||||
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `/backend/src/startup.rs` | 9, 23, 83-87 | Current CORS implementation |
|
||||
| `/backend/src/settings.rs` | 13-28, 75-90 | Settings structure |
|
||||
| `/backend/src/middleware/rate_limit.rs` | 16-127 | Middleware pattern reference |
|
||||
| `/backend/settings/development.yaml` | 1-9 | Development configuration |
|
||||
| `/backend/settings/production.yaml` | 1-9 | Production configuration |
|
||||
| `/specs/001-modbus-relay-control/decisions.md` | 71-92 | Deployment architecture |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Critical Points
|
||||
|
||||
1. **Never use wildcard `*` origin with credentials enabled** - browsers reject this
|
||||
2. **Specific origins only in production** - wildcards increase attack surface
|
||||
3. **Minimal methods and headers** - only expose what's needed by API
|
||||
4. **1-hour max_age** - allows policy updates within reasonable timeframe
|
||||
5. **Test with actual frontend** - CORS errors only appear in browser, not curl/Postman
|
||||
|
||||
### Attack Vectors Mitigated
|
||||
|
||||
- **CSRF via foreign domains**: Specific origins prevent malicious sites from making requests
|
||||
- **Credential theft**: Credentials only sent to whitelisted origins
|
||||
- **Data exfiltration**: Restrictive CORS prevents unauthorized cross-origin reads
|
||||
|
||||
## User Decisions (2026-01-02)
|
||||
|
||||
### Q1: Configuration Approach
|
||||
**Decision**: **Option C (Hybrid)** - Configure origins, credentials, and max_age
|
||||
**Rationale**: Balance of simplicity and production needs. Methods/headers hardcoded based on API requirements.
|
||||
|
||||
### Q2: Production URL
|
||||
**Decision**: `https://REDACTED`
|
||||
**Usage**: Set in `production.yaml` as allowed origin
|
||||
|
||||
### Q3: Development Port
|
||||
**Decision**: Port 5173 (Vite's default)
|
||||
**Note**: Previous `frontend_url: http://localhost:3000` was leftover from Nuxt project. Update to 5173.
|
||||
|
||||
### Q4: Exposed Headers
|
||||
**Decision**: Not needed for v1.0
|
||||
**Future**: May add in v1.1+ (e.g., `x-ratelimit-remaining`)
|
||||
|
||||
### Q5: CORS Disable Option
|
||||
**Decision**: No `enabled` flag
|
||||
**Approach**: Development.yaml will have permissive settings (`*` origin, all methods)
|
||||
|
||||
### Q6: Default Behavior
|
||||
**Decision**: Restrictive (fail-safe) when `cors:` section missing
|
||||
**Note**: Development.yaml explicitly sets permissive settings, so this only affects missing config
|
||||
|
||||
### Q7: Multiple Origins Support
|
||||
**Decision**: Nice to have, include support via `Vec<String>`
|
||||
**Implementation**: Use array in YAML, trivial to support
|
||||
|
||||
### Q8: Specification Update
|
||||
**Decision**: Add new FR-023 for configurable CORS in production
|
||||
**Action**: Update `spec.md` with new functional requirement
|
||||
|
||||
## Implementation Decisions Summary
|
||||
|
||||
**Configuration Structure**:
|
||||
```rust
|
||||
#[derive(Debug, serde::Deserialize, Clone)]
|
||||
pub struct CorsSettings {
|
||||
pub allowed_origins: Vec<String>,
|
||||
pub allow_credentials: bool,
|
||||
pub max_age_secs: i32,
|
||||
}
|
||||
|
||||
impl Default for CorsSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Restrictive default (fail-safe)
|
||||
allowed_origins: vec![],
|
||||
allow_credentials: false,
|
||||
max_age_secs: 3600,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Development Configuration** (`development.yaml`):
|
||||
```yaml
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "*" # Permissive for local development
|
||||
allow_credentials: false
|
||||
max_age_secs: 3600
|
||||
|
||||
frontend_url: http://localhost:5173 # Updated from 3000
|
||||
```
|
||||
|
||||
**Production Configuration** (`production.yaml`):
|
||||
```yaml
|
||||
cors:
|
||||
allowed_origins:
|
||||
- "https://REDACTED"
|
||||
allow_credentials: true # Required for Authelia authentication
|
||||
max_age_secs: 3600
|
||||
|
||||
frontend_url: "https://REDACTED"
|
||||
```
|
||||
|
||||
**Hardcoded Settings** (in code, not configurable):
|
||||
- **Methods**: GET, POST, PUT (based on API requirements)
|
||||
- **Allowed Headers**: content-type, authorization (minimum for API)
|
||||
- **Exposed Headers**: None for v1.0
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ **Clarify ambiguities** - Complete
|
||||
2. ✅ **Choose approach** - Option C (Hybrid)
|
||||
3. **Design architecture** with multiple implementation approaches
|
||||
4. **Get user approval** on preferred approach
|
||||
5. **Generate implementation plan** with tasks
|
||||
6. **Review plan** for completeness
|
||||
|
||||
## References
|
||||
|
||||
- Poem CORS documentation: Inferred from codebase patterns
|
||||
- CORS specification: MDN Web Docs (Cross-Origin Resource Sharing)
|
||||
- Project constitution: `specs/constitution.md` v1.1.0
|
||||
- Deployment decisions: `specs/001-modbus-relay-control/decisions.md`
|
||||
@@ -169,7 +169,13 @@ As a user, I want to assign custom labels to each relay (e.g., "Garage Light", "
|
||||
- **FR-019**: System MUST return HTTP 504 for Modbus timeout errors
|
||||
- **FR-020**: System MUST include OpenAPI 3.0 specification accessible at `/api/specs`
|
||||
- **FR-021**: System MUST apply rate limiting middleware (100 requests/minute per IP)
|
||||
- **FR-022**: System MUST apply CORS middleware allowing all origins (local network deployment)
|
||||
- **FR-022**: System MUST apply CORS middleware in development allowing all origins (`*`) for local development (port 5173)
|
||||
- **FR-022a**: System MUST apply configurable CORS middleware in production with:
|
||||
- Specific allowed origin from configuration (default: `https://REDACTED`)
|
||||
- Credential support for Authelia authentication (`allow_credentials: true`)
|
||||
- Configurable preflight cache duration (default: 1 hour)
|
||||
- Hardcoded HTTP methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
|
||||
- Hardcoded allowed headers: content-type, authorization
|
||||
- **FR-023**: System MUST start successfully even if Modbus device is unreachable at startup, marking device as unhealthy
|
||||
- **FR-024**: System MUST persist relay labels to configuration file (YAML) for persistence across restarts
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Implementation Tasks: Modbus Relay Control System
|
||||
|
||||
**Feature**: 001-modbus-relay-control
|
||||
**Total Tasks**: 94 tasks across 8 phases
|
||||
**MVP Delivery**: Phase 4 complete (Task 49)
|
||||
**Parallelizable Tasks**: 35 tasks marked with `[P]`
|
||||
**Total Tasks**: 102 tasks across 9 phases
|
||||
**MVP Delivery**: Phase 4 complete (Task 57)
|
||||
**Parallelizable Tasks**: 39 tasks marked with `[P]`
|
||||
**Approach**: Type-Driven Development (TyDD) + Test-Driven Development (TDD), Backend API first
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup & Foundation (0.5 days)
|
||||
## Phase 1: Setup & Foundation (0.5 days) DONE
|
||||
|
||||
**Purpose**: Initialize project dependencies and directory structure
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
- **Test**: `npm run dev` starts frontend dev server
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T008** [P] [Setup] [TDD] Generate TypeScript API client from OpenAPI
|
||||
- [x] **T008** [P] [Setup] [TDD] Generate TypeScript API client from OpenAPI
|
||||
- Add poem-openapi spec generation in startup.rs
|
||||
- Generate frontend/src/api/client.ts from OpenAPI spec
|
||||
- **Test**: TypeScript client compiles without errors
|
||||
@@ -64,13 +64,140 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 0.5: CORS Configuration & Production Security (0.5 days)
|
||||
|
||||
**Purpose**: Replace permissive `Cors::new()` with configurable production-ready CORS
|
||||
|
||||
**⚠️ TDD CRITICAL**: Write failing tests FIRST for every configuration and function
|
||||
|
||||
- [x] **T009** [P] [Setup] [TDD] Write tests for CorsSettings struct
|
||||
- Test: CorsSettings deserializes from YAML correctly ✓
|
||||
- Test: Default CorsSettings has empty allowed_origins (restrictive fail-safe) ✓
|
||||
- Test: CorsSettings with wildcard origin deserializes correctly ✓
|
||||
- Test: Settings::new() loads cors section from development.yaml ✓
|
||||
- Test: CorsSettings with partial fields uses defaults ✓
|
||||
- **File**: backend/src/settings.rs (in tests module)
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
- **Tests Written**: 5 tests (cors_settings_deserialize_from_yaml, cors_settings_default_has_empty_origins, cors_settings_with_wildcard_deserializes, settings_loads_cors_section_from_yaml, cors_settings_deserialize_with_defaults)
|
||||
|
||||
- [ ] **T010** [P] [Setup] [TDD] Add CorsSettings struct to settings.rs
|
||||
- Struct fields: `allowed_origins: Vec<String>`, `allow_credentials: bool`, `max_age_secs: i32`
|
||||
- Implement Default with restrictive settings: `allowed_origins: vec![]`, `allow_credentials: false`, `max_age_secs: 3600`
|
||||
- Add `#[derive(Debug, serde::Deserialize, Clone)]` to struct
|
||||
- Add `#[serde(default)]` attribute to Settings.cors field
|
||||
- Update Settings struct to include `pub cors: CorsSettings`
|
||||
- **File**: backend/src/settings.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T011** [Setup] [TDD] Update development.yaml with permissive CORS settings
|
||||
- Add cors section with `allowed_origins: ["*"]`, `allow_credentials: false`, `max_age_secs: 3600`
|
||||
- Update `frontend_url` from `http://localhost:3000` to `http://localhost:5173` (Vite default port)
|
||||
- **Test**: cargo run loads development config without errors
|
||||
- **File**: backend/settings/development.yaml
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T012** [P] [Setup] [TDD] Create production.yaml with restrictive CORS settings
|
||||
- Add cors section with `allowed_origins: ["REACTED"]`, `allow_credentials: true`, `max_age_secs: 3600`
|
||||
- Add `frontend_url: "https://REDACTED"`
|
||||
- Add production-specific application settings (protocol: https, host: 0.0.0.0)
|
||||
- **Test**: Settings::new() with APP_ENVIRONMENT=production loads config
|
||||
- **File**: backend/settings/production.yaml
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T013** [Setup] [TDD] Write tests for build_cors() function
|
||||
- Test: build_cors() with wildcard origin creates permissive Cors (allows any origin)
|
||||
- Test: build_cors() with specific origin creates restrictive Cors
|
||||
- Test: build_cors() with `credentials=true` and wildcard origin returns error (browser constraint violation)
|
||||
- Test: build_cors() sets correct methods (GET, POST, PUT, PATCH, DELETE, OPTIONS)
|
||||
- Test: build_cors() sets correct headers (content-type, authorization)
|
||||
- Test: build_cors() sets max_age from settings
|
||||
- **File**: backend/src/startup.rs (in tests module)
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T014** [Setup] [TDD] Implement build_cors() free function in startup.rs
|
||||
- Function signature: `fn build_cors(settings: &CorsSettings) -> Cors`
|
||||
- Validate: if `allow_credentials=true` AND `allowed_origins` contains "*", panic with clear error message
|
||||
- Iterate over `allowed_origins` and call `cors.allow_origin()` for each
|
||||
- Hardcode methods: `vec![Method::GET, Method::POST, Method::PUT, Method::PATCH, Method::DELETE, Method::OPTIONS]`
|
||||
- Hardcode headers: `vec![header::CONTENT_TYPE, header::AUTHORIZATION]`
|
||||
- Set `allow_credentials` from settings
|
||||
- Set `max_age` from settings.max_age_secs
|
||||
- Add structured logging: `tracing::info!(allowed_origins = ?settings.allowed_origins, allow_credentials = settings.allow_credentials, "CORS middleware configured")`
|
||||
- **File**: backend/src/startup.rs
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
**Pseudocode**:
|
||||
```rust
|
||||
fn build_cors(settings: &CorsSettings) -> Cors {
|
||||
// Validation
|
||||
if settings.allow_credentials && settings.allowed_origins.contains(&"*".to_string()) {
|
||||
panic!("CORS misconfiguration: wildcard origin not allowed with credentials=true");
|
||||
}
|
||||
|
||||
let mut cors = Cors::new();
|
||||
|
||||
// Configure origins
|
||||
for origin in &settings.allowed_origins {
|
||||
cors = cors.allow_origin(origin.as_str());
|
||||
}
|
||||
|
||||
// Hardcoded methods (API-specific)
|
||||
cors = cors.allow_methods(vec![
|
||||
Method::GET, Method::POST, Method::PUT,
|
||||
Method::PATCH, Method::DELETE, Method::OPTIONS
|
||||
]);
|
||||
|
||||
// Hardcoded headers (minimum for API)
|
||||
cors = cors.allow_headers(vec![
|
||||
header::CONTENT_TYPE,
|
||||
header::AUTHORIZATION,
|
||||
]);
|
||||
|
||||
// Configure from settings
|
||||
cors = cors
|
||||
.allow_credentials(settings.allow_credentials)
|
||||
.max_age(settings.max_age_secs);
|
||||
|
||||
tracing::info!(
|
||||
target: "backend::startup",
|
||||
allowed_origins = ?settings.allowed_origins,
|
||||
allow_credentials = settings.allow_credentials,
|
||||
max_age_secs = settings.max_age_secs,
|
||||
"CORS middleware configured"
|
||||
);
|
||||
|
||||
cors
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **T015** [Setup] [TDD] Replace Cors::new() with build_cors() in middleware chain
|
||||
- In `From<Application> for RunnableApplication`, replace `.with(Cors::new())` with `.with(build_cors(&value.settings.cors))`
|
||||
- Add necessary imports: `poem::http::{Method, header}`
|
||||
- Ensure CORS is applied after rate limiting (order: RateLimit → CORS → Data)
|
||||
- **Test**: Integration test verifies CORS headers are present
|
||||
- **File**: backend/src/startup.rs (line ~86)
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T016** [P] [Setup] [TDD] Write integration tests for CORS headers
|
||||
- Test: OPTIONS preflight request to `/api/health` returns correct CORS headers
|
||||
- Test: GET `/api/health` with Origin header returns `Access-Control-Allow-Origin` header
|
||||
- Test: Preflight response includes `Access-Control-Max-Age` matching configuration
|
||||
- Test: Response includes `Access-Control-Allow-Credentials` when configured
|
||||
- Test: Response includes correct `Access-Control-Allow-Methods` (GET, POST, PUT, PATCH, DELETE, OPTIONS)
|
||||
- **File**: backend/tests/integration/cors_test.rs (new file)
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
**Checkpoint**: CORS configuration complete, production-ready security with environment-specific settings
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Domain Layer - Type-Driven Development (1 day)
|
||||
|
||||
**Purpose**: Build domain types with 100% test coverage, bottom-to-top
|
||||
|
||||
**⚠️ TDD CRITICAL**: Write failing tests FIRST for every type, then implement
|
||||
|
||||
- [ ] **T009** [US1] [TDD] Write tests for RelayId newtype
|
||||
- [ ] **T017** [US1] [TDD] Write tests for RelayId newtype
|
||||
- Test: RelayId::new(1) → Ok(RelayId(1))
|
||||
- Test: RelayId::new(8) → Ok(RelayId(8))
|
||||
- Test: RelayId::new(0) → Err(InvalidRelayId)
|
||||
@@ -79,27 +206,27 @@
|
||||
- **File**: src/domain/relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T010** [US1] [TDD] Implement RelayId newtype with validation
|
||||
- [ ] **T018** [US1] [TDD] Implement RelayId newtype with validation
|
||||
- #[repr(transparent)] newtype wrapping u8
|
||||
- Constructor validates 1..=8 range
|
||||
- Implement Display, Debug, Clone, Copy, PartialEq, Eq
|
||||
- **File**: src/domain/relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T011** [P] [US1] [TDD] Write tests for RelayState enum
|
||||
- [ ] **T019** [P] [US1] [TDD] Write tests for RelayState enum
|
||||
- Test: RelayState::On → serializes to "on"
|
||||
- Test: RelayState::Off → serializes to "off"
|
||||
- Test: Parse "on"/"off" from strings
|
||||
- **File**: src/domain/relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T012** [P] [US1] [TDD] Implement RelayState enum
|
||||
- [ ] **T020** [P] [US1] [TDD] Implement RelayState enum
|
||||
- Enum: On, Off
|
||||
- Implement Display, Debug, Clone, Copy, PartialEq, Eq, serde::Serialize/Deserialize
|
||||
- **File**: src/domain/relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T013** [US1] [TDD] Write tests for Relay aggregate
|
||||
- [ ] **T021** [US1] [TDD] Write tests for Relay aggregate
|
||||
- Test: Relay::new(RelayId(1), RelayState::Off, None) creates relay
|
||||
- Test: relay.toggle() flips state
|
||||
- Test: relay.turn_on() sets state to On
|
||||
@@ -107,13 +234,13 @@
|
||||
- **File**: src/domain/relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T014** [US1] [TDD] Implement Relay aggregate
|
||||
- [ ] **T022** [US1] [TDD] Implement Relay aggregate
|
||||
- Struct: Relay { id: RelayId, state: RelayState, label: Option<RelayLabel> }
|
||||
- Methods: new(), toggle(), turn_on(), turn_off(), state(), label()
|
||||
- **File**: src/domain/relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T015** [P] [US4] [TDD] Write tests for RelayLabel newtype
|
||||
- [ ] **T023** [P] [US4] [TDD] Write tests for RelayLabel newtype
|
||||
- Test: RelayLabel::new("Pump") → Ok
|
||||
- Test: RelayLabel::new("A".repeat(50)) → Ok
|
||||
- Test: RelayLabel::new("") → Err(EmptyLabel)
|
||||
@@ -121,26 +248,26 @@
|
||||
- **File**: src/domain/relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T016** [P] [US4] [TDD] Implement RelayLabel newtype
|
||||
- [ ] **T024** [P] [US4] [TDD] Implement RelayLabel newtype
|
||||
- #[repr(transparent)] newtype wrapping String
|
||||
- Constructor validates 1..=50 length
|
||||
- Implement Display, Debug, Clone, PartialEq, Eq
|
||||
- **File**: src/domain/relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T017** [US1] [TDD] Write tests for ModbusAddress type
|
||||
- [ ] **T025** [US1] [TDD] Write tests for ModbusAddress type
|
||||
- Test: ModbusAddress::from(RelayId(1)) → ModbusAddress(0)
|
||||
- Test: ModbusAddress::from(RelayId(8)) → ModbusAddress(7)
|
||||
- **File**: src/domain/modbus.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T018** [US1] [TDD] Implement ModbusAddress type with From<RelayId>
|
||||
- [ ] **T026** [US1] [TDD] Implement ModbusAddress type with From<RelayId>
|
||||
- #[repr(transparent)] newtype wrapping u16
|
||||
- Implement From<RelayId> with offset: user 1-8 → Modbus 0-7
|
||||
- **File**: src/domain/modbus.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T019** [US3] [TDD] Write tests and implement HealthStatus enum
|
||||
- [ ] **T027** [US3] [TDD] Write tests and implement HealthStatus enum
|
||||
- Enum: Healthy, Degraded { consecutive_errors: u32 }, Unhealthy { reason: String }
|
||||
- Test transitions between states
|
||||
- **File**: src/domain/health.rs
|
||||
@@ -154,20 +281,20 @@
|
||||
|
||||
**Purpose**: Implement Modbus client, mocks, and persistence
|
||||
|
||||
- [ ] **T020** [P] [US1] [TDD] Write tests for MockRelayController
|
||||
- [ ] **T028** [P] [US1] [TDD] Write tests for MockRelayController
|
||||
- Test: read_state() returns mocked state
|
||||
- Test: write_state() updates mocked state
|
||||
- Test: read_all() returns 8 relays in known state
|
||||
- **File**: src/infrastructure/modbus/mock_controller.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T021** [P] [US1] [TDD] Implement MockRelayController
|
||||
- [ ] **T029** [P] [US1] [TDD] Implement MockRelayController
|
||||
- Struct with Arc<Mutex<HashMap<RelayId, RelayState>>>
|
||||
- Implement RelayController trait with in-memory state
|
||||
- **File**: src/infrastructure/modbus/mock_controller.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T022** [US1] [TDD] Define RelayController trait
|
||||
- [ ] **T030** [US1] [TDD] Define RelayController trait
|
||||
- async fn read_state(&self, id: RelayId) → Result<RelayState, ControllerError>
|
||||
- async fn write_state(&self, id: RelayId, state: RelayState) → Result<(), ControllerError>
|
||||
- async fn read_all(&self) → Result<Vec<(RelayId, RelayState)>, ControllerError>
|
||||
@@ -175,14 +302,14 @@
|
||||
- **File**: src/infrastructure/modbus/controller.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T023** [P] [US1] [TDD] Define ControllerError enum
|
||||
- [ ] **T031** [P] [US1] [TDD] Define ControllerError enum
|
||||
- Variants: ConnectionError(String), Timeout(u64), ModbusException(String), InvalidRelayId(u8)
|
||||
- Implement std::error::Error, Display, Debug
|
||||
- Use thiserror derive macros
|
||||
- **File**: src/infrastructure/modbus/error.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T024** [US1] [TDD] Write tests for ModbusRelayController
|
||||
- [ ] **T032** [US1] [TDD] Write tests for ModbusRelayController
|
||||
- **REQUIRES HARDWARE/MOCK**: Integration test with tokio_modbus::test utilities
|
||||
- Test: Connection succeeds with valid config (Modbus TCP on port 502)
|
||||
- Test: read_state() returns correct coil value
|
||||
@@ -390,43 +517,43 @@
|
||||
|
||||
---
|
||||
|
||||
- [ ] **T026** [US1] [TDD] Integration test with real hardware (optional)
|
||||
- [ ] **T034** [US1] [TDD] Integration test with real hardware (optional)
|
||||
- **REQUIRES PHYSICAL DEVICE**: Test against actual Modbus relay at configured IP
|
||||
- Skip if device unavailable, rely on MockRelayController for CI
|
||||
- **File**: tests/integration/modbus_hardware_test.rs
|
||||
- **Complexity**: Medium | **Uncertainty**: High
|
||||
- **Note**: Use #[ignore] attribute, run with cargo test -- --ignored
|
||||
|
||||
- [ ] **T027** [P] [US4] [TDD] Write tests for RelayLabelRepository trait
|
||||
- [ ] **T035** [P] [US4] [TDD] Write tests for RelayLabelRepository trait
|
||||
- Test: get_label(RelayId(1)) → Option<RelayLabel>
|
||||
- Test: set_label(RelayId(1), label) → Result<(), RepositoryError>
|
||||
- Test: delete_label(RelayId(1)) → Result<(), RepositoryError>
|
||||
- **File**: src/infrastructure/persistence/label_repository.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T028** [P] [US4] [TDD] Implement SQLite RelayLabelRepository
|
||||
- [ ] **T036** [P] [US4] [TDD] Implement SQLite RelayLabelRepository
|
||||
- Implement get_label(), set_label(), delete_label() using SQLx
|
||||
- Use sqlx::query! macros for compile-time SQL verification
|
||||
- **File**: src/infrastructure/persistence/sqlite_label_repository.rs
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T029** [US4] [TDD] Write tests for in-memory mock LabelRepository
|
||||
- [ ] **T037** [US4] [TDD] Write tests for in-memory mock LabelRepository
|
||||
- For testing without SQLite dependency
|
||||
- **File**: src/infrastructure/persistence/mock_label_repository.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T030** [US4] [TDD] Implement in-memory mock LabelRepository
|
||||
- [ ] **T038** [US4] [TDD] Implement in-memory mock LabelRepository
|
||||
- HashMap-based implementation
|
||||
- **File**: src/infrastructure/persistence/mock_label_repository.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T031** [US3] [TDD] Write tests for HealthMonitor service
|
||||
- [ ] **T039** [US3] [TDD] Write tests for HealthMonitor service
|
||||
- Test: track_success() transitions Degraded → Healthy
|
||||
- Test: track_failure() transitions Healthy → Degraded → Unhealthy
|
||||
- **File**: src/application/health_monitor.rs
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T032** [US3] [TDD] Implement HealthMonitor service
|
||||
- [ ] **T040** [US3] [TDD] Implement HealthMonitor service
|
||||
- Track consecutive errors, transition states per FR-020, FR-021
|
||||
- **File**: src/application/health_monitor.rs
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
@@ -443,36 +570,36 @@
|
||||
|
||||
### Application Layer
|
||||
|
||||
- [ ] **T033** [US1] [TDD] Write tests for ToggleRelayUseCase
|
||||
- [ ] **T041** [US1] [TDD] Write tests for ToggleRelayUseCase
|
||||
- Test: execute(RelayId(1)) toggles relay state via controller
|
||||
- Test: execute() returns error if controller fails
|
||||
- **File**: src/application/use_cases/toggle_relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T034** [US1] [TDD] Implement ToggleRelayUseCase
|
||||
- [ ] **T042** [US1] [TDD] Implement ToggleRelayUseCase
|
||||
- Orchestrate: read current state → toggle → write new state
|
||||
- **File**: src/application/use_cases/toggle_relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T035** [P] [US1] [TDD] Write tests for GetAllRelaysUseCase
|
||||
- [ ] **T043** [P] [US1] [TDD] Write tests for GetAllRelaysUseCase
|
||||
- Test: execute() returns all 8 relays with states
|
||||
- **File**: src/application/use_cases/get_all_relays.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T036** [P] [US1] [TDD] Implement GetAllRelaysUseCase
|
||||
- [ ] **T044** [P] [US1] [TDD] Implement GetAllRelaysUseCase
|
||||
- Call controller.read_all(), map to domain Relay objects
|
||||
- **File**: src/application/use_cases/get_all_relays.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
### Presentation Layer (Backend API)
|
||||
|
||||
- [ ] **T037** [US1] [TDD] Define RelayDto in presentation layer
|
||||
- [ ] **T045** [US1] [TDD] Define RelayDto in presentation layer
|
||||
- Fields: id (u8), state ("on"/"off"), label (Option<String>)
|
||||
- Implement From<Relay> for RelayDto
|
||||
- **File**: src/presentation/dto/relay_dto.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T038** [US1] [TDD] Define API error responses
|
||||
- [ ] **T046** [US1] [TDD] Define API error responses
|
||||
- ApiError enum with status codes and messages
|
||||
- Implement poem::error::ResponseError
|
||||
- **File**: src/presentation/error.rs
|
||||
@@ -627,26 +754,26 @@
|
||||
|
||||
---
|
||||
|
||||
- [ ] **T040** [US1] [TDD] Write contract tests for GET /api/relays
|
||||
- [ ] **T048** [US1] [TDD] Write contract tests for GET /api/relays
|
||||
- Test: Returns 200 with array of 8 RelayDto
|
||||
- Test: Each relay has id 1-8, state, and optional label
|
||||
- **File**: tests/contract/test_relay_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T041** [US1] [TDD] Implement GET /api/relays endpoint
|
||||
- [ ] **T049** [US1] [TDD] Implement GET /api/relays endpoint
|
||||
- #[oai(path = "/relays", method = "get")]
|
||||
- Call GetAllRelaysUseCase, map to RelayDto
|
||||
- **File**: src/presentation/api/relay_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T042** [US1] [TDD] Write contract tests for POST /api/relays/{id}/toggle
|
||||
- [ ] **T050** [US1] [TDD] Write contract tests for POST /api/relays/{id}/toggle
|
||||
- Test: Returns 200 with updated RelayDto
|
||||
- Test: Returns 404 for id < 1 or id > 8
|
||||
- Test: State actually changes in controller
|
||||
- **File**: tests/contract/test_relay_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T043** [US1] [TDD] Implement POST /api/relays/{id}/toggle endpoint
|
||||
- [ ] **T051** [US1] [TDD] Implement POST /api/relays/{id}/toggle endpoint
|
||||
- #[oai(path = "/relays/:id/toggle", method = "post")]
|
||||
- Parse id, call ToggleRelayUseCase, return updated state
|
||||
- **File**: src/presentation/api/relay_api.rs
|
||||
@@ -654,12 +781,12 @@
|
||||
|
||||
### Frontend Implementation
|
||||
|
||||
- [ ] **T044** [P] [US1] [TDD] Create RelayDto TypeScript interface
|
||||
- [ ] **T052** [P] [US1] [TDD] Create RelayDto TypeScript interface
|
||||
- Generate from OpenAPI spec or manually define
|
||||
- **File**: frontend/src/types/relay.ts
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T045** [P] [US1] [TDD] Create API client service
|
||||
- [ ] **T053** [P] [US1] [TDD] Create API client service
|
||||
- getAllRelays(): Promise<RelayDto[]>
|
||||
- toggleRelay(id: number): Promise<RelayDto>
|
||||
- **File**: frontend/src/api/relayApi.ts
|
||||
@@ -816,14 +943,14 @@
|
||||
|
||||
---
|
||||
|
||||
- [ ] **T047** [US1] [TDD] Create RelayCard component
|
||||
- [ ] **T055** [US1] [TDD] Create RelayCard component
|
||||
- Props: relay (RelayDto)
|
||||
- Display relay ID, state, label
|
||||
- Emit toggle event on button click
|
||||
- **File**: frontend/src/components/RelayCard.vue
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T048** [US1] [TDD] Create RelayGrid component
|
||||
- [ ] **T056** [US1] [TDD] Create RelayGrid component
|
||||
- Use useRelayPolling composable
|
||||
- Render 8 RelayCard components
|
||||
- Handle toggle events by calling API
|
||||
@@ -831,7 +958,7 @@
|
||||
- **File**: frontend/src/components/RelayGrid.vue
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T049** [US1] [TDD] Integration test for US1
|
||||
- [ ] **T057** [US1] [TDD] Integration test for US1
|
||||
- End-to-end test: Load page → see 8 relays → toggle relay 1 → verify state change
|
||||
- Use Playwright or Cypress
|
||||
- **File**: frontend/tests/e2e/relay-control.spec.ts
|
||||
@@ -847,49 +974,49 @@
|
||||
|
||||
**Independent Test**: POST /api/relays/all/on turns all 8 relays on
|
||||
|
||||
- [ ] **T050** [US2] [TDD] Write tests for BulkControlUseCase
|
||||
- [ ] **T058** [US2] [TDD] Write tests for BulkControlUseCase
|
||||
- Test: execute(BulkOperation::AllOn) turns all relays on
|
||||
- Test: execute(BulkOperation::AllOff) turns all relays off
|
||||
- **File**: src/application/use_cases/bulk_control.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T051** [US2] [TDD] Implement BulkControlUseCase
|
||||
- [ ] **T059** [US2] [TDD] Implement BulkControlUseCase
|
||||
- Call controller.write_all(state)
|
||||
- **File**: src/application/use_cases/bulk_control.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T052** [US2] [TDD] Define BulkOperation enum
|
||||
- [ ] **T060** [US2] [TDD] Define BulkOperation enum
|
||||
- Variants: AllOn, AllOff
|
||||
- **File**: src/domain/relay.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T053** [US2] [TDD] Write contract tests for POST /api/relays/all/on
|
||||
- [ ] **T061** [US2] [TDD] Write contract tests for POST /api/relays/all/on
|
||||
- Test: Returns 200, all relays turn on
|
||||
- **File**: tests/contract/test_relay_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T054** [US2] [TDD] Implement POST /api/relays/all/on endpoint
|
||||
- [ ] **T062** [US2] [TDD] Implement POST /api/relays/all/on endpoint
|
||||
- Call BulkControlUseCase with AllOn
|
||||
- **File**: src/presentation/api/relay_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T055** [P] [US2] [TDD] Write contract tests for POST /api/relays/all/off
|
||||
- [ ] **T063** [P] [US2] [TDD] Write contract tests for POST /api/relays/all/off
|
||||
- Test: Returns 200, all relays turn off
|
||||
- **File**: tests/contract/test_relay_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T056** [P] [US2] [TDD] Implement POST /api/relays/all/off endpoint
|
||||
- [ ] **T064** [P] [US2] [TDD] Implement POST /api/relays/all/off endpoint
|
||||
- Call BulkControlUseCase with AllOff
|
||||
- **File**: src/presentation/api/relay_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T057** [US2] [TDD] Add bulk control buttons to frontend
|
||||
- [ ] **T065** [US2] [TDD] Add bulk control buttons to frontend
|
||||
- Add "All On" and "All Off" buttons to RelayGrid component
|
||||
- Call API endpoints and refresh relay states
|
||||
- **File**: frontend/src/components/RelayGrid.vue
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T058** [US2] [TDD] Integration test for US2
|
||||
- [ ] **T066** [US2] [TDD] Integration test for US2
|
||||
- Click "All On" → verify all 8 relays turn on
|
||||
- Click "All Off" → verify all 8 relays turn off
|
||||
- **File**: frontend/tests/e2e/bulk-control.spec.ts
|
||||
@@ -905,47 +1032,47 @@
|
||||
|
||||
**Independent Test**: GET /api/health returns health status
|
||||
|
||||
- [ ] **T059** [US3] [TDD] Write tests for GetHealthUseCase
|
||||
- [ ] **T067** [US3] [TDD] Write tests for GetHealthUseCase
|
||||
- Test: Returns Healthy when controller is responsive
|
||||
- Test: Returns Degraded after 3 consecutive errors
|
||||
- Test: Returns Unhealthy after 10 consecutive errors
|
||||
- **File**: src/application/use_cases/get_health.rs
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T060** [US3] [TDD] Implement GetHealthUseCase
|
||||
- [ ] **T068** [US3] [TDD] Implement GetHealthUseCase
|
||||
- Use HealthMonitor to track controller status
|
||||
- Return current HealthStatus
|
||||
- **File**: src/application/use_cases/get_health.rs
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T061** [US3] [TDD] Define HealthDto
|
||||
- [ ] **T069** [US3] [TDD] Define HealthDto
|
||||
- Fields: status ("healthy"/"degraded"/"unhealthy"), consecutive_errors (optional), reason (optional)
|
||||
- **File**: src/presentation/dto/health_dto.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T062** [US3] [TDD] Write contract tests for GET /api/health
|
||||
- [ ] **T070** [US3] [TDD] Write contract tests for GET /api/health
|
||||
- Test: Returns 200 with HealthDto
|
||||
- **File**: tests/contract/test_health_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T063** [US3] [TDD] Implement GET /api/health endpoint
|
||||
- [ ] **T071** [US3] [TDD] Implement GET /api/health endpoint
|
||||
- Call GetHealthUseCase, map to HealthDto
|
||||
- **File**: src/presentation/api/health_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T064** [P] [US3] [TDD] Add firmware version display (optional)
|
||||
- [ ] **T072** [P] [US3] [TDD] Add firmware version display (optional)
|
||||
- If controller supports firmware_version(), display in UI
|
||||
- **File**: frontend/src/components/DeviceInfo.vue
|
||||
- **Complexity**: Low | **Uncertainty**: Medium
|
||||
- **Note**: Device may not support this feature
|
||||
|
||||
- [ ] **T065** [US3] [TDD] Create HealthIndicator component
|
||||
- [ ] **T073** [US3] [TDD] Create HealthIndicator component
|
||||
- Display connection status with color-coded indicator
|
||||
- Show firmware version if available
|
||||
- **File**: frontend/src/components/HealthIndicator.vue
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T066** [US3] [TDD] Integrate HealthIndicator in RelayGrid
|
||||
- [ ] **T074** [US3] [TDD] Integrate HealthIndicator in RelayGrid
|
||||
- Fetch health status in useRelayPolling composable
|
||||
- Pass to HealthIndicator component
|
||||
- **File**: frontend/src/components/RelayGrid.vue
|
||||
@@ -961,37 +1088,37 @@
|
||||
|
||||
**Independent Test**: PUT /api/relays/{id}/label sets label, GET /api/relays returns label
|
||||
|
||||
- [ ] **T067** [US4] [TDD] Write tests for SetLabelUseCase
|
||||
- [ ] **T075** [US4] [TDD] Write tests for SetLabelUseCase
|
||||
- Test: execute(RelayId(1), "Pump") sets label
|
||||
- Test: execute with empty label returns error
|
||||
- Test: execute with 51-char label returns error
|
||||
- **File**: src/application/use_cases/set_label.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T068** [US4] [TDD] Implement SetLabelUseCase
|
||||
- [ ] **T076** [US4] [TDD] Implement SetLabelUseCase
|
||||
- Validate label with RelayLabel::new()
|
||||
- Call label_repository.set_label()
|
||||
- **File**: src/application/use_cases/set_label.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T069** [US4] [TDD] Write contract tests for PUT /api/relays/{id}/label
|
||||
- [ ] **T077** [US4] [TDD] Write contract tests for PUT /api/relays/{id}/label
|
||||
- Test: Returns 200, label is persisted
|
||||
- Test: Returns 400 for invalid label
|
||||
- **File**: tests/contract/test_relay_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T070** [US4] [TDD] Implement PUT /api/relays/{id}/label endpoint
|
||||
- [ ] **T078** [US4] [TDD] Implement PUT /api/relays/{id}/label endpoint
|
||||
- Parse id and label, call SetLabelUseCase
|
||||
- **File**: src/presentation/api/relay_api.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T071** [US4] [TDD] Add label editing to RelayCard component
|
||||
- [ ] **T079** [US4] [TDD] Add label editing to RelayCard component
|
||||
- Click label → show input field
|
||||
- Submit → call PUT /api/relays/{id}/label
|
||||
- **File**: frontend/src/components/RelayCard.vue
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T072** [US4] [TDD] Integration test for US4
|
||||
- [ ] **T080** [US4] [TDD] Integration test for US4
|
||||
- Set label for relay 1 → refresh → verify label persists
|
||||
- **File**: frontend/tests/e2e/relay-labeling.spec.ts
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
@@ -1004,56 +1131,56 @@
|
||||
|
||||
**Purpose**: Testing, documentation, and production readiness
|
||||
|
||||
- [ ] **T073** [P] Add comprehensive logging at all architectural boundaries
|
||||
- [ ] **T081** [P] Add comprehensive logging at all architectural boundaries
|
||||
- Log all API requests/responses
|
||||
- Log all Modbus operations
|
||||
- Log health status transitions
|
||||
- **Files**: All API and infrastructure modules
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T074** [P] Add OpenAPI documentation for all endpoints
|
||||
- [ ] **T082** [P] Add OpenAPI documentation for all endpoints
|
||||
- Document request/response schemas
|
||||
- Add example values
|
||||
- Tag endpoints appropriately
|
||||
- **File**: src/presentation/api/*.rs
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T075** [P] Run cargo clippy and fix all warnings
|
||||
- [ ] **T083** [P] Run cargo clippy and fix all warnings
|
||||
- Ensure compliance with strict linting
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T076** [P] Run cargo fmt and format all code
|
||||
- [ ] **T084** [P] Run cargo fmt and format all code
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T077** Generate test coverage report
|
||||
- [ ] **T085** Generate test coverage report
|
||||
- Run: just coverage
|
||||
- Ensure > 80% coverage for domain and application layers
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T078** [P] Run cargo audit for dependency vulnerabilities
|
||||
- [ ] **T086** [P] Run cargo audit for dependency vulnerabilities
|
||||
- Fix any high/critical vulnerabilities
|
||||
- **Complexity**: Low | **Uncertainty**: Medium
|
||||
|
||||
- [ ] **T079** [P] Update README.md with deployment instructions
|
||||
- [ ] **T087** [P] Update README.md with deployment instructions
|
||||
- Document environment variables
|
||||
- Document Modbus device configuration
|
||||
- Add quickstart guide
|
||||
- **File**: README.md
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T080** [P] Create Docker image for backend
|
||||
- [ ] **T088** [P] Create Docker image for backend
|
||||
- Multi-stage build with Rust
|
||||
- Include SQLite database setup
|
||||
- **File**: Dockerfile
|
||||
- **Complexity**: Medium | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T081** [P] Create production settings/production.yaml
|
||||
- [ ] **T089** [P] Create production settings/production.yaml
|
||||
- Configure for actual device IP
|
||||
- Set appropriate timeouts and retry settings
|
||||
- **File**: settings/production.yaml
|
||||
- **Complexity**: Low | **Uncertainty**: Low
|
||||
|
||||
- [ ] **T082** Deploy to production environment
|
||||
- [ ] **T090** Deploy to production environment
|
||||
- Test with actual Modbus relay device
|
||||
- Verify all user stories work end-to-end
|
||||
- **Complexity**: Medium | **Uncertainty**: High
|
||||
|
||||
Reference in New Issue
Block a user