a73x

internal/server/api/client/console_test.go

Ref:   Size: 5.4 KiB   History

package client_test

import (
	"context"
	"errors"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"github.com/coder/websocket"

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

func TestMintStreamTicket(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusCreated, `{"ticket":"tkt-123"}`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	ticket, err := c.MintStreamTicket(context.Background())
	if err != nil {
		t.Fatalf("MintStreamTicket: %v", err)
	}
	if ticket != "tkt-123" {
		t.Errorf("ticket = %q, want tkt-123", ticket)
	}
	if cap.method != http.MethodPost || cap.path != "/api/v1/stream-tickets" {
		t.Errorf("request = %s %s, want POST /api/v1/stream-tickets", cap.method, cap.path)
	}
	if cap.auth != "Bearer tok" {
		t.Errorf("auth = %q, want the PAT in the header", cap.auth)
	}
}

// TestMintStreamTicketRejectsAnEmptyTicket: an empty ticket dials a console
// that answers 401, so the failure belongs at the mint, naming the mint.
func TestMintStreamTicketRejectsAnEmptyTicket(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusCreated, `{"ticket":""}`)
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	if _, err := c.MintStreamTicket(context.Background()); err == nil {
		t.Fatal("MintStreamTicket: want error for an empty ticket, got nil")
	} else if !strings.Contains(err.Error(), "stream-tickets") {
		t.Errorf("error = %q, want it to name the endpoint", err.Error())
	}
}

func TestMintStreamTicketPropagatesTheAPIError(t *testing.T) {
	var cap capture
	srv := serve(t, &cap, http.StatusUnauthorized, `unauthorized`)
	c := &client.Client{BaseURL: srv.URL, Token: "stale"}

	_, err := c.MintStreamTicket(context.Background())
	var apiErr *client.Error
	if !errors.As(err, &apiErr) {
		t.Fatalf("error = %v, want a *client.Error", err)
	}
	if apiErr.Status != http.StatusUnauthorized {
		t.Errorf("status = %d, want 401", apiErr.Status)
	}
}

// consoleServer stands in for the control plane's console endpoint: it mints a
// ticket on POST /api/v1/stream-tickets and accepts the WebSocket only when
// that same ticket comes back in the query, writing text to whoever attaches.
func consoleServer(t *testing.T, text string) (*httptest.Server, *string) {
	t.Helper()
	dialedPath := new(string)
	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/v1/stream-tickets", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusCreated)
		io.WriteString(w, `{"ticket":"tkt-abc"}`)
	})
	mux.HandleFunc("GET /api/v1/vms/{id}/console/ws", func(w http.ResponseWriter, r *http.Request) {
		*dialedPath = r.URL.EscapedPath() + "?" + r.URL.RawQuery
		if r.URL.Query().Get("ticket") != "tkt-abc" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		c, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
		if err != nil {
			return
		}
		defer c.CloseNow()
		nc := websocket.NetConn(r.Context(), c, websocket.MessageBinary)
		io.WriteString(nc, text)
		time.Sleep(50 * time.Millisecond)
		_ = c.Close(websocket.StatusNormalClosure, "")
	})
	srv := httptest.NewServer(mux)
	t.Cleanup(srv.Close)
	return srv, dialedPath
}

// TestDialConsoleMintsThenDials pins the two-step: the console is reached with
// a freshly minted ticket in the URL, and the bytes come back raw.
func TestDialConsoleMintsThenDials(t *testing.T) {
	srv, dialedPath := consoleServer(t, "ubuntu-vm login: ")
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	stream, err := c.DialConsole(ctx, "vm-1")
	if err != nil {
		t.Fatalf("DialConsole: %v", err)
	}
	defer stream.Close()

	got, err := io.ReadAll(stream)
	if err != nil && err != io.EOF {
		t.Fatalf("read console: %v", err)
	}
	if !strings.Contains(string(got), "login:") {
		t.Errorf("console bytes = %q, want the guest's output", string(got))
	}
	if *dialedPath != "/api/v1/vms/vm-1/console/ws?ticket=tkt-abc" {
		t.Errorf("dialed %q, want the VM's console path carrying the minted ticket", *dialedPath)
	}
}

// TestDialConsoleFailsWhenTheTicketIsRefused: a console that will not open is
// an error at dial, not a silent stream that never says anything.
func TestDialConsoleFailsWhenTheTicketIsRefused(t *testing.T) {
	mux := http.NewServeMux()
	mux.HandleFunc("POST /api/v1/stream-tickets", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusCreated)
		io.WriteString(w, `{"ticket":"expired"}`)
	})
	mux.HandleFunc("GET /api/v1/vms/{id}/console/ws", func(w http.ResponseWriter, r *http.Request) {
		http.Error(w, "unauthorized", http.StatusUnauthorized)
	})
	srv := httptest.NewServer(mux)
	defer srv.Close()

	c := &client.Client{BaseURL: srv.URL, Token: "tok"}
	if _, err := c.DialConsole(context.Background(), "vm-1"); err == nil {
		t.Fatal("DialConsole: want error when the console refuses the ticket, got nil")
	} else if !strings.Contains(err.Error(), "vm-1") {
		t.Errorf("error = %q, want it to name the VM", err.Error())
	}
}

// TestDialConsoleEscapesTheVMID pins that an id with URL-significant characters
// addresses one VM rather than reshaping the path.
func TestDialConsoleEscapesTheVMID(t *testing.T) {
	srv, dialedPath := consoleServer(t, "x")
	c := &client.Client{BaseURL: srv.URL, Token: "tok"}

	stream, err := c.DialConsole(context.Background(), "vm/1")
	if err == nil {
		stream.Close()
	}
	if !strings.Contains(*dialedPath, "vm%2F1") {
		t.Errorf("dialed %q, want the id escaped into one path segment", *dialedPath)
	}
}