Skip to content

Commit 40b2ddc

Browse files
committed
Refactor dashboard extension to support remote contributors
- Adjusted the discovery integration to start after all extensions have registered, ensuring proper wiring of services. - Updated layout paths to differentiate between local and remote contributors, allowing for correct routing. - Enhanced the `extractContributorName` function to recognize both local and remote prefixes in URLs. - Added unit tests for the `extractContributorName` function to ensure correct behavior across various scenarios. - Modified the `PagesManager` to handle routing for both local and remote contributors, including proper context preparation and access control. - Implemented a fallback mechanism for contributor pages, redirecting to remote paths when necessary. - Enhanced the `FragmentProxy` to forward query parameters to remote contributors and cache responses based on route and query. - Added tests for the `FragmentProxy` to verify query forwarding, cache behavior, and handling of unknown contributors.
1 parent 15a7889 commit 40b2ddc

17 files changed

Lines changed: 2268 additions & 112 deletions

extensions/dashboard/contributor/interfaces.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,26 @@ func (p Params) PagePath(subPath string) string {
2828
return p.PageBase + subPath
2929
}
3030

31+
// pageBaseContextKey scopes the page-base value stored on the request context.
32+
type pageBaseContextKey struct{}
33+
34+
// WithPageBase returns a context with the contributor's page-base prefix
35+
// embedded. The host injects this so manifest-level templ components
36+
// (e.g. SidebarHeaderContent / TopbarExtraContent) can render links that
37+
// resolve back to the consumer's URL space without needing to be passed Params
38+
// directly.
39+
func WithPageBase(ctx context.Context, pageBase string) context.Context {
40+
return context.WithValue(ctx, pageBaseContextKey{}, pageBase)
41+
}
42+
43+
// PageBaseFromContext returns the page-base prefix embedded by WithPageBase,
44+
// or "" when none was set.
45+
func PageBaseFromContext(ctx context.Context) string {
46+
v, _ := ctx.Value(pageBaseContextKey{}).(string)
47+
48+
return v
49+
}
50+
3151
// DashboardContributor is the base interface every dashboard extension implements.
3252
// It provides a manifest describing the contributor's capabilities.
3353
type DashboardContributor interface {

extensions/dashboard/contributor/remote.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,15 @@ func (rc *RemoteContributor) BaseURL() string {
6464
return rc.baseURL
6565
}
6666

67-
// FetchPage fetches an HTML page fragment from the remote service.
68-
func (rc *RemoteContributor) FetchPage(ctx context.Context, route string) ([]byte, error) {
67+
// FetchPage fetches an HTML page fragment from the remote service. rawQuery
68+
// is the request's URL query string (without the leading "?") and is forwarded
69+
// verbatim — detail pages and any other route relying on query parameters
70+
// (e.g. /users/detail?id=…) will 404 if it's dropped.
71+
func (rc *RemoteContributor) FetchPage(ctx context.Context, route, rawQuery string) ([]byte, error) {
6972
url := rc.baseURL + "/_forge/dashboard/pages" + route
73+
if rawQuery != "" {
74+
url += "?" + rawQuery
75+
}
7076

7177
return rc.fetch(ctx, url)
7278
}
Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
package contributor
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"path/filepath"
10+
"testing"
11+
"time"
12+
)
13+
14+
// recorderServer captures the most recent inbound request and lets each test
15+
// drive the response. It removes the boilerplate of writing fresh httptest
16+
// servers per case.
17+
type recorderServer struct {
18+
t *testing.T
19+
server *httptest.Server
20+
lastPath string
21+
lastQuery string
22+
lastHeaders http.Header
23+
hits int
24+
respond func(w http.ResponseWriter, r *http.Request)
25+
}
26+
27+
func newRecorderServer(t *testing.T) *recorderServer {
28+
t.Helper()
29+
30+
rs := &recorderServer{t: t}
31+
rs.respond = func(w http.ResponseWriter, _ *http.Request) {
32+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
33+
_, _ = w.Write([]byte("<div>ok</div>"))
34+
}
35+
36+
rs.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
37+
rs.lastPath = r.URL.Path
38+
rs.lastQuery = r.URL.RawQuery
39+
rs.lastHeaders = r.Header.Clone()
40+
rs.hits++
41+
rs.respond(w, r)
42+
}))
43+
44+
t.Cleanup(rs.server.Close)
45+
46+
return rs
47+
}
48+
49+
func TestRemoteContributor_FetchPage_ForwardsRoute(t *testing.T) {
50+
rs := newRecorderServer(t)
51+
52+
rc := NewRemoteContributor(rs.server.URL, &Manifest{Name: "authsome"})
53+
54+
body, err := rc.FetchPage(context.Background(), "/users", "")
55+
if err != nil {
56+
t.Fatalf("FetchPage: %v", err)
57+
}
58+
59+
if got, want := rs.lastPath, "/_forge/dashboard/pages/users"; got != want {
60+
t.Fatalf("upstream path = %q, want %q", got, want)
61+
}
62+
63+
if rs.lastQuery != "" {
64+
t.Fatalf("upstream query = %q, want empty", rs.lastQuery)
65+
}
66+
67+
if string(body) != "<div>ok</div>" {
68+
t.Fatalf("body = %q, want <div>ok</div>", body)
69+
}
70+
}
71+
72+
func TestRemoteContributor_FetchPage_ForwardsRawQuery(t *testing.T) {
73+
rs := newRecorderServer(t)
74+
75+
rc := NewRemoteContributor(rs.server.URL, &Manifest{Name: "authsome"})
76+
77+
if _, err := rc.FetchPage(context.Background(), "/environments/detail", "id=aenv_xyz&extra=1"); err != nil {
78+
t.Fatalf("FetchPage: %v", err)
79+
}
80+
81+
if got, want := rs.lastPath, "/_forge/dashboard/pages/environments/detail"; got != want {
82+
t.Fatalf("path = %q, want %q", got, want)
83+
}
84+
85+
if got, want := rs.lastQuery, "id=aenv_xyz&extra=1"; got != want {
86+
t.Fatalf("query = %q, want %q", got, want)
87+
}
88+
}
89+
90+
func TestRemoteContributor_FetchPage_PropagatesAPIKey(t *testing.T) {
91+
rs := newRecorderServer(t)
92+
93+
rc := NewRemoteContributor(rs.server.URL, &Manifest{Name: "authsome"}, WithAPIKey("svc-key-123"))
94+
95+
if _, err := rc.FetchPage(context.Background(), "/users", ""); err != nil {
96+
t.Fatalf("FetchPage: %v", err)
97+
}
98+
99+
if got, want := rs.lastHeaders.Get("X-Forge-Api-Key"), "svc-key-123"; got != want {
100+
t.Fatalf("X-Forge-Api-Key header = %q, want %q", got, want)
101+
}
102+
103+
if got, want := rs.lastHeaders.Get("X-Forge-Dashboard"), "true"; got != want {
104+
t.Fatalf("X-Forge-Dashboard header = %q, want %q", got, want)
105+
}
106+
}
107+
108+
func TestRemoteContributor_FetchPage_ErrorOnNon2xx(t *testing.T) {
109+
rs := newRecorderServer(t)
110+
rs.respond = func(w http.ResponseWriter, _ *http.Request) {
111+
http.Error(w, "boom", http.StatusInternalServerError)
112+
}
113+
114+
rc := NewRemoteContributor(rs.server.URL, &Manifest{Name: "authsome"})
115+
116+
body, err := rc.FetchPage(context.Background(), "/users", "")
117+
if err == nil {
118+
t.Fatalf("expected error on 500, got body=%q", body)
119+
}
120+
}
121+
122+
func TestRemoteContributor_FetchPage_ContextCancelled(t *testing.T) {
123+
rs := newRecorderServer(t)
124+
rs.respond = func(w http.ResponseWriter, r *http.Request) {
125+
select {
126+
case <-r.Context().Done():
127+
case <-time.After(2 * time.Second):
128+
}
129+
}
130+
131+
rc := NewRemoteContributor(rs.server.URL, &Manifest{Name: "authsome"})
132+
133+
ctx, cancel := context.WithCancel(context.Background())
134+
cancel()
135+
136+
if _, err := rc.FetchPage(ctx, "/users", ""); err == nil {
137+
t.Fatalf("expected error from cancelled context")
138+
}
139+
}
140+
141+
func TestFetchManifest_DecodesPluginNav(t *testing.T) {
142+
fixturePath := filepath.Join("testdata", "manifest_full.json")
143+
144+
body, err := os.ReadFile(fixturePath)
145+
if err != nil {
146+
t.Fatalf("read fixture: %v", err)
147+
}
148+
149+
rs := newRecorderServer(t)
150+
rs.respond = func(w http.ResponseWriter, _ *http.Request) {
151+
w.Header().Set("Content-Type", "application/json")
152+
_, _ = w.Write(body)
153+
}
154+
155+
manifest, err := FetchManifest(context.Background(), rs.server.URL, 5*time.Second, "")
156+
if err != nil {
157+
t.Fatalf("FetchManifest: %v", err)
158+
}
159+
160+
if got, want := rs.lastPath, "/_forge/dashboard/manifest"; got != want {
161+
t.Fatalf("manifest path = %q, want %q", got, want)
162+
}
163+
164+
if manifest.Name != "authsome" {
165+
t.Fatalf("name = %q, want authsome", manifest.Name)
166+
}
167+
168+
if got, want := manifest.Layout, "extension"; got != want {
169+
t.Fatalf("layout = %q, want %q", got, want)
170+
}
171+
172+
if !manifest.ShowSidebarOrDefault() {
173+
t.Fatalf("show_sidebar should be true for the authsome fixture")
174+
}
175+
176+
// The fixture is the live identity manifest captured during this work
177+
// (see plan). It must round-trip every group authsome contributes,
178+
// including plugin-only ones. If a future change drops a plugin from the
179+
// platform manifest, recapture the fixture and update the assertion.
180+
wantGroups := map[string]bool{
181+
"Authsome": false,
182+
"User Management": false,
183+
"Access Control": false,
184+
"Configuration": false,
185+
"Developer": false,
186+
"Authentication": false,
187+
"Security": false,
188+
"Billing": false,
189+
"Provisioning": false,
190+
}
191+
192+
for _, item := range manifest.Nav {
193+
if _, ok := wantGroups[item.Group]; ok {
194+
wantGroups[item.Group] = true
195+
}
196+
}
197+
198+
for group, seen := range wantGroups {
199+
if !seen {
200+
t.Errorf("manifest fixture missing nav group %q — plugin nav merging regressed", group)
201+
}
202+
}
203+
204+
// Spot-check a plugin-contributed item that's only there if the
205+
// organization plugin's DashboardNavItems() round-tripped.
206+
wantItems := map[string]string{
207+
"/organizations": "Organizations",
208+
"/oauth2-clients": "OAuth2 Clients",
209+
"/scim": "SCIM",
210+
"/passkeys": "Passkeys",
211+
"/anomaly-detection": "Anomaly Detection",
212+
}
213+
214+
for path, label := range wantItems {
215+
found := false
216+
217+
for _, item := range manifest.Nav {
218+
if item.Path == path && item.Label == label {
219+
found = true
220+
221+
break
222+
}
223+
}
224+
225+
if !found {
226+
t.Errorf("nav item %q (label %q) missing from fixture", path, label)
227+
}
228+
}
229+
}
230+
231+
func TestFetchManifest_NonJSONBodyFails(t *testing.T) {
232+
rs := newRecorderServer(t)
233+
rs.respond = func(w http.ResponseWriter, _ *http.Request) {
234+
_, _ = w.Write([]byte("<html>not json</html>"))
235+
}
236+
237+
if _, err := FetchManifest(context.Background(), rs.server.URL, 1*time.Second, ""); err == nil {
238+
t.Fatalf("expected JSON decode error, got nil")
239+
}
240+
}
241+
242+
func TestFetchManifest_FixtureMatchesLiveSchema(t *testing.T) {
243+
// Dual-decode: once into Manifest, once back to JSON, parse the byte-by-byte
244+
// re-encoding into a generic map to confirm we don't silently lose any
245+
// top-level keys the live identity service produces.
246+
body, err := os.ReadFile(filepath.Join("testdata", "manifest_full.json"))
247+
if err != nil {
248+
t.Fatalf("read fixture: %v", err)
249+
}
250+
251+
var manifest Manifest
252+
if err := json.Unmarshal(body, &manifest); err != nil {
253+
t.Fatalf("decode: %v", err)
254+
}
255+
256+
if len(manifest.Nav) < 25 {
257+
t.Fatalf("expected at least 25 nav items in fixture, got %d", len(manifest.Nav))
258+
}
259+
260+
if len(manifest.Widgets) < 10 {
261+
t.Fatalf("expected at least 10 widgets in fixture, got %d", len(manifest.Widgets))
262+
}
263+
}

extensions/dashboard/contributor/ssr.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ func (sc *SSRContributor) RenderPage(ctx context.Context, route string, _ Params
175175
return nil, fmt.Errorf("ssr contributor %q: not started", sc.manifest.Name)
176176
}
177177

178-
data, err := remote.FetchPage(ctx, route)
178+
data, err := remote.FetchPage(ctx, route, "")
179179
if err != nil {
180180
return nil, err
181181
}

0 commit comments

Comments
 (0)