feat(server): online session history with configurable stats window (#1243)

Add persisted online sessions, configurable retention and online history statistics with lifecycle, recovery, timezone and concurrency regression coverage.

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
Sam5440andnaiba/CloudCode authored and GitHub committed 2026-10-16 19:25:13 +08:00
1 parent 9881c47f0c
commit 8721a15b12
22 files changed
+2242 -20

No files matched your search

+1
View File
@@ -100,6 +100,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
optionalAuth.GET("/service/:id/history", restScopeMiddleware(model.ScopeServiceRead), commonHandler(getServiceHistory))
optionalAuth.GET("/server/:id/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerServices))
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerMetrics))
optionalAuth.GET("/server/:id/online-history", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerOnlineHistory))
// CSRF middleware applies group-wide. Safe methods short-circuit and
// PAT bearer requests bypass — so the only callers gated are
+121
View File
@@ -0,0 +1,121 @@
package controller
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// Get server online history
// @Summary Get server online history
// @Security BearerAuth
// @Schemes
// @Description Get online periods and aggregated uptime statistics of a specific server
// @Tags common
// @Param id path uint true "Server ID"
// @Param days query int false "Query window in days (default: configured online_stats_days, clamped to it)"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.OnlineHistoryResponse]
// @Router /server/{id}/online-history [get]
func getServerOnlineHistory(c *gin.Context) (*model.OnlineHistoryResponse, error) {
idStr := c.Param("id")
serverID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
server, ok := singleton.ServerShared.Get(serverID)
if !ok || server == nil {
return nil, singleton.Localizer.ErrorT("server not found")
}
if !userCanViewServer(c, server) {
return nil, singleton.Localizer.ErrorT("unauthorized")
}
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
maxDays := singleton.Conf.OnlineHistoryRetentionDays()
if maxDays < model.MinOnlineStatsDays || maxDays > model.MaxOnlineStatsDays {
maxDays = model.DefaultOnlineStatsDays
}
days := maxDays
if daysStr := c.Query("days"); daysStr != "" {
d, err := strconv.Atoi(daysStr)
if err != nil || d < 1 {
return nil, singleton.Localizer.ErrorT("invalid days parameter")
}
// 查询窗口收敛到后台设置的统计范围内
if d > maxDays {
d = maxDays
}
days = d
}
if !isMember && days > 1 {
return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
}
now := time.Now()
windowEnd := now
windowStart := model.OnlineHistoryWindowStart(now, days, singleton.Loc)
sessions, err := singleton.OnlineHistoryShared.Sessions(serverID, windowStart, windowEnd)
if err != nil {
return nil, newGormError("%v", err)
}
periods := model.ClipOnlineSessions(sessions, windowStart, windowEnd)
daily := model.DailyOnlineBreakdown(periods, windowStart, windowEnd, singleton.Loc)
// 总时长取各日之和,与 daily[] 严格对账(每日累计原始时长后统一取整,
// 口径见 DailyOnlineBreakdown)
var totalSeconds uint64
for _, d := range daily {
totalSeconds += d.OnlineSeconds
}
windowSeconds := uint64(windowEnd.Sub(windowStart).Seconds())
onlineRate := 0.0
if windowSeconds > 0 {
onlineRate = float64(totalSeconds) / float64(windowSeconds) * 100
}
runtime := server.RuntimeSnapshot()
currentlyOnline := !runtime.LastActive.IsZero() && now.Sub(runtime.LastActive) <= model.OnlineActiveWindow
var lastActive *time.Time
if !runtime.LastActive.IsZero() {
lastActiveUTC := runtime.LastActive.UTC()
lastActive = &lastActiveUTC
}
// 响应统一以 UTC 序列化,避免窗口时间与库中读回的会话时间混用
// 本地偏移与 Z 两种写法(时段为值拷贝,不影响调用方)
utcPeriods := make([]model.OnlinePeriod, len(periods))
for i, p := range periods {
utcPeriods[i] = model.OnlinePeriod{Start: p.Start.UTC(), End: p.End.UTC(), Ongoing: p.Ongoing}
}
return &model.OnlineHistoryResponse{
ServerID: serverID,
ServerName: server.Name,
Days: days,
MaxDays: maxDays,
Timezone: singleton.Loc.String(),
TzOffset: tzOffsetSeconds(now, singleton.Loc),
WindowStart: windowStart.UTC(),
WindowEnd: windowEnd.UTC(),
TotalOnlineSeconds: totalSeconds,
OnlineRate: onlineRate,
CurrentlyOnline: currentlyOnline,
LastActive: lastActive,
Periods: utcPeriods,
Daily: daily,
}, nil
}
// tzOffsetSeconds 返回指定时区在给定时刻相对 UTC 的偏移秒数。
func tzOffsetSeconds(at time.Time, loc *time.Location) int {
_, offset := at.In(loc).Zone()
return offset
}
@@ -0,0 +1,356 @@
package controller
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/i18n"
"github.com/nezhahq/nezha/service/singleton"
)
func setupOnlineHistoryControllerTest(t *testing.T) (visibleServer, hiddenServer *model.Server) {
t.Helper()
originalDB := singleton.DB
originalLocalizer := singleton.Localizer
originalServer := singleton.ServerShared
originalConf := singleton.Conf
originalLoc := singleton.Loc
if singleton.Localizer == nil {
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Server{}, &model.OnlineSession{}))
singleton.DB = db
singleton.ServerShared = singleton.NewEmptyServerClassForTest()
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{}}
singleton.Conf.OnlineStatsDays = 30
singleton.Loc = time.FixedZone("TEST", 8*3600)
visibleServer = &model.Server{
Common: model.Common{ID: 1},
Name: "visible",
UUID: "online-history-visible",
LastActive: time.Now(),
}
hiddenServer = &model.Server{
Common: model.Common{ID: 2},
Name: "hidden",
UUID: "online-history-hidden",
HideForGuest: true,
}
singleton.ServerShared.InsertForTest(visibleServer)
singleton.ServerShared.InsertForTest(hiddenServer)
t.Cleanup(func() {
_ = sqlDB.Close()
singleton.DB = originalDB
singleton.Localizer = originalLocalizer
singleton.ServerShared = originalServer
singleton.Conf = originalConf
singleton.Loc = originalLoc
})
return visibleServer, hiddenServer
}
func onlineHistoryCtx(t *testing.T, serverID string, query string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/server/"+serverID+"/online-history?"+query, nil)
c.Params = gin.Params{{Key: "id", Value: serverID}}
return c
}
func onlineHistoryCtxAsMember(t *testing.T, serverID string, query string) *gin.Context {
t.Helper()
c := onlineHistoryCtx(t, serverID, query)
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 100}, Role: model.RoleMember})
return c
}
func seedOnlineSessions(t *testing.T, serverID uint64, now time.Time) []model.OnlineSession {
t.Helper()
now = now.Truncate(time.Second)
sessions := []model.OnlineSession{
{ServerID: serverID, StartedAt: now.Add(-48 * time.Hour), EndedAt: now.Add(-47 * time.Hour), LastActiveAt: now.Add(-47 * time.Hour)},
{ServerID: serverID, StartedAt: now.Add(-25 * time.Hour), EndedAt: now.Add(-23*time.Hour - 30*time.Minute), LastActiveAt: now.Add(-23 * time.Hour)},
{ServerID: serverID, StartedAt: now.Add(-10 * time.Minute), Ongoing: true, LastActiveAt: now},
// 超出默认 7 天窗口、但在 30 天窗口内的会话
{ServerID: serverID, StartedAt: now.Add(-20 * time.Hour * 24), EndedAt: now.Add(-20*time.Hour*24 + time.Hour), LastActiveAt: now.Add(-20 * time.Hour * 24)},
}
for i := range sessions {
require.NoError(t, singleton.DB.Create(&sessions[i]).Error)
}
return sessions
}
func TestGetServerOnlineHistoryDefaultsToConfiguredWindow(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
now := time.Now()
seedOnlineSessions(t, visible.ID, now)
// 不带 days 参数:使用后台设置的统计范围(30 天)
c := onlineHistoryCtxAsMember(t, "1", "")
resp, err := getServerOnlineHistory(c)
require.NoError(t, err)
require.Equal(t, 30, resp.Days)
require.Equal(t, 30, resp.MaxDays)
// 4 个会话全部落在 30 天窗口内
require.Len(t, resp.Periods, 4)
require.True(t, resp.CurrentlyOnline)
}
func TestGetServerOnlineHistoryDefaultIsSevenDays(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
now := time.Now()
seedOnlineSessions(t, visible.ID, now)
// 出厂默认统计范围为 7 天
singleton.Conf.OnlineStatsDays = model.DefaultOnlineStatsDays
c := onlineHistoryCtxAsMember(t, "1", "")
resp, err := getServerOnlineHistory(c)
require.NoError(t, err)
require.Equal(t, 7, resp.Days)
// 20 天前的会话不进入 7 天窗口
require.Len(t, resp.Periods, 3)
}
func TestGetServerOnlineHistoryAggregatesAndClampsWindow(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
now := time.Now()
seedOnlineSessions(t, visible.ID, now)
c := onlineHistoryCtxAsMember(t, "1", "days=9999")
resp, err := getServerOnlineHistory(c)
require.NoError(t, err)
// 请求超出后台设置窗口时收敛到设置值
require.Equal(t, 30, resp.Days)
// 聚合正确性:三个已结束/进行中会话都计入,进行中会话按当前时间截断
var total uint64
for _, p := range resp.Periods {
total += uint64(p.End.Sub(p.Start).Seconds())
}
require.Equal(t, resp.TotalOnlineSeconds, total)
require.GreaterOrEqual(t, resp.TotalOnlineSeconds, uint64(3600+5400+599))
// 每日拆分合计等于总时长
var dailySum uint64
for _, d := range resp.Daily {
dailySum += d.OnlineSeconds
}
require.Equal(t, resp.TotalOnlineSeconds, dailySum)
// 在线率按窗口秒数计算
windowSeconds := resp.WindowEnd.Sub(resp.WindowStart).Seconds()
require.InDelta(t, float64(resp.TotalOnlineSeconds)/windowSeconds*100, resp.OnlineRate, 0.001)
}
func TestGetServerOnlineHistoryInvalidDays(t *testing.T) {
setupOnlineHistoryControllerTest(t)
for _, q := range []string{"days=abc", "days=0", "days=-1"} {
c := onlineHistoryCtxAsMember(t, "1", q)
_, err := getServerOnlineHistory(c)
require.EqualError(t, err, singleton.Localizer.ErrorT("invalid days parameter").Error(), "query %q should be rejected", q)
}
}
func TestGetServerOnlineHistoryUnknownServer(t *testing.T) {
setupOnlineHistoryControllerTest(t)
c := onlineHistoryCtx(t, "424242", "")
_, err := getServerOnlineHistory(c)
require.Error(t, err)
}
func TestGetServerOnlineHistoryGuestLimitedToOneDay(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
now := time.Now()
seedOnlineSessions(t, visible.ID, now)
// 游客不带 days 参数时默认为配置窗口(>1 天),与 metrics 的游客语义一致被拒绝
_, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", ""))
require.Error(t, err)
// 游客显式请求多天被拒绝,1 天可用
_, err = getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=3"))
require.Error(t, err)
resp, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=1"))
require.NoError(t, err)
require.Equal(t, 1, resp.Days)
// 与 1 天窗口相交的两个会话:一个被裁剪到窗口起点,一个进行中
require.Len(t, resp.Periods, 2)
// 登录用户不受游客窗口限制
resp, err = getServerOnlineHistory(onlineHistoryCtxAsMember(t, "1", "days=30"))
require.NoError(t, err)
require.Equal(t, 30, resp.Days)
require.Len(t, resp.Periods, 4)
}
func TestGetServerOnlineHistoryPermission(t *testing.T) {
visible, _ := setupOnlineHistoryControllerTest(t)
// 游客可以查看未隐藏服务器(窗口受限于 1 天)
guest := onlineHistoryCtx(t, "1", "days=1")
resp, err := getServerOnlineHistory(guest)
require.NoError(t, err)
require.Equal(t, visible.ID, resp.ServerID)
// 游客不能查看 HideForGuest 服务器
guestHidden := onlineHistoryCtx(t, "2", "days=1")
_, err = getServerOnlineHistory(guestHidden)
require.EqualError(t, err, singleton.Localizer.ErrorT("unauthorized").Error())
// 成员不能查看他人隐藏服务器
member := onlineHistoryCtx(t, "2", "days=7")
member.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 100}, Role: model.RoleMember})
_, err = getServerOnlineHistory(member)
require.Error(t, err)
}
func patchOnlineStatsDays(t *testing.T, days any) error {
t.Helper()
gin.SetMode(gin.TestMode)
body, err := json.Marshal(map[string]any{"user_template": "user-dist", "online_stats_days": days})
require.NoError(t, err)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPatch, "/api/v1/setting", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
_, err = updateConfig(c)
return err
}
func TestUpdateConfigOnlineStatsDaysRange(t *testing.T) {
originalLocalizer := singleton.Localizer
if singleton.Localizer == nil {
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
}
t.Cleanup(func() { singleton.Localizer = originalLocalizer })
templates := []model.FrontendTemplate{{Path: "user-dist", IsAdmin: false}}
originalFrontendTemplates := singleton.FrontendTemplates
singleton.FrontendTemplates = templates
t.Cleanup(func() { singleton.FrontendTemplates = originalFrontendTemplates })
conf := &singleton.ConfigClass{Config: &model.Config{}}
configPath := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, conf.Read(configPath, templates))
originalConf := singleton.Conf
singleton.Conf = conf
t.Cleanup(func() { singleton.Conf = originalConf })
require.Equal(t, model.DefaultOnlineStatsDays, conf.OnlineStatsDays)
// 合法边界值
for _, d := range []int{model.MinOnlineStatsDays, 30, model.MaxOnlineStatsDays} {
require.NoError(t, patchOnlineStatsDays(t, d), "days=%d should be accepted", d)
require.Equal(t, d, conf.OnlineStatsDays)
}
// 越界值整体拒绝且不修改现值
for _, d := range []int{6, model.MaxOnlineStatsDays + 1, 0, -5} {
require.Error(t, patchOnlineStatsDays(t, d), "days=%d should be rejected", d)
require.Equal(t, model.MaxOnlineStatsDays, conf.OnlineStatsDays)
}
// 非法值必须整体拒绝:校验先于任何对 Conf 的写入完成,其他字段不得
// 半提交(评审复现场景:错误返回后 SiteName 不能被请求内容覆盖)
conf.SiteName = "existing-site"
require.Error(t, patchOnlineStatsDays(t, 6))
require.Equal(t, "existing-site", conf.SiteName)
conf.SiteName = ""
// 字段缺省时保留现值(部分 PATCH 语义)
require.NoError(t, patchOnlineStatsDays(t, nil))
require.Equal(t, model.MaxOnlineStatsDays, conf.OnlineStatsDays)
// 重启持久化断言:updateConfig 显式 Save 后,重新从磁盘读取的配置
// 必须带上刚写入的值(持久化不再依赖 MCP 过渡块的副作用)
reloaded := &singleton.ConfigClass{Config: &model.Config{}}
require.NoError(t, reloaded.Read(configPath, templates))
require.Equal(t, model.MaxOnlineStatsDays, reloaded.OnlineStatsDays)
}
func TestSettingFormOnlineStatsDaysDecode(t *testing.T) {
var absent model.SettingForm
require.NoError(t, json.NewDecoder(bytes.NewReader([]byte(`{"site_name":"x"}`))).Decode(&absent))
require.Nil(t, absent.OnlineStatsDays, "omitted key must decode to nil")
var explicit model.SettingForm
require.NoError(t, json.NewDecoder(bytes.NewReader([]byte(`{"online_stats_days":3650}`))).Decode(&explicit))
require.NotNil(t, explicit.OnlineStatsDays)
require.Equal(t, 3650, *explicit.OnlineStatsDays)
}
func TestOnlineHistoryReadsPendingCloseBoundary(t *testing.T) {
setupOnlineHistoryControllerTest(t)
previous := singleton.OnlineHistoryShared
t.Cleanup(func() { singleton.OnlineHistoryShared = previous })
r := singleton.NewOnlineHistoryRecorder()
singleton.OnlineHistoryShared = r
start := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
r.RecordOnline(1, start)
require.NoError(t, singleton.DB.Exec(`CREATE TRIGGER reject_close BEFORE UPDATE ON online_sessions WHEN NEW.ongoing = 0 BEGIN SELECT RAISE(ABORT, 'close failure'); END`).Error)
r.RecordOffline(1, start.Add(time.Minute))
r.RecordOnline(1, start.Add(30*time.Minute))
resp, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=1"))
require.NoError(t, err)
require.Len(t, resp.Periods, 2)
require.False(t, resp.Periods[0].Ongoing)
require.True(t, resp.Periods[0].End.Equal(start.Add(time.Minute)))
require.Less(t, resp.TotalOnlineSeconds, uint64(1900))
}
func TestOnlineHistoryQueryWithNonUTCSession(t *testing.T) {
setupOnlineHistoryControllerTest(t)
now := time.Now()
offset := time.FixedZone("UTC+12", 12*3600)
session := model.OnlineSession{ServerID: 1, StartedAt: now.Add(-time.Hour).In(offset), LastActiveAt: now.Add(-time.Minute).In(offset), Ongoing: true}
require.NoError(t, singleton.DB.Create(&session).Error)
resp, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=1"))
require.NoError(t, err)
require.Len(t, resp.Periods, 1)
require.True(t, resp.Periods[0].Start.Equal(session.StartedAt))
require.InDelta(t, 3600, resp.TotalOnlineSeconds, 2)
}
func TestUpdateConfigFailedSavePreservesRetention(t *testing.T) {
server, _ := setupOnlineHistoryControllerTest(t)
require.NoError(t, singleton.DB.Create(server).Error)
templates := []model.FrontendTemplate{{Path: "user-dist"}}
previous := singleton.FrontendTemplates
singleton.FrontendTemplates = templates
t.Cleanup(func() { singleton.FrontendTemplates = previous })
path := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, singleton.Conf.Read(path, templates))
singleton.Conf.OnlineStatsDays = 30
require.NoError(t, singleton.Conf.Save())
// Replacing the destination with a directory deterministically fails Save even as root.
require.NoError(t, os.Remove(path))
require.NoError(t, os.Mkdir(path, 0700))
now := time.Now().UTC()
old := model.OnlineSession{ServerID: 1, StartedAt: now.AddDate(0, 0, -15), EndedAt: now.AddDate(0, 0, -14)}
require.NoError(t, singleton.DB.Create(&old).Error)
require.Error(t, patchOnlineStatsDays(t, 7))
require.Equal(t, 30, singleton.Conf.OnlineStatsDays)
singleton.PruneOnlineSessions()
var retained model.OnlineSession
require.NoError(t, singleton.DB.First(&retained, old.ID).Error)
require.Equal(t, old.ID, retained.ID)
}
+21 -9
View File
@@ -2,6 +2,7 @@ package controller
import (
"errors"
"fmt"
"log"
"strings"
@@ -29,7 +30,7 @@ func listConfig(c *gin.Context) (*model.SettingResponse, error) {
isAdmin = user.Role.IsAdmin()
}
config := *singleton.Conf
config := singleton.Conf.SettingsSnapshot()
config.Language = strings.ReplaceAll(config.Language, "_", "-")
conf := model.SettingResponse{
@@ -92,6 +93,12 @@ func updateConfig(c *gin.Context) (any, error) {
if !userTemplateValid {
return nil, errors.New("invalid user template")
}
// 全部输入校验必须先于任何对 singleton.Conf 的写入:任一字段非法都应
// 整体拒绝,避免"请求以错误收场、先处理的字段却已生效"的半提交状态
if sf.OnlineStatsDays != nil &&
(*sf.OnlineStatsDays < model.MinOnlineStatsDays || *sf.OnlineStatsDays > model.MaxOnlineStatsDays) {
return nil, fmt.Errorf("online_stats_days must be between %d and %d", model.MinOnlineStatsDays, model.MaxOnlineStatsDays)
}
singleton.Conf.Language = strings.ReplaceAll(sf.Language, "-", "_")
@@ -114,18 +121,23 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.AgentRealIPHeader = sf.AgentRealIPHeader
singleton.Conf.AgentTLS = sf.AgentTLS
singleton.Conf.UserTemplate = sf.UserTemplate
mcpWasEnabled := singleton.Conf.MCPEnabled()
mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled)
if err := applyEnableMCPTransition(
mcpWasEnabled, mcpNext,
singleton.Conf.SetMCPEnabled,
singleton.Conf.Save,
fireMCPKillSwitch,
); err != nil {
if err := singleton.Conf.CommitOnlineHistoryRetention(sf.OnlineStatsDays, func() error {
mcpWasEnabled := singleton.Conf.MCPEnabled()
mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled)
return applyEnableMCPTransition(
mcpWasEnabled, mcpNext,
singleton.Conf.SetMCPEnabled,
singleton.Conf.Save,
fireMCPKillSwitch,
)
}); err != nil {
return nil, newGormError("%v", err)
}
// applyEnableMCPTransition already persisted every setting, even when MCP
// is unchanged. A second save could fail after irreversible cleanup.
singleton.OnUpdateLang(singleton.Conf.Language)
return nil, nil
}
@@ -0,0 +1,49 @@
package controller
import (
"go/ast"
"go/parser"
"go/token"
"testing"
"github.com/stretchr/testify/require"
)
// The transition helper owns Save (including unchanged MCP settings). A direct
// second Save after it can fail after persistence and irreversible revocation.
func TestUpdateConfigHasSinglePersistenceOwner(t *testing.T) {
file, err := parser.ParseFile(token.NewFileSet(), "setting.go", nil, 0)
require.NoError(t, err)
transitions, directSaves := 0, 0
found := false
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Name.Name != "updateConfig" {
continue
}
found = true
ast.Inspect(fn.Body, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "applyEnableMCPTransition" {
transitions++
}
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "Save" {
directSaves++
}
return true
})
}
require.True(t, found)
require.Equal(t, 1, transitions)
require.Zero(t, directSaves)
}
func TestUnchangedMCPTransitionStillSavesOnce(t *testing.T) {
saves := 0
err := applyEnableMCPTransition(true, true, func(bool) {}, func() error { saves++; return nil }, func() { t.Error("unchanged MCP must not revoke streams") })
require.NoError(t, err)
require.Equal(t, 1, saves)
}