quorate/services/stalwart/cmd/controller/app_password_test.go

309 lines
10 KiB
Go
Raw Normal View History

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": "[email protected]",
"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 != "[email protected]" || 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 != "[email protected]" || 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: "[email protected]",
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)
}
}