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)
}
}