package offsite import ( "context" "database/sql" "encoding/json" "errors" "io" "log" "path/filepath" "strings" "testing" "gitea.dooplex.hu/admin/felhom-hub/internal/hetznerapi" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) func newTestProvisioner(t *testing.T) (*Provisioner, *hetznerapi.Fake, *store.Store) { t.Helper() st, err := store.New(filepath.Join(t.TempDir(), "off.db"), log.New(io.Discard, "", 0)) if err != nil { t.Fatal(err) } t.Cleanup(func() { st.Close() }) fake := hetznerapi.NewFake() return &Provisioner{API: fake, Store: st, PoolBoxID: 611421, Location: "fsn1", Logger: log.New(io.Discard, "", 0)}, fake, st } // Scenario A — enable shared → sub-account provisioned, descriptor built, one-time password stored (NOT in // ConfigJSON), password absent from the merged config. func TestProvision_Shared(t *testing.T) { p, fake, st := newTestProvisioner(t) d, err := p.ProvisionOffsite(context.Background(), "cust-a", Input{Enabled: true, Type: "shared", QuotaGB: 50}) if err != nil { t.Fatalf("provision: %v", err) } if fake.CreatedSubaccounts != 1 { t.Fatalf("want 1 subaccount created, got %d", fake.CreatedSubaccounts) } if d.Type != "shared" || d.Port != 23 || d.RepoPath != "/home/felhom-repo" || d.QuotaGB != 50 || d.User == "" || d.Host == "" { t.Fatalf("descriptor wrong: %+v", d) } // one-time password stored + is NOT the descriptor / config pw, err := st.ConsumeOneTimeSecret("cust-a") if err != nil || pw == "" { t.Fatalf("one-time password not stored: %v", err) } merged, err := MergeDescriptor(`{"git":{"token":"x"}}`, d) if err != nil { t.Fatal(err) } if strings.Contains(merged, pw) { t.Fatal("the transient password LEAKED into ConfigJSON") } if !strings.Contains(merged, `"offsite"`) || !strings.Contains(merged, `"git"`) { t.Fatalf("merge lost keys: %s", merged) } // descriptor struct has no password field at all db, _ := json.Marshal(d) if strings.Contains(strings.ToLower(string(db)), "password") { t.Fatalf("descriptor carries a password field: %s", db) } } // Scenario B — enable dedicated → box provisioned. func TestProvision_Dedicated(t *testing.T) { p, fake, st := newTestProvisioner(t) d, err := p.ProvisionOffsite(context.Background(), "cust-b", Input{Enabled: true, Type: "dedicated", BoxType: "bx11"}) if err != nil { t.Fatalf("provision: %v", err) } if fake.CreatedBoxes != 1 { t.Fatalf("want 1 box created, got %d", fake.CreatedBoxes) } if d.Type != "dedicated" || d.BoxType != "bx11" || d.User == "" || d.Host == "" || d.RepoPath != "/home/felhom-repo" { t.Fatalf("descriptor wrong: %+v", d) } if pw, err := st.ConsumeOneTimeSecret("cust-b"); err != nil || pw == "" { t.Fatalf("one-time password not stored: %v", err) } } // Scenario C — idempotent re-save does NOT create a second resource. func TestProvision_Idempotent(t *testing.T) { p, fake, _ := newTestProvisioner(t) if _, err := p.ProvisionOffsite(context.Background(), "cust-c", Input{Enabled: true, Type: "shared", QuotaGB: 20}); err != nil { t.Fatal(err) } d2, err := p.ProvisionOffsite(context.Background(), "cust-c", Input{Enabled: true, Type: "shared", QuotaGB: 20}) if err != nil { t.Fatal(err) } if fake.CreatedSubaccounts != 1 { t.Fatalf("re-provision created a SECOND resource (%d) — not idempotent", fake.CreatedSubaccounts) } if d2.User == "" || d2.Type != "shared" { t.Fatalf("idempotent descriptor wrong: %+v", d2) } } // Scenario D — a provisioning error surfaces; nothing is recorded (fail-closed). Companion: the caller must // not mark offsite provisioned — modelled here by asserting no descriptor + no stored password on error. func TestProvision_FailClosed(t *testing.T) { p, fake, st := newTestProvisioner(t) fake.FailCreate = errors.New("hetzner 500") d, err := p.ProvisionOffsite(context.Background(), "cust-d", Input{Enabled: true, Type: "shared", QuotaGB: 10}) if err == nil { t.Fatal("a create failure must return an error (fail-closed)") } if d != nil { t.Fatalf("no descriptor may be returned on error, got %+v", d) } if _, cerr := st.ConsumeOneTimeSecret("cust-d"); cerr != sql.ErrNoRows { t.Fatal("no one-time password may be stored on a failed provision") } // action-failure path (create ok, action errors) is also fail-closed fake.FailCreate = nil fake.FailAction = true if _, err := p.ProvisionOffsite(context.Background(), "cust-d2", Input{Enabled: true, Type: "dedicated", BoxType: "bx11"}); err == nil { t.Fatal("a failed create-action must return an error") } } // Scenario E — the one-time password is consumable exactly once. func TestOneTimeSecret_ConsumedOnce(t *testing.T) { _, _, st := newTestProvisioner(t) if err := st.SaveOneTimeSecret("cust-e", "secretpw"); err != nil { t.Fatal(err) } got, err := st.ConsumeOneTimeSecret("cust-e") if err != nil || got != "secretpw" { t.Fatalf("first consume: got %q err %v", got, err) } if _, err := st.ConsumeOneTimeSecret("cust-e"); err != sql.ErrNoRows { t.Fatalf("second consume must be ErrNoRows, got %v", err) } if _, err := st.ConsumeOneTimeSecret("never-provisioned"); err != sql.ErrNoRows { t.Fatalf("absent consume must be ErrNoRows, got %v", err) } } // Disable → {Enabled:false}, no deprovision (no API delete). func TestProvision_DisableNoDeprovision(t *testing.T) { p, fake, _ := newTestProvisioner(t) d, err := p.ProvisionOffsite(context.Background(), "cust-f", Input{Enabled: false}) if err != nil || d == nil || d.Enabled { t.Fatalf("disable must return {enabled:false}, got %+v err %v", d, err) } if fake.DeletedSubaccounts != 0 || fake.DeletedBoxes != 0 { t.Fatal("disable must NOT deprovision (data-loss guard)") } }