Share the team chooser across native suite apps
9 files changed,
+169
−28
internal/home/app_chooser_test.go+88 −0internal/home/directory.go+4 −2internal/home/directory_catalog.go+6 −0internal/home/directory_catalog_test.go+4 −4internal/home/directory_runtime.go+31 −6internal/home/directory_runtime_test.go+6 −1internal/home/docs_chooser.go+27 −12internal/home/templates/docs_chooser.html+2 −2internal/home/templates/docs_empty.html+1 −1
CI cancelled — superseded by a newer push — run details
diff --git a/internal/home/app_chooser_test.go b/internal/home/app_chooser_test.go| new file mode 100644 |
| index 0000000..5bc9afc |
| --- /dev/null |
| +++ b/internal/home/app_chooser_test.go |
| @@ -0,0 +1,88 @@ |
| +package home |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestNativeAppChoicesKeepTeamAndAppBoundaries(t *testing.T) { |
| + for _, app := range []string{"docs", "sheets", "slides", "calendar", "meet", "memoria"} { |
| + t.Run(app, func(t *testing.T) { |
| + f := newMountedChooser(t) |
| + cookie := f.session(t, chooserPerson) |
| + path := "/apps/" + app |
| + if w := f.request("GET", path, "", "", nil); w.Code != 303 || !strings.Contains(w.Header().Get("Location"), "return_to=%2Fapps%2F"+app) { |
| + t.Fatal("lost app after sign-in", w.Code, w.Header()) |
| + } |
| + empty := f.request("GET", path, "", "", cookie) |
| + if empty.Code != 200 || !strings.Contains(empty.Body.String(), "No teams available") { |
| + t.Fatal("empty", empty.Code) |
| + } |
| + a := f.team(t, "A1", "alpha", "Alpha", chooserPerson) |
| + b := f.team(t, "B2", "beta", "Beta", chooserPerson) |
| + c := f.team(t, "C3", "foreign", "Hidden other team", chooserOther) |
| + if app != "docs" { |
| + for _, row := range []struct { |
| + id int64 |
| + label string |
| + }{{a, "alpha"}, {b, "beta"}, {c, "foreign"}} { |
| + _, err := f.p.directory.db.Writer().Exec(`INSERT INTO team_instances VALUES(?,?,?,?,?)`, row.id, app, "https://"+row.label+"."+app+".oficina.works", row.label+"-"+app, "live") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + w := f.request("GET", path, "", "", cookie) |
| + if w.Code != 200 || !strings.Contains(w.Body.String(), `action="`+path+`/team"`) || strings.Contains(w.Body.String(), "Hidden other team") { |
| + t.Fatal("choice boundaries", w.Code, w.Body.String()) |
| + } |
| + picked := f.request("POST", path+"/team", directoryOrigin, "team=A1", cookie) |
| + assertContinuationPage(t, picked, "https://alpha."+app+".oficina.works/auth/start?return_to=%2F") |
| + if _, err := f.p.directory.db.Writer().Exec(`DELETE FROM memberships WHERE team_id=? AND person_ref=?`, a, chooserPerson); err != nil { |
| + t.Fatal(err) |
| + } |
| + if w := f.request("POST", path+"/team", directoryOrigin, "team=A1", cookie); w.Code != 404 { |
| + t.Fatal("stale team selection accepted", w.Code) |
| + } |
| + single := f.request("GET", path, "", "", cookie) |
| + if single.Code != 303 || single.Header().Get("Location") != "https://beta."+app+".oficina.works/auth/start?return_to=%2F" { |
| + t.Fatal("one-team shortcut", single.Code, single.Header()) |
| + } |
| + if w := f.request("POST", path+"/team", "https://attacker.example", "team=B2", cookie); w.Code != 403 { |
| + t.Fatal("CSRF", w.Code) |
| + } |
| + if w := f.request("GET", path+"?return_to=https://attacker.example", "", "", cookie); w.Code != 404 { |
| + t.Fatal("untrusted navigation", w.Code) |
| + } |
| + r := httptest.NewRequest("GET", directoryOrigin+path, nil) |
| + r.AddCookie(cookie) |
| + r.AddCookie(cookie) |
| + dup := httptest.NewRecorder() |
| + f.handler.ServeHTTP(dup, r) |
| + if dup.Code != http.StatusBadRequest { |
| + t.Fatal("duplicate cookie", dup.Code) |
| + } |
| + wrongApp := "mail" |
| + if _, err := f.p.directory.db.Writer().Exec(`UPDATE team_instances SET origin=? WHERE team_id=? AND app=?`, "https://beta."+wrongApp+".oficina.works", b, app); err != nil { |
| + t.Fatal(err) |
| + } |
| + if w := f.request("GET", path, "", "", cookie); w.Code != 503 || w.Header().Get("Location") != "" { |
| + t.Fatal("wrong-app origin accepted", w.Code) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestProviderAndUnknownAppsDoNotBecomeTeamConsumers(t *testing.T) { |
| + f := newMountedChooser(t) |
| + for _, app := range []string{"mail", "files", "unknown", "Docs"} { |
| + if _, err := f.p.directory.newAppChooser(f.store, app); err == nil { |
| + t.Fatal("unintegrated app accepted", app) |
| + } |
| + if w := f.request("GET", "/apps/"+app, "", "", f.session(t, chooserPerson)); w.Code != 404 { |
| + t.Fatal("unintegrated route", app, w.Code) |
| + } |
| + } |
| +} |
diff --git a/internal/home/directory.go b/internal/home/directory.go| index 20150fc..cead9f3 100644 |
| --- a/internal/home/directory.go |
| +++ b/internal/home/directory.go |
| @@ -70,8 +70,10 @@ func (a *App) directoryRouter() http.Handler { |
| r.Post(handoff.CallbackPath, a.consumer.Callback) |
| r.Group(func(r chi.Router) { |
| r.Use(csrf.Protect(a.origins.Directory)) |
| - r.Get(docsEntryPath, a.directory.docs.Entry) |
| - r.Post(docsChoicePath, a.directory.docs.Select) |
| + for _, chooser := range a.directory.choosers { |
| + r.Get(chooser.entryPath, chooser.Entry) |
| + r.Post(chooser.choicePath, chooser.Select) |
| + } |
| r.Get(handoff.StartPath, a.consumer.Start) |
| r.Get(handoff.FinishPath, a.consumer.Finish) |
| r.Get("/signin", func(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/home/directory_catalog.go b/internal/home/directory_catalog.go| index cf1b8e1..6cdab5a 100644 |
| --- a/internal/home/directory_catalog.go |
| +++ b/internal/home/directory_catalog.go |
| @@ -25,6 +25,12 @@ func directoryCatalog(cfg Runtime) []catalogApp { |
| {Name: "Files", Status: "Separate sign-in"}, |
| } |
| for i := range apps { |
| + for _, app := range directoryApps() { |
| + if app.name == apps[i].Name && app.integrated && apps[i].Href == "" { |
| + apps[i].Href = "/apps/" + app.slug |
| + apps[i].Status = "Choose a team" |
| + } |
| + } |
| if origin := cfg.appPreviewOrigins[strings.ToLower(apps[i].Name)]; origin != "" { |
| apps[i].Href = origin + "/signin" |
| apps[i].Status = "Open preview" |
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go| index 4194ce4..2debd23 100644 |
| --- a/internal/home/directory_catalog_test.go |
| +++ b/internal/home/directory_catalog_test.go |
| @@ -23,10 +23,10 @@ func TestDirectoryCatalogRequiresSigninAndDoesNotInventLaunchDestinations(t *tes |
| t.Fatal("missing app", name) |
| } |
| } |
| - if strings.Count(body, "<a ") != 1 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || strings.Contains(body, "<script") { |
| + if strings.Count(body, "<a ") != 6 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || strings.Contains(body, "<script") { |
| t.Fatal("catalog advertised unsupported launch", body) |
| } |
| - if strings.Count(body, "Not connected to Home yet") != 4 || strings.Count(body, "Coming soon") != 1 || strings.Count(body, "Separate sign-in") != 2 { |
| + if strings.Count(body, "Not connected to Home yet") != 0 || strings.Count(body, "Coming soon") != 0 || strings.Count(body, "Separate sign-in") != 2 { |
| t.Fatal("catalog readiness missing", body) |
| } |
| entry := f.request("GET", docsEntryPath, "", "", cookie) |
| @@ -47,7 +47,7 @@ func TestDirectoryCatalogPreviewKeepsDocsChooserPolicy(t *testing.T) { |
| cookie := f.session(t, chooserPerson) |
| page := f.request("GET", "/?return_to=https://evil.example", "", "", cookie) |
| body := page.Body.String() |
| - if page.Code != 200 || !strings.Contains(body, `href="https://overnight-docs.bds.oncarlos.com/signin"`) || !strings.Contains(body, "Open preview") || strings.Contains(body, "Choose a team") || strings.Contains(body, "evil.example") || strings.Count(body, "<a ") != 1 { |
| + if page.Code != 200 || !strings.Contains(body, `href="https://overnight-docs.bds.oncarlos.com/signin"`) || !strings.Contains(body, "Open preview") || strings.Count(body, "Choose a team") != 5 || strings.Contains(body, "evil.example") || strings.Count(body, "<a ") != 6 { |
| t.Fatal("preview navigation incorrect", page.Code, body) |
| } |
| noStore(t, page) |
| @@ -141,7 +141,7 @@ func TestConfiguredAppsLaunchOnlyOperatorDestinations(t *testing.T) { |
| f.handler = app.Router() |
| page := f.request("GET", "/?app=slides&origin=https://attacker.example", "", "", f.session(t, chooserPerson)) |
| body := page.Body.String() |
| - if page.Code != 200 || strings.Count(body, "<a ") != 3 || !strings.Contains(body, `href="https://sheets.example/signin"`) || !strings.Contains(body, `href="https://memoria.example/signin"`) || !strings.Contains(body, `href="/apps/docs"`) || strings.Contains(body, "attacker.example") { |
| + if page.Code != 200 || strings.Count(body, "<a ") != 6 || !strings.Contains(body, `href="https://sheets.example/signin"`) || !strings.Contains(body, `href="https://memoria.example/signin"`) || !strings.Contains(body, `href="/apps/docs"`) || strings.Contains(body, "attacker.example") { |
| t.Fatal(page.Code, body) |
| } |
| noStore(t, page) |
diff --git a/internal/home/directory_runtime.go b/internal/home/directory_runtime.go| index a829720..cc8b4b3 100644 |
| --- a/internal/home/directory_runtime.go |
| +++ b/internal/home/directory_runtime.go |
| @@ -14,7 +14,14 @@ type directoryApp struct { |
| // The registry describes shipped integration, not team standing or readiness. |
| // Provisioning remains disabled until the audited operator path is available. |
| func directoryApps() []directoryApp { |
| - return []directoryApp{{slug: "docs", name: "Docs", integrated: true}} |
| + return []directoryApp{ |
| + {slug: "docs", name: "Docs", integrated: true}, |
| + {slug: "sheets", name: "Sheets", integrated: true}, |
| + {slug: "slides", name: "Slides", integrated: true}, |
| + {slug: "calendar", name: "Calendar", integrated: true}, |
| + {slug: "meet", name: "Meet", integrated: true}, |
| + {slug: "memoria", name: "Memoria", integrated: true}, |
| + } |
| } |
| func directoryRouteAllowed(route directorydata.Route) bool { |
| @@ -27,9 +34,10 @@ func directoryRouteAllowed(route directorydata.Route) bool { |
| } |
| type directoryFeatures struct { |
| - roster *rosterHandler |
| - store *directorydata.Store |
| - docs *docsChooser |
| + roster *rosterHandler |
| + store *directorydata.Store |
| + docs *docsChooser |
| + choosers []*docsChooser |
| } |
| func (a *App) newDirectoryFeatures() (*directoryFeatures, error) { |
| @@ -47,6 +55,17 @@ func (a *App) newDirectoryFeatures() (*directoryFeatures, error) { |
| if err != nil { |
| return nil, errExchangeConfig |
| } |
| + choosers := []*docsChooser{chooser} |
| + for _, app := range directoryApps() { |
| + if app.slug == "docs" || !app.integrated { |
| + continue |
| + } |
| + c, err := a.newAppChooser(store, app.slug) |
| + if err != nil { |
| + return nil, errExchangeConfig |
| + } |
| + choosers = append(choosers, c) |
| + } |
| var source rosterReadSource |
| if a.runtime.roster.mode == "serve" { |
| source, err = directorydata.NewRosterSource(a.db.Writer(), a.secrets.Sealer, directorydata.Config{Apps: apps, ValidateRoute: directoryRouteAllowed}, a.origins.Directory) |
| @@ -58,11 +77,17 @@ func (a *App) newDirectoryFeatures() (*directoryFeatures, error) { |
| if err != nil { |
| return nil, err |
| } |
| - return &directoryFeatures{store: store, docs: chooser, roster: handler}, nil |
| + return &directoryFeatures{store: store, docs: chooser, roster: handler, choosers: choosers}, nil |
| } |
| func chooserPath(r *http.Request) bool { |
| - return r.URL.Path == docsEntryPath || r.URL.Path == docsChoicePath |
| + for _, app := range directoryApps() { |
| + path := "/apps/" + app.slug |
| + if app.integrated && (r.URL.Path == path || r.URL.Path == path+"/team") { |
| + return true |
| + } |
| + } |
| + return false |
| } |
| // Place this before host and method routing so their refusals are not cached. |
diff --git a/internal/home/directory_runtime_test.go b/internal/home/directory_runtime_test.go| index 98b466e..ff240d7 100644 |
| --- a/internal/home/directory_runtime_test.go |
| +++ b/internal/home/directory_runtime_test.go |
| @@ -122,9 +122,14 @@ func TestDirectoryRuntimeAnonymousReturnAndRoleSchemaSeparation(t *testing.T) { |
| } |
| } |
| apps := directoryApps() |
| - if len(apps) != 1 || apps[0].slug != "docs" || !apps[0].integrated || apps[0].provisionable || apps[0].foreignOrigin != "" { |
| + if len(apps) != 6 || apps[0].slug != "docs" || !apps[0].integrated || apps[0].provisionable || apps[0].foreignOrigin != "" { |
| t.Fatal("registry advertised unsupported provisioning") |
| } |
| + for _, app := range apps { |
| + if app.provisionable || app.foreignOrigin != "" { |
| + t.Fatal("entry enabled provisioning") |
| + } |
| + } |
| apps[0].slug = "changed" |
| if directoryApps()[0].slug != "docs" { |
| t.Fatal("registry shared mutable backing") |
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go| index b4513e5..bfaf180 100644 |
| --- a/internal/home/docs_chooser.go |
| +++ b/internal/home/docs_chooser.go |
| @@ -22,19 +22,34 @@ type docsChoices interface { |
| } |
| type docsChooser struct { |
| - app *App |
| - store docsChoices |
| - selectHandler http.Handler |
| + app *App |
| + store docsChoices |
| + appID, appName, entryPath, choicePath string |
| + selectHandler http.Handler |
| } |
| // newDocsChooser prepares handlers without mounting routes or applying schema. |
| // The parent directory router must supply its existing sessions middleware and |
| // exact-host guard. Selection carries its own same-origin CSRF protection. |
| func (a *App) newDocsChooser(store docsChoices) (*docsChooser, error) { |
| + return a.newAppChooser(store, "docs") |
| +} |
| + |
| +func (a *App) newAppChooser(store docsChoices, appID string) (*docsChooser, error) { |
| + name := "" |
| + for _, app := range directoryApps() { |
| + if app.slug == appID && app.integrated { |
| + name = app.name |
| + } |
| + } |
| + if name == "" { |
| + return nil, errExchangeConfig |
| + } |
| + |
| if a == nil || a.runtime == nil || a.runtime.role != "directory" || a.renderer == nil || a.origins.validate() != nil || store == nil { |
| return nil, errors.New("invalid Docs chooser configuration") |
| } |
| - c := &docsChooser{app: a, store: store} |
| + c := &docsChooser{app: a, store: store, appID: appID, appName: name, entryPath: "/apps/" + appID, choicePath: "/apps/" + appID + "/team"} |
| c.selectHandler = csrf.Protect(a.origins.Directory)(http.HandlerFunc(c.choose)) |
| return c, nil |
| } |
| @@ -45,7 +60,7 @@ func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) { |
| w.WriteHeader(http.StatusMethodNotAllowed) |
| return |
| } |
| - if r.URL.Path != docsEntryPath || r.URL.RawQuery != "" || r.URL.ForceQuery { |
| + if r.URL.Path != c.entryPath || r.URL.RawQuery != "" || r.URL.ForceQuery { |
| http.NotFound(w, r) |
| return |
| } |
| @@ -53,18 +68,18 @@ func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| - choices, err := c.store.Choices(r.Context(), subject, "docs") |
| + choices, err := c.store.Choices(r.Context(), subject, c.appID) |
| if err != nil { |
| c.unavailable(w) |
| return |
| } |
| switch len(choices) { |
| case 0: |
| - c.app.render(w, http.StatusOK, "docs_empty.html", nil) |
| + c.app.render(w, http.StatusOK, "docs_empty.html", map[string]any{"AppName": c.appName}) |
| case 1: |
| c.redirect(w, r, choices[0]) |
| default: |
| - c.app.render(w, http.StatusOK, "docs_chooser.html", struct{ Choices []directorydata.Choice }{choices}) |
| + c.app.render(w, http.StatusOK, "docs_chooser.html", map[string]any{"Choices": choices, "AppName": c.appName, "ChoicePath": c.choicePath}) |
| } |
| } |
| func (c *docsChooser) Select(w http.ResponseWriter, r *http.Request) { |
| @@ -74,7 +89,7 @@ func (c *docsChooser) Select(w http.ResponseWriter, r *http.Request) { |
| w.WriteHeader(http.StatusMethodNotAllowed) |
| return |
| } |
| - if r.URL.Path != docsChoicePath || r.URL.RawQuery != "" || r.URL.ForceQuery { |
| + if r.URL.Path != c.choicePath || r.URL.RawQuery != "" || r.URL.ForceQuery { |
| http.NotFound(w, r) |
| return |
| } |
| @@ -100,7 +115,7 @@ func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| return |
| } |
| - choices, err := c.store.Choices(r.Context(), subject, "docs") |
| + choices, err := c.store.Choices(r.Context(), subject, c.appID) |
| if err != nil { |
| c.unavailable(w) |
| return |
| @@ -116,7 +131,7 @@ func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) { |
| func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, bool) { |
| session, ok := sessions.Current(r) |
| if !ok { |
| - target := handoff.StartPath + "?" + url.Values{"return_to": {docsEntryPath}}.Encode() |
| + target := handoff.StartPath + "?" + url.Values{"return_to": {c.entryPath}}.Encode() |
| if r.Method == http.MethodPost { |
| // Start a new GET navigation after the form response. The submitted |
| // selection is discarded; sign-in returns to current team choices. |
| @@ -153,5 +168,5 @@ func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata |
| // Callers supply either a validated stored app origin plus the fixed app path, |
| // or the validated directory origin plus the fixed sign-in continuation path. |
| func (c *docsChooser) renderContinuation(w http.ResponseWriter, destination string) { |
| - c.app.renderContinuation(w, destination, "Continue to Docs") |
| + c.app.renderContinuation(w, destination, "Continue to "+c.appName) |
| } |
diff --git a/internal/home/templates/docs_chooser.html b/internal/home/templates/docs_chooser.html| index 5447580..f8de751 100644 |
| --- a/internal/home/templates/docs_chooser.html |
| +++ b/internal/home/templates/docs_chooser.html |
| @@ -1,8 +1,8 @@ |
| {{define "title"}}Choose a team · Oficina{{end}} |
| {{define "content"}} |
| <h1>Choose a team</h1> |
| -<p>Open Docs for one of your teams.</p> |
| -<form method="post" action="/apps/docs/team" rst-form> |
| +<p>Open {{.AppName}} for one of your teams.</p> |
| +<form method="post" action="{{.ChoicePath}}" rst-form> |
| {{range .Choices}} |
| <button type="submit" name="team" value="{{.SQID}}" rst-btn>{{.Name}}</button> |
| {{end}} |
diff --git a/internal/home/templates/docs_empty.html b/internal/home/templates/docs_empty.html| index 4525085..483356e 100644 |
| --- a/internal/home/templates/docs_empty.html |
| +++ b/internal/home/templates/docs_empty.html |
| @@ -1,6 +1,6 @@ |
| {{define "title"}}No teams available · Oficina{{end}} |
| {{define "content"}} |
| <h1>No teams available</h1> |
| -<p>This account has no team with Docs available.</p> |
| +<p>This account has no team with {{.AppName}} available.</p> |
| <p><a href="/">Back to Home</a></p> |
| {{end}} |