Files
felhom.eu/hub/internal/hetznerapi/fake.go
T
admin 17cc67f7cd hub v0.39.0: offsite hardening — F4 credential re-issue, F2 scan retry, F5 save UX
F4: ReissueCredentials — explicit operator recovery for consumed-password
dead-ends; resets the labelled resource's password (exactly-1 guard,
red-proofed), stores a fresh one-time secret, bumps ConfigVersion.
New hetznerapi.ResetBoxPassword for the dedicated path.
F2: host-key scan retry-with-backoff (~60s ladder, red-proofed) — first
save survives fresh-subaccount DNS lag.
F5: config form disables submits + shows an in-flight notice (the re-click
bait that caused live F1).

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

201 lines
5.3 KiB
Go

package hetznerapi
import (
"context"
"fmt"
"sync"
)
// Fake is an in-memory CloudAPI for tests (no live Hetzner calls). It records created resources by label
// (so idempotency lookups work) and lets a test force a failure. Actions it returns are already-terminal
// (success unless FailCreate/FailAction is set), so WaitAction resolves without a network poll.
type Fake struct {
mu sync.Mutex
Subaccounts map[int64]Subaccount // by id
Boxes map[int64]StorageBox // by id
nextID int64
// Records of calls (for assertions).
CreatedSubaccounts int
CreatedBoxes int
ResetCalls int
BoxResetCalls int
DeletedSubaccounts int
DeletedBoxes int
// Failure injection.
FailCreate error // if set, CreateSubaccount/CreateStorageBox return this error
FailAction bool // if set, created actions come back status "error" (WaitAction fails)
}
// NewFake returns an empty Fake.
func NewFake() *Fake {
return &Fake{Subaccounts: map[int64]Subaccount{}, Boxes: map[int64]StorageBox{}, nextID: 1000}
}
func (f *Fake) newAction(cmd string) Action {
if f.FailAction {
a := Action{ID: f.nextID, Command: cmd, Status: "error"}
a.Error = &struct {
Code string `json:"code"`
Message string `json:"message"`
}{Code: "action_failed", Message: "injected action failure"}
f.nextID++
return a
}
a := Action{ID: f.nextID, Command: cmd, Status: "success", Progress: 100}
f.nextID++
return a
}
func labelMatch(labels map[string]string, selector string) bool {
if selector == "" {
return true
}
// minimal k=v matcher (the hub only uses single-key equality selectors)
for i := 0; i < len(selector); i++ {
if selector[i] == '=' {
k, v := selector[:i], selector[i+1:]
return labels[k] == v
}
}
return false
}
func (f *Fake) ListSubaccounts(_ context.Context, boxID int64, sel string) ([]Subaccount, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []Subaccount
for _, s := range f.Subaccounts {
if s.StorageBox == boxID && labelMatch(s.Labels, sel) {
out = append(out, s)
}
}
return out, nil
}
func (f *Fake) GetSubaccount(_ context.Context, _ int64, subID int64) (Subaccount, error) {
f.mu.Lock()
defer f.mu.Unlock()
s, ok := f.Subaccounts[subID]
if !ok {
return Subaccount{}, fmt.Errorf("hetznerapi(fake): subaccount %d not found", subID)
}
return s, nil
}
func (f *Fake) CreateSubaccount(_ context.Context, boxID int64, req CreateSubaccountRequest) (int64, Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.FailCreate != nil {
return 0, Action{}, f.FailCreate
}
f.CreatedSubaccounts++
id := f.nextID
f.nextID++
f.Subaccounts[id] = Subaccount{
ID: id, StorageBox: boxID,
Username: fmt.Sprintf("u629193-sub%d", id),
Server: fmt.Sprintf("u629193-sub%d.your-storagebox.de", id),
HomeDirectory: req.HomeDirectory,
AccessSettings: req.AccessSettings,
Labels: req.Labels,
}
return id, f.newAction("create_subaccount"), nil
}
func (f *Fake) ResetSubaccountPassword(_ context.Context, _, _ int64, _ string) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.ResetCalls++
return f.newAction("reset_subaccount_password"), nil
}
func (f *Fake) UpdateSubaccountAccess(_ context.Context, _, subID int64, as AccessSettings) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
if s, ok := f.Subaccounts[subID]; ok {
s.AccessSettings = as
f.Subaccounts[subID] = s
}
return f.newAction("update_access_settings"), nil
}
func (f *Fake) DeleteSubaccount(_ context.Context, _, subID int64) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.Subaccounts, subID)
f.DeletedSubaccounts++
return f.newAction("delete_subaccount"), nil
}
func (f *Fake) ListStorageBoxes(_ context.Context, sel string) ([]StorageBox, error) {
f.mu.Lock()
defer f.mu.Unlock()
var out []StorageBox
for _, b := range f.Boxes {
if labelMatch(b.Labels, sel) {
out = append(out, b)
}
}
return out, nil
}
func (f *Fake) GetStorageBox(_ context.Context, boxID int64) (StorageBox, error) {
f.mu.Lock()
defer f.mu.Unlock()
b, ok := f.Boxes[boxID]
if !ok {
return StorageBox{}, fmt.Errorf("hetznerapi(fake): box %d not found", boxID)
}
return b, nil
}
func (f *Fake) CreateStorageBox(_ context.Context, req CreateBoxRequest) (int64, Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.FailCreate != nil {
return 0, Action{}, f.FailCreate
}
f.CreatedBoxes++
id := f.nextID
f.nextID++
f.Boxes[id] = StorageBox{
ID: id, Name: req.Name, Status: "active",
Username: fmt.Sprintf("u6294%d", id),
Server: fmt.Sprintf("u6294%d.your-storagebox.de", id),
Labels: req.Labels,
}
return id, f.newAction("create_storage_box"), nil
}
func (f *Fake) ChangeType(_ context.Context, _ int64, _ string) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.newAction("change_type"), nil
}
func (f *Fake) ResetBoxPassword(_ context.Context, _ int64, _ string) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.BoxResetCalls++
return f.newAction("reset_password"), nil
}
func (f *Fake) DeleteStorageBox(_ context.Context, boxID int64) (Action, error) {
f.mu.Lock()
defer f.mu.Unlock()
delete(f.Boxes, boxID)
f.DeletedBoxes++
return f.newAction("delete_storage_box"), nil
}
// WaitAction resolves based on the (already-terminal) fake action status — no polling.
func (f *Fake) WaitAction(_ context.Context, a Action) error {
if a.Status == "error" {
return actionErr(a)
}
return nil
}