44ec06b50f
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
100 lines
3.5 KiB
Go
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)
|
|
}
|
|
}
|