package admin import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "testing" "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/handler/dto" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" "github.com/stretchr/testify/require" ) func TestSettingHandler_UpdateSettings_CustomMenuNewItemDefaultsSafe(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{}, "") rec := updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{ "id": "shop", "label": "Shop", "url": "https://shop.example.com/pay", "visibility": "user", }}) require.Equal(t, http.StatusOK, rec.Code) items := storedCustomMenuItems(t, repo) require.Len(t, items, 1) require.NotNil(t, items[0].OmitAuthContext) require.True(t, *items[0].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuLegacyMissingFieldPreservedForSameTrustedURL(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, `[{"id":"legacy","label":"Legacy","url":"https://app.example.com/internal","visibility":"user"}]`) updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{ "id": "legacy", "label": "Legacy Renamed", "url": "https://app.example.com/internal", "visibility": "user", }}) items := storedCustomMenuItems(t, repo) require.Len(t, items, 1) require.Nil(t, items[0].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuLegacyWithoutIDPreservedByFingerprint(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, `[{"label":"Legacy","url":"https://app.example.com/internal","visibility":"user"}]`) updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{ "label": "Legacy", "url": "https://app.example.com/internal", "visibility": "user", }}) items := storedCustomMenuItems(t, repo) require.Len(t, items, 1) require.NotEmpty(t, items[0].ID) require.Nil(t, items[0].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuLegacyUntrustedSameOriginPreserved(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, `[{"id":"legacy","label":"Legacy","url":"https://shop.example.com/old","visibility":"user"}]`) updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{ "id": "legacy", "label": "Legacy Renamed", "url": "https://shop.example.com/new", "visibility": "user", }}) items := storedCustomMenuItems(t, repo) require.Len(t, items, 1) require.Nil(t, items[0].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuLegacyMovedToUntrustedExternalBecomesSafe(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, `[{"id":"legacy","label":"Legacy","url":"https://app.example.com/internal","visibility":"user"}]`) updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{ "id": "legacy", "label": "Legacy", "url": "https://shop.example.com/pay", "visibility": "user", }}) items := storedCustomMenuItems(t, repo) require.Len(t, items, 1) require.NotNil(t, items[0].OmitAuthContext) require.True(t, *items[0].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuRejectsExplicitFalseForUntrustedURL(t *testing.T) { _, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, "") updateCustomMenuSettings(t, handler, http.StatusBadRequest, []map[string]any{{ "id": "leak", "label": "Leak", "url": "https://shop.example.com/pay", "visibility": "user", "omit_auth_context": false, }}) } func TestSettingHandler_UpdateSettings_CustomMenuStoredFrontendURLDoesNotSelfAuthorize(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{}, `[]`) repo.values[service.SettingKeyFrontendURL] = "https://app.example.com" updateCustomMenuSettings(t, handler, http.StatusBadRequest, []map[string]any{{ "id": "internal", "label": "Internal", "url": "https://app.example.com/internal", "visibility": "user", "omit_auth_context": false, }}) } func TestSettingHandler_UpdateSettings_CustomMenuTrustUsesEnvAllowlist(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{ Security: config.SecurityConfig{ CustomMenuAuthContextTrustedOrigins: []string{"https://app.example.com"}, }, }, `[]`) updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{ "id": "internal", "label": "Internal", "url": "https://app.example.com/internal", "visibility": "user", "omit_auth_context": false, }}) items := storedCustomMenuItems(t, repo) require.Len(t, items, 1) require.NotNil(t, items[0].OmitAuthContext) require.False(t, *items[0].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuNewItemsWithoutIDGetUniqueIDs(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{}, "") updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{ { "label": "Shop One", "url": "https://shop.example.com/one", "visibility": "user", }, { "label": "Shop Two", "url": "https://shop.example.com/two", "visibility": "user", }, }) items := storedCustomMenuItems(t, repo) require.Len(t, items, 2) require.NotEmpty(t, items[0].ID) require.NotEmpty(t, items[1].ID) require.NotEqual(t, items[0].ID, items[1].ID) require.NotNil(t, items[0].OmitAuthContext) require.True(t, *items[0].OmitAuthContext) require.NotNil(t, items[1].OmitAuthContext) require.True(t, *items[1].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuLegacyItemsWithoutIDRoundTrip(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, `[ {"label":"Legacy One","url":"https://app.example.com/one","visibility":"user"}, {"label":"Legacy Two","url":"https://app.example.com/two","visibility":"user"} ]`) updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{ { "label": "Legacy One Renamed", "url": "https://app.example.com/one", "visibility": "user", }, { "label": "Legacy Two Renamed", "url": "https://app.example.com/two", "visibility": "user", }, }) items := storedCustomMenuItems(t, repo) require.Len(t, items, 2) require.NotEmpty(t, items[0].ID) require.NotEmpty(t, items[1].ID) require.NotEqual(t, items[0].ID, items[1].ID) require.Nil(t, items[0].OmitAuthContext) require.Nil(t, items[1].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuLegacyWithoutIDRenamePreservesMissingField(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, `[{"label":"Legacy","url":"https://app.example.com/internal","visibility":"user"}]`) updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{ "label": "Legacy Renamed", "url": "https://app.example.com/internal", "visibility": "user", }}) items := storedCustomMenuItems(t, repo) require.Len(t, items, 1) require.NotEmpty(t, items[0].ID) require.Nil(t, items[0].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuLegacyExplicitFalseRoundTripPreserved(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, `[{"id":"legacy","label":"Legacy","url":"https://shop.example.com/old","visibility":"user","omit_auth_context":false}]`) updateCustomMenuSettings(t, handler, http.StatusOK, []map[string]any{{ "id": "legacy", "label": "Legacy Renamed", "url": "https://shop.example.com/new", "visibility": "user", "omit_auth_context": false, }}) items := storedCustomMenuItems(t, repo) require.Len(t, items, 1) require.NotNil(t, items[0].OmitAuthContext) require.False(t, *items[0].OmitAuthContext) } func TestSettingHandler_UpdateSettings_CustomMenuLegacyExplicitFalseMovedToNewUntrustedRejected(t *testing.T) { _, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, `[{"id":"legacy","label":"Legacy","url":"https://shop.example.com/old","visibility":"user","omit_auth_context":false}]`) updateCustomMenuSettings(t, handler, http.StatusBadRequest, []map[string]any{{ "id": "legacy", "label": "Legacy", "url": "https://other.example.com/pay", "visibility": "user", "omit_auth_context": false, }}) } func TestSettingHandler_GetSettings_CustomMenuReturnsStoredValue(t *testing.T) { repo, handler := newCustomMenuSettingHandler(t, &config.Config{ Server: config.ServerConfig{FrontendURL: "https://app.example.com"}, }, `[ {"id":"legacy","label":"Legacy","url":"https://shop.example.com/pay","visibility":"user"}, {"id":"unsafe","label":"Unsafe","url":"https://shop.example.com/pay","visibility":"admin","omit_auth_context":false}, {"id":"trusted","label":"Trusted","url":"https://app.example.com/internal","visibility":"user"} ]`) _ = repo rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/admin/settings", nil) handler.GetSettings(c) require.Equal(t, http.StatusOK, rec.Code) var resp response.Response require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) data := resp.Data.(map[string]any) rawItems, err := json.Marshal(data["custom_menu_items"]) require.NoError(t, err) var items []dto.CustomMenuItem require.NoError(t, json.Unmarshal(rawItems, &items)) require.Len(t, items, 3) require.Nil(t, items[0].OmitAuthContext) require.NotNil(t, items[1].OmitAuthContext) require.False(t, *items[1].OmitAuthContext) require.Nil(t, items[2].OmitAuthContext) } func newCustomMenuSettingHandler(t *testing.T, cfg *config.Config, existingCustomMenuJSON string) (*settingHandlerRepoStub, *SettingHandler) { t.Helper() gin.SetMode(gin.TestMode) values := map[string]string{ service.SettingKeyPromoCodeEnabled: "true", } if existingCustomMenuJSON != "" { values[service.SettingKeyCustomMenuItems] = existingCustomMenuJSON } repo := &settingHandlerRepoStub{values: values} svc := service.NewSettingService(repo, cfg) return repo, NewSettingHandler(svc, nil, nil, nil, nil, nil, nil) } func updateCustomMenuSettings(t *testing.T, handler *SettingHandler, wantStatus int, items []map[string]any) *httptest.ResponseRecorder { t.Helper() body := map[string]any{ "promo_code_enabled": true, "custom_menu_items": items, } rawBody, err := json.Marshal(body) require.NoError(t, err) rec := httptest.NewRecorder() c, _ := gin.CreateTestContext(rec) c.Request = httptest.NewRequest(http.MethodPut, "/api/v1/admin/settings", bytes.NewReader(rawBody)) c.Request.Header.Set("Content-Type", "application/json") handler.UpdateSettings(c) require.Equal(t, wantStatus, rec.Code, rec.Body.String()) return rec } func storedCustomMenuItems(t *testing.T, repo *settingHandlerRepoStub) []dto.CustomMenuItem { t.Helper() raw := repo.values[service.SettingKeyCustomMenuItems] require.NotEmpty(t, raw) var items []dto.CustomMenuItem require.NoError(t, json.Unmarshal([]byte(raw), &items)) return items }