a73x

internal/smoke/login_test.go

Ref:   Size: 5.5 KiB   History

package smoke

import (
	"context"
	"net/http"
	"net/http/httptest"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/oidcprovider"
	"github.com/a73x/eitri/internal/server/api"
	"github.com/a73x/eitri/internal/server/api/client"
	"github.com/a73x/eitri/internal/server/hub"
	"github.com/a73x/eitri/internal/server/registry"
	"github.com/a73x/eitri/internal/server/store"
)

// smokeLoginEnv is a whole eitri-server /auth + /api stack wired to a real
// internal/oidcprovider issuer — the exact credential chain a deploy walks. It
// exists to prove loginPAT end-to-end: sign in through the login form, land a
// session, mint a PAT, and use it against the API.
type smokeLoginEnv struct {
	serverURL string
	st        *store.Store
}

// newSmokeLoginEnv stands up the issuer and server with one seeded user and
// returns the server's base URL. It mirrors internal/server/api/auth_test.go's
// fixture: the httptest listener binds on construction so we know the server's
// address (hence the OIDC redirect URL) before wiring the two ends.
func newSmokeLoginEnv(t *testing.T, email, password string) smokeLoginEnv {
	t.Helper()

	apiSrv := httptest.NewUnstartedServer(nil)
	publicURL := "http://" + apiSrv.Listener.Addr().String()

	st, err := store.Open(t.TempDir()+"/db", "10.77.0.0/16")
	if err != nil {
		t.Fatalf("store.Open: %v", err)
	}
	t.Cleanup(func() { st.Close() })

	usersPath := filepath.Join(t.TempDir(), "users.json")
	if err := oidcprovider.AddUser(usersPath, email, password); err != nil {
		t.Fatalf("AddUser: %v", err)
	}
	prov, err := oidcprovider.New(oidcprovider.Config{
		UsersFile:  usersPath,
		SigningKey: filepath.Join(t.TempDir(), "signing.key"),
		Clients:    []oidcprovider.Client{{ID: "eitri-console", RedirectURL: publicURL + "/auth/callback"}},
	})
	if err != nil {
		t.Fatalf("oidcprovider.New: %v", err)
	}
	oidcSrv := httptest.NewServer(prov.Handler())
	t.Cleanup(oidcSrv.Close)
	prov.SetIssuer(oidcSrv.URL)

	a := api.New(api.Config{
		HostSecret: []byte("hostsecret"),
		OIDC:       api.OIDCConfig{Issuer: oidcSrv.URL, ClientID: "eitri-console", PublicURL: publicURL},
	}, st, registry.New(time.Now), hub.New())
	t.Cleanup(a.Close)

	root := http.NewServeMux()
	root.Handle("/api/", a.Handler())
	root.Handle("/auth/", a.AuthHandler())
	apiSrv.Config.Handler = root
	apiSrv.Start()
	t.Cleanup(apiSrv.Close)

	return smokeLoginEnv{serverURL: publicURL, st: st}
}

func TestLoginPATMintsUsableToken(t *testing.T) {
	const (
		email    = "ci@eitri.local"
		password = "hunter2hunter2"
	)
	env := newSmokeLoginEnv(t, email, password)

	token, err := loginPAT(env.serverURL, email, password)
	if err != nil {
		t.Fatalf("loginPAT: %v", err)
	}
	if token == "" {
		t.Fatal("loginPAT returned an empty token")
	}

	// The PAT must actually authenticate an ordinary API call.
	c := &client.Client{BaseURL: env.serverURL, Token: token, HTTP: &http.Client{Timeout: 10 * time.Second}}
	me, err := c.Me()
	if err != nil {
		t.Fatalf("Me() with minted PAT: %v", err)
	}
	if me.Email != email {
		t.Errorf("Me().Email = %q, want %q", me.Email, email)
	}
	if _, err := c.ListHosts(context.Background()); err != nil {
		t.Errorf("ListHosts() with minted PAT: %v", err)
	}
}

// TestProveCredentialChain is the phase-1 proof: signing in and minting a PAT
// must resolve to a non-empty tenant via Me() (the ci user's JIT tenant),
// without any VM involvement.
func TestProveCredentialChain(t *testing.T) {
	const (
		email    = "ci@eitri.local"
		password = "hunter2hunter2"
	)
	env := newSmokeLoginEnv(t, email, password)

	token, err := loginPAT(env.serverURL, email, password)
	if err != nil {
		t.Fatalf("loginPAT: %v", err)
	}
	tenant, err := proveCredentialChain(env.serverURL, token)
	if err != nil {
		t.Fatalf("proveCredentialChain: %v", err)
	}
	if tenant == "" {
		t.Fatal("proveCredentialChain returned an empty tenant")
	}
}

// TestScenarioPATDerivesTenant is the phase-2 contract: a scenario client built
// from an operator-minted PAT derives its tenant via Me() — not from any env or
// hardcoded default. The store mints the PAT directly, standing in for the
// console-minted "deploy" token a real operator saves to CI_PAT_FILE.
func TestScenarioPATDerivesTenant(t *testing.T) {
	env := newSmokeLoginEnv(t, "ci@eitri.local", "hunter2hunter2")

	// A distinct operator tenant with a row (handleMe 401s on a rowless tenant).
	tn, err := env.st.CreateTenantForIdentity("https://op.example", "op-subject", "op@example.com")
	if err != nil {
		t.Fatalf("CreateTenantForIdentity: %v", err)
	}
	secret, _, err := env.st.CreateAPIToken(tn.ID, "deploy", 0)
	if err != nil {
		t.Fatalf("CreateAPIToken: %v", err)
	}

	c := &client.Client{BaseURL: env.serverURL, Token: secret, HTTP: &http.Client{Timeout: 10 * time.Second}}
	me, err := c.Me()
	if err != nil {
		t.Fatalf("Me() with store-minted PAT: %v", err)
	}
	if me.Tenant != tn.ID {
		t.Errorf("Me().Tenant = %q, want %q (derived, not assumed)", me.Tenant, tn.ID)
	}
}

func TestLoginPATWrongPasswordFails(t *testing.T) {
	const email = "ci@eitri.local"
	env := newSmokeLoginEnv(t, email, "the-right-password")

	_, err := loginPAT(env.serverURL, email, "the-wrong-password")
	if err == nil {
		t.Fatal("loginPAT: want error on wrong password, got nil")
	}
	// The failure must name the sign-in problem and must not echo the password.
	if !strings.Contains(err.Error(), "sign-in failed") || !strings.Contains(err.Error(), email) {
		t.Errorf("error = %q, want it to mention the sign-in failure and the user", err)
	}
	if strings.Contains(err.Error(), "the-wrong-password") {
		t.Errorf("error must not echo the password: %q", err)
	}
}