feat: dashboard PIN change flow
- Backend: Add POST /api/auth/change-pin endpoint
- Requires valid session; body: {old_pin, new_pin}
- Verifies old PIN against bcrypt hash; returns 403 on mismatch
- Hashes new PIN with bcrypt cost=12
- Existing sessions remain valid after PIN change
- Returns {ok:true} on success
- Dashboard: Security section in settings panel
- Add "Security" section with Change PIN button
- Modal form: old PIN → new PIN → confirm new PIN → Submit
- Inline error display for incorrect current PIN (403)
- Success toast notification on PIN change
- Validation: 4-8 digits, numeric only, PINs must match, new ≠ old
- Tests: Add comprehensive tests for change PIN endpoint
- Success case: old PIN verified, new PIN works
- Wrong old PIN: returns 403, original PIN still works
- Unauthenticated: returns 401
- Invalid new PIN: validation for length, digits, etc.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
93d3faceee
commit
3da0c32ba3
6 changed files with 731 additions and 3 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
68334a9b7894907da80e42e6ebdb3deb0d9fbee2
|
||||
747940f9328660c49cb3add730005dd542b5ad4f
|
||||
|
|
|
|||
|
|
@ -2198,3 +2198,166 @@
|
|||
font-size: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Change PIN Modal Styles
|
||||
============================================ */
|
||||
|
||||
.panel-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: panel-fade-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes panel-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.panel-modal {
|
||||
background: #1e1e3a;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: panel-slide-up 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes panel-slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95) translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-modal-header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 24px 16px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.panel-modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.panel-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-modal-close:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
.panel-modal-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.panel-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 6px;
|
||||
color: #eee;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.panel-input:focus {
|
||||
outline: none;
|
||||
border-color: #4fc3f7;
|
||||
box-shadow: 0 0 0 3px rgba(79, 195, 247, 0.15);
|
||||
}
|
||||
|
||||
.panel-input::placeholder {
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.panel-modal-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.panel-error {
|
||||
background: rgba(239, 83, 80, 0.15);
|
||||
border: 1px solid rgba(239, 83, 80, 0.3);
|
||||
border-radius: 6px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
color: #ef5350;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments for change PIN modal */
|
||||
@media (max-width: 480px) {
|
||||
.panel-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border-radius: 12px 12px 0 0;
|
||||
margin: 0 auto;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
|
||||
.panel-modal-overlay.visible .panel-modal {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.panel-modal-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.panel-modal-actions .panel-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,7 @@
|
|||
|
||||
content.innerHTML = `
|
||||
${renderDetectionSettings(settings)}
|
||||
${renderSecuritySettings(settings)}
|
||||
${renderNotificationSettings(settings)}
|
||||
${renderSystemInfo()}
|
||||
`;
|
||||
|
|
@ -199,6 +200,24 @@
|
|||
`;
|
||||
}
|
||||
|
||||
function renderSecuritySettings(settings) {
|
||||
return `
|
||||
<div class="panel-section">
|
||||
<div class="panel-section-header">Security</div>
|
||||
|
||||
<div class="panel-info-card">
|
||||
<div class="panel-info-card-title">Dashboard PIN</div>
|
||||
<div class="panel-info-card-value">Configured</div>
|
||||
<div class="panel-info-card-subtitle">Protects access to your dashboard</div>
|
||||
</div>
|
||||
|
||||
<button class="panel-btn panel-btn-secondary panel-btn-full" id="change-pin-btn">
|
||||
Change PIN
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNotificationSettings(settings) {
|
||||
const notificationChannels = settings.notification_channels || {};
|
||||
const ntfyEnabled = notificationChannels.ntfy && notificationChannels.ntfy.enabled;
|
||||
|
|
@ -395,6 +414,12 @@
|
|||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener('click', handleLogout);
|
||||
}
|
||||
|
||||
// Change PIN
|
||||
const changePinBtn = document.getElementById('change-pin-btn');
|
||||
if (changePinBtn) {
|
||||
changePinBtn.addEventListener('click', openChangePINModal);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -516,6 +541,224 @@
|
|||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Change PIN Modal
|
||||
// ============================================
|
||||
|
||||
const changePINState = {
|
||||
oldPin: '',
|
||||
newPin: '',
|
||||
confirmPin: '',
|
||||
error: '',
|
||||
isSubmitting: false
|
||||
};
|
||||
|
||||
function openChangePINModal() {
|
||||
changePINState.oldPin = '';
|
||||
changePINState.newPin = '';
|
||||
changePINState.confirmPin = '';
|
||||
changePINState.error = '';
|
||||
changePINState.isSubmitting = false;
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'change-pin-modal';
|
||||
modal.className = 'panel-modal-overlay';
|
||||
modal.innerHTML = renderChangePINModal();
|
||||
document.body.appendChild(modal);
|
||||
|
||||
attachChangePINEvents(modal);
|
||||
|
||||
// Focus first input
|
||||
setTimeout(function() {
|
||||
const firstInput = modal.querySelector('#change-pin-old');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 10);
|
||||
}
|
||||
|
||||
function closeChangePINModal() {
|
||||
const modal = document.getElementById('change-pin-modal');
|
||||
if (modal) {
|
||||
modal.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function renderChangePINModal() {
|
||||
return `
|
||||
<div class="panel-modal">
|
||||
<div class="panel-modal-header">
|
||||
<h2>Change PIN</h2>
|
||||
<button class="panel-modal-close" id="change-pin-close">×</button>
|
||||
</div>
|
||||
<div class="panel-modal-body">
|
||||
${changePINState.error ? `
|
||||
<div class="panel-error" id="change-pin-error">${escapeHtml(changePINState.error)}</div>
|
||||
` : ''}
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label for="change-pin-old">Current PIN</label>
|
||||
<input type="password" id="change-pin-old" class="panel-input"
|
||||
maxlength="8" placeholder="Enter current PIN" autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label for="change-pin-new">New PIN</label>
|
||||
<input type="password" id="change-pin-new" class="panel-input"
|
||||
maxlength="8" placeholder="Enter new PIN (4-8 digits)" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="panel-form-group">
|
||||
<label for="change-pin-confirm">Confirm New PIN</label>
|
||||
<input type="password" id="change-pin-confirm" class="panel-input"
|
||||
maxlength="8" placeholder="Confirm new PIN" autocomplete="new-password">
|
||||
</div>
|
||||
|
||||
<div class="panel-modal-actions">
|
||||
<button class="panel-btn panel-btn-secondary" id="change-pin-cancel" ${changePINState.isSubmitting ? 'disabled' : ''}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="panel-btn panel-btn-primary" id="change-pin-submit" ${changePINState.isSubmitting ? 'disabled' : ''}>
|
||||
${changePINState.isSubmitting ? 'Changing...' : 'Change PIN'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function attachChangePINEvents(modal) {
|
||||
// Close button
|
||||
const closeBtn = modal.querySelector('#change-pin-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', closeChangePINModal);
|
||||
}
|
||||
|
||||
// Cancel button
|
||||
const cancelBtn = modal.querySelector('#change-pin-cancel');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.addEventListener('click', closeChangePINModal);
|
||||
}
|
||||
|
||||
// Submit button
|
||||
const submitBtn = modal.querySelector('#change-pin-submit');
|
||||
if (submitBtn) {
|
||||
submitBtn.addEventListener('click', submitChangePIN);
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
modal.addEventListener('click', function(e) {
|
||||
if (e.target === modal) {
|
||||
closeChangePINModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle Enter key
|
||||
const inputs = modal.querySelectorAll('.panel-input');
|
||||
inputs.forEach(function(input) {
|
||||
input.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
submitChangePIN();
|
||||
}
|
||||
});
|
||||
|
||||
// Only allow digits
|
||||
input.addEventListener('input', function(e) {
|
||||
const value = e.target.value;
|
||||
if (!/^\d*$/.test(value)) {
|
||||
e.target.value = value.replace(/\D/g, '');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function submitChangePIN() {
|
||||
const oldPinInput = document.getElementById('change-pin-old');
|
||||
const newPinInput = document.getElementById('change-pin-new');
|
||||
const confirmPinInput = document.getElementById('change-pin-confirm');
|
||||
|
||||
if (!oldPinInput || !newPinInput || !confirmPinInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldPin = oldPinInput.value.trim();
|
||||
const newPin = newPinInput.value.trim();
|
||||
const confirmPin = confirmPinInput.value.trim();
|
||||
|
||||
// Validation
|
||||
if (!oldPin || oldPin.length < 4) {
|
||||
changePINState.error = 'Please enter your current PIN (4-8 digits)';
|
||||
updateChangePINModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!newPin || newPin.length < 4 || newPin.length > 8) {
|
||||
changePINState.error = 'New PIN must be 4-8 digits';
|
||||
updateChangePINModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPin !== confirmPin) {
|
||||
changePINState.error = 'New PINs do not match';
|
||||
updateChangePINModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldPin === newPin) {
|
||||
changePINState.error = 'New PIN must be different from current PIN';
|
||||
updateChangePINModal();
|
||||
return;
|
||||
}
|
||||
|
||||
changePINState.oldPin = oldPin;
|
||||
changePINState.newPin = newPin;
|
||||
changePINState.confirmPin = confirmPin;
|
||||
changePINState.error = '';
|
||||
changePINState.isSubmitting = true;
|
||||
updateChangePINModal();
|
||||
|
||||
// Send change PIN request
|
||||
fetch('/api/auth/change-pin', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
old_pin: oldPin,
|
||||
new_pin: newPin
|
||||
})
|
||||
})
|
||||
.then(function(res) {
|
||||
if (res.status === 403) {
|
||||
throw new Error('Incorrect current PIN');
|
||||
}
|
||||
if (!res.ok) {
|
||||
return res.text().then(function(text) {
|
||||
throw new Error(text || 'Failed to change PIN');
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
closeChangePINModal();
|
||||
SpaxelPanels.showSuccess('PIN changed successfully');
|
||||
})
|
||||
.catch(function(err) {
|
||||
changePINState.error = err.message || 'Failed to change PIN';
|
||||
changePINState.isSubmitting = false;
|
||||
updateChangePINModal();
|
||||
});
|
||||
}
|
||||
|
||||
function updateChangePINModal() {
|
||||
const modal = document.getElementById('change-pin-modal');
|
||||
if (!modal) return;
|
||||
|
||||
const modalContent = modal.querySelector('.panel-modal');
|
||||
if (modalContent) {
|
||||
modalContent.innerHTML = renderChangePINModal().match(/<div class="panel-modal">([\s\S]*)<\/div>/)[1];
|
||||
attachChangePINEvents(modal);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Panel Registration
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -168,6 +168,7 @@ func (h *Handler) RegisterRoutes(mux interface{ HandleFunc(pattern string, handl
|
|||
mux.HandleFunc("POST /api/auth/setup", h.handleSetup)
|
||||
mux.HandleFunc("POST /api/auth/login", h.handleLogin)
|
||||
mux.HandleFunc("POST /api/auth/logout", h.handleLogout)
|
||||
mux.HandleFunc("POST /api/auth/change-pin", h.RequireAuth(h.handleChangePIN))
|
||||
}
|
||||
|
||||
// handleStatus returns whether a PIN is configured.
|
||||
|
|
@ -386,6 +387,89 @@ func (h *Handler) handleLogout(w http.ResponseWriter, r *http.Request) {
|
|||
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
|
||||
}
|
||||
|
||||
// handleChangePIN changes the user's PIN.
|
||||
// Requires valid session (authenticated).
|
||||
func (h *Handler) handleChangePIN(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request
|
||||
var req struct {
|
||||
OldPIN string `json:"old_pin"`
|
||||
NewPIN string `json:"new_pin"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get current PIN hash
|
||||
var currentHash string
|
||||
err := h.db.QueryRow("SELECT pin_bcrypt FROM auth WHERE id = 1").Scan(¤tHash)
|
||||
if err != nil {
|
||||
http.Error(w, "Database error", http.StatusInternalServerError)
|
||||
log.Printf("[ERROR] Failed to get current PIN: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if currentHash == "" {
|
||||
http.Error(w, "PIN not configured", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify old PIN matches current hash
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(currentHash), []byte(req.OldPIN)); err != nil {
|
||||
// Old PIN doesn't match
|
||||
http.Error(w, "Incorrect current PIN", http.StatusForbidden)
|
||||
log.Printf("[WARN] Failed PIN change attempt from %s: incorrect old PIN", r.RemoteAddr)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate new PIN
|
||||
if len(req.NewPIN) < 4 || len(req.NewPIN) > 8 {
|
||||
http.Error(w, "PIN must be 4-8 digits", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure new PIN is numeric
|
||||
for _, c := range req.NewPIN {
|
||||
if c < '0' || c > '9' {
|
||||
http.Error(w, "PIN must contain only digits", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Hash new PIN with bcrypt (cost 12)
|
||||
newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPIN), 12)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to hash PIN", http.StatusInternalServerError)
|
||||
log.Printf("[ERROR] Failed to hash new PIN: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Update PIN in database
|
||||
_, err = h.db.Exec(`
|
||||
UPDATE auth
|
||||
SET pin_bcrypt = ?, updated_at = ?
|
||||
WHERE id = 1
|
||||
`, newHash, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to update PIN", http.StatusInternalServerError)
|
||||
log.Printf("[ERROR] Failed to update PIN: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] PIN changed successfully from %s", r.RemoteAddr)
|
||||
|
||||
// Note: Existing sessions remain valid after PIN change
|
||||
// (session tokens are independent of PIN)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"ok": "true"})
|
||||
}
|
||||
|
||||
// createSession creates a new session and returns the session ID.
|
||||
func (h *Handler) createSession() (string, error) {
|
||||
// Generate 32-byte random session ID (64 hex chars)
|
||||
|
|
|
|||
|
|
@ -724,3 +724,241 @@ func TestHandleInstallSecret_AfterPINSet_Authorized(t *testing.T) {
|
|||
t.Errorf("Expected 64-char hex secret, got %d chars", len(resp["install_secret"]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ChangePIN_Success(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
h, err := NewHandler(Config{DB: db})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
// Setup PIN first
|
||||
reqBody := `{"pin": "1234"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSetup(w, req)
|
||||
|
||||
// Get session cookie
|
||||
cookies := w.Result().Cookies()
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c.Name == "spaxel_session" {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("Session cookie not set")
|
||||
}
|
||||
|
||||
// Change PIN
|
||||
changeReqBody := `{"old_pin": "1234", "new_pin": "5678"}`
|
||||
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(sessionCookie)
|
||||
w = httptest.NewRecorder()
|
||||
h.handleChangePIN(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]string
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resp["ok"] != "true" {
|
||||
t.Error("Expected ok: true response")
|
||||
}
|
||||
|
||||
// Verify old PIN no longer works
|
||||
loginReqBody := `{"pin": "1234"}`
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
h.handleLogin(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected old PIN to be invalid (401), got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify new PIN works
|
||||
loginReqBody = `{"pin": "5678"}`
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
h.handleLogin(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected new PIN to work (200), got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify original session still works
|
||||
req = httptest.NewRequest("GET", "/api/test", nil)
|
||||
req.AddCookie(sessionCookie)
|
||||
if !h.IsAuthenticated(req) {
|
||||
t.Error("Expected original session to remain valid after PIN change")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ChangePIN_WrongOldPIN(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
h, err := NewHandler(Config{DB: db})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
// Setup PIN first
|
||||
reqBody := `{"pin": "1234"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSetup(w, req)
|
||||
|
||||
// Get session cookie
|
||||
cookies := w.Result().Cookies()
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c.Name == "spaxel_session" {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("Session cookie not set")
|
||||
}
|
||||
|
||||
// Try to change with wrong old PIN
|
||||
changeReqBody := `{"old_pin": "9999", "new_pin": "5678"}`
|
||||
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(sessionCookie)
|
||||
w = httptest.NewRecorder()
|
||||
h.handleChangePIN(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403 for wrong old PIN, got %d", w.Code)
|
||||
}
|
||||
|
||||
// Verify original PIN still works
|
||||
loginReqBody := `{"pin": "1234"}`
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(loginReqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
h.handleLogin(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected original PIN to still work after failed change, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ChangePIN_Unauthenticated(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
h, err := NewHandler(Config{DB: db})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
// Setup PIN first
|
||||
reqBody := `{"pin": "1234"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSetup(w, req)
|
||||
|
||||
// Try to change PIN without authentication (no cookie)
|
||||
changeReqBody := `{"old_pin": "1234", "new_pin": "5678"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w = httptest.NewRecorder()
|
||||
|
||||
// Use RequireAuth wrapper
|
||||
wrappedHandler := h.RequireAuth(h.handleChangePIN)
|
||||
wrappedHandler(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401 for unauthenticated request, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_ChangePIN_InvalidNewPIN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
oldPIN string
|
||||
newPIN string
|
||||
wantStatus int
|
||||
}{
|
||||
{"too short", "1234", "123", http.StatusBadRequest},
|
||||
{"too long", "1234", "123456789", http.StatusBadRequest},
|
||||
{"non-numeric", "1234", "abcd", http.StatusBadRequest},
|
||||
{"mixed", "1234", "12a4", http.StatusBadRequest},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
h, err := NewHandler(Config{DB: db})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer h.Close()
|
||||
|
||||
// Setup PIN first
|
||||
reqBody := `{"pin": "1234"}`
|
||||
req := httptest.NewRequest("POST", "/api/auth/setup", strings.NewReader(reqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
h.handleSetup(w, req)
|
||||
|
||||
// Get session cookie
|
||||
cookies := w.Result().Cookies()
|
||||
var sessionCookie *http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c.Name == "spaxel_session" {
|
||||
sessionCookie = c
|
||||
break
|
||||
}
|
||||
}
|
||||
if sessionCookie == nil {
|
||||
t.Fatal("Session cookie not set")
|
||||
}
|
||||
|
||||
// Try to change with invalid new PIN
|
||||
changeReqBody := `{"old_pin": "` + tt.oldPIN + `", "new_pin": "` + tt.newPIN + `"}`
|
||||
req = httptest.NewRequest("POST", "/api/auth/change-pin", strings.NewReader(changeReqBody))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.AddCookie(sessionCookie)
|
||||
w = httptest.NewRecorder()
|
||||
h.handleChangePIN(w, req)
|
||||
|
||||
if w.Code != tt.wantStatus {
|
||||
t.Errorf("Expected status %d, got %d", tt.wantStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue