309 lines
10 KiB
Go
309 lines
10 KiB
Go
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)
|
|
}
|
|
}
|