Files
felhom.eu/hub/internal/hetznerapi/hetznerapi_test.go
T
admin 44ec06b50f hub v0.37.0: offsite provisioning SLICE 1 — Cloud-API client + provisioning core
Hetzner storage-box provisioning against api.hetzner.com/v1 (NOT .cloud).
internal/hetznerapi (typed client + CloudAPI interface + Fake + WaitAction);
internal/offsite (Provisioner.ProvisionOffsite — idempotent by label, shared
sub-account/dedicated box, transient password, non-secret Descriptor,
fail-closed); one_time_secrets store (single-use Save/Consume); POST
/offsite/consume-password/{id} (customer-key auth, once); config-form Offsite
section → applyOffsite (502+no-save on error) → descriptor in ConfigJSON →
version bump. Token/passwords never logged/committed/in ConfigJSON. Tested vs a
faked Cloud API + fail-closed red-proof. NOT yet live-provisioned (needs the
dedicated-project scoped token; current token can delete ep0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-09 18:38:24 +02:00

100 lines
3.5 KiB
Go

package hetznerapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// compile-time: both the real client and the fake satisfy CloudAPI.
var _ CloudAPI = (*Client)(nil)
var _ CloudAPI = (*Fake)(nil)
func newTestClient(t *testing.T, h http.Handler) (*Client, *httptest.Server) {
t.Helper()
srv := httptest.NewServer(h)
t.Cleanup(srv.Close)
c := NewClient(func() string { return "TESTTOKEN" })
c.BaseURL = srv.URL
c.PollEvery = 5 * time.Millisecond
c.PollMax = 2 * time.Second
return c, srv
}
// The create request carries the bearer + JSON, and the {action,subaccount} envelope decodes.
func TestClient_CreateSubaccount(t *testing.T) {
var gotAuth, gotBody string
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
buf := make([]byte, r.ContentLength)
r.Body.Read(buf)
gotBody = string(buf)
w.WriteHeader(201)
w.Write([]byte(`{"action":{"id":42,"command":"create_subaccount","status":"success"},"subaccount":{"id":268917,"storage_box":611421}}`))
}))
id, act, err := c.CreateSubaccount(context.Background(), 611421, CreateSubaccountRequest{HomeDirectory: "spike-sub", Password: "Xx1%", AccessSettings: AccessSettings{SSHEnabled: true}})
if err != nil {
t.Fatal(err)
}
if gotAuth != "Bearer TESTTOKEN" {
t.Errorf("bearer not set, got %q", gotAuth)
}
if !strings.Contains(gotBody, `"home_directory":"spike-sub"`) {
t.Errorf("request body missing home_directory: %s", gotBody)
}
if id != 268917 || act.ID != 42 || act.Status != "success" {
t.Fatalf("got id=%d action=%+v", id, act)
}
}
// A non-2xx surfaces the API's error code/message (the 422 password shape).
func TestClient_ErrorEnvelope(t *testing.T) {
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(422)
w.Write([]byte(`{"error":{"code":"invalid_input","message":"invalid input in field password"}}`))
}))
_, _, err := c.CreateSubaccount(context.Background(), 1, CreateSubaccountRequest{})
if err == nil || !strings.Contains(err.Error(), "invalid_input") {
t.Fatalf("want invalid_input error, got %v", err)
}
}
// WaitAction: an already-success action returns immediately; running→success polls; error fails.
func TestClient_WaitAction(t *testing.T) {
if err := (&Client{}).WaitAction(context.Background(), Action{Status: "success"}); err != nil {
t.Fatalf("terminal success must return nil, got %v", err)
}
if err := (&Client{}).WaitAction(context.Background(), Action{ID: 9, Command: "x", Status: "error"}); err == nil {
t.Fatal("terminal error must return an error")
}
calls := 0
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
st := "running"
if calls >= 3 {
st = "success"
}
w.Write([]byte(`{"action":{"id":7,"command":"create","status":"` + st + `"}}`))
}))
if err := c.WaitAction(context.Background(), Action{ID: 7, Command: "create", Status: "running"}); err != nil {
t.Fatalf("running→success must resolve, got %v", err)
}
if calls < 3 {
t.Fatalf("expected polling, only %d calls", calls)
}
}
func TestClient_WaitActionTimeout(t *testing.T) {
c, _ := newTestClient(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"action":{"id":7,"status":"running"}}`))
}))
c.PollMax = 30 * time.Millisecond
if err := c.WaitAction(context.Background(), Action{ID: 7, Status: "running"}); err == nil || !strings.Contains(err.Error(), "did not reach success") {
t.Fatalf("want timeout error, got %v", err)
}
}