ce7cb702218693d08ea6d83464d2457c9250027c
This commit is contained in:
commit
34c5f8963c
1 changed files with 145336 additions and 0 deletions
309
services/stalwart/cmd/controller/app_password_test.go
Normal file
309
services/stalwart/cmd/controller/app_password_test.go
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue