199 lines
7.2 KiB
Go
199 lines
7.2 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"felis.lolicon.best/internal/config"
|
|
"felis.lolicon.best/internal/mail"
|
|
"felis.lolicon.best/internal/platform"
|
|
"felis.lolicon.best/internal/watchdog"
|
|
|
|
corev1 "k8s.io/api/core/v1"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
)
|
|
|
|
func TestHostCredentialFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "smtp-password")
|
|
|
|
if _, ok, err := readHostCredential(path); ok || err != nil {
|
|
t.Fatalf("a missing file read as ok=%v err=%v; want not there", ok, err)
|
|
}
|
|
// A copy an operator put there by hand, readable by everyone, is tightened.
|
|
if err := os.WriteFile(path, []byte("by hand"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, v := range []string{"first secret", "a \"quoted\" $second\nsecret", ""} {
|
|
if err := writeHostCredential(path, v); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, ok, err := readHostCredential(path)
|
|
if err != nil || !ok || got != v {
|
|
t.Fatalf("read back (%q, %v, %v); want (%q, true, nil)", got, ok, err, v)
|
|
}
|
|
fi, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mode := fi.Mode().Perm(); mode != 0o600 {
|
|
t.Fatalf("mode %v; want 0600", mode)
|
|
}
|
|
}
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(entries) != 1 {
|
|
t.Fatalf("the directory holds %d entries; want the credential alone, no leftover temporary file", len(entries))
|
|
}
|
|
|
|
if _, _, err := readHostCredential(dir); err == nil {
|
|
t.Fatal("an unreadable credential read as fine")
|
|
}
|
|
}
|
|
|
|
func smtpSecretClient(t *testing.T, password string) client.Client {
|
|
t.Helper()
|
|
return fake.NewClientBuilder().WithScheme(haltScheme(t)).WithObjects(&corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Namespace: "felis", Name: platform.SMTPSecretName},
|
|
Data: map[string][]byte{platform.SMTPSecretPasswordKey: []byte(password)},
|
|
}).Build()
|
|
}
|
|
|
|
func TestRelayPassword(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
host := filepath.Join(dir, "smtp-password")
|
|
cl := smtpSecretClient(t, "from-secret")
|
|
|
|
if pw, err := relayPassword(ctx, host, cl, "felis"); err != nil || pw != "from-secret" {
|
|
t.Fatalf("without a host copy = (%q, %v); want the Secret's", pw, err)
|
|
}
|
|
if _, err := relayPassword(ctx, host, nil, "felis"); !errors.Is(err, errClusterUnreachable) {
|
|
t.Fatalf("without a host copy or a cluster err = %v; want errClusterUnreachable", err)
|
|
}
|
|
if err := writeHostCredential(host, "from-host"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range []client.Client{cl, nil} {
|
|
if pw, err := relayPassword(ctx, host, c, "felis"); err != nil || pw != "from-host" {
|
|
t.Fatalf("with a host copy (cluster %v) = (%q, %v); want the host copy", c != nil, pw, err)
|
|
}
|
|
}
|
|
if _, err := relayPassword(ctx, dir, cl, "felis"); err == nil {
|
|
t.Fatal("an unreadable host copy fell through to the Secret")
|
|
}
|
|
}
|
|
|
|
// The watchdog mails the most while the cluster is down: the host copy must
|
|
// reach it then, and without one the password the last good run cached stays.
|
|
func TestRefreshSMTPPassword(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
host := filepath.Join(dir, "smtp-password")
|
|
var stderr bytes.Buffer
|
|
|
|
state := &watchdog.State{SMTPPassword: "cached"}
|
|
refreshSMTPPassword(ctx, host, nil, "felis", state, &stderr)
|
|
if state.SMTPPassword != "cached" || stderr.Len() != 0 {
|
|
t.Fatalf("cluster down, no host copy: password %q, stderr %q; want the cached one kept quietly", state.SMTPPassword, stderr.String())
|
|
}
|
|
refreshSMTPPassword(ctx, host, smtpSecretClient(t, "from-secret"), "felis", state, &stderr)
|
|
if state.SMTPPassword != "from-secret" {
|
|
t.Fatalf("cluster up, no host copy: password %q; want the Secret's", state.SMTPPassword)
|
|
}
|
|
if err := writeHostCredential(host, "from-host"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
refreshSMTPPassword(ctx, host, nil, "felis", state, &stderr)
|
|
if state.SMTPPassword != "from-host" {
|
|
t.Fatalf("cluster down, host copy: password %q; want the host copy", state.SMTPPassword)
|
|
}
|
|
refreshSMTPPassword(ctx, dir, nil, "felis", state, &stderr)
|
|
if state.SMTPPassword != "from-host" || !strings.Contains(stderr.String(), "keeping the cached one") {
|
|
t.Fatalf("unreadable host copy: password %q, stderr %q; want the cached one kept and the failure said", state.SMTPPassword, stderr.String())
|
|
}
|
|
}
|
|
|
|
func TestHostRecoveryMailerHostCopy(t *testing.T) {
|
|
ctx := context.Background()
|
|
// No kubeconfig anywhere: reaching for the cluster fails, so a pass proves
|
|
// the host copy was enough.
|
|
t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "no-kubeconfig"))
|
|
dir := t.TempDir()
|
|
host := filepath.Join(dir, "smtp-password")
|
|
if err := writeHostCredential(host, "from-host"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
off := false
|
|
c := config.SMTPConfig{Host: "mail.example.com", Port: 2525, From: "[email protected]", Username: "felis", PasswordRef: "FELIS_TEST_UNSET_RELAY_PW", RequireTLS: &off}
|
|
|
|
got, err := hostRecoveryMailer(c, host, "felis")(ctx)
|
|
if err != nil {
|
|
t.Fatalf("with the host copy: %v", err)
|
|
}
|
|
if relay, ok := got.(*mail.SMTP); !ok || relay.Password != "from-host" {
|
|
t.Fatalf("relay = %#v; want the host copy's password", got)
|
|
}
|
|
if _, err := hostRecoveryMailer(c, dir, "felis")(ctx); err == nil || !strings.Contains(err.Error(), "read the relay password") {
|
|
t.Fatalf("unreadable host copy: err = %v; want it named", err)
|
|
}
|
|
if _, err := hostRecoveryMailer(c, filepath.Join(dir, "none"), "felis")(ctx); err == nil || !strings.Contains(err.Error(), "reach the cluster") {
|
|
t.Fatalf("no host copy and no cluster: err = %v; want the cluster named", err)
|
|
}
|
|
}
|
|
|
|
// A whole watchdog run with the API server and PostgreSQL both down still
|
|
// takes the relay password from the host copy, so the outage mail can
|
|
// authenticate even when no earlier run cached it.
|
|
func TestWatchdogReadsHostCopyWhileClusterDown(t *testing.T) {
|
|
dir := t.TempDir()
|
|
t.Setenv("KUBECONFIG", filepath.Join(dir, "no-kubeconfig"))
|
|
cfgPath := filepath.Join(dir, "felis.toml")
|
|
if err := os.WriteFile(cfgPath, []byte(`[database]
|
|
url = "postgres://felis:[email protected]:1/felis?sslmode=disable&connect_timeout=2"
|
|
[server]
|
|
root_domain = "example.com"
|
|
[archive]
|
|
store = "tarLocal"
|
|
[k8s]
|
|
egress_mode = "nodeport"
|
|
[smtp]
|
|
host = "127.0.0.1"
|
|
port = 1
|
|
from = "[email protected]"
|
|
username = "felis"
|
|
password_ref = "FELIS_TEST_UNSET_RELAY_PW"
|
|
`), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pwPath := filepath.Join(dir, "smtp-password")
|
|
if err := writeHostCredential(pwPath, "from-host"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
statePath := filepath.Join(dir, "state.json")
|
|
var stdout, stderr bytes.Buffer
|
|
cmdWatchdog([]string{
|
|
"-config", cfgPath, "-state", statePath, "-quiet-file", filepath.Join(dir, "quiet"),
|
|
"-backup-dir", "", "-disk-paths", dir, "-smtp-password-file", pwPath, "-heartbeat-file", filepath.Join(dir, "no-heartbeat"),
|
|
}, &stdout, &stderr)
|
|
if !strings.Contains(stdout.String(), "kube-api") {
|
|
t.Fatalf("the run found the API server up; the test needs it down (stdout %s)", stdout.String())
|
|
}
|
|
state, err := watchdog.LoadState(statePath)
|
|
if err != nil {
|
|
t.Fatalf("load state: %v (stderr %s)", err, stderr.String())
|
|
}
|
|
if state.SMTPPassword != "from-host" {
|
|
t.Fatalf("cached relay password %q; want the host copy (stdout %s, stderr %s)", state.SMTPPassword, stdout.String(), stderr.String())
|
|
}
|
|
}
|