39ef64e128
Hub (v0.16.0):
- store: ArtifactManifest{agent,golden version+sha256} in hub_settings; Get/SetArtifactManifest.
- handler: GET /api/v1/artifacts/{id} (passphrase auth, mirrors config-retrieve). Unset => 200 empty.
- web: operator UI "Day-0 artifacts" card (POST /configs/artifacts), semver + 64-hex validation.
- artifact_test.go: returned-verbatim / unset-empty / 401 / 404 / store round-trip.
host-install (v1.1.0):
- new step 5/8 agent-install: manifest + git token (config-retrieve) -> fetch binary from Gitea ->
verify sha256 vs hub manifest (abort on mismatch) -> install non-root felhom-agent user + binary +
sudoers (visudo -cf) + canonical unit. Idempotent.
- new step 7/8 golden: local fallback else fetch+verify+import from Gitea (--force-gitea-golden).
- agent now runs non-root (privileged.mode sudo), config chowned to the service user.
- README prerequisites trimmed to: install PVE + create customer.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
118 lines
4.1 KiB
Go
118 lines
4.1 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
|
)
|
|
|
|
// doArtifacts GETs /artifacts/{id} with the passphrase header (the do() helper only sets Bearer).
|
|
func doArtifacts(h *Handler, customerID, pw string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/artifacts/"+customerID, nil)
|
|
if pw != "" {
|
|
req.Header.Set("X-Retrieval-Password", pw)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
h.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// Scenario A — a recorded manifest is returned verbatim.
|
|
func TestArtifacts_ReturnsRecordedSet(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
|
if err := st.SetArtifactManifest(store.ArtifactManifest{
|
|
AgentVersion: "0.43.0",
|
|
AgentSHA256: strings.Repeat("a", 64),
|
|
GoldenVersion: "0.85.1",
|
|
GoldenSHA256: strings.Repeat("b", 64),
|
|
}); err != nil {
|
|
t.Fatalf("SetArtifactManifest: %v", err)
|
|
}
|
|
|
|
rr := doArtifacts(h, "c1", "pass-phrase")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var got artifactManifestResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.Agent.Version != "0.43.0" || got.Agent.SHA256 != strings.Repeat("a", 64) {
|
|
t.Errorf("agent = %+v", got.Agent)
|
|
}
|
|
if got.Golden.Version != "0.85.1" || got.Golden.SHA256 != strings.Repeat("b", 64) {
|
|
t.Errorf("golden = %+v", got.Golden)
|
|
}
|
|
}
|
|
|
|
// Scenario B — an unset manifest returns 200 with empty fields (NOT an error). The script falls back
|
|
// to the local golden / fails clearly on a missing binary.
|
|
func TestArtifacts_EmptyWhenUnset(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
|
|
|
rr := doArtifacts(h, "c1", "pass-phrase")
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
var got artifactManifestResponse
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("decode: %v", err)
|
|
}
|
|
if got.Agent.Version != "" || got.Agent.SHA256 != "" || got.Golden.Version != "" || got.Golden.SHA256 != "" {
|
|
t.Errorf("expected all-empty manifest, got %+v", got)
|
|
}
|
|
}
|
|
|
|
// Scenario C — wrong passphrase is 401 (auth mirrors config-retrieve).
|
|
func TestArtifacts_WrongPassphrase(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
|
|
|
rr := doArtifacts(h, "c1", "wrong")
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// Scenario D — missing passphrase header is 401.
|
|
func TestArtifacts_MissingPassphrase(t *testing.T) {
|
|
h, st, _ := newTestHandler(t)
|
|
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
|
|
|
|
rr := doArtifacts(h, "c1", "")
|
|
if rr.Code != http.StatusUnauthorized {
|
|
t.Fatalf("status = %d, want 401, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// Scenario E — unknown customer is 404 (before any auth comparison leaks).
|
|
func TestArtifacts_UnknownCustomer(t *testing.T) {
|
|
h, _, _ := newTestHandler(t)
|
|
|
|
rr := doArtifacts(h, "nope", "whatever")
|
|
if rr.Code != http.StatusNotFound {
|
|
t.Fatalf("status = %d, want 404, body=%s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// Scenario F — store round-trip: a partial set (agent only) persists independently.
|
|
func TestArtifacts_PartialSetRoundTrips(t *testing.T) {
|
|
_, st, _ := newTestHandler(t)
|
|
if err := st.SetArtifactManifest(store.ArtifactManifest{AgentVersion: "0.43.0", AgentSHA256: strings.Repeat("c", 64)}); err != nil {
|
|
t.Fatalf("SetArtifactManifest: %v", err)
|
|
}
|
|
m := st.GetArtifactManifest()
|
|
if m.AgentVersion != "0.43.0" || m.AgentSHA256 != strings.Repeat("c", 64) {
|
|
t.Errorf("agent fields not round-tripped: %+v", m)
|
|
}
|
|
if m.GoldenVersion != "" || m.GoldenSHA256 != "" {
|
|
t.Errorf("golden fields should be empty: %+v", m)
|
|
}
|
|
}
|