Configure isolated paired Home origins
11 files changed,
+280
−51
cmd/home/main.go+7 −1internal/home/app.go+35 −21internal/home/keymail_test.go+1 −1internal/home/origins.go+81 −5internal/home/origins_config_test.go+139 −0internal/home/origins_test.go+3 −2internal/home/passkey.go+2 −14internal/home/passkey_test.go+1 −1internal/home/rpid_test.go+2 −2internal/home/signin.go+3 −3internal/home/testapp_test.go+6 −1
CI passed — run details
Intent
Attributed to this commit: 12.7 g CO₂e (500546 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on feat/overnight-origin-split, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/cmd/home/main.go b/cmd/home/main.go| index 27df38a..355cb9e 100644 |
| --- a/cmd/home/main.go |
| +++ b/cmd/home/main.go |
| @@ -95,7 +95,13 @@ func main() { |
| os.Exit(1) |
| } |
| - app, err := home.New(secrets, d, logger) |
| + origins, err := home.LoadOrigins() |
| + if err != nil { |
| + logger.Error("config", "err", err) |
| + os.Exit(1) |
| + } |
| + |
| + app, err := home.NewWithOrigins(secrets, d, logger, origins) |
| if err != nil { |
| logger.Error("wire app", "err", err) |
| os.Exit(1) |
diff --git a/internal/home/app.go b/internal/home/app.go| index bec990a..e827061 100644 |
| --- a/internal/home/app.go |
| +++ b/internal/home/app.go |
| @@ -19,6 +19,7 @@ import ( |
| "errors" |
| "log/slog" |
| "net/http" |
| + "strings" |
| "time" |
| "github.com/carlosframework/rastrillo/csrf" |
| @@ -35,14 +36,17 @@ import ( |
| // is sealed on the way in, and the database handle because that is what |
| // it is sealed into. Nothing here reaches for a raw key. |
| type App struct { |
| - secrets Secrets |
| - db *db.DB |
| - links *linkStore |
| - logger *slog.Logger |
| - renderer *renderer |
| - flow *signin.Flow |
| - sessions *sessions.Sessions |
| - passkeys *passkey.Handlers |
| + origins Origins |
| + credentialHost string |
| + directoryHost string |
| + secrets Secrets |
| + db *db.DB |
| + links *linkStore |
| + logger *slog.Logger |
| + renderer *renderer |
| + flow *signin.Flow |
| + sessions *sessions.Sessions |
| + passkeys *passkey.Handlers |
| // passkeyCfg is the config handed to passkey.New. Retained because |
| // passkey.Handlers keeps its own copy unexported, and the RP-ID |
| @@ -63,6 +67,13 @@ type App struct { |
| // boot path; this guards every other way an App can be constructed, |
| // which is every test and every future caller. |
| func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) { |
| + return NewWithOrigins(secrets, d, logger, Origins{Origin, directoryOrigin}) |
| +} |
| + |
| +func NewWithOrigins(secrets Secrets, d *db.DB, logger *slog.Logger, origins Origins) (*App, error) { |
| + if err := origins.validate(); err != nil { |
| + return nil, err |
| + } |
| if logger == nil { |
| logger = slog.Default() |
| } |
| @@ -91,7 +102,7 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) { |
| // rastrillo. |
| sess, err := sessions.New(sessions.Config{ |
| DB: d.Writer(), |
| - Origin: Origin, |
| + Origin: origins.Credential, |
| TTL: 30 * 24 * time.Hour, |
| SigninPath: "/signin", |
| Logger: logger, |
| @@ -101,13 +112,16 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) { |
| } |
| a := &App{ |
| - secrets: secrets, |
| - db: d, |
| - links: links, |
| - logger: logger, |
| - renderer: rend, |
| - flow: newFlow(secrets, links), |
| - sessions: sess, |
| + origins: origins, |
| + credentialHost: strings.TrimPrefix(origins.Credential, "https://"), |
| + directoryHost: strings.TrimPrefix(origins.Directory, "https://"), |
| + secrets: secrets, |
| + db: d, |
| + links: links, |
| + logger: logger, |
| + renderer: rend, |
| + flow: newFlow(secrets, links, origins.Credential), |
| + sessions: sess, |
| } |
| if a.passkeys, err = newPasskeys(a); err != nil { |
| @@ -130,11 +144,11 @@ func New(secrets Secrets, d *db.DB, logger *slog.Logger) (*App, error) { |
| func (a *App) Router() http.Handler { |
| r := chi.NewRouter() |
| - r.Use(serveKnownHost) |
| + r.Use(a.serveKnownHost) |
| r.Get("/", func(w http.ResponseWriter, r *http.Request) { |
| - if r.Host == credentialHost { |
| - w.Header().Set("Location", directoryOrigin+"/") |
| + if strings.ToLower(r.Host) == a.credentialHost { |
| + w.Header().Set("Location", a.origins.Directory+"/") |
| w.WriteHeader(http.StatusSeeOther) |
| return |
| } |
| @@ -152,13 +166,13 @@ func (a *App) Router() http.Handler { |
| }) |
| r.Group(func(r chi.Router) { |
| - r.Use(credentialOnly) |
| + r.Use(a.credentialOnly) |
| // POST /signin is an unauthenticated endpoint that sends mail and makes |
| // outbound DNS and HTTPS calls decided by the submitted address — a |
| // blind-SSRF surface by design — and csrf.Protect is pure origin |
| // evidence (Sec-Fetch-Site, then Origin, then Referer) that never |
| // consults a session, so it works before anybody is signed in. |
| - r.Use(csrf.Protect(Origin)) |
| + r.Use(csrf.Protect(a.origins.Credential)) |
| r.Get("/signin", a.handleSigninForm) |
| r.Post("/signin", a.handleSigninBegin) |
| r.Get("/signin/sent", a.handleSigninSent) |
diff --git a/internal/home/keymail_test.go b/internal/home/keymail_test.go| index 52f0f62..888de15 100644 |
| --- a/internal/home/keymail_test.go |
| +++ b/internal/home/keymail_test.go |
| @@ -174,7 +174,7 @@ func TestCallbackWithWrongStateMintsNothing(t *testing.T) { |
| // submitter picked. |
| func TestFlowUsesBoundedHTTPClients(t *testing.T) { |
| app := newTestApp(t) |
| - fresh := newFlow(app.secrets, app.links) |
| + fresh := newFlow(app.secrets, app.links, app.origins.Credential) |
| if fresh.Classifier.HTTP == nil { |
| t.Error("the classifier has no HTTP client, so it uses an unbounded default") |
diff --git a/internal/home/origins.go b/internal/home/origins.go| index 553ed1a..fb6829f 100644 |
| --- a/internal/home/origins.go |
| +++ b/internal/home/origins.go |
| @@ -1,6 +1,13 @@ |
| package home |
| -import "net/http" |
| +import ( |
| + "errors" |
| + "net" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "strings" |
| +) |
| const ( |
| suiteDomain = "oficina.works" |
| @@ -9,9 +16,78 @@ const ( |
| directoryOrigin = "https://" + directoryHost |
| ) |
| -func serveKnownHost(next http.Handler) http.Handler { |
| +var errOrigins = errors.New("home: invalid origin configuration") |
| + |
| +type Origins struct { |
| + Credential string |
| + Directory string |
| +} |
| + |
| +func LoadOrigins() (Origins, error) { |
| + credential, haveCredential := os.LookupEnv("HOME_CREDENTIAL_ORIGIN") |
| + directory, haveDirectory := os.LookupEnv("HOME_DIRECTORY_ORIGIN") |
| + if !haveCredential && !haveDirectory { |
| + return Origins{Origin, directoryOrigin}, nil |
| + } |
| + origins := Origins{credential, directory} |
| + if !haveCredential || !haveDirectory { |
| + return Origins{}, errOrigins |
| + } |
| + return origins, origins.validate() |
| +} |
| + |
| +func originHost(origin string) (string, error) { |
| + u, err := url.Parse(origin) |
| + if err != nil || u.Scheme != "https" || u.User != nil || u.Host == "" || origin != "https://"+u.Host || u.Host != strings.ToLower(u.Host) || net.ParseIP(u.Host) != nil { |
| + return "", errOrigins |
| + } |
| + if len(u.Host) > 253 { |
| + return "", errOrigins |
| + } |
| + labels := strings.Split(u.Host, ".") |
| + if len(labels) < 2 { |
| + return "", errOrigins |
| + } |
| + for _, label := range labels { |
| + if len(label) == 0 || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { |
| + return "", errOrigins |
| + } |
| + for _, c := range label { |
| + if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') { |
| + return "", errOrigins |
| + } |
| + } |
| + } |
| + return u.Host, nil |
| +} |
| + |
| +func (o Origins) validate() error { |
| + credential, err := originHost(o.Credential) |
| + if err != nil { |
| + return err |
| + } |
| + directory, err := originHost(o.Directory) |
| + if err != nil { |
| + return err |
| + } |
| + if directory == credential || strings.HasSuffix(directory, "."+credential) { |
| + return errOrigins |
| + } |
| + if o.Credential == Origin && o.Directory == directoryOrigin { |
| + return nil |
| + } |
| + for _, host := range []string{credential, directory} { |
| + if host == directoryHost || host == credentialHost || strings.HasSuffix(host, "."+credentialHost) { |
| + return errOrigins |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +func (a *App) serveKnownHost(next http.Handler) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - if r.Host != credentialHost && r.Host != directoryHost { |
| + host := strings.ToLower(r.Host) |
| + if host != a.credentialHost && host != a.directoryHost { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
| @@ -19,9 +95,9 @@ func serveKnownHost(next http.Handler) http.Handler { |
| }) |
| } |
| -func credentialOnly(next http.Handler) http.Handler { |
| +func (a *App) credentialOnly(next http.Handler) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| - if r.Host != credentialHost { |
| + if strings.ToLower(r.Host) != a.credentialHost { |
| w.WriteHeader(http.StatusNotFound) |
| return |
| } |
diff --git a/internal/home/origins_config_test.go b/internal/home/origins_config_test.go| new file mode 100644 |
| index 0000000..9859282 |
| --- /dev/null |
| +++ b/internal/home/origins_config_test.go |
| @@ -0,0 +1,139 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "os" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestConfiguredOriginsRequireAnIsolatedPair(t *testing.T) { |
| + valid := Origins{"https://id.preview.example", "https://home.preview.example"} |
| + for _, pair := range []Origins{{Origin, directoryOrigin}, valid, {"https://notid.oficina.works", "https://preview.oficina.works"}} { |
| + if err := pair.validate(); err != nil { |
| + t.Errorf("valid %+v: %v", pair, err) |
| + } |
| + } |
| + for _, pair := range []Origins{ |
| + {}, {Origin, valid.Directory}, {valid.Credential, directoryOrigin}, |
| + {valid.Credential, valid.Credential}, {valid.Credential, "https://child.id.preview.example"}, |
| + {"https://preview.id.oficina.works", valid.Directory}, {valid.Credential, "https://child.id.oficina.works"}, |
| + } { |
| + if err := pair.validate(); err == nil { |
| + t.Errorf("accepted unsafe pair %+v", pair) |
| + } |
| + } |
| + for _, bad := range []string{"", "http://id.preview.example", "https://ID.preview.example", "https://id.preview.example/", "https://id.preview.example:443", "https://user@id.preview.example", "https://id.preview.example?x", "https://id.preview.example#x", "https://127.0.0.1", "https://[::1]", "https://localhost", "https://id.preview.example.", "https://-id.example", "https://id_.example"} { |
| + for _, pair := range []Origins{{bad, valid.Directory}, {valid.Credential, bad}} { |
| + if err := pair.validate(); err == nil { |
| + t.Errorf("accepted malformed pair %+v", pair) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func TestLoadOriginsRequiresBothVariablesOrNeither(t *testing.T) { |
| + for _, key := range []string{"HOME_CREDENTIAL_ORIGIN", "HOME_DIRECTORY_ORIGIN"} { |
| + t.Setenv(key, "") |
| + if err := os.Unsetenv(key); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if got, err := LoadOrigins(); err != nil || got != (Origins{Origin, directoryOrigin}) { |
| + t.Fatalf("defaults %+v %v", got, err) |
| + } |
| + t.Setenv("HOME_CREDENTIAL_ORIGIN", "https://id.preview.example") |
| + if _, err := LoadOrigins(); err == nil { |
| + t.Fatal("accepted credential-only setting") |
| + } |
| + t.Setenv("HOME_DIRECTORY_ORIGIN", "https://home.preview.example") |
| + if got, err := LoadOrigins(); err != nil || got.Credential != "https://id.preview.example" { |
| + t.Fatalf("pair %+v %v", got, err) |
| + } |
| + t.Setenv("HOME_CREDENTIAL_ORIGIN", "") |
| + if _, err := LoadOrigins(); err == nil { |
| + t.Fatal("accepted explicit empty setting") |
| + } |
| +} |
| + |
| +func TestCustomOriginsReachEveryCredentialSeam(t *testing.T) { |
| + origins := Origins{"https://id.preview.example", "https://home.preview.example"} |
| + app := newTestAppWithOrigins(t, origins) |
| + if app.flow.Origin != origins.Credential || app.flow.Keymail("keymail.example").Origin != origins.Credential || app.passkeyCfg.Origin != origins.Credential || app.passkeyCfg.LegacyRPID != "" || app.passkeyPageData("subject", nil)["RPID"] != "id.preview.example" { |
| + t.Fatal("credential origin did not reach signin/keymail/passkey configuration") |
| + } |
| + router := app.Router() |
| + for _, c := range []struct { |
| + origin, path string |
| + status int |
| + }{ |
| + {origins.Credential, "/signin", 200}, {origins.Directory, "/", 200}, {origins.Credential, "/", 303}, |
| + {origins.Directory, "/signin", 404}, {Origin, "/signin", 404}, {directoryOrigin, "/", 404}, |
| + {strings.ToUpper(origins.Credential), "/signin", 200}, |
| + } { |
| + req := httptest.NewRequest(http.MethodGet, c.origin+c.path, nil) |
| + rec := httptest.NewRecorder() |
| + router.ServeHTTP(rec, req) |
| + if rec.Code != c.status { |
| + t.Errorf("%s%s=%d want%d", c.origin, c.path, rec.Code, c.status) |
| + } |
| + if c.status == 303 && (rec.Header().Get("Location") != origins.Directory+"/" || rec.Body.Len() != 0) { |
| + t.Fatal("credential root escaped configured directory") |
| + } |
| + } |
| + for _, origin := range []string{origins.Credential, origins.Directory} { |
| + for _, path := range []string{"/healthz", "/static/tokens.css", "/static/theme.css"} { |
| + rec := httptest.NewRecorder() |
| + router.ServeHTTP(rec, httptest.NewRequest("GET", origin+path, nil)) |
| + if rec.Code != 200 { |
| + t.Errorf("shared %s%s=%d", origin, path, rec.Code) |
| + } |
| + } |
| + } |
| + post := func(path, source string, values url.Values) *httptest.ResponseRecorder { |
| + req := httptest.NewRequest("POST", origins.Credential+path, strings.NewReader(values.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", source) |
| + rec := httptest.NewRecorder() |
| + router.ServeHTTP(rec, req) |
| + return rec |
| + } |
| + if rec := post("/signin", Origin, url.Values{"address": {"person@example.invalid"}}); rec.Code != 403 { |
| + t.Fatalf("production CSRF origin accepted: %d", rec.Code) |
| + } |
| + if _, ok := app.mailer.last(); ok { |
| + t.Fatal("wrong CSRF origin sent mail") |
| + } |
| + if rec := post("/signin", origins.Credential, url.Values{"address": {"person@example.invalid"}}); rec.Code != 303 { |
| + t.Fatalf("configured signin=%d", rec.Code) |
| + } |
| + sent, ok := app.mailer.last() |
| + if !ok { |
| + t.Fatal("no captured mail") |
| + } |
| + var token string |
| + for _, word := range strings.Fields(sent.Body) { |
| + if strings.HasPrefix(word, origins.Credential+linkPath) { |
| + u, err := url.Parse(word) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + token = u.Query().Get("token") |
| + } |
| + } |
| + if token == "" { |
| + t.Fatal("mail did not contain configured credential link") |
| + } |
| + rec := post(linkPath, origins.Credential, url.Values{"token": {token}}) |
| + cookie := sessionCookie(rec) |
| + if rec.Code != 303 || cookie == nil { |
| + t.Fatalf("redemption=%d missing session=%v", rec.Code, cookie == nil) |
| + } |
| + for _, c := range rec.Result().Cookies() { |
| + if c.Domain != "" || !c.Secure || c.Path != "/" { |
| + t.Errorf("unsafe configured cookie %s", c.Name) |
| + } |
| + } |
| +} |
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go| index 6438875..51b8fa6 100644 |
| --- a/internal/home/origins_test.go |
| +++ b/internal/home/origins_test.go |
| @@ -49,7 +49,7 @@ func TestDirectoryContentNeverServedOnCredentialHost(t *testing.T) { |
| func TestUnknownHostsCannotReachRoutes(t *testing.T) { |
| app := newTestApp(t) |
| router := app.Router() |
| - hosts := []string{"", "example.com", "oficina.works.attacker.invalid", "id.oficina.works.attacker.invalid", "ID.OFICINA.WORKS", "id.oficina.works.", "id.oficina.works:443", "oficina.works:443", "localhost"} |
| + hosts := []string{"", "example.com", "oficina.works.attacker.invalid", "id.oficina.works.attacker.invalid", "id.oficina.works.", "id.oficina.works:443", "oficina.works:443", "localhost"} |
| err := chi.Walk(router.(chi.Routes), func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error { |
| for _, host := range hosts { |
| req := httptest.NewRequest(method, route, nil) |
| @@ -71,7 +71,8 @@ func TestUnknownHostsCannotReachRoutes(t *testing.T) { |
| } |
| func TestDirectoryIsNotWithinCredentialRP(t *testing.T) { |
| - if credentialHost == directoryHost || rpID() == directoryHost || Origin == directoryOrigin { |
| + app := newTestApp(t) |
| + if credentialHost == directoryHost || app.credentialHost == directoryHost || Origin == directoryOrigin { |
| t.Fatal("directory overlaps the credential origin or relying party") |
| } |
| } |
diff --git a/internal/home/passkey.go b/internal/home/passkey.go| index b713368..a82da68 100644 |
| --- a/internal/home/passkey.go |
| +++ b/internal/home/passkey.go |
| @@ -4,7 +4,6 @@ import ( |
| "embed" |
| "encoding/base64" |
| "net/http" |
| - "net/url" |
| "time" |
| "github.com/carlosframework/rastrillo/passkey" |
| @@ -97,7 +96,7 @@ func passkeyConfig(a *App) passkey.Config { |
| return passkey.Config{ |
| Sessions: a.sessions, |
| DB: a.db.Writer(), |
| - Origin: Origin, |
| + Origin: a.origins.Credential, |
| ConfirmPath: confirmPath, |
| Logger: a.logger, |
| } |
| @@ -119,7 +118,7 @@ func newPasskeys(a *App) (*passkey.Handlers, error) { |
| // around. |
| func (a *App) passkeyPageData(subject string, extra map[string]any) map[string]any { |
| data := map[string]any{ |
| - "RPID": rpID(), |
| + "RPID": a.credentialHost, |
| "LegacyRPID": a.passkeyCfg.LegacyRPID, |
| "UserID": base64.RawURLEncoding.EncodeToString([]byte(subject)), |
| "UserName": subject, |
| @@ -218,14 +217,3 @@ func staticAsset(name string) []byte { |
| } |
| func webauthnJS() []byte { return webauthn.JS() } |
| - |
| -// rpID is the WebAuthn relying-party ID: the credential origin's |
| -// hostname, derived from the one Origin constant rather than written out |
| -// again, so the two cannot drift. |
| -func rpID() string { |
| - u, err := url.Parse(Origin) |
| - if err != nil { |
| - panic("home: Origin is not a URL: " + err.Error()) |
| - } |
| - return u.Hostname() |
| -} |
diff --git a/internal/home/passkey_test.go b/internal/home/passkey_test.go| index 6c71d40..d1b1aed 100644 |
| --- a/internal/home/passkey_test.go |
| +++ b/internal/home/passkey_test.go |
| @@ -78,7 +78,7 @@ func enrol(t *testing.T, app *testApp, cookie *http.Cookie) *authtest.Authentica |
| t.Fatal(err) |
| } |
| challenge := challengeFrom(t, postJSON(t, app, "/passkey/register/begin", cookie, nil)) |
| - clientData, attestation := a.Create(challenge, authtest.Options{RPID: rpID(), Origin: Origin}) |
| + clientData, attestation := a.Create(challenge, authtest.Options{RPID: app.credentialHost, Origin: Origin}) |
| rec := postJSON(t, app, "/passkey/register/finish", cookie, map[string]string{ |
| "clientDataJSON": base64.RawURLEncoding.EncodeToString(clientData), |
| "attestationObject": base64.RawURLEncoding.EncodeToString(attestation), |
diff --git a/internal/home/rpid_test.go b/internal/home/rpid_test.go| index 003fe2d..ebc1105 100644 |
| --- a/internal/home/rpid_test.go |
| +++ b/internal/home/rpid_test.go |
| @@ -60,8 +60,8 @@ func TestRPIDIsExactlyTheCredentialHost(t *testing.T) { |
| // the ceremony and the server disagree about who the relying party |
| // is and every assertion fails — or worse, succeeds under a wider |
| // name. |
| - if rpID() != u.Hostname() { |
| - t.Errorf("the pages advertise RP ID %q but the server derives %q", rpID(), u.Hostname()) |
| + if app.credentialHost != u.Hostname() { |
| + t.Errorf("the pages advertise RP ID %q but the server derives %q", app.credentialHost, u.Hostname()) |
| } |
| } |
diff --git a/internal/home/signin.go b/internal/home/signin.go| index 973f3c7..fcae68f 100644 |
| --- a/internal/home/signin.go |
| +++ b/internal/home/signin.go |
| @@ -77,14 +77,14 @@ const classifierTimeout = 5 * time.Second |
| // newFlow wires signin.Flow. Exactly one per process: the rate limiter's |
| // state lives on the value, so one per request would hand every caller a |
| // fresh empty budget and rate-limit nothing, silently. |
| -func newFlow(secrets Secrets, links signin.LinkStore) *signin.Flow { |
| +func newFlow(secrets Secrets, links signin.LinkStore, origin string) *signin.Flow { |
| http := &http.Client{Timeout: classifierTimeout} |
| return &signin.Flow{ |
| Classifier: &signin.Classifier{HTTP: http, Scheme: "https"}, |
| Keymail: func(server string) *signin.Keymail { |
| return &signin.Keymail{ |
| Base: "https://" + server, |
| - Origin: Origin, |
| + Origin: origin, |
| RedirectPath: callbackPath, |
| HTTP: http, |
| } |
| @@ -92,7 +92,7 @@ func newFlow(secrets Secrets, links signin.LinkStore) *signin.Flow { |
| Links: links, |
| Mailer: secrets.Mailer, |
| Key: secrets.SigninKey, |
| - Origin: Origin, |
| + Origin: origin, |
| LinkPath: linkPath, |
| Subject: "Sign in to Oficina", |
| } |
diff --git a/internal/home/testapp_test.go b/internal/home/testapp_test.go| index 89b0cf1..a36a848 100644 |
| --- a/internal/home/testapp_test.go |
| +++ b/internal/home/testapp_test.go |
| @@ -97,6 +97,11 @@ type testApp struct { |
| // long-lived connection, and a hook registered after that misses the |
| // only connection that ever deletes anything. |
| func newTestApp(t *testing.T) *testApp { |
| + t.Helper() |
| + return newTestAppWithOrigins(t, Origins{Origin, directoryOrigin}) |
| +} |
| + |
| +func newTestAppWithOrigins(t *testing.T, origins Origins) *testApp { |
| t.Helper() |
| secrets, mailer := testSecrets(t) |
| @@ -113,7 +118,7 @@ func newTestApp(t *testing.T) *testApp { |
| t.Fatalf("migrate.Apply: %v", err) |
| } |
| - app, err := New(secrets, d, quietLogger()) |
| + app, err := NewWithOrigins(secrets, d, quietLogger(), origins) |
| if err != nil { |
| t.Fatalf("New: %v", err) |
| } |