sub2api/backend/internal/handler/admin/setting_handler_custom_menu_test.go
zizi ac5ff854b2 feat(custom-menu): secure embedded shop pages
Add omit_auth_context as a safe default for custom menu iframe pages, converge untrusted public output, and document the deployed shop menu configuration.
2026-05-27 14:28:17 +08:00

332 lines
12 KiB
Go

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
}