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