Files
zitadel/internal/api/oidc/userinfo_test.go
T
6127b567e7 feat(oidc): provide actor information in userinfo actions (#12566)
# Which Problems Are Solved

Expose impersonating actor to `preAccessToken` and `preUserinfo`
actions.

# How the Problems Are Solved

Provide the `actor` information already present in the OIDC session
model of JWT token to the relevant actions. Both goja-based actions "v1"
and webhook based execution targets carry the actor information now.
Actor remains null in case of non-impersonated tokens. Actor may contain
nested actors to display a delegation chain of impersonators.

# Additional Changes

- dba6261c8ed0dfd00ffde6193910b1405b17432f: refactor `userinfoFlows` to
reduce complexity, add test coverage and solve a couple of potential
bugs.
- `getClientId` in actions was previously undocumented. Added to
documentation.
- A skill that reproduces manual testing using webhook.site or a local
sink.

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/12097

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Co-authored-by: Wim Van Laer <[email protected]>
2026-08-12 07:36:56 +00:00

861 lines
23 KiB
Go

package oidc
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/domain"
target_domain "github.com/zitadel/zitadel/internal/execution/target"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
"github.com/zitadel/zitadel/internal/query"
exec_repo "github.com/zitadel/zitadel/internal/repository/execution"
)
func Test_prepareRoles(t *testing.T) {
type args struct {
projectID string
scope []string
projectRoleAssertion bool
currentProjectOnly bool
}
tests := []struct {
name string
args args
wantRoleAudience []string
wantRequestedRoles []string
}{
{
name: "empty scope",
args: args{
projectID: "projID",
scope: nil,
projectRoleAssertion: false,
currentProjectOnly: false,
},
wantRoleAudience: nil,
wantRequestedRoles: nil,
},
{
name: "project role assertion",
args: args{
projectID: "projID",
projectRoleAssertion: true,
scope: nil,
currentProjectOnly: false,
},
wantRoleAudience: []string{"projID"},
wantRequestedRoles: nil,
},
{
name: "some scope, current project only",
args: args{
projectID: "projID",
projectRoleAssertion: false,
scope: []string{"openid", "profile"},
currentProjectOnly: true,
},
wantRoleAudience: []string{"projID"},
wantRequestedRoles: nil,
},
{
name: "scope projects roles",
args: args{
projectID: "projID",
projectRoleAssertion: false,
scope: []string{
"openid", "profile",
ScopeProjectsRoles,
domain.ProjectIDScope + "project2" + domain.AudSuffix,
},
currentProjectOnly: false,
},
wantRoleAudience: []string{"project2", "projID"},
wantRequestedRoles: nil,
},
{
name: "scope projects roles ignored, current project only",
args: args{
projectID: "projID",
projectRoleAssertion: false,
scope: []string{
"openid", "profile",
ScopeProjectsRoles,
domain.ProjectIDScope + "project2" + domain.AudSuffix,
},
currentProjectOnly: true,
},
wantRoleAudience: []string{"projID"},
wantRequestedRoles: nil,
},
{
name: "scope project role prefix",
args: args{
projectID: "projID",
projectRoleAssertion: false,
scope: []string{
"openid", "profile",
ScopeProjectRolePrefix + "foo",
ScopeProjectRolePrefix + "bar",
},
currentProjectOnly: false,
},
wantRoleAudience: []string{"projID"},
wantRequestedRoles: []string{"foo", "bar"},
},
{
name: "scope project role prefix and audience",
args: args{
projectID: "projID",
projectRoleAssertion: false,
scope: []string{
"openid", "profile",
ScopeProjectRolePrefix + "foo",
ScopeProjectRolePrefix + "bar",
domain.ProjectIDScope + "project2" + domain.AudSuffix,
},
currentProjectOnly: false,
},
wantRoleAudience: []string{"projID", "project2"},
wantRequestedRoles: []string{"foo", "bar"},
},
{
name: "scope project role prefix and audience ignored, current project only",
args: args{
projectID: "projID",
projectRoleAssertion: false,
scope: []string{
"openid", "profile",
ScopeProjectRolePrefix + "foo",
ScopeProjectRolePrefix + "bar",
domain.ProjectIDScope + "project2" + domain.AudSuffix,
},
currentProjectOnly: true,
},
wantRoleAudience: []string{"projID"},
wantRequestedRoles: []string{"foo", "bar"},
},
{
name: "no projectID, scope project role prefix and audience",
args: args{
projectID: "",
projectRoleAssertion: false,
scope: []string{
"openid", "profile",
ScopeProjectRolePrefix + "foo",
ScopeProjectRolePrefix + "bar",
domain.ProjectIDScope + "project2" + domain.AudSuffix,
},
currentProjectOnly: false,
},
wantRoleAudience: []string{"project2"},
wantRequestedRoles: []string{"foo", "bar"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotRoleAudience, gotRequestedRoles := prepareRoles(context.Background(), tt.args.scope, tt.args.projectID, tt.args.projectRoleAssertion, tt.args.currentProjectOnly)
assert.ElementsMatch(t, tt.wantRoleAudience, gotRoleAudience, "roleAudience")
assert.ElementsMatch(t, tt.wantRequestedRoles, gotRequestedRoles, "requestedRoles")
})
}
}
func Test_userInfoToOIDC(t *testing.T) {
metadata := []query.UserMetadata{
{
Key: "key1",
Value: []byte{1, 2, 3},
},
{
Key: "key2",
Value: []byte{4, 5, 6},
},
}
organization := &query.UserInfoOrg{
ID: "orgID",
Name: "orgName",
PrimaryDomain: "orgDomain",
}
userGroups := []query.UserInfoUserGroup{
{
Name: "group1",
ID: "group1-id",
},
{
Name: "group2",
ID: "group2-id",
},
}
humanUserInfo := &query.OIDCUserInfo{
User: &query.User{
ID: "human1",
CreationDate: time.Unix(123, 456),
ChangeDate: time.Unix(567, 890),
ResourceOwner: "orgID",
Sequence: 22,
State: domain.UserStateActive,
Type: domain.UserTypeHuman,
Username: "username",
LoginNames: []string{"foo", "bar"},
PreferredLoginName: "foo",
Human: &query.Human{
FirstName: "user",
LastName: "name",
NickName: "foobar",
DisplayName: "xxx",
AvatarKey: "picture.png",
PreferredLanguage: language.Dutch,
Gender: domain.GenderDiverse,
Email: "[email protected]",
IsEmailVerified: true,
Phone: "+31123456789",
IsPhoneVerified: true,
},
},
Metadata: metadata,
Org: organization,
UserGrants: []query.UserGrant{
{
ID: "ug1",
CreationDate: time.Unix(444, 444),
ChangeDate: time.Unix(555, 555),
Sequence: 55,
Roles: []string{"role1", "role2"},
GrantID: "grantID",
State: domain.UserGrantStateActive,
UserID: "human1",
Username: "username",
ResourceOwner: "orgID",
ProjectID: "project1",
OrgName: "orgName",
OrgPrimaryDomain: "orgDomain",
ProjectName: "projectName",
UserResourceOwner: "org1",
},
},
UserGroups: userGroups,
}
machineUserInfo := &query.OIDCUserInfo{
User: &query.User{
ID: "machine1",
CreationDate: time.Unix(123, 456),
ChangeDate: time.Unix(567, 890),
ResourceOwner: "orgID",
Sequence: 23,
State: domain.UserStateActive,
Type: domain.UserTypeMachine,
Username: "machine",
PreferredLoginName: "meanMachine",
Machine: &query.Machine{
Name: "machine",
Description: "I'm a robot",
},
},
Org: organization,
UserGrants: []query.UserGrant{
{
ID: "ug1",
CreationDate: time.Unix(444, 444),
ChangeDate: time.Unix(555, 555),
Sequence: 55,
Roles: []string{"role1", "role2"},
GrantID: "grantID",
State: domain.UserGrantStateActive,
UserID: "human1",
Username: "username",
ResourceOwner: "orgID",
ProjectID: "project1",
OrgName: "orgName",
OrgPrimaryDomain: "orgDomain",
ProjectName: "projectName",
UserResourceOwner: "org1",
},
},
}
type args struct {
user *query.OIDCUserInfo
userInfoAssertion bool
scope []string
}
tests := []struct {
name string
args args
want *oidc.UserInfo
}{
{
name: "human, empty",
args: args{
user: humanUserInfo,
},
want: &oidc.UserInfo{
Subject: "human1",
},
},
{
name: "machine, empty",
args: args{
user: machineUserInfo,
},
want: &oidc.UserInfo{
Subject: "machine1",
},
},
{
name: "human, scope openid",
args: args{
user: humanUserInfo,
scope: []string{oidc.ScopeOpenID},
},
want: &oidc.UserInfo{
Subject: "human1",
},
},
{
name: "machine, scope openid",
args: args{
user: machineUserInfo,
scope: []string{oidc.ScopeOpenID},
},
want: &oidc.UserInfo{
Subject: "machine1",
},
},
{
name: "human, scope email, profileInfoAssertion",
args: args{
user: humanUserInfo,
userInfoAssertion: true,
scope: []string{oidc.ScopeEmail},
},
want: &oidc.UserInfo{
Subject: "human1",
UserInfoEmail: oidc.UserInfoEmail{
Email: "[email protected]",
EmailVerified: true,
},
},
},
{
name: "human, scope email",
args: args{
user: humanUserInfo,
scope: []string{oidc.ScopeEmail},
},
want: &oidc.UserInfo{
Subject: "human1",
},
},
{
name: "machine, scope email, profileInfoAssertion",
args: args{
user: machineUserInfo,
scope: []string{oidc.ScopeEmail},
},
want: &oidc.UserInfo{
Subject: "machine1",
},
},
{
name: "human, scope profile, profileInfoAssertion",
args: args{
user: humanUserInfo,
userInfoAssertion: true,
scope: []string{oidc.ScopeProfile},
},
want: &oidc.UserInfo{
Subject: "human1",
UserInfoProfile: oidc.UserInfoProfile{
Name: "xxx",
GivenName: "user",
FamilyName: "name",
Nickname: "foobar",
Picture: "https://foo.com/assets/orgID/picture.png",
Gender: "diverse",
Locale: oidc.NewLocale(language.Dutch),
UpdatedAt: oidc.FromTime(time.Unix(567, 890)),
PreferredUsername: "foo",
},
},
},
{
name: "machine, scope profile, profileInfoAssertion",
args: args{
user: machineUserInfo,
userInfoAssertion: true,
scope: []string{oidc.ScopeProfile},
},
want: &oidc.UserInfo{
Subject: "machine1",
UserInfoProfile: oidc.UserInfoProfile{
Name: "machine",
UpdatedAt: oidc.FromTime(time.Unix(567, 890)),
PreferredUsername: "meanMachine",
},
},
},
{
name: "machine, scope profile",
args: args{
user: machineUserInfo,
scope: []string{oidc.ScopeProfile},
},
want: &oidc.UserInfo{
Subject: "machine1",
},
},
{
name: "human, scope phone, profileInfoAssertion",
args: args{
user: humanUserInfo,
userInfoAssertion: true,
scope: []string{oidc.ScopePhone},
},
want: &oidc.UserInfo{
Subject: "human1",
UserInfoPhone: oidc.UserInfoPhone{
PhoneNumber: "+31123456789",
PhoneNumberVerified: true,
},
},
},
{
name: "human, scope phone",
args: args{
user: humanUserInfo,
scope: []string{oidc.ScopePhone},
},
want: &oidc.UserInfo{
Subject: "human1",
},
},
{
name: "machine, scope phone",
args: args{
user: machineUserInfo,
scope: []string{oidc.ScopePhone},
},
want: &oidc.UserInfo{
Subject: "machine1",
UserInfoPhone: oidc.UserInfoPhone{},
},
},
{
name: "human, scope metadata",
args: args{
user: humanUserInfo,
scope: []string{ScopeUserMetaData},
},
want: &oidc.UserInfo{
Subject: "human1",
UserInfoEmail: oidc.UserInfoEmail{},
Claims: map[string]any{
ClaimUserMetaData: map[string]string{
"key1": base64.RawURLEncoding.EncodeToString([]byte{1, 2, 3}),
"key2": base64.RawURLEncoding.EncodeToString([]byte{4, 5, 6}),
},
},
},
},
{
name: "machine, scope metadata, none found",
args: args{
user: machineUserInfo,
scope: []string{ScopeUserMetaData},
},
want: &oidc.UserInfo{
Subject: "machine1",
},
},
{
name: "machine, scope resource owner",
args: args{
user: machineUserInfo,
scope: []string{ScopeResourceOwner},
},
want: &oidc.UserInfo{
Subject: "machine1",
Claims: map[string]any{
ClaimResourceOwnerID: "orgID",
ClaimResourceOwnerName: "orgName",
ClaimResourceOwnerPrimaryDomain: "orgDomain",
},
},
},
{
name: "human, scope org primary domain prefix",
args: args{
user: humanUserInfo,
scope: []string{domain.OrgDomainPrimaryScope + "foo.com"},
},
want: &oidc.UserInfo{
Subject: "human1",
Claims: map[string]any{
domain.OrgDomainPrimaryClaim: "foo.com",
},
},
},
{
name: "machine, scope org id",
args: args{
user: machineUserInfo,
scope: []string{domain.OrgIDScope + "orgID"},
},
want: &oidc.UserInfo{
Subject: "machine1",
Claims: map[string]any{
domain.OrgIDClaim: "orgID",
ClaimResourceOwnerID: "orgID",
ClaimResourceOwnerName: "orgName",
ClaimResourceOwnerPrimaryDomain: "orgDomain",
},
},
},
{
name: "human, scope custom user groups, found",
args: args{
user: humanUserInfo,
scope: []string{ScopeCustomUserGroups},
},
want: &oidc.UserInfo{
Subject: "human1",
Claims: map[string]any{
ClaimCustomUserGroups: []query.UserInfoUserGroup{
{
Name: "group1",
ID: "group1-id",
},
{
Name: "group2",
ID: "group2-id",
},
},
},
},
},
{
name: "human, scope user groups (group names), found",
args: args{
user: humanUserInfo,
scope: []string{ScopeUserGroups},
},
want: &oidc.UserInfo{
Subject: "human1",
Claims: map[string]any{
ClaimUserGroups: []string{"group1", "group2"},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assetPrefix := "https://foo.com/assets"
got := userInfoToOIDC(tt.args.user, tt.args.userInfoAssertion, tt.args.scope, assetPrefix)
assert.Equal(t, tt.want, got)
})
}
}
func Test_functionForTriggerType(t *testing.T) {
tests := []struct {
name string
triggerType domain.TriggerType
want string
}{
{
name: "pre userinfo creation",
triggerType: domain.TriggerTypePreUserinfoCreation,
want: exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreUserinfo.LocalizationKey()),
},
{
name: "pre access token creation",
triggerType: domain.TriggerTypePreAccessTokenCreation,
want: exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreAccessToken.LocalizationKey()),
},
{
name: "unspecified",
triggerType: domain.TriggerTypeUnspecified,
want: "",
},
{
name: "post authentication",
triggerType: domain.TriggerTypePostAuthentication,
want: "",
},
{
name: "pre creation",
triggerType: domain.TriggerTypePreCreation,
want: "",
},
{
name: "post creation",
triggerType: domain.TriggerTypePostCreation,
want: "",
},
{
name: "pre saml response creation",
triggerType: domain.TriggerTypePreSAMLResponseCreation,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, functionForTriggerType(tt.triggerType))
})
}
}
func Test_appendOrLogClaim(t *testing.T) {
tests := []struct {
name string
userInfo *oidc.UserInfo
key string
value any
wantClaims map[string]any
wantLogs []string
}{
{
name: "new claim is added",
userInfo: &oidc.UserInfo{Claims: map[string]any{}},
key: "foo",
value: "bar",
wantClaims: map[string]any{"foo": "bar"},
},
{
name: "reserved prefix is skipped",
userInfo: &oidc.UserInfo{Claims: map[string]any{}},
key: ClaimPrefix + ":something",
value: "bar",
wantClaims: map[string]any{},
},
{
name: "existing claim is not overwritten and logs a conflict",
userInfo: &oidc.UserInfo{Claims: map[string]any{"foo": "existing"}},
key: "foo",
value: "bar",
wantClaims: map[string]any{"foo": "existing"},
wantLogs: []string{`key "foo" already exists`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var logs []string
appendOrLogClaim(tt.userInfo, tt.key, tt.value, &logs)
assert.Equal(t, tt.wantClaims, tt.userInfo.Claims)
assert.Equal(t, tt.wantLogs, logs)
})
}
}
func Test_errorFromRecover(t *testing.T) {
tests := []struct {
name string
r any
wantMsg string
}{
{
name: "error value is passed through",
r: errors.New("boom"),
wantMsg: "boom",
},
{
name: "string value is wrapped",
r: "boom",
wantMsg: "boom",
},
{
name: "other value falls back to a generic message",
r: 42,
wantMsg: "unknown error occurred: 42",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errorFromRecover(tt.r)
require.Error(t, err)
assert.Equal(t, tt.wantMsg, err.Error())
})
}
}
func Test_runUserinfoActions(t *testing.T) {
actions.SetLogstoreService(logstore.New[*record.ExecutionLog](nil, nil))
t.Run("happy path sets claim from script", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.userinfo.setClaim("foo", "bar")
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.NoError(t, err)
assert.Equal(t, "bar", userInfo.Claims["foo"])
})
t.Run("panic while building ctxFields is recovered, not left uncaught", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
// A non-JSON-marshalable claim value makes userinfoClaims' eager json.Marshal panic
// while ctxFields are being built for this (or a later) action - a phase that runs
// before internal/actions.executeScript's own recover is armed. Without our own
// recover in the loop, this panic would escape uncaught and skip cancel().
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{"bad": make(chan int)}}
queriedActions := []*query.Action{
{Name: "testFunc", Script: `function testFunc(ctx, api) {}`},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.Error(t, err)
})
t.Run("panic from setMetadata bad arg count is recovered", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.user.setMetadata("onlyonearg")
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly 2")
})
t.Run("error from a script stops the loop and is returned", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) { throw new Error("script failure") }`,
},
{
Name: "shouldNotRun",
Script: `function shouldNotRun(ctx, api) {
api.v1.userinfo.setClaim("unreachable", "value")
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.Error(t, err)
assert.NotContains(t, userInfo.Claims, "unreachable")
})
t.Run("actor delegation chain is readable from the script", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
actor := &domain.TokenActor{
UserID: "actor1",
Issuer: "https://issuer1.example.com",
Actor: &domain.TokenActor{
UserID: "actor2",
Issuer: "https://issuer2.example.com",
},
}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.claims.setClaim("actor_user", ctx.v1.actor.userId)
api.v1.claims.setClaim("actor_issuer", ctx.v1.actor.issuer)
api.v1.claims.setClaim("nested_actor_user", ctx.v1.actor.actor.userId)
api.v1.claims.setClaim("chain_end", ctx.v1.actor.actor.actor === null)
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", actor, queriedActions)
require.NoError(t, err)
assert.Equal(t, "actor1", userInfo.Claims["actor_user"])
assert.Equal(t, "https://issuer1.example.com", userInfo.Claims["actor_issuer"])
assert.Equal(t, "actor2", userInfo.Claims["nested_actor_user"])
assert.Equal(t, true, userInfo.Claims["chain_end"])
})
t.Run("missing actor is null in the script", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.claims.setClaim("no_actor", ctx.v1.actor === null)
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.NoError(t, err)
assert.Equal(t, true, userInfo.Claims["no_actor"])
})
}
func Test_runUserinfoExecutionTargets(t *testing.T) {
respBody := []byte(`{"append_claims":[{"key":"foo","value":"bar"},{"key":"foo","value":"baz"}],"append_log_claims":["extra log entry"]}`)
var gotBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(respBody)
}))
defer server.Close()
s := &Server{httpClient: server.Client()}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
actor := &domain.TokenActor{
UserID: "actor1",
Issuer: "https://issuer1.example.com",
Actor: &domain.TokenActor{UserID: "actor2"},
}
targets := []target_domain.Target{
{TargetType: target_domain.TargetTypeCall, Endpoint: server.URL, Timeout: 5 * time.Second},
}
err := s.runUserinfoExecutionTargets(context.Background(), qu, userInfo, "clientID", actor, "function/test", targets)
require.NoError(t, err)
var sent ContextInfo
require.NoError(t, json.Unmarshal(gotBody, &sent))
assert.Equal(t, actor, sent.Actor)
assert.Equal(t, "bar", userInfo.Claims["foo"])
logClaim, ok := userInfo.Claims[fmt.Sprintf(ClaimActionLogFormat, "function/test")]
require.True(t, ok)
logs, ok := logClaim.([]string)
require.True(t, ok)
assert.Contains(t, logs, `key "foo" already exists`)
assert.Contains(t, logs, "extra log entry")
}