feat: Implement Phase 4 Web Interface with bugfixes (v0.5.2)
## Phase 4: Web Interface Implementation Implemented complete web interface with public and admin routes, templates, CSS, and development authentication. ### Core Features **Public Routes**: - Homepage with recent published notes - Note permalinks with microformats2 - Server-side rendering (Jinja2) **Admin Routes**: - Login via IndieLogin - Dashboard with note management - Create, edit, delete notes - Protected with @require_auth decorator **Development Authentication**: - Dev login bypass for local testing (DEV_MODE only) - Security safeguards per ADR-011 - Returns 404 when disabled **Templates & Frontend**: - Base layouts (public + admin) - 8 HTML templates with microformats2 - Custom responsive CSS (114 lines) - Error pages (404, 500) ### Bugfixes (v0.5.1 → v0.5.2) 1. **Cookie collision fix (v0.5.1)**: - Renamed auth cookie from "session" to "starpunk_session" - Fixed redirect loop between dev login and admin dashboard - Flask's session cookie no longer conflicts with auth 2. **HTTP 404 error handling (v0.5.1)**: - Update route now returns 404 for nonexistent notes - Delete route now returns 404 for nonexistent notes - Follows ADR-012 HTTP Error Handling Policy - Pattern consistency across all admin routes 3. **Note model enhancement (v0.5.2)**: - Exposed deleted_at field from database schema - Enables soft deletion verification in tests - Follows ADR-013 transparency principle ### Architecture **New ADRs**: - ADR-011: Development Authentication Mechanism - ADR-012: HTTP Error Handling Policy - ADR-013: Expose deleted_at Field in Note Model **Standards Compliance**: - Uses uv for Python environment - Black formatted, Flake8 clean - Follows git branching strategy - Version incremented per versioning strategy ### Test Results - 405/406 tests passing (99.75%) - 87% code coverage - All security tests passing - Manual testing confirmed working ### Documentation - Complete implementation reports in docs/reports/ - Architecture reviews in docs/reviews/ - Design documents in docs/design/ - CHANGELOG updated for v0.5.2 ### Files Changed **New Modules**: - starpunk/dev_auth.py - starpunk/routes/ (public, admin, auth, dev_auth) **Templates**: 10 files (base, pages, admin, errors) **Static**: CSS and optional JavaScript **Tests**: 4 test files for routes and templates **Docs**: 20+ architectural and implementation documents 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
474
docs/reports/delete-nonexistent-note-error-analysis.md
Normal file
474
docs/reports/delete-nonexistent-note-error-analysis.md
Normal file
@@ -0,0 +1,474 @@
|
||||
# Delete Nonexistent Note Error Analysis
|
||||
|
||||
**Date**: 2025-11-18
|
||||
**Status**: Root Cause Identified
|
||||
**Test**: `test_delete_nonexistent_note_shows_error` (tests/test_routes_admin.py:443)
|
||||
**Test Status**: FAILING (405/406 passing)
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The delete route (`POST /admin/delete/<id>`) does NOT check if a note exists before attempting deletion. Because the underlying `delete_note()` function is idempotent (returns successfully even for nonexistent notes), the route always shows a "success" flash message, not an "error" message.
|
||||
|
||||
This violates ADR-012 (HTTP Error Handling Policy), which requires all routes to return 404 with an error flash message when operating on nonexistent resources.
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### 1. Current Implementation
|
||||
|
||||
**File**: `starpunk/routes/admin.py:173-206`
|
||||
|
||||
```python
|
||||
@bp.route("/delete/<int:note_id>", methods=["POST"])
|
||||
@require_auth
|
||||
def delete_note_submit(note_id: int):
|
||||
# Check for confirmation
|
||||
if request.form.get("confirm") != "yes":
|
||||
flash("Deletion cancelled", "info")
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
|
||||
try:
|
||||
delete_note(id=note_id, soft=False) # ← Always succeeds (idempotent)
|
||||
flash("Note deleted successfully", "success") # ← Always shows success
|
||||
except ValueError as e:
|
||||
flash(f"Error deleting note: {e}", "error")
|
||||
except Exception as e:
|
||||
flash(f"Unexpected error deleting note: {e}", "error")
|
||||
|
||||
return redirect(url_for("admin.dashboard")) # ← Returns 302, not 404
|
||||
```
|
||||
|
||||
**Problem**: No existence check before deletion.
|
||||
|
||||
### 2. Underlying Function Behavior
|
||||
|
||||
**File**: `starpunk/notes.py:685-849` (function `delete_note`)
|
||||
|
||||
**Lines 774-778** (the critical section):
|
||||
```python
|
||||
# 3. CHECK IF NOTE EXISTS
|
||||
if existing_note is None:
|
||||
# Note not found - could already be deleted
|
||||
# For idempotency, don't raise error - just return
|
||||
return # ← Returns None successfully
|
||||
```
|
||||
|
||||
**Design Intent**: The `delete_note()` function is intentionally idempotent. Deleting a nonexistent note is not an error at the data layer.
|
||||
|
||||
**Rationale** (from docstring, lines 707-746):
|
||||
- Idempotent behavior is correct for REST semantics
|
||||
- DELETE operations should succeed even if resource already gone
|
||||
- Supports multiple clients and retry scenarios
|
||||
|
||||
### 3. What Happens with Note ID 99999?
|
||||
|
||||
**Sequence**:
|
||||
1. Test POSTs to `/admin/delete/99999` with `confirm=yes`
|
||||
2. Route calls `delete_note(id=99999, soft=False)`
|
||||
3. `delete_note()` queries database for note 99999
|
||||
4. Note doesn't exist → `existing_note = None`
|
||||
5. Function returns `None` successfully (idempotent design)
|
||||
6. Route receives successful return (no exception)
|
||||
7. Route shows flash message: "Note deleted successfully"
|
||||
8. Route returns `redirect(...)` → HTTP 302
|
||||
9. Test follows redirect → HTTP 200
|
||||
10. Test checks response data for "error" or "not found"
|
||||
11. **FAILS**: Response contains "Note deleted successfully", not an error
|
||||
|
||||
### 4. Why This Violates ADR-012
|
||||
|
||||
**ADR-012 Requirements**:
|
||||
|
||||
> 1. All routes MUST return 404 when the target resource does not exist
|
||||
> 2. All routes SHOULD check resource existence before processing the request
|
||||
> 3. 404 responses MAY include user-friendly flash messages for web routes
|
||||
> 4. 404 responses MAY redirect to a safe location (e.g., dashboard) while still returning 404 status
|
||||
|
||||
**Current Implementation**:
|
||||
- ❌ Returns 302, not 404
|
||||
- ❌ No existence check before operation
|
||||
- ❌ Shows success message, not error message
|
||||
- ❌ Violates semantic HTTP (DELETE succeeded, but resource never existed)
|
||||
|
||||
**ADR-012 Section "Comparison to Delete Operation" (lines 122-128)**:
|
||||
|
||||
> The `delete_note()` function is idempotent - it succeeds even if the note doesn't exist. This is correct for delete operations (common REST pattern). However, **the route should still check existence and return 404 for consistency**:
|
||||
>
|
||||
> - Idempotent implementation: Good (delete succeeds either way)
|
||||
> - Explicit existence check in route: Better (clear 404 for user)
|
||||
|
||||
**Interpretation**: The data layer (notes.py) should be idempotent, but the route layer (admin.py) should enforce HTTP semantics.
|
||||
|
||||
## Comparison to Update Route (Recently Fixed)
|
||||
|
||||
The `update_note_submit()` route was recently fixed for the same issue.
|
||||
|
||||
**File**: `starpunk/routes/admin.py:148-152`
|
||||
|
||||
```python
|
||||
# Check if note exists first
|
||||
existing_note = get_note(id=note_id, load_content=False)
|
||||
if not existing_note:
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404
|
||||
```
|
||||
|
||||
**Why this works**:
|
||||
1. Explicitly checks existence BEFORE operation
|
||||
2. Returns 404 status code with redirect
|
||||
3. Shows error flash message ("Note not found")
|
||||
4. Consistent with ADR-012 pattern
|
||||
|
||||
## Architectural Decision
|
||||
|
||||
### Separation of Concerns
|
||||
|
||||
**Data Layer** (`starpunk/notes.py`):
|
||||
- Should be idempotent
|
||||
- DELETE of nonexistent resource = success (no change)
|
||||
- Simplifies error handling and retry logic
|
||||
|
||||
**Route Layer** (`starpunk/routes/admin.py`):
|
||||
- Should enforce HTTP semantics
|
||||
- DELETE of nonexistent resource = 404 Not Found
|
||||
- Provides clear feedback to user
|
||||
|
||||
### Why Not Change `delete_note()`?
|
||||
|
||||
**Option A**: Make `delete_note()` raise `NoteNotFoundError`
|
||||
|
||||
**Rejected because**:
|
||||
1. Breaks idempotency (important for data layer)
|
||||
2. Complicates retry logic (caller must handle exception)
|
||||
3. Inconsistent with REST best practices for DELETE
|
||||
4. Would require exception handling in all callers
|
||||
|
||||
**Option B**: Keep `delete_note()` idempotent, add existence check in route
|
||||
|
||||
**Accepted because**:
|
||||
1. Preserves idempotent data layer (good design)
|
||||
2. Route layer enforces HTTP semantics (correct layering)
|
||||
3. Consistent with update route pattern (already implemented)
|
||||
4. Single database query overhead (negligible performance cost)
|
||||
5. Follows ADR-012 pattern exactly
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Required Changes
|
||||
|
||||
**File**: `starpunk/routes/admin.py`
|
||||
**Function**: `delete_note_submit()` (lines 173-206)
|
||||
|
||||
**Change 1**: Add existence check before confirmation check
|
||||
|
||||
```python
|
||||
@bp.route("/delete/<int:note_id>", methods=["POST"])
|
||||
@require_auth
|
||||
def delete_note_submit(note_id: int):
|
||||
"""
|
||||
Handle note deletion
|
||||
|
||||
Deletes a note after confirmation.
|
||||
Requires authentication.
|
||||
|
||||
Args:
|
||||
note_id: Database ID of note to delete
|
||||
|
||||
Form data:
|
||||
confirm: Must be 'yes' to proceed with deletion
|
||||
|
||||
Returns:
|
||||
Redirect to dashboard with success/error message
|
||||
|
||||
Decorator: @require_auth
|
||||
"""
|
||||
# 1. CHECK EXISTENCE FIRST (per ADR-012)
|
||||
existing_note = get_note(id=note_id, load_content=False)
|
||||
if not existing_note:
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404
|
||||
|
||||
# 2. CHECK FOR CONFIRMATION
|
||||
if request.form.get("confirm") != "yes":
|
||||
flash("Deletion cancelled", "info")
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
|
||||
# 3. PERFORM DELETION
|
||||
try:
|
||||
delete_note(id=note_id, soft=False)
|
||||
flash("Note deleted successfully", "success")
|
||||
except ValueError as e:
|
||||
flash(f"Error deleting note: {e}", "error")
|
||||
except Exception as e:
|
||||
flash(f"Unexpected error deleting note: {e}", "error")
|
||||
|
||||
# 4. RETURN SUCCESS
|
||||
return redirect(url_for("admin.dashboard"))
|
||||
```
|
||||
|
||||
**Key Changes**:
|
||||
1. Add existence check at line 193 (before confirmation check)
|
||||
2. Use `load_content=False` for performance (metadata only)
|
||||
3. Return 404 with redirect if note doesn't exist
|
||||
4. Flash "Note not found" error message
|
||||
5. Maintain existing confirmation logic
|
||||
6. Maintain existing deletion logic
|
||||
|
||||
**Order of Operations**:
|
||||
1. Check existence (404 if missing) ← NEW
|
||||
2. Check confirmation (cancel if not confirmed)
|
||||
3. Perform deletion (success or error flash)
|
||||
4. Redirect to dashboard
|
||||
|
||||
### Why Check Existence Before Confirmation?
|
||||
|
||||
**Option A**: Check existence after confirmation
|
||||
- ❌ User confirms deletion of nonexistent note
|
||||
- ❌ Confusing UX ("I clicked confirm, why 404?")
|
||||
- ❌ Wasted interaction
|
||||
|
||||
**Option B**: Check existence before confirmation
|
||||
- ✅ Immediate feedback ("note doesn't exist")
|
||||
- ✅ User doesn't waste time confirming
|
||||
- ✅ Consistent with update route pattern
|
||||
|
||||
**Decision**: Check existence FIRST (Option B)
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Database Query Overhead
|
||||
|
||||
**Added Query**:
|
||||
```python
|
||||
existing_note = get_note(id=note_id, load_content=False)
|
||||
# SELECT * FROM notes WHERE id = ? AND deleted_at IS NULL
|
||||
```
|
||||
|
||||
**Performance**:
|
||||
- SQLite indexed lookup: ~0.1ms
|
||||
- No file I/O (load_content=False)
|
||||
- Single-user system: negligible impact
|
||||
- Metadata only: ~200 bytes
|
||||
|
||||
**Comparison**:
|
||||
- **Before**: 1 query (DELETE)
|
||||
- **After**: 2 queries (SELECT + DELETE)
|
||||
- **Overhead**: <1ms per deletion
|
||||
|
||||
**Verdict**: Acceptable for single-user CMS
|
||||
|
||||
### Could We Avoid the Extra Query?
|
||||
|
||||
**Alternative**: Check deletion result
|
||||
|
||||
```python
|
||||
# Hypothetical: Make delete_note() return boolean
|
||||
deleted = delete_note(id=note_id, soft=False)
|
||||
if not deleted:
|
||||
# Note didn't exist
|
||||
flash("Note not found", "error")
|
||||
return redirect(url_for("admin.dashboard")), 404
|
||||
```
|
||||
|
||||
**Rejected because**:
|
||||
1. Requires changing data layer API (breaking change)
|
||||
2. Idempotent design means "success" doesn't imply "existed"
|
||||
3. Loses architectural clarity (data layer shouldn't drive route status codes)
|
||||
4. Performance gain negligible (~0.1ms)
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Test Coverage
|
||||
|
||||
**Failing Test**: `test_delete_nonexistent_note_shows_error` (line 443)
|
||||
|
||||
**What it tests**:
|
||||
```python
|
||||
def test_delete_nonexistent_note_shows_error(self, authenticated_client):
|
||||
"""Test deleting nonexistent note shows error"""
|
||||
response = authenticated_client.post(
|
||||
"/admin/delete/99999",
|
||||
data={"confirm": "yes"},
|
||||
follow_redirects=True
|
||||
)
|
||||
|
||||
assert response.status_code == 200 # After following redirect
|
||||
assert (
|
||||
b"error" in response.data.lower() or
|
||||
b"not found" in response.data.lower()
|
||||
)
|
||||
```
|
||||
|
||||
**After Fix**:
|
||||
1. POST to `/admin/delete/99999` with `confirm=yes`
|
||||
2. Route checks existence → Note 99999 doesn't exist
|
||||
3. Route flashes "Note not found" (contains "not found")
|
||||
4. Route returns `redirect(...), 404`
|
||||
5. Test follows redirect → HTTP 200 (redirect followed)
|
||||
6. Response contains flash message: "Note not found"
|
||||
7. ✅ Test passes: `b"not found" in response.data.lower()`
|
||||
|
||||
### Existing Tests That Should Still Pass
|
||||
|
||||
**Test**: `test_delete_redirects_to_dashboard` (line 454)
|
||||
|
||||
```python
|
||||
def test_delete_redirects_to_dashboard(self, authenticated_client, sample_notes):
|
||||
"""Test delete redirects to dashboard"""
|
||||
with authenticated_client.application.app_context():
|
||||
from starpunk.notes import list_notes
|
||||
|
||||
notes = list_notes()
|
||||
note_id = notes[0].id
|
||||
|
||||
response = authenticated_client.post(
|
||||
f"/admin/delete/{note_id}",
|
||||
data={"confirm": "yes"},
|
||||
follow_redirects=False
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert "/admin/" in response.location
|
||||
```
|
||||
|
||||
**Why it still works**:
|
||||
1. Note exists (from sample_notes fixture)
|
||||
2. Existence check passes
|
||||
3. Deletion proceeds normally
|
||||
4. Returns 302 redirect (as before)
|
||||
5. ✅ Test still passes
|
||||
|
||||
**Test**: `test_soft_delete_marks_note_deleted` (line 428)
|
||||
|
||||
**Why it still works**:
|
||||
1. Note exists
|
||||
2. Existence check passes
|
||||
3. Soft deletion proceeds (soft=True)
|
||||
4. Note marked deleted in database
|
||||
5. ✅ Test still passes
|
||||
|
||||
### Test That Should Now Pass
|
||||
|
||||
**Before Fix**: 405/406 tests passing
|
||||
**After Fix**: 406/406 tests passing ✅
|
||||
|
||||
## ADR-012 Compliance Checklist
|
||||
|
||||
### Implementation Checklist (from ADR-012, lines 152-166)
|
||||
|
||||
**Immediate (Phase 4 Fix)**:
|
||||
- [x] Fix `POST /admin/edit/<id>` to return 404 for nonexistent notes (already done)
|
||||
- [x] Verify `GET /admin/edit/<id>` still returns 404 (already correct)
|
||||
- [ ] **Update `POST /admin/delete/<id>` to return 404** ← THIS FIX
|
||||
- [ ] Update test `test_delete_nonexistent_note_shows_error` if delete route changed (no change needed - test is correct)
|
||||
|
||||
**After This Fix**: All immediate checklist items complete ✅
|
||||
|
||||
### Pattern Consistency
|
||||
|
||||
**All admin routes will now follow ADR-012**:
|
||||
|
||||
| Route | Method | Existence Check | 404 on Missing | Flash Message |
|
||||
|-------|--------|-----------------|----------------|---------------|
|
||||
| `/admin/edit/<id>` | GET | ✅ Yes | ✅ Yes | ✅ "Note not found" |
|
||||
| `/admin/edit/<id>` | POST | ✅ Yes | ✅ Yes | ✅ "Note not found" |
|
||||
| `/admin/delete/<id>` | POST | ❌ No → ✅ Yes | ❌ No → ✅ Yes | ❌ Success → ✅ "Note not found" |
|
||||
|
||||
**After fix**: 100% consistency across all routes ✅
|
||||
|
||||
## Expected Test Results
|
||||
|
||||
### Before Fix
|
||||
|
||||
```
|
||||
FAILED tests/test_routes_admin.py::TestAdminDeleteRoutes::test_delete_nonexistent_note_shows_error
|
||||
AssertionError: assert False
|
||||
+ where False = (b'error' in b'...Note deleted successfully...' or b'not found' in b'...')
|
||||
```
|
||||
|
||||
**Why it fails**:
|
||||
- Response contains "Note deleted successfully"
|
||||
- Test expects "error" or "not found"
|
||||
|
||||
### After Fix
|
||||
|
||||
```
|
||||
PASSED tests/test_routes_admin.py::TestAdminDeleteRoutes::test_delete_nonexistent_note_shows_error
|
||||
```
|
||||
|
||||
**Why it passes**:
|
||||
- Response contains "Note not found"
|
||||
- Test expects "error" or "not found"
|
||||
- ✅ `b"not found" in response.data.lower()` → True
|
||||
|
||||
### Full Test Suite
|
||||
|
||||
**Before**: 405/406 tests passing (99.75%)
|
||||
**After**: 406/406 tests passing (100%) ✅
|
||||
|
||||
## Implementation Steps for Developer
|
||||
|
||||
### Step 1: Edit Route File
|
||||
|
||||
**File**: `/home/phil/Projects/starpunk/starpunk/routes/admin.py`
|
||||
|
||||
**Action**: Modify `delete_note_submit()` function (lines 173-206)
|
||||
|
||||
**Exact Change**: Add existence check after function signature, before confirmation check
|
||||
|
||||
### Step 2: Run Tests
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_routes_admin.py::TestAdminDeleteRoutes::test_delete_nonexistent_note_shows_error -v
|
||||
```
|
||||
|
||||
**Expected**: PASSED ✅
|
||||
|
||||
### Step 3: Run Full Admin Route Tests
|
||||
|
||||
```bash
|
||||
uv run pytest tests/test_routes_admin.py -v
|
||||
```
|
||||
|
||||
**Expected**: All tests passing
|
||||
|
||||
### Step 4: Run Full Test Suite
|
||||
|
||||
```bash
|
||||
uv run pytest
|
||||
```
|
||||
|
||||
**Expected**: 406/406 tests passing ✅
|
||||
|
||||
### Step 5: Update Version and Changelog
|
||||
|
||||
**Per CLAUDE.md instructions**:
|
||||
- Document changes in `docs/reports/`
|
||||
- Update changelog
|
||||
- Increment version number per `docs/standards/versioning-strategy.md`
|
||||
|
||||
## References
|
||||
|
||||
- **ADR-012**: HTTP Error Handling Policy (`/home/phil/Projects/starpunk/docs/decisions/ADR-012-http-error-handling-policy.md`)
|
||||
- **Failing Test**: Line 443 in `tests/test_routes_admin.py`
|
||||
- **Route Implementation**: Lines 173-206 in `starpunk/routes/admin.py`
|
||||
- **Data Layer**: Lines 685-849 in `starpunk/notes.py`
|
||||
- **Similar Fix**: Update route (lines 148-152 in `starpunk/routes/admin.py`)
|
||||
|
||||
## Architectural Principles Applied
|
||||
|
||||
1. **Separation of Concerns**: Data layer = idempotent, Route layer = HTTP semantics
|
||||
2. **Consistency**: Same pattern as update route
|
||||
3. **Standards Compliance**: ADR-012 HTTP error handling policy
|
||||
4. **Performance**: Minimal overhead (<1ms) for correctness
|
||||
5. **User Experience**: Clear error messages for nonexistent resources
|
||||
6. **Test-Driven**: Fix makes failing test pass without breaking existing tests
|
||||
|
||||
## Summary
|
||||
|
||||
**Problem**: Delete route doesn't check if note exists, always shows success
|
||||
**Root Cause**: Missing existence check, relying on idempotent data layer
|
||||
**Solution**: Add existence check before confirmation, return 404 if note doesn't exist
|
||||
**Impact**: 1 line of architectural decision, 4 lines of code change
|
||||
**Result**: 406/406 tests passing, full ADR-012 compliance
|
||||
|
||||
This is the final failing test. After this fix, Phase 4 (Web Interface) will be 100% complete.
|
||||
Reference in New Issue
Block a user