a73x

internal/smoke/exposure_test.go

Ref:   Size: 7.1 KiB   History

package smoke

import (
	"context"
	"errors"
	"net"
	"strings"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/server/api/client"
)

// TestProveExposureReadsTheBannerBack also pins where the dial target comes
// from: the grant names its own address and port, so the leg dials what the
// fleet published rather than an address assembled from anything else (the
// fake's host listing is deliberately absent — asking it would panic).
func TestProveExposureReadsTheBannerBack(t *testing.T) {
	clock := &fakeClock{}
	deleted := ""
	api := &testAPI{
		createExposureFunc: func(ctx context.Context, vmID string, guest, host int64, proto string) (client.Exposure, error) {
			if vmID != "vm-1" {
				t.Errorf("CreateExposure vmID = %q, want vm-1", vmID)
			}
			if guest != 22 {
				t.Errorf("CreateExposure guestPort = %d, want 22", guest)
			}
			if host != 0 {
				t.Errorf("CreateExposure hostPort = %d, want 0 — the fleet allocates the host port", host)
			}
			return client.Exposure{ID: "x-1", HostPort: 30080, HostAddr: "192.168.0.190"}, nil
		},
		deleteExposureFunc: func(ctx context.Context, id string) error { deleted = id; return nil },
	}
	dialed := ""
	dial := func(ctx context.Context, addr string) (string, error) {
		dialed = addr
		return "SSH-2.0-OpenSSH_9.6\r\n", nil
	}

	if err := proveExposure(context.Background(), api, "vm-1", clock.now, clock.sleep, dial); err != nil {
		t.Fatalf("proveExposure: %v", err)
	}
	if dialed != "192.168.0.190:30080" {
		t.Errorf("dialed = %q, want 192.168.0.190:30080", dialed)
	}
	if deleted != "x-1" {
		t.Errorf("revoked exposure = %q, want x-1 — the smoke leaves no exposure behind", deleted)
	}
}

// TestProveExposureDialsAnIPv6Uplink pins the dial target's composition: a host
// that answers on an IPv6 address only resolves when the address is bracketed,
// which is what net.JoinHostPort does and plain concatenation does not.
func TestProveExposureDialsAnIPv6Uplink(t *testing.T) {
	clock := &fakeClock{}
	api := &testAPI{
		createExposureFunc: func(context.Context, string, int64, int64, string) (client.Exposure, error) {
			return client.Exposure{ID: "x-1", HostPort: 30080, HostAddr: "2001:db8::1"}, nil
		},
		deleteExposureFunc: func(context.Context, string) error { return nil },
	}
	dialed := ""
	dial := func(ctx context.Context, addr string) (string, error) {
		dialed = addr
		return "SSH-2.0-OpenSSH_9.6\r\n", nil
	}

	if err := proveExposure(context.Background(), api, "vm-1", clock.now, clock.sleep, dial); err != nil {
		t.Fatalf("proveExposure: %v", err)
	}
	if dialed != "[2001:db8::1]:30080" {
		t.Errorf("dialed = %q, want [2001:db8::1]:30080", dialed)
	}
}

func TestProveExposureRetriesUntilTheListenerConverges(t *testing.T) {
	clock := &fakeClock{}
	api := &testAPI{
		createExposureFunc: func(context.Context, string, int64, int64, string) (client.Exposure, error) {
			return client.Exposure{ID: "x-1", HostPort: 30080, HostAddr: "192.168.0.190"}, nil
		},
		deleteExposureFunc: func(context.Context, string) error { return nil },
	}
	calls := 0
	dial := func(ctx context.Context, addr string) (string, error) {
		calls++
		if calls < 3 {
			return "", errors.New("connection refused")
		}
		return "SSH-2.0-OpenSSH_9.6\r\n", nil
	}

	if err := proveExposure(context.Background(), api, "vm-1", clock.now, clock.sleep, dial); err != nil {
		t.Fatalf("proveExposure: %v", err)
	}
	if calls != 3 {
		t.Errorf("dial calls = %d, want 3", calls)
	}
}

func TestProveExposureFailsOnTheWrongBanner(t *testing.T) {
	clock := &fakeClock{}
	api := &testAPI{
		createExposureFunc: func(context.Context, string, int64, int64, string) (client.Exposure, error) {
			return client.Exposure{ID: "x-1", HostPort: 30080, HostAddr: "192.168.0.190"}, nil
		},
		deleteExposureFunc: func(context.Context, string) error { return nil },
	}
	dial := func(ctx context.Context, addr string) (string, error) { return "HTTP/1.1 400\r\n", nil }

	err := proveExposure(context.Background(), api, "vm-1", clock.now, clock.sleep, dial)
	if err == nil {
		t.Fatal("proveExposure: want error for a non-sshd answer, got nil")
	}
	if !strings.Contains(err.Error(), "SSH-2.0") {
		t.Errorf("error = %q, want it to name the banner it wanted", err.Error())
	}
}

// TestProveExposureFailsWhenTheGrantNamesNoAddress: an exposure whose host has
// not said where it answers is published nowhere, and the leg says so instead
// of dialing a port on an empty host.
func TestProveExposureFailsWhenTheGrantNamesNoAddress(t *testing.T) {
	clock := &fakeClock{}
	revoked := ""
	api := &testAPI{
		createExposureFunc: func(context.Context, string, int64, int64, string) (client.Exposure, error) {
			return client.Exposure{ID: "x-1", HostPort: 30080}, nil
		},
		deleteExposureFunc: func(_ context.Context, id string) error { revoked = id; return nil },
	}
	err := proveExposure(context.Background(), api, "vm-1", clock.now, clock.sleep, nil)
	if err == nil {
		t.Fatal("proveExposure: want error when the exposure named no address, got nil")
	}
	if !strings.Contains(err.Error(), "host address") {
		t.Errorf("error = %q, want it to mention the missing host address", err.Error())
	}
	if revoked != "x-1" {
		t.Errorf("revoked exposure = %q, want the grant revoked even when it named nowhere to dial", revoked)
	}
}

// bannerListener serves one connection, writing each of writes in turn with a
// pause between them, and returns the address to dial it on.
func bannerListener(t *testing.T, writes ...string) string {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatalf("listen: %v", err)
	}
	t.Cleanup(func() { ln.Close() })
	go func() {
		c, err := ln.Accept()
		if err != nil {
			return
		}
		defer c.Close()
		for _, w := range writes {
			if _, err := c.Write([]byte(w)); err != nil {
				return
			}
			time.Sleep(20 * time.Millisecond)
		}
		time.Sleep(50 * time.Millisecond)
	}()
	return ln.Addr().String()
}

func TestReadBannerReadsWhatAListenerWrites(t *testing.T) {
	got, err := readBanner(context.Background(), bannerListener(t, "SSH-2.0-Test\r\n"))
	if err != nil {
		t.Fatalf("readBanner: %v", err)
	}
	if !strings.HasPrefix(got, "SSH-2.0") {
		t.Errorf("banner = %q, want an SSH-2.0 prefix", got)
	}
}

// TestReadBannerReassemblesAFragmentedBanner pins the read-to-newline: a
// spliced connection can deliver the banner in segments, and a first segment
// shorter than the prefix must not fail a listener that is answering
// correctly.
func TestReadBannerReassemblesAFragmentedBanner(t *testing.T) {
	got, err := readBanner(context.Background(), bannerListener(t, "SS", "H-2.0-Test\r\n"))
	if err != nil {
		t.Fatalf("readBanner: %v", err)
	}
	if !strings.HasPrefix(got, "SSH-2.0") {
		t.Errorf("banner = %q, want the fragments reassembled into an SSH-2.0 prefix", got)
	}
}

// TestReadBannerKeepsTheErrorWhenNothingArrives pins the retryable case: a
// listener that accepts and hangs up without a byte returns its error, which
// the leg treats as "not converged yet" rather than as a wrong answer.
func TestReadBannerKeepsTheErrorWhenNothingArrives(t *testing.T) {
	got, err := readBanner(context.Background(), bannerListener(t))
	if err == nil {
		t.Fatalf("readBanner = %q, want the read error from a silent listener", got)
	}
	if got != "" {
		t.Errorf("banner = %q, want empty", got)
	}
}