mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 04:03:47 +03:00
284 lines
12 KiB
Go
284 lines
12 KiB
Go
package api
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
|
)
|
|
|
|
func TestAPICrudAndPermissionDiff(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := New(Dependencies{DB: db}).service
|
|
|
|
created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders API", Resource: "https://api.orders.example.com"})
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, created.ID)
|
|
|
|
// The resource is unique.
|
|
_, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"})
|
|
require.ErrorIs(t, err, &common.AlreadyInUseError{})
|
|
|
|
desc := "Read orders"
|
|
updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
|
{Key: "read:orders", Name: "Read orders", Description: &desc},
|
|
{Key: "write:orders", Name: "Write orders"},
|
|
}})
|
|
require.NoError(t, err)
|
|
assert.Len(t, updated.Permissions, 2)
|
|
|
|
// Grant a client the read:orders permission for both subject types, then remove that permission
|
|
// and confirm the grants are cleaned up while write:orders (and its key) survives.
|
|
readPerm := findPermission(updated, "read:orders")
|
|
require.NotNil(t, readPerm)
|
|
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
|
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeUser}).Error)
|
|
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeClient}).Error)
|
|
|
|
updated, err = svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
|
{Key: "write:orders", Name: "Write orders (renamed)"},
|
|
}})
|
|
require.NoError(t, err)
|
|
require.Len(t, updated.Permissions, 1)
|
|
assert.Equal(t, "write:orders", updated.Permissions[0].Key)
|
|
assert.Equal(t, "Write orders (renamed)", updated.Permissions[0].Name)
|
|
|
|
var grantCount int64
|
|
require.NoError(t, db.Model(&OidcClientAllowedAPIPermission{}).Where("api_permission_id = ?", readPerm.ID).Count(&grantCount).Error)
|
|
assert.Equal(t, int64(0), grantCount)
|
|
|
|
renamed, err := svc.Update(t.Context(), created.ID, apiUpdateDto{Name: "Orders"})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Orders", renamed.Name)
|
|
require.NotNil(t, renamed.UpdatedAt)
|
|
|
|
require.NoError(t, svc.Delete(t.Context(), created.ID))
|
|
_, err = svc.Get(t.Context(), nil, created.ID)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestClientApiAccessAllowList(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := New(Dependencies{DB: db}).service
|
|
|
|
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
|
|
|
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
|
require.NoError(t, err)
|
|
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
|
{Key: "read:orders", Name: "Read"},
|
|
{Key: "write:orders", Name: "Write"},
|
|
}})
|
|
require.NoError(t, err)
|
|
readID := findPermission(orders, "read:orders").ID
|
|
writeID := findPermission(orders, "write:orders").ID
|
|
|
|
// Unknown IDs are filtered out, and the subject types are stored independently.
|
|
applied, err := svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
|
UserDelegatedPermissionIDs: []string{readID, "does-not-exist"},
|
|
ClientPermissionIDs: []string{writeID, "does-not-exist"},
|
|
})
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{readID}, applied.UserDelegatedPermissionIDs)
|
|
assert.ElementsMatch(t, []string{writeID}, applied.ClientPermissionIDs)
|
|
|
|
got, err := svc.GetClientAPIAccess(t.Context(), "client-1")
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{readID}, got.UserDelegatedPermissionIDs)
|
|
assert.ElementsMatch(t, []string{writeID}, got.ClientPermissionIDs)
|
|
|
|
// The same permission can be granted for both subject types, and both sets are fully replaced on each call.
|
|
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
|
UserDelegatedPermissionIDs: []string{readID, writeID},
|
|
ClientPermissionIDs: []string{readID},
|
|
})
|
|
require.NoError(t, err)
|
|
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{readID, writeID}, got.UserDelegatedPermissionIDs)
|
|
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
|
|
|
|
// Clearing one subject type leaves the other untouched.
|
|
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{ClientPermissionIDs: []string{readID}})
|
|
require.NoError(t, err)
|
|
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
|
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
|
|
|
|
// Clearing everything.
|
|
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{})
|
|
require.NoError(t, err)
|
|
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
|
require.NoError(t, err)
|
|
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
|
assert.Empty(t, got.ClientPermissionIDs)
|
|
|
|
// An unknown client is rejected (surfaces as 404 at the HTTP layer).
|
|
_, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}})
|
|
require.Error(t, err)
|
|
}
|
|
|
|
// TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow
|
|
// only come from the grants of that flow's subject type.
|
|
func TestAllowedScopesForAudienceFiltersBySubjectType(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := New(Dependencies{DB: db}).service
|
|
|
|
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
|
|
|
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
|
require.NoError(t, err)
|
|
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
|
{Key: "read:orders", Name: "Read"},
|
|
{Key: "write:orders", Name: "Write"},
|
|
}})
|
|
require.NoError(t, err)
|
|
readID := findPermission(orders, "read:orders").ID
|
|
writeID := findPermission(orders, "write:orders").ID
|
|
|
|
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
|
UserDelegatedPermissionIDs: []string{readID},
|
|
ClientPermissionIDs: []string{writeID},
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
userScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser)
|
|
require.NoError(t, err)
|
|
require.True(t, exists)
|
|
assert.ElementsMatch(t, []string{"read:orders"}, userScopes)
|
|
|
|
clientScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient)
|
|
require.NoError(t, err)
|
|
require.True(t, exists)
|
|
assert.ElementsMatch(t, []string{"write:orders"}, clientScopes)
|
|
|
|
// The fosite widening still sees the union of both subject types.
|
|
scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1")
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"read:orders", "write:orders"}, scopes)
|
|
assert.ElementsMatch(t, []string{"https://api.orders.example.com"}, audiences)
|
|
}
|
|
|
|
func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := New(Dependencies{DB: db}).service
|
|
|
|
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
|
require.NoError(t, err)
|
|
|
|
for _, key := range []string{"openid", "profile", "email", "email_verified", "groups", "offline_access", "Email"} {
|
|
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
|
{Key: key, Name: "Reserved"},
|
|
}})
|
|
require.Error(t, err, "key %q must be rejected", key)
|
|
var validationErr *common.ValidationError
|
|
require.ErrorAs(t, err, &validationErr)
|
|
}
|
|
}
|
|
|
|
func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := New(Dependencies{DB: db}).service
|
|
|
|
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
|
require.NoError(t, err)
|
|
|
|
// Two rows with the same key must be rejected rather than silently coalesced last-wins
|
|
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
|
{Key: "read:orders", Name: "Read"},
|
|
{Key: "read:orders", Name: "Read again"},
|
|
}})
|
|
require.Error(t, err)
|
|
var validationErr *common.ValidationError
|
|
require.ErrorAs(t, err, &validationErr)
|
|
}
|
|
|
|
func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := New(Dependencies{DB: db}).service
|
|
|
|
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
|
require.NoError(t, err)
|
|
|
|
// A space corrupts the space-delimited scope claim, and the unit separator is the consent delimiter
|
|
for _, key := range []string{"read orders", "read\x1forders", "read\"orders", "bad\\key", "tab\tkey"} {
|
|
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
|
{Key: key, Name: "Invalid"},
|
|
}})
|
|
require.Error(t, err, "key %q must be rejected", key)
|
|
var validationErr *common.ValidationError
|
|
require.ErrorAs(t, err, &validationErr)
|
|
}
|
|
|
|
// A valid scope-token key is accepted
|
|
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
|
{Key: "read:orders", Name: "Read"},
|
|
}})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestCreateRejectsIssuerResource(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
const issuer = "https://id.example.com"
|
|
svc := New(Dependencies{DB: db, Issuer: issuer}).service
|
|
|
|
// The issuer itself, a trailing-slash variant, and a different-cased variant are all reserved
|
|
for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} {
|
|
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource})
|
|
require.Error(t, err, "resource %q must be rejected", resource)
|
|
var validationErr *common.ValidationError
|
|
require.ErrorAs(t, err, &validationErr)
|
|
}
|
|
|
|
// A normal resource is accepted
|
|
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestCreateAcceptsAbsoluteResourceURIs(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := New(Dependencies{DB: db}).service
|
|
|
|
for _, resource := range []string{"https://api.orders.example.com", "api://PocketID", "urn:my-app"} {
|
|
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: resource})
|
|
require.NoError(t, err, "resource %q must be accepted", resource)
|
|
}
|
|
}
|
|
|
|
func TestDescribePermissions(t *testing.T) {
|
|
db := testutils.NewDatabaseForTest(t)
|
|
svc := New(Dependencies{DB: db}).service
|
|
|
|
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
|
require.NoError(t, err)
|
|
desc := "Read orders"
|
|
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
|
{Key: "read:orders", Name: "Read orders", Description: &desc},
|
|
{Key: "write:orders", Name: "Write orders"},
|
|
}})
|
|
require.NoError(t, err)
|
|
|
|
infos, err := svc.DescribePermissions(t.Context(), "https://api.orders.example.com", []string{"read:orders", "unknown"})
|
|
require.NoError(t, err)
|
|
require.Len(t, infos, 1)
|
|
assert.Equal(t, "read:orders", infos[0].Key)
|
|
assert.Equal(t, "Read orders", infos[0].Name)
|
|
require.NotNil(t, infos[0].Description)
|
|
assert.Equal(t, "Read orders", *infos[0].Description)
|
|
}
|
|
|
|
func findPermission(api API, key string) *Permission {
|
|
for i := range api.Permissions {
|
|
if api.Permissions[i].Key == key {
|
|
return &api.Permissions[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|