a73x

internal/oidcprovider/cli_test.go

Ref:   Size: 7.6 KiB   History

package oidcprovider

import (
	"bytes"
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// writeCLIConfig writes a complete, valid config to dir and returns its path.
func writeCLIConfig(t *testing.T, dir string) string {
	t.Helper()
	p := filepath.Join(dir, "eitri-oidc.json")
	cfg := `{
		"listen": "127.0.0.1:0",
		"issuer": "http://127.0.0.1:9111",
		"users_file": "` + filepath.Join(dir, "users.json") + `",
		"signing_key": "` + filepath.Join(dir, "signing.key") + `",
		"clients": [{"id": "eitri-console", "redirect_url": "http://127.0.0.1:8080/auth/callback"}]
	}`
	if err := os.WriteFile(p, []byte(cfg), 0o600); err != nil {
		t.Fatal(err)
	}
	return p
}

// writePasswordFile writes content to a temp file and returns its path.
func writePasswordFile(t *testing.T, dir, content string) string {
	t.Helper()
	p := filepath.Join(dir, "pw")
	if err := os.WriteFile(p, []byte(content), 0o600); err != nil {
		t.Fatal(err)
	}
	return p
}

func TestValidateCLIConfig(t *testing.T) {
	valid := CLIConfig{
		Listen: ":9111", Issuer: "http://x", UsersFile: "u", SigningKey: "k",
		Clients: []Client{{ID: "c", RedirectURL: "http://cb"}},
	}
	if err := validateCLIConfig(valid); err != nil {
		t.Fatalf("valid config rejected: %v", err)
	}

	// Every missing key is named in ONE error, not one restart per key.
	err := validateCLIConfig(CLIConfig{Clients: []Client{{}}})
	if err == nil {
		t.Fatal("empty config accepted")
	}
	for _, want := range []string{"listen", "issuer", "users_file", "signing_key", "clients[0].id", "clients[0].redirect_url"} {
		if !strings.Contains(err.Error(), want) {
			t.Errorf("error %q must name %q", err, want)
		}
	}

	// No clients at all is its own named gap.
	err = validateCLIConfig(CLIConfig{Listen: ":1", Issuer: "i", UsersFile: "u", SigningKey: "k"})
	if err == nil || !strings.Contains(err.Error(), "clients (at least one)") {
		t.Errorf("clientless config: got %v", err)
	}
}

func TestLoadCLIConfigErrors(t *testing.T) {
	if _, err := loadCLIConfig(filepath.Join(t.TempDir(), "absent.json")); err == nil {
		t.Error("missing file must error")
	}
	p := filepath.Join(t.TempDir(), "bad.json")
	os.WriteFile(p, []byte("{nope"), 0o600)
	if _, err := loadCLIConfig(p); err == nil || !strings.Contains(err.Error(), "parse") {
		t.Errorf("malformed json: got %v", err)
	}
}

// TestUserLifecycleViaCLI drives add → list → rm through RunCLI exactly as the
// deploy script does (config flag + password file), pinning the whole surface.
func TestUserLifecycleViaCLI(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeCLIConfig(t, dir)
	pwPath := writePasswordFile(t, dir, "hunter2hunter2\n")
	var stdout, stderr bytes.Buffer

	err := RunCLI([]string{"user", "add", "--config", cfgPath, "--password-file", pwPath, "a@x.com"}, &stdout, &stderr)
	if err != nil {
		t.Fatalf("user add: %v", err)
	}
	if !strings.Contains(stderr.String(), "added a@x.com") {
		t.Errorf("add confirmation missing: %q", stderr.String())
	}

	stdout.Reset()
	if err := RunCLI([]string{"user", "list", "--config", cfgPath}, &stdout, &stderr); err != nil {
		t.Fatalf("user list: %v", err)
	}
	if got := strings.TrimSpace(stdout.String()); got != "a@x.com" {
		t.Errorf("list = %q, want a@x.com", got)
	}

	if err := RunCLI([]string{"user", "rm", "--config", cfgPath, "a@x.com"}, &stdout, &stderr); err != nil {
		t.Fatalf("user rm: %v", err)
	}
	stdout.Reset()
	if err := RunCLI([]string{"user", "list", "--config", cfgPath}, &stdout, &stderr); err != nil {
		t.Fatal(err)
	}
	if got := strings.TrimSpace(stdout.String()); got != "" {
		t.Errorf("list after rm = %q, want empty", got)
	}
}

// TestUserAddFlagsMustPrecedeEmail pins Go flag semantics the deploy script
// depends on: parsing stops at the first positional, so trailing flags are
// swallowed as positionals and the command fails loudly instead of silently
// prompting.
func TestUserAddFlagsMustPrecedeEmail(t *testing.T) {
	dir := t.TempDir()
	cfgPath := writeCLIConfig(t, dir)
	pwPath := writePasswordFile(t, dir, "pw12345678\n")
	var out bytes.Buffer

	err := RunCLI([]string{"user", "add", "a@x.com", "--config", cfgPath, "--password-file", pwPath}, &out, &out)
	if err == nil || !strings.Contains(err.Error(), "usage:") {
		t.Errorf("flags after the positional must fail with usage, got %v", err)
	}
}

func TestUserCLIErrors(t *testing.T) {
	var out bytes.Buffer
	if err := RunCLI([]string{"user"}, &out, &out); err == nil {
		t.Error("bare `user` must error with usage")
	}
	if err := RunCLI([]string{"user", "frobnicate"}, &out, &out); err == nil || !strings.Contains(err.Error(), "unknown user subcommand") {
		t.Errorf("unknown subcommand: got %v", err)
	}
	// users_file unset in the config is a named error, not a nil-path write.
	dir := t.TempDir()
	p := filepath.Join(dir, "cfg.json")
	os.WriteFile(p, []byte(`{"listen": ":1"}`), 0o600)
	if err := RunCLI([]string{"user", "list", "--config", p}, &out, &out); err == nil || !strings.Contains(err.Error(), "users_file not set") {
		t.Errorf("unset users_file: got %v", err)
	}
}

func TestReadPasswordFile(t *testing.T) {
	dir := t.TempDir()
	var stderr bytes.Buffer

	// Exactly one trailing newline (or CRLF) is trimmed; inner space survives.
	for raw, want := range map[string]string{
		"secret pass\n":   "secret pass",
		"secret\r\n":      "secret",
		"secret":          "secret",
		"secret\n\r\n\r ": "secret\n\r\n\r ",
	} {
		got, err := readPassword(writePasswordFile(t, t.TempDir(), raw), &stderr)
		if err != nil {
			t.Errorf("readPassword(%q): %v", raw, err)
			continue
		}
		if got != want {
			t.Errorf("readPassword(%q) = %q, want %q", raw, got, want)
		}
	}

	// An empty (or newline-only) file is rejected, not accepted as "".
	if _, err := readPassword(writePasswordFile(t, dir, "\n"), &stderr); err == nil {
		t.Error("newline-only password file must error")
	}
	if _, err := readPassword(filepath.Join(dir, "absent"), &stderr); err == nil {
		t.Error("missing password file must error")
	}
}

// TestServeStartsAndStops boots the real issuer on an ephemeral port and shuts
// it down via context cancel — the full serve path minus signals.
func TestServeStartsAndStops(t *testing.T) {
	cfgPath := writeCLIConfig(t, t.TempDir())
	ctx, cancel := context.WithCancel(context.Background())
	done := make(chan error, 1)
	go func() { done <- Serve(ctx, cfgPath) }()
	// Give the listener a moment to bind, then cancel; Serve must return nil.
	time.Sleep(100 * time.Millisecond)
	cancel()
	select {
	case err := <-done:
		if err != nil {
			t.Fatalf("Serve returned %v on cancel, want nil", err)
		}
	case <-time.After(10 * time.Second):
		t.Fatal("Serve did not return after cancel")
	}
}

func TestServeRejectsIncompleteConfig(t *testing.T) {
	p := filepath.Join(t.TempDir(), "cfg.json")
	os.WriteFile(p, []byte(`{"listen": ":0"}`), 0o600)
	if err := Serve(context.Background(), p); err == nil || !strings.Contains(err.Error(), "config incomplete") {
		t.Errorf("incomplete config: got %v", err)
	}
}

func TestRunCLIFlagAndInitErrors(t *testing.T) {
	var out bytes.Buffer
	// An unknown flag fails parse rather than silently serving.
	if err := RunCLI([]string{"-frobnicate"}, &out, &out); err == nil {
		t.Error("unknown flag must error")
	}
	// A signing_key path that cannot be created (a directory) fails provider
	// init with context, not a panic deeper in.
	dir := t.TempDir()
	p := filepath.Join(dir, "cfg.json")
	cfg := `{
		"listen": "127.0.0.1:0",
		"issuer": "http://127.0.0.1:9111",
		"users_file": "` + filepath.Join(dir, "users.json") + `",
		"signing_key": "` + dir + `",
		"clients": [{"id": "c", "redirect_url": "http://cb"}]
	}`
	os.WriteFile(p, []byte(cfg), 0o600)
	if err := Serve(context.Background(), p); err == nil || !strings.Contains(err.Error(), "init provider") {
		t.Errorf("directory signing_key: got %v", err)
	}
}