package main import ( "crypto/x509" "encoding/json" "encoding/pem" "errors" "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "sync" "sync/atomic" "testing" ) func TestConvergeAppPasswordPreservesCredentialWhenVerificationIsUnavailable(t *testing.T) { unavailable := errors.New("stalwart unavailable") operations := appPasswordOperations{ query: func() ([]appPassword, error) { return []appPassword{{ID: "existing-id", Description: "alert-submit-v1"}}, nil }, read: func() (storedAppPassword, bool, error) { return storedAppPassword{ID: "existing-id", Password: "app_existing"}, true, nil }, verify: func(string) (bool, error) { return false, unavailable }, create: func([]appPassword) (storedAppPassword, error) { t.Fatal("created a credential while verification was unavailable") return storedAppPassword{}, nil }, delete: func([]string) error { t.Fatal("deleted a credential while verification was unavailable") return nil }, write: func(storedAppPassword) error { t.Fatal("wrote a credential while verification was unavailable") return nil }, } if err := convergeAppPassword("alert-submit-v1", operations); !errors.Is(err, unavailable) { t.Fatalf("error = %v, want %v", err, unavailable) } } func TestConvergeAppPasswordStoresReplacementBeforeDeletingOld(t *testing.T) { var calls []string operations := appPasswordOperations{ query: func() ([]appPassword, error) { calls = append(calls, "query") return []appPassword{{ID: "old-id", Description: "alert-submit-v1"}}, nil }, read: func() (storedAppPassword, bool, error) { calls = append(calls, "read") return storedAppPassword{ID: "old-id", Password: "app_old"}, true, nil }, verify: func(password string) (bool, error) { calls = append(calls, "verify-"+password) return password == "app_new", nil }, create: func(existing []appPassword) (storedAppPassword, error) { calls = append(calls, "create") if len(existing) != 1 || existing[0].ID != "old-id" { t.Fatalf("existing inventory = %#v", existing) } return storedAppPassword{ID: "new-id", Password: "app_new"}, nil }, write: func(credential storedAppPassword) error { calls = append(calls, "write") if credential.ID != "new-id" || credential.Password != "app_new" { t.Fatalf("written credential = %#v", credential) } return nil }, delete: func(ids []string) error { calls = append(calls, "delete") if !slices.Equal(ids, []string{"old-id"}) { t.Fatalf("deleted IDs = %q", ids) } return nil }, } if err := convergeAppPassword("alert-submit-v1", operations); err != nil { t.Fatal(err) } want := []string{"query", "read", "verify-app_old", "create", "verify-app_new", "write", "delete"} if !slices.Equal(calls, want) { t.Fatalf("calls = %q, want %q", calls, want) } } func TestAppPasswords(t *testing.T) { items, err := appPasswords([]byte("{\"id\":\"one\",\"description\":\"first\"}\n{\"id\":\"two\",\"description\":\"second\"}\n")) if err != nil { t.Fatal(err) } if len(items) != 2 || items[0].ID != "one" || items[0].Description != "first" || items[1].ID != "two" || items[1].Description != "second" { t.Fatalf("unexpected AppPasswords: %v", items) } if _, err := appPasswords([]byte("{\"id\":\"\"}\n")); err == nil { t.Fatal("empty id accepted") } } func TestCreatedAppPasswordRequiresOneCredential(t *testing.T) { password, err := createdAppPassword([]byte("Created app_synthetic123\n")) if err != nil { t.Fatal(err) } if password != "app_synthetic123" { t.Fatalf("unexpected credential: %s", password) } if _, err := createdAppPassword([]byte("none")); err == nil { t.Fatal("missing credential accepted") } if _, err := createdAppPassword([]byte("app_one app_two")); err == nil { t.Fatal("multiple credentials accepted") } } func TestLoadAppPasswordIdentities(t *testing.T) { path := filepath.Join(t.TempDir(), "app-passwords.json") content := `[{ "clientIdFile": "/run/control/client-id", "clientSecretFile": "/run/control/client-secret", "username": "client@example.invalid", "description": "synthetic-client-v1", "vaultPath": "secret/data/example/client", "vaultKey": "password", "permissions": {"@type":"Replace","permissions":{"authenticate":true}} }]` if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } identities, err := loadAppPasswordIdentities(path) if err != nil { t.Fatal(err) } if len(identities) != 1 || identities[0].Username != "client@example.invalid" || identities[0].VaultKey != "password" { t.Fatalf("identities = %#v", identities) } } func TestReconcileSubmitterAppPasswordCreatesThenRetains(t *testing.T) { const generated = "app_synthetic123" const vaultPath = "secret/data/stalwart/alert-identity" var stored string var storedID string var vaultMu sync.Mutex vaultServer := httptest.NewTLSServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch request.URL.Path { case "/v1/auth/kubernetes/login": _ = json.NewEncoder(response).Encode(map[string]any{"auth": map[string]string{"client_token": "synthetic-vault-token"}}) case "/v1/" + vaultPath: if request.Header.Get("X-Vault-Token") != "synthetic-vault-token" { response.WriteHeader(http.StatusForbidden) return } vaultMu.Lock() defer vaultMu.Unlock() if request.Method == http.MethodGet { if stored == "" { response.WriteHeader(http.StatusNotFound) return } _ = json.NewEncoder(response).Encode(map[string]any{"data": map[string]any{"data": map[string]string{ "smtp_password": stored, "stalwart_app_password_id": storedID, }}}) return } var payload struct { Data map[string]string `json:"data"` } if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { t.Fatal(err) } stored = payload.Data["smtp_password"] storedID = payload.Data["stalwart_app_password_id"] response.WriteHeader(http.StatusNoContent) default: response.WriteHeader(http.StatusNotFound) } })) defer vaultServer.Close() var stalwartStatus atomic.Int32 stalwartStatus.Store(http.StatusOK) stalwartServer := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if status := int(stalwartStatus.Load()); status != http.StatusOK { response.WriteHeader(status) return } username, password, ok := request.BasicAuth() if request.URL.Path != "/jmap/session" || !ok || username != "submitter@mail-auth.quorate.internal" || password != generated { response.WriteHeader(http.StatusUnauthorized) return } response.WriteHeader(http.StatusOK) })) defer stalwartServer.Close() var oidcServer *httptest.Server oidcServer = httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { switch request.URL.Path { case "/.well-known/openid-configuration": _ = json.NewEncoder(response).Encode(map[string]string{ "issuer": oidcServer.URL, "jwks_uri": oidcServer.URL + "/keys", "token_endpoint": oidcServer.URL + "/token", }) case "/token": _ = json.NewEncoder(response).Encode(map[string]string{"access_token": "synthetic-submitter-token"}) default: response.WriteHeader(http.StatusNotFound) } })) defer oidcServer.Close() directory := t.TempDir() writeTestFile := func(name, content string) string { path := filepath.Join(directory, name) if err := os.WriteFile(path, []byte(content), 0o600); err != nil { t.Fatal(err) } return path } state := filepath.Join(directory, "state") count := filepath.Join(directory, "count") cli := writeTestFile("stalwart-cli", fmt.Sprintf(`#!/bin/sh if [ "$1 $2" = "query AppPassword" ]; then if [ -f %q ]; then printf '{"id":"synthetic-id","description":"alert-submit-v1"}\n'; fi elif [ "$1 $2" = "create AppPassword" ]; then touch %q current=0 if [ -f %q ]; then current=$(tr -cd '0-9' < %q); fi current=$((current + 1)) printf '%%s' "$current" > %q printf 'Created %s\n' elif [ "$1 $2" = "delete AppPassword" ]; then rm -f %q else exit 1 fi `, state, state, count, count, count, generated, state)) if err := os.Chmod(cli, 0o700); err != nil { t.Fatal(err) } certificate := vaultServer.Certificate() caPath := filepath.Join(directory, "vault-ca.pem") if err := os.WriteFile(caPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certificate.Raw}), 0o600); err != nil { t.Fatal(err) } if _, err := x509.ParseCertificate(certificate.Raw); err != nil { t.Fatal(err) } clientIDFile := writeTestFile("client-id", "synthetic-client") clientSecretFile := writeTestFile("client-secret", "synthetic-client-secret") identities, err := json.Marshal([]appPasswordIdentity{{ ClientIDFile: clientIDFile, ClientSecretFile: clientSecretFile, Username: "submitter@mail-auth.quorate.internal", Description: "alert-submit-v1", VaultPath: vaultPath, VaultKey: "smtp_password", Permissions: json.RawMessage(`{"@type":"Replace","permissions":{"authenticate":true,"emailSend":true}}`), }}) if err != nil { t.Fatal(err) } value := options{ cli: cli, stalwartURL: stalwartServer.URL, oidcIssuerFile: writeTestFile("issuer", oidcServer.URL), oidcAudienceFile: writeTestFile("audience", "synthetic-project"), appPasswords: writeTestFile("app-passwords.json", string(identities)), vaultAddress: vaultServer.URL, vaultRole: "synthetic-role", vaultTokenFile: writeTestFile("vault-token", "synthetic-service-account-token"), vaultCACertFile: caPath, } if err := reconcileAppPasswords(t.Context(), value); err != nil { t.Fatal(err) } if err := reconcileAppPasswords(t.Context(), value); err != nil { t.Fatal(err) } stalwartStatus.Store(http.StatusServiceUnavailable) if err := reconcileAppPasswords(t.Context(), value); err == nil { t.Fatal("unavailable Stalwart did not stop reconciliation") } created, err := os.ReadFile(count) if err != nil { t.Fatal(err) } if string(created) != "1" { t.Fatalf("credential created %s times", created) } vaultMu.Lock() defer vaultMu.Unlock() if stored != generated { t.Fatal("generated credential was not stored") } if storedID != "synthetic-id" { t.Fatalf("stored app-password ID = %q", storedID) } }