package server import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "glchat/internal/permissions" "glchat/internal/store" ) // permissionViewChannel — сокращение для оверрайдов в тестах. const permissionViewChannel = permissions.ViewChannel // registerAndLogin регистрирует пользователя и возвращает cookie сессии. func registerAndLogin(t *testing.T, srv *Server, username, email string) *http.Cookie { t.Helper() rec := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register", `{"username":"`+username+`","email":"`+email+`","password":"correct-horse-battery"}`) if rec.Code != http.StatusOK { t.Fatalf("register %s = %d, body = %s", username, rec.Code, rec.Body.String()) } cookies := rec.Result().Cookies() if len(cookies) == 0 { t.Fatalf("register %s did not return a session cookie", username) } return cookies[0] } func decodeResponse[T any](t *testing.T, rec *httptest.ResponseRecorder) T { t.Helper() var payload T if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { t.Fatalf("decode body: %v (raw: %s)", err, rec.Body.String()) } return payload } func errorCodeOf(t *testing.T, rec *httptest.ResponseRecorder) string { t.Helper() payload := decodeResponse[struct { Error struct { Code string `json:"code"` Message string `json:"message"` } `json:"error"` }](t, rec) if payload.Error.Code == "" { t.Fatalf("response has no error code: %s", rec.Body.String()) } return payload.Error.Code } // promoteAdmin делает пользователя администратором инстанса напрямую в БД. func promoteAdmin(t *testing.T, srv *Server, email string) { t.Helper() user, err := srv.auth.UserByEmail(t.Context(), email) if err != nil { t.Fatalf("find user %s: %v", email, err) } if err := srv.store.SetInstanceAdmin(t.Context(), user.ID, true); err != nil { t.Fatalf("promote %s: %v", email, err) } } func TestHumaErrorsUseAPIEnvelope(t *testing.T) { srv, _ := newTestServer(t) rec := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "") if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want 401", rec.Code) } if code := errorCodeOf(t, rec); code != "auth.session_expired" { t.Fatalf("error code = %q, want auth.session_expired", code) } rec = doJSON(t, srv, http.MethodGet, "/api/v1/instance/settings", "") if rec.Code != http.StatusUnauthorized { t.Fatalf("instance settings without session = %d, want 401", rec.Code) } } func TestProfileUpdateFlow(t *testing.T) { srv, _ := newTestServer(t) cookie := registerAndLogin(t, srv, "profile_user", "profile@example.com") me := doJSON(t, srv, http.MethodGet, "/api/v1/users/@me", "", cookie) if me.Code != http.StatusOK { t.Fatalf("GET /users/@me = %d, body = %s", me.Code, me.Body.String()) } profile := decodeResponse[struct { User struct { Username string `json:"username"` OnboardingCompleted bool `json:"onboarding_completed"` Locale string `json:"locale"` } `json:"user"` }](t, me) if profile.User.Username != "profile_user" { t.Fatalf("username = %q", profile.User.Username) } if profile.User.OnboardingCompleted { t.Fatal("new user must not have onboarding completed") } if profile.User.Locale != "ru" { t.Fatalf("default locale = %q, want ru", profile.User.Locale) } updated := doJSON(t, srv, http.MethodPatch, "/api/v1/users/@me", `{"display_name":"Профиль","bio":"о себе","status":"idle","locale":"en"}`, cookie) if updated.Code != http.StatusOK { t.Fatalf("PATCH /users/@me = %d, body = %s", updated.Code, updated.Body.String()) } payload := decodeResponse[struct { User struct { DisplayName string `json:"display_name"` Bio string `json:"bio"` Status string `json:"status"` Locale string `json:"locale"` } `json:"user"` }](t, updated) if payload.User.DisplayName != "Профиль" || payload.User.Bio != "о себе" || payload.User.Status != "idle" { t.Fatalf("unexpected profile: %+v", payload.User) } // Онбординг завершается отдельной ручкой и выставляет флаг. onboarding := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/onboarding/complete", `{"display_name":"Новый ник","bio":"привет"}`, cookie) if onboarding.Code != http.StatusOK { t.Fatalf("onboarding = %d, body = %s", onboarding.Code, onboarding.Body.String()) } after := decodeResponse[struct { User struct { DisplayName string `json:"display_name"` OnboardingCompleted bool `json:"onboarding_completed"` } `json:"user"` }](t, onboarding) if !after.User.OnboardingCompleted || after.User.DisplayName != "Новый ник" { t.Fatalf("unexpected onboarding result: %+v", after.User) } // Публичный профиль доступен другому пользователю и не содержит приватных полей. otherCookie := registerAndLogin(t, srv, "other_user", "other@example.com") user, err := srv.auth.UserByEmail(t.Context(), "profile@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } public := doJSON(t, srv, http.MethodGet, "/api/v1/users/"+formatSnowflake(user.ID), "", otherCookie) if public.Code != http.StatusOK { t.Fatalf("public profile = %d, body = %s", public.Code, public.Body.String()) } if strings.Contains(public.Body.String(), "@example.com") { t.Fatalf("public profile must not leak email: %s", public.Body.String()) } } func TestGuildLifecycleAndPermissions(t *testing.T) { srv, _ := newTestServer(t) ownerCookie := registerAndLogin(t, srv, "guild_owner", "owner@example.com") memberCookie := registerAndLogin(t, srv, "guild_member", "member@example.com") created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Тестовый сервер"}`, ownerCookie) if created.Code != http.StatusOK { t.Fatalf("create guild = %d, body = %s", created.Code, created.Body.String()) } guild := decodeResponse[struct { Guild struct { ID string `json:"id"` Name string `json:"name"` IsMain bool `json:"is_main"` Roles []struct { ID string `json:"id"` IsDefault bool `json:"is_default"` } `json:"roles"` Channels []struct { ID string `json:"id"` Name string `json:"name"` } `json:"channels"` MyPermissions []string `json:"my_permissions"` } `json:"guild"` }](t, created) if guild.Guild.Name != "Тестовый сервер" { t.Fatalf("guild name = %q", guild.Guild.Name) } if len(guild.Guild.Roles) != 2 { t.Fatalf("default roles = %d, want 2", len(guild.Guild.Roles)) } if len(guild.Guild.Channels) != 1 || guild.Guild.Channels[0].Name != "общий" { t.Fatalf("default channels = %+v", guild.Guild.Channels) } if !containsString(guild.Guild.MyPermissions, "ADMINISTRATOR") { t.Fatalf("owner must have ADMINISTRATOR, got %v", guild.Guild.MyPermissions) } // Посторонний не видит сервер: 404 вместо 403 (AGENT.md 9.7). stranger := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie) if stranger.Code != http.StatusNotFound { t.Fatalf("stranger channels = %d, want 404", stranger.Code) } // Главный сервер открыт для присоединения. join := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie) if join.Code != http.StatusOK { t.Fatalf("join main guild = %d, body = %s", join.Code, join.Body.String()) } // Участник видит комнату, но не может её создать: нет MANAGE_CHANNELS. channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie) if channels.Code != http.StatusOK { t.Fatalf("member channels = %d", channels.Code) } list := decodeResponse[struct { Channels []struct { Name string `json:"name"` CanView bool `json:"can_view"` CanSend bool `json:"can_send"` } `json:"channels"` }](t, channels) if len(list.Channels) != 1 || !list.Channels[0].CanView || !list.Channels[0].CanSend { t.Fatalf("unexpected member channels: %+v", list.Channels) } denied := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels", `{"name":"секретная","type":"text"}`, memberCookie) if denied.Code != http.StatusForbidden { t.Fatalf("member create channel = %d, want 403", denied.Code) } if code := errorCodeOf(t, denied); code != "perm.denied" { t.Fatalf("error code = %q, want perm.denied", code) } // Владелец создаёт комнату и роль. newChannel := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels", `{"name":"флудилка","type":"voice"}`, ownerCookie) if newChannel.Code != http.StatusOK { t.Fatalf("owner create channel = %d, body = %s", newChannel.Code, newChannel.Body.String()) } roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles", `{"name":"Модератор","permissions":"VIEW_CHANNEL|KICK_MEMBERS","color":16711680}`, ownerCookie) if roleRec.Code != http.StatusOK { t.Fatalf("create role = %d, body = %s", roleRec.Code, roleRec.Body.String()) } role := decodeResponse[struct { Role struct { ID string `json:"id"` Permissions string `json:"permissions"` } `json:"role"` }](t, roleRec) if !strings.Contains(role.Role.Permissions, "KICK_MEMBERS") { t.Fatalf("role permissions = %q", role.Role.Permissions) } // Роль можно выдать участнику. member, err := srv.auth.UserByEmail(t.Context(), "member@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } assign := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID)+"/roles/"+role.Role.ID, "", ownerCookie) if assign.Code != http.StatusOK { t.Fatalf("assign role = %d, body = %s", assign.Code, assign.Body.String()) } members := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/members", "", ownerCookie) roster := decodeResponse[struct { Members []struct { UserID string `json:"user_id"` RoleIDs []string `json:"role_ids"` } `json:"members"` }](t, members) if len(roster.Members) != 2 { t.Fatalf("members = %d, want 2", len(roster.Members)) } found := false for _, entry := range roster.Members { if entry.UserID == formatSnowflake(member.ID) && containsString(entry.RoleIDs, role.Role.ID) { found = true } } if !found { t.Fatalf("role was not assigned: %+v", roster.Members) } // Участник не может исключить владельца даже с правом KICK_MEMBERS. owner, err := srv.auth.UserByEmail(t.Context(), "owner@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } kickOwner := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(owner.ID), "", memberCookie) if kickOwner.Code != http.StatusForbidden { t.Fatalf("kick owner = %d, want 403", kickOwner.Code) } // Владелец исключает участника. kick := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), "", ownerCookie) if kick.Code != http.StatusOK { t.Fatalf("kick member = %d, body = %s", kick.Code, kick.Body.String()) } // Журнал аудита содержит действие исключения. audit := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/audit-log", "", ownerCookie) entries := decodeResponse[struct { Entries []struct { Action string `json:"action"` } `json:"entries"` }](t, audit) if !hasAction(entries.Entries, "member.kick") { t.Fatalf("audit log has no member.kick: %+v", entries.Entries) } // Главный сервер удалить нельзя. deleteMain := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guild.Guild.ID, "", ownerCookie) if deleteMain.Code != http.StatusForbidden { t.Fatalf("delete main guild = %d, want 403", deleteMain.Code) } } func TestInstanceAdminEndpointsAndLimits(t *testing.T) { srv, _ := newTestServer(t) adminCookie := registerAndLogin(t, srv, "instance_admin", "admin@example.com") promoteAdmin(t, srv, "admin@example.com") userCookie := registerAndLogin(t, srv, "plain_user", "plain@example.com") // Публичная информация об инстансе доступна без сессии. public := doJSON(t, srv, http.MethodGet, "/api/v1/instance", "") if public.Code != http.StatusOK { t.Fatalf("GET /instance = %d", public.Code) } publicPayload := decodeResponse[struct { Instance struct { RegistrationEnabled bool `json:"registration_enabled"` UserCount int `json:"user_count"` } `json:"instance"` }](t, public) if !publicPayload.Instance.RegistrationEnabled { t.Fatal("registration must be enabled by default") } if publicPayload.Instance.UserCount != 0 { t.Fatalf("public payload must not expose user count, got %d", publicPayload.Instance.UserCount) } // Обычный пользователь не имеет доступа к админ-ручкам. for _, path := range []string{"/api/v1/instance/settings", "/api/v1/instance/guilds", "/api/v1/instance/users", "/api/v1/instance/audit"} { rec := doJSON(t, srv, http.MethodGet, path, "", userCookie) if rec.Code != http.StatusForbidden { t.Fatalf("GET %s as user = %d, want 403", path, rec.Code) } if code := errorCodeOf(t, rec); code != "instance.admin_required" { t.Fatalf("GET %s code = %q", path, code) } } // Лимит в один сервер на пользователя. patch := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", `{"max_guilds_per_user":1}`, adminCookie) if patch.Code != http.StatusOK { t.Fatalf("patch settings = %d, body = %s", patch.Code, patch.Body.String()) } first := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Первый"}`, userCookie) if first.Code != http.StatusOK { t.Fatalf("first guild = %d, body = %s", first.Code, first.Body.String()) } second := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Второй"}`, userCookie) if second.Code != http.StatusForbidden { t.Fatalf("second guild = %d, want 403", second.Code) } if code := errorCodeOf(t, second); code != "limits.guilds_reached" { t.Fatalf("limit error code = %q, want limits.guilds_reached", code) } // Администратор инстанса обходит лимит, и это попадает в аудит. adminGuild := doJSON(t, srv, http.MethodPost, "/api/v1/instance/guilds", `{"name":"Админский"}`, adminCookie) if adminGuild.Code != http.StatusOK { t.Fatalf("admin guild = %d, body = %s", adminGuild.Code, adminGuild.Body.String()) } guilds := doJSON(t, srv, http.MethodGet, "/api/v1/instance/guilds", "", adminCookie) guildList := decodeResponse[struct { Guilds []struct { Name string `json:"name"` } `json:"guilds"` }](t, guilds) if len(guildList.Guilds) != 2 { t.Fatalf("instance guilds = %d, want 2", len(guildList.Guilds)) } audit := doJSON(t, srv, http.MethodGet, "/api/v1/instance/audit", "", adminCookie) entries := decodeResponse[struct { Entries []struct { Action string `json:"action"` ActorInstanceAdmin bool `json:"actor_instance_admin"` } `json:"entries"` }](t, audit) if !hasAction(entries.Entries, "limits.bypass") { t.Fatalf("audit has no limits.bypass: %+v", entries.Entries) } for _, entry := range entries.Entries { if !entry.ActorInstanceAdmin { t.Fatalf("instance audit entry without admin flag: %+v", entry) } } // Права администратора выдаются только со step-up. user, err := srv.auth.UserByEmail(t.Context(), "plain@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } noStepUp := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin", `{"admin":true}`, adminCookie) if noStepUp.Code != http.StatusForbidden { t.Fatalf("admin grant without step-up = %d, want 403", noStepUp.Code) } grant := doJSON(t, srv, http.MethodPost, "/api/v1/instance/users/"+formatSnowflake(user.ID)+"/admin", `{"admin":true,"step_up_password":"correct-horse-battery"}`, adminCookie) if grant.Code != http.StatusOK { t.Fatalf("admin grant = %d, body = %s", grant.Code, grant.Body.String()) } // Смена настроек регистрации закрывает регистрацию. off := doJSON(t, srv, http.MethodPatch, "/api/v1/instance/settings", `{"registration_enabled":false}`, adminCookie) if off.Code != http.StatusOK { t.Fatalf("disable registration = %d", off.Code) } blocked := doJSON(t, srv, http.MethodPost, "/api/v1/auth/register", `{"username":"blocked_user","email":"blocked@example.com","password":"correct-horse-battery"}`) if blocked.Code == http.StatusOK { t.Fatal("registration must be rejected when disabled") } } func TestMemberTimeoutRequiresPermission(t *testing.T) { srv, _ := newTestServer(t) ownerCookie := registerAndLogin(t, srv, "timeout_owner", "timeout-owner@example.com") memberCookie := registerAndLogin(t, srv, "timeout_member", "timeout-member@example.com") created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Модерация"}`, ownerCookie) guild := decodeResponse[struct { Guild struct { ID string `json:"id"` } `json:"guild"` }](t, created) doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie) member, err := srv.auth.UserByEmail(t.Context(), "timeout-member@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } // У участника нет TIMEOUT_MEMBERS: тайм-аут запрещён. denied := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), `{"timeout_until":"2030-01-01T00:00:00Z"}`, memberCookie) if denied.Code != http.StatusForbidden { t.Fatalf("member self timeout = %d, want 403", denied.Code) } // Владелец выдаёт тайм-аут: права SEND_MESSAGES отключаются. ok := doJSON(t, srv, http.MethodPatch, "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(member.ID), `{"timeout_until":"2030-01-01T00:00:00Z"}`, ownerCookie) if ok.Code != http.StatusOK { t.Fatalf("owner timeout = %d, body = %s", ok.Code, ok.Body.String()) } channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie) list := decodeResponse[struct { Channels []struct { CanSend bool `json:"can_send"` } `json:"channels"` }](t, channels) if len(list.Channels) == 0 || list.Channels[0].CanSend { t.Fatalf("timed out member must not be able to send: %+v", list.Channels) } } func TestRoleHierarchyProtectsHigherRoles(t *testing.T) { srv, _ := newTestServer(t) ownerCookie := registerAndLogin(t, srv, "hier_owner", "hier-owner@example.com") modCookie := registerAndLogin(t, srv, "hier_mod", "hier-mod@example.com") created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Иерархия"}`, ownerCookie) guild := decodeResponse[struct { Guild struct { ID string `json:"id"` } `json:"guild"` }](t, created) doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", modCookie) mod, err := srv.auth.UserByEmail(t.Context(), "hier-mod@example.com") if err != nil { t.Fatalf("UserByEmail: %v", err) } // Роль модератора с правом управления ролями. roleRec := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/roles", `{"name":"Модератор","permissions":"VIEW_CHANNEL|MANAGE_ROLES|KICK_MEMBERS"}`, ownerCookie) role := decodeResponse[struct { Role struct { ID string `json:"id"` } `json:"role"` }](t, roleRec) assign := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", ownerCookie) if assign.Code != http.StatusOK { t.Fatalf("assign moderator role = %d", assign.Code) } // Модератор пытается снять собственную роль: себе модерировать нельзя. self := doJSON(t, srv, http.MethodDelete, "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+role.Role.ID, "", modCookie) if self.Code != http.StatusForbidden { t.Fatalf("self moderation = %d, want 403", self.Code) } // Модератор пытается выдать себе роль администратора: она выше его роли. roles := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/roles", "", ownerCookie) roleList := decodeResponse[struct { Roles []struct { ID string `json:"id"` Name string `json:"name"` } `json:"roles"` }](t, roles) var adminRoleID string for _, candidate := range roleList.Roles { if candidate.Name == "Администратор" { adminRoleID = candidate.ID } } if adminRoleID == "" { t.Fatal("administrator role is missing") } escalate := doJSON(t, srv, http.MethodPut, "/api/v1/guilds/"+guild.Guild.ID+"/members/"+formatSnowflake(mod.ID)+"/roles/"+adminRoleID, "", modCookie) if escalate.Code != http.StatusForbidden { t.Fatalf("privilege escalation = %d, want 403", escalate.Code) } } func TestChannelOverridesHideChannel(t *testing.T) { srv, _ := newTestServer(t) ownerCookie := registerAndLogin(t, srv, "override_owner", "override-owner@example.com") memberCookie := registerAndLogin(t, srv, "override_member", "override-member@example.com") created := doJSON(t, srv, http.MethodPost, "/api/v1/guilds", `{"name":"Оверрайды"}`, ownerCookie) guild := decodeResponse[struct { Guild struct { ID string `json:"id"` Roles []struct { ID string `json:"id"` IsDefault bool `json:"is_default"` } `json:"roles"` } `json:"guild"` }](t, created) doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/join", "", memberCookie) secret := doJSON(t, srv, http.MethodPost, "/api/v1/guilds/"+guild.Guild.ID+"/channels", `{"name":"тайная","type":"text"}`, ownerCookie) channel := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, secret) // Оверрайд для роли по умолчанию: VIEW_CHANNEL запрещён. channelID, err := parseID("channel_id", channel.Channel.ID) if err != nil { t.Fatalf("parse channel id: %v", err) } var defaultRoleID uint64 for _, role := range guild.Guild.Roles { if role.IsDefault { defaultRoleID, err = parseID("role_id", role.ID) if err != nil { t.Fatalf("parse role id: %v", err) } } } if defaultRoleID == 0 { t.Fatal("default role not found") } if err := srv.store.SetChannelOverride(t.Context(), store.ChannelOverride{ ChannelID: channelID, TargetType: "role", TargetID: defaultRoleID, Deny: uint64(permissionViewChannel), }); err != nil { t.Fatalf("SetChannelOverride: %v", err) } srv.perms.InvalidateGuild(guildIDOf(t, guild.Guild.ID)) channels := doJSON(t, srv, http.MethodGet, "/api/v1/guilds/"+guild.Guild.ID+"/channels", "", memberCookie) list := decodeResponse[struct { Channels []struct { Name string `json:"name"` } `json:"channels"` }](t, channels) for _, entry := range list.Channels { if entry.Name == "тайная" { t.Fatalf("channel with denied VIEW_CHANNEL must be hidden: %+v", list.Channels) } } } func guildIDOf(t *testing.T, raw string) uint64 { t.Helper() id, err := parseID("guild_id", raw) if err != nil { t.Fatalf("parse guild id: %v", err) } return id } func containsString(values []string, wanted string) bool { for _, value := range values { if value == wanted { return true } } return false } func hasAction[T any](entries []T, action string) bool { for _, entry := range entries { encoded, err := json.Marshal(entry) if err != nil { continue } if strings.Contains(string(encoded), `"`+action+`"`) { return true } } return false }