Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ ALPHONE_TEST_DATABASE_MAX_AGE=1h
# ALPHONE_ADDR=localhost:8080
# ALPHONE_WEB_DIR=

# The address people reach AlphOne at, which plugins and invitation links use, required with a mail relay.
# ALPHONE_PUBLIC_URL=

# Comma-separated CIDR ranges of reverse proxies allowed for login rate limiting.
# ALPHONE_TRUSTED_PROXIES=10.0.0.0/8

Expand Down Expand Up @@ -94,8 +97,6 @@ ALPHONE_TEST_DATABASE_MAX_AGE=1h
# ALPHONE_SMTP_FROM=
# Transport security to the relay: mandatory, opportunistic or none.
# ALPHONE_SMTP_TLS=mandatory
# The address email links lead back to, required with a mailer.
# ALPHONE_PUBLIC_URL=
# Directory whose files override the built-in mail templates by name.
# ALPHONE_MAIL_TEMPLATE_DIR=

Expand Down
4 changes: 2 additions & 2 deletions cmd/alphone/compose.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func compose(ctx context.Context, cfg composeConfig, plugins func(sdk.Deps) ([]s
resolver := contact.NewResolver(built.contacts, contact.WithEvents(built.events))
built.registered, built.failed = plugins(sdk.Deps{
DatabaseURL: cfg.databaseURL,
PublicURL: cfg.mail.publicURL,
PublicURL: cfg.publicURL,
MachineGrace: cfg.machineGrace,
TenantsHeld: cfg.tenants.held,
TenantsRefresh: cfg.tenants.refresh,
Expand All @@ -99,7 +99,7 @@ func compose(ctx context.Context, cfg composeConfig, plugins func(sdk.Deps) ([]s
if err := declareEvents(built.registered); err != nil {
return built, fmt.Errorf("declare plugin events: %w", err)
}
sender, mailer, err := buildMail(cfg.mail, cfg.logger)
sender, mailer, err := buildMail(cfg.mail, cfg.publicURL, cfg.logger)
if err != nil {
return built, err
}
Expand Down
12 changes: 6 additions & 6 deletions cmd/alphone/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import (
"github.com/gopherium/alphone/sdk"
)

// buildMail returns the relay sender and the mailer the account flows deliver through.
func buildMail(settings mailSettings, logger *slog.Logger) (mailkit.Sender, graphres.Mailer, error) {
sender, err := buildMailSender(settings, logger)
// buildMail returns the relay sender greeting as the host of publicURL and the mailer the account flows use.
func buildMail(settings mailSettings, publicURL string, logger *slog.Logger) (mailkit.Sender, graphres.Mailer, error) {
sender, err := buildMailSender(settings, publicURL, logger)
if err != nil {
return nil, nil, err
}
Expand All @@ -29,8 +29,8 @@ func buildMail(settings mailSettings, logger *slog.Logger) (mailkit.Sender, grap
return sender, mailer, nil
}

// buildMailSender returns the relay sender, nil when no relay is named.
func buildMailSender(settings mailSettings, logger *slog.Logger) (mailkit.Sender, error) {
// buildMailSender returns the relay sender greeting as the host of publicURL, nil when no relay is named.
func buildMailSender(settings mailSettings, publicURL string, logger *slog.Logger) (mailkit.Sender, error) {
if settings.host == "" {
logger.Info("no mail relay configured, invitations answer their activation link")
return nil, nil
Expand All @@ -42,7 +42,7 @@ func buildMailSender(settings mailSettings, logger *slog.Logger) (mailkit.Sender
Password: settings.password,
From: settings.from,
TLS: smtp.TLS(settings.tls),
HELO: heloFor(settings.publicURL),
HELO: heloFor(publicURL),
})
if err != nil {
return nil, fmt.Errorf("build mail sender: %w", err)
Expand Down
16 changes: 7 additions & 9 deletions cmd/alphone/mailbuild_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func TestTheHostRefusesAMailSenderItCannotBuild(t *testing.T) {
t.Run(testName, func(t *testing.T) {
t.Parallel()

held, err := buildMailSender(refusedSettings, discardLogger())
held, err := buildMailSender(refusedSettings, "", discardLogger())

if err == nil {
t.Fatal("buildMailSender() error = nil, want the relay settings refused")
Expand All @@ -69,12 +69,11 @@ func TestBuildingMailStopsAtARefusedSender(t *testing.T) {
t.Parallel()

sender, mailer, err := buildMail(mailSettings{
host: "mail.example.com",
port: 2525,
from: "not an address",
tls: "none",
publicURL: "https://crm.example.com",
}, discardLogger())
host: "mail.example.com",
port: 2525,
from: "not an address",
tls: "none",
}, "https://crm.example.com", discardLogger())

if err == nil {
t.Fatal("buildMail() error = nil, want the refused sender surfaced")
Expand All @@ -97,9 +96,8 @@ func TestBuildingMailStopsAtARefusedMailer(t *testing.T) {
port: 2525,
from: "crm@example.com",
tls: "none",
publicURL: "https://crm.example.com",
templateDir: absentTemplateDir,
}, discardLogger())
}, "https://crm.example.com", discardLogger())

if err == nil {
t.Fatal("buildMail() error = nil, want the unusable template directory refused")
Expand Down
50 changes: 45 additions & 5 deletions cmd/alphone/mailconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package main

import (
"fmt"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -51,12 +52,14 @@ func TestTheMailSettingsAreReadFromTheEnvironment(t *testing.T) {
password: "correct horse battery",
from: "crm@example.com",
tls: "none",
publicURL: "https://crm.example.com",
templateDir: "/srv/mail",
}
if held.mail != want {
t.Errorf("mail = %+v, want %+v", held.mail, want)
}
if held.publicURL != "https://crm.example.com" {
t.Errorf("publicURL = %q, want the configured address", held.publicURL)
}
}

func TestTheMailDefaultsApply(t *testing.T) {
Expand Down Expand Up @@ -153,6 +156,7 @@ func TestAMailerNamesItsRequiredCompanions(t *testing.T) {
"the sender address": {unset: "ALPHONE_SMTP_FROM"},
"the public url": {unset: "ALPHONE_PUBLIC_URL"},
}
want := func(unset string) string { return unset + " is required when ALPHONE_SMTP_HOST is set" }
for testName, tt := range tests {
t.Run(testName, func(t *testing.T) {
t.Parallel()
Expand All @@ -170,8 +174,8 @@ func TestAMailerNamesItsRequiredCompanions(t *testing.T) {
if err == nil {
t.Fatalf("loadRunConfig() without %s error = nil, want it required", tt.unset)
}
if !strings.Contains(err.Error(), tt.unset) {
t.Errorf("error %q does not name %s", err, tt.unset)
if err.Error() != want(tt.unset) {
t.Errorf("error %q, want %q", err, want(tt.unset))
}
})
}
Expand Down Expand Up @@ -205,6 +209,42 @@ func TestMailSettingsWithoutAHostAreRefused(t *testing.T) {
}
}

func TestAMailSettingIsNamedBeforeThePublicURL(t *testing.T) {
t.Parallel()

tests := map[string]struct {
vars map[string]string
want string
}{
"a relay on an unreadable port": {
vars: map[string]string{
"ALPHONE_SMTP_HOST": "mail.example.com",
"ALPHONE_SMTP_PORT": "abc",
"ALPHONE_SMTP_FROM": "crm@example.com",
},
want: `ALPHONE_SMTP_PORT: must be a whole number, got "abc"`,
},
"a stray port without a relay": {
vars: map[string]string{"ALPHONE_SMTP_PORT": "2525"},
want: "ALPHONE_SMTP_PORT is set but ALPHONE_SMTP_HOST is not",
},
}
for testName, tt := range tests {
t.Run(testName, func(t *testing.T) {
t.Parallel()

tt.vars["ALPHONE_DATABASE_URL"] = "postgres://localhost/x"
tt.vars["ALPHONE_PUBLIC_URL"] = refusedPublicURL

_, err := loadRunConfig(testkit.Getenv(tt.vars))

if fmt.Sprint(err) != tt.want {
t.Errorf("loadRunConfig() error = %v, want %q", err, tt.want)
}
})
}
}

func TestTheMailPortRefusesAnUnreadableValue(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -321,8 +361,8 @@ func TestThePublicURLDropsItsTrailingSlash(t *testing.T) {
if err != nil {
t.Fatalf("loadRunConfig() error = %v, want nil", err)
}
if held.mail.publicURL != "https://crm.example.com" {
t.Errorf("mail.publicURL = %q, want the slash dropped", held.mail.publicURL)
if held.publicURL != "https://crm.example.com" {
t.Errorf("publicURL = %q, want the slash dropped", held.publicURL)
}
}

Expand Down
2 changes: 1 addition & 1 deletion cmd/alphone/mailwiring_seam_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func TestTheMailSenderBridgeSurfacesAFailure(t *testing.T) {
func TestTheHostBuildsNoMailSenderWithoutARelay(t *testing.T) {
t.Parallel()

held, err := buildMailSender(mailSettings{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
held, err := buildMailSender(mailSettings{}, "", slog.New(slog.NewTextHandler(io.Discard, nil)))

if err != nil {
t.Fatalf("buildMailSender() error = %v, want nil", err)
Expand Down
53 changes: 37 additions & 16 deletions cmd/alphone/mailwiring_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,21 +132,17 @@ func tokenFromMail(t *testing.T, relay *smtpmock.Server, base string) string {
return strings.TrimSpace(token)
}

func TestRunAnswersTheActivationLinkWithoutARelay(t *testing.T) {
t.Parallel()

if testing.Short() {
t.Skip("skipping database test in short mode")
}
addr := testkit.FreeAddr(t)
// invitedWithoutARelay serves run over env and a fresh database with no relay, answering the invite the admin sends.
func invitedWithoutARelay(t *testing.T, env map[string]string) string {
t.Helper()
databaseURL := testDatabaseURL(t)
addr := testkit.FreeAddr(t)
env["ALPHONE_DATABASE_URL"] = databaseURL
env["ALPHONE_ADDR"] = addr
ctx, cancel := context.WithCancel(t.Context())
runErr := make(chan error, 1)
go func() {
runErr <- run(ctx, testkit.Getenv(map[string]string{
"ALPHONE_DATABASE_URL": databaseURL,
"ALPHONE_ADDR": addr,
}), io.Discard, registerPlugins)
runErr <- run(ctx, testkit.Getenv(env), io.Discard, registerPlugins)
}()
t.Cleanup(func() { stopRun(t, cancel, runErr) })

Expand All @@ -155,15 +151,40 @@ func TestRunAnswersTheActivationLinkWithoutARelay(t *testing.T) {
seedAdmin(t, databaseURL)
session := loginSession(t, baseURL)

invited := postGraphAuthed(t, ctx, session, baseURL,
return postGraphAuthed(t, ctx, session, baseURL,
`{"query":"mutation { invite(email: \"grace@example.com\", name: \"Grace Hopper\")`+
` { delivered activationLink } }"}`)
}

if !strings.Contains(invited, `"delivered":false`) {
t.Fatalf("invite = %q, want it undelivered without a relay", invited)
func TestRunAnswersTheActivationLinkWithoutARelay(t *testing.T) {
t.Parallel()

tests := map[string]struct {
env map[string]string
link string
}{
"no public address": {
env: map[string]string{},
link: `"activationLink":"/activate?token=`,
},
"a public address": {
env: map[string]string{"ALPHONE_PUBLIC_URL": "https://crm.example.com"},
link: `"activationLink":"https://crm.example.com/activate?token=`,
},
}
if !strings.Contains(invited, `"activationLink":"/activate?token=`) {
t.Fatalf("invite = %q, want the link answered for hand delivery", invited)
for testName, tc := range tests {
t.Run(testName, func(t *testing.T) {
t.Parallel()

invited := invitedWithoutARelay(t, tc.env)

if !strings.Contains(invited, `"delivered":false`) {
t.Fatalf("invite = %q, want it undelivered without a relay", invited)
}
if !strings.Contains(invited, tc.link) {
t.Fatalf("invite = %q, want the link handed over as %s", invited, tc.link)
}
})
}
}

Expand Down
64 changes: 60 additions & 4 deletions cmd/alphone/publicurl_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ import (
"strings"
"testing"

"github.com/gopherium/framework/gonsole"
"github.com/gopherium/framework/gonsole/testkit"

"github.com/gopherium/alphone/sdk"
)

var errDepsRecorded = errors.New("the dependencies were recorded")

// refusedPublicURL is a public address on a scheme AlphOne refuses.
const refusedPublicURL = "ftp://crm.example.com"

// refusedPublicURLError is the error a public address on that scheme meets.
const refusedPublicURLError = `ALPHONE_PUBLIC_URL: must be an http or https address, got "ftp://crm.example.com"`

// recordedPublicURL boots run with env, answering the public URL the host handed the plugins.
func recordedPublicURL(t *testing.T, env map[string]string) string {
t.Helper()
Expand All @@ -28,7 +35,7 @@ func recordedPublicURL(t *testing.T, env map[string]string) string {
err := run(t.Context(), testkit.Getenv(env), io.Discard, recording)

if !errors.Is(err, errDepsRecorded) {
t.Fatalf("run() error = %v, want the recording plugin's own refusal", err)
t.Fatalf("run() error = %v, want the recording plugin's own error", err)
}
return handed
}
Expand All @@ -49,12 +56,61 @@ func TestRunHandsThePublicURLToPlugins(t *testing.T) {
}
}

func TestRunHandsAnEmptyPublicURLWithoutARelay(t *testing.T) {
func TestRunHandsThePublicURLToPluginsWithoutARelay(t *testing.T) {
t.Parallel()

handed := recordedPublicURL(t, map[string]string{"ALPHONE_PUBLIC_URL": "https://crm.example.com"})

if handed != "https://crm.example.com" {
t.Errorf("plugins received public URL %q, want the configured address without a relay", handed)
}
}

func TestRunHandsAnEmptyPublicURLWhenNoneIsSet(t *testing.T) {
t.Parallel()

handed := recordedPublicURL(t, map[string]string{})

if strings.TrimSpace(handed) != "" {
t.Errorf("plugins received public URL %q, want none without a relay", handed)
if handed != "" {
t.Errorf("plugins received public URL %q, want none when no address is set", handed)
}
}

func TestRunRefusesAMalformedPublicURLWithoutARelay(t *testing.T) {
t.Parallel()

err := run(t.Context(), testkit.Getenv(map[string]string{
"ALPHONE_DATABASE_URL": unreachableDatabaseURL,
"ALPHONE_PUBLIC_URL": refusedPublicURL,
}), io.Discard, registeringNothing)

if err == nil || err.Error() != refusedPublicURLError {
t.Errorf("run() error = %v, want %q", err, refusedPublicURLError)
}
}

func TestTheCommandsRefuseAMalformedPublicURLWithoutARelay(t *testing.T) {
t.Parallel()

lines := map[string][]string{
"check": {"check"},
"account:grant-role": {"account:grant-role", "-role", "admin", "-yes", "-as", "admin@example.com"},
"account:role": {"account:role", "maria.perez@example.com", "admin", "-yes", "-as", "admin@example.com"},
"account:disable": {"account:disable", "maria.perez@example.com", "-yes", "-as", "admin@example.com"},
"account:enable": {"account:enable", "maria.perez@example.com", "-yes", "-as", "admin@example.com"},
}
for name, args := range lines {
t.Run(name, func(t *testing.T) {
t.Parallel()

got := testkit.Run(t, bareProgram(map[string]string{
"ALPHONE_DATABASE_URL": unreachableDatabaseURL,
"ALPHONE_PUBLIC_URL": refusedPublicURL,
}), "", args...)

if got.Code != gonsole.ExitFailed || !strings.Contains(got.Stderr, refusedPublicURLError) {
t.Errorf("%s = %d with stderr %q, want 1 and %q", name, got.Code, got.Stderr, refusedPublicURLError)
}
})
}
}
Loading
Loading