Files
felhom.eu/hub/internal/offsite/offsite_test.go
T
admin 229650b4ee hub v0.38.0: offsite SLICE 2 — capture the box host-key fingerprint
Descriptor.HostFingerprint (SHA256, non-secret), captured at provision via an
x/crypto/ssh keyscan (SSHHostKeyScanner — dials :23, grabs the host key from the
handshake, no ssh binary). Fail-closed: nil scanner or scan failure → error (don't
serve a descriptor the controller can't verify). Pairs with controller v0.106.0
which re-scans + refuses on mismatch (no blind TOFU).

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

187 lines
6.8 KiB
Go

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, Scanner: &fakeScanner{fp: "SHA256:testfp"}, PoolBoxID: 611421, Location: "fsn1", Logger: log.New(io.Discard, "", 0)}, fake, st
}
// fakeScanner returns a fixed fingerprint (or an error) — no live SSH in tests.
type fakeScanner struct {
fp string
err error
}
func (f *fakeScanner) Fingerprint(_ context.Context, _ string, _ int) (string, error) {
return f.fp, f.err
}
// 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)
}
}
// Part 0 — the descriptor carries the box host-key fingerprint (captured at provision).
func TestProvision_HostFingerprint(t *testing.T) {
p, _, _ := newTestProvisioner(t)
d, err := p.ProvisionOffsite(context.Background(), "cust-fp", Input{Enabled: true, Type: "shared", QuotaGB: 10})
if err != nil {
t.Fatal(err)
}
if d.HostFingerprint != "SHA256:testfp" {
t.Fatalf("descriptor must carry the host fingerprint, got %q", d.HostFingerprint)
}
}
// Part 0 — a host-key scan failure is fail-closed (no descriptor served).
func TestProvision_ScanFailClosed(t *testing.T) {
p, _, st := newTestProvisioner(t)
p.Scanner = &fakeScanner{err: errors.New("keyscan timeout")}
d, err := p.ProvisionOffsite(context.Background(), "cust-sf", Input{Enabled: true, Type: "shared", QuotaGB: 10})
if err == nil || d != nil {
t.Fatalf("a keyscan failure must fail-closed, got d=%+v err=%v", d, err)
}
// the resource may have been created + password stored, but no verifiable descriptor is served
_ = st
}
// 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)")
}
}