P1-34: e2e smoke runbook + redacted GET /repo-credentials

Adds docs/e2e-smoke.md — an ~5-minute runbook that walks the full
P1 happy path against a sibling restic/rest-server: bootstrap
admin, mint token with repo creds, enrol an agent, watch the
config.update push land, run a backup, confirm the snapshot, edit
creds and watch the second push fire. Per the design discussion
this is a runbook (not a Go integration test); the Playwright
version lands in P5-06.

GET /api/hosts/{id}/repo-credentials returns the redacted view —
{repo_url, repo_username, has_password} — so the UI can pre-fill
the edit form without ever pulling the password out of the AEAD
blob.

Marks P1-32 / P1-33 / P1-34 done in tasks.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 13:49:34 +01:00
parent 27086783da
commit 6cfbdfc7ab
4 changed files with 326 additions and 3 deletions
+47
View File
@@ -18,6 +18,53 @@ import (
func nowUTC() time.Time { return time.Now().UTC() }
// hostRepoCredsView is the body of GET /api/hosts/{id}/repo-credentials.
// Password is always redacted; the UI uses this to pre-fill an edit
// form with the URL/username already populated.
type hostRepoCredsView struct {
RepoURL string `json:"repo_url"`
RepoUsername string `json:"repo_username,omitempty"`
HasPassword bool `json:"has_password"`
}
// handleGetHostCredentials returns a redacted view of the host's repo
// creds for UI display. 404 if no credential has ever been set.
func (s *Server) handleGetHostCredentials(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if !s.authedUser(r) {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorized", "")
return
}
hostID := chi.URLParam(r, "id")
if hostID == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_id", "")
return
}
enc, err := s.deps.Store.GetHostCredentials(r.Context(), hostID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "not_set", "")
return
}
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
plain, err := s.deps.AEAD.Decrypt(enc, []byte("host:"+hostID))
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "decrypt_failed", "")
return
}
var blob repoCredsBlob
if err := json.Unmarshal(plain, &blob); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
writeJSON(w, stdhttp.StatusOK, hostRepoCredsView{
RepoURL: blob.RepoURL,
RepoUsername: blob.RepoUsername,
HasPassword: blob.RepoPassword != "",
})
}
// hostRepoCredsRequest is the body of PUT /api/hosts/{id}/repo-credentials.
// Operator can edit any subset; missing fields preserve the existing
// value (so changing only the password doesn't require resending the URL).
+2
View File
@@ -92,6 +92,8 @@ func (s *Server) routes(r chi.Router) {
// Repo credentials — operator can edit after enrollment. The
// initial set is supplied at token-mint time (see enrollment.go).
// GET returns a redacted view (URL, username, has_password).
r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
})