package api import ( "encoding/base64" "encoding/json" "net/http" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // Recovery-mode arm is global-key-only; re-enroll is REFUSED unless recovery mode is active. func TestReEnroll_GatedOnRecoveryMode(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "OLDKEY") // Re-enroll with recovery mode OFF → 403. if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"NEWKEY"}`); rr.Code != http.StatusForbidden { t.Fatalf("re-enroll without recovery mode = %d, want 403", rr.Code) } // Arm recovery mode requires the global key (per-host key refused). if rr := do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", "OLDKEY", `{"ttl_seconds":600}`); rr.Code != http.StatusForbidden { t.Errorf("per-host arm recovery = %d, want 403", rr.Code) } if rr := do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`); rr.Code != http.StatusOK { t.Fatalf("global arm recovery = %d, want 200", rr.Code) } // Now re-enroll succeeds, rotates the credential, returns the directive. rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"NEWKEY"}`) if rr.Code != http.StatusOK { t.Fatalf("re-enroll in recovery mode = %d body=%s", rr.Code, rr.Body.String()) } var resp struct { APIKeyRotated bool `json:"api_key_rotated"` NewAPIKey string `json:"new_api_key"` } json.Unmarshal(rr.Body.Bytes(), &resp) if !resp.APIKeyRotated || resp.NewAPIKey != "NEWKEY" { t.Errorf("re-enroll resp = %+v, want rotated NEWKEY", resp) } } // Re-enroll ROTATES the hub credential: the old key no longer authenticates; the new one does. func TestReEnroll_RevokesOldKey(t *testing.T) { h, st, _ := newTestHandler(t) st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"}) seedHost(t, st, "h1", "c1", "OLDKEY") do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`) // Before: the OLD key authenticates a host-report. if rr := do(h, http.MethodPost, "/host-report", "OLDKEY", validReportBody("h1")); rr.Code != 200 { t.Fatalf("pre-rotate host-report with OLD key = %d, want 200", rr.Code) } // Re-enroll → rotate to NEWKEY. if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"NEWKEY"}`); rr.Code != 200 { t.Fatalf("re-enroll = %d", rr.Code) } // After: the OLD key is REVOKED (401), the NEW key works. if rr := do(h, http.MethodPost, "/host-report", "OLDKEY", validReportBody("h1")); rr.Code != http.StatusUnauthorized { t.Errorf("post-rotate OLD key = %d, want 401 (revoked)", rr.Code) } if rr := do(h, http.MethodPost, "/host-report", "NEWKEY", validReportBody("h1")); rr.Code != 200 { t.Errorf("post-rotate NEW key = %d, want 200", rr.Code) } } // The restore directive (the opaque blobs) is served ONLY in recovery mode, and expires. func TestRestoreDirective_GatedAndExpires(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY") // Seed a DR bundle: K-escrow row + identity blob + directive. st.SaveHostEscrow("h1", []byte("opaque-K-escrow"), "01:36:e9:…", "zero_knowledge", time.Now().UTC().Format(time.RFC3339)) st.SaveHostDRBundle("h1", []byte("opaque-identity"), `{"pbs_repo":"r","tunnel_id":"t","expected_key_fingerprint":"01:36:e9:…"}`) // Not in recovery mode → 403. if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusForbidden { t.Fatalf("directive without recovery mode = %d, want 403", rr.Code) } // Arm recovery mode → served, with both opaque blobs + directive. do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`) rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", "") if rr.Code != 200 { t.Fatalf("directive in recovery mode = %d", rr.Code) } var d struct { KEscrowB64 string `json:"k_escrow_b64"` IdentityEscrowB64 string `json:"identity_escrow_b64"` Directive json.RawMessage `json:"directive"` } json.Unmarshal(rr.Body.Bytes(), &d) kb, _ := base64.StdEncoding.DecodeString(d.KEscrowB64) ib, _ := base64.StdEncoding.DecodeString(d.IdentityEscrowB64) if string(kb) != "opaque-K-escrow" || string(ib) != "opaque-identity" { t.Errorf("served blobs wrong: K=%q identity=%q", kb, ib) } // Simulate EXPIRY: set recovery_mode_until in the past → directive refused again. st.SetRecoveryMode("h1", time.Now().UTC().Add(-time.Minute)) if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusForbidden { t.Errorf("expired recovery mode directive = %d, want 403", rr.Code) } } // Clearing recovery mode (global key) disables re-enroll. func TestRecoveryMode_Clear(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY") do(h, http.MethodPut, "/admin/hosts/h1/recovery-mode", globalKey, `{"ttl_seconds":600}`) if rr := do(h, http.MethodDelete, "/admin/hosts/h1/recovery-mode", globalKey, ""); rr.Code != 200 { t.Fatalf("clear recovery = %d", rr.Code) } if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "", `{"new_api_key":"X"}`); rr.Code != http.StatusForbidden { t.Errorf("re-enroll after clear = %d, want 403", rr.Code) } }