Skip to content

Commit 94251fc

Browse files
committed
feat(scim): add /ResourceTypes and /Schemas
Add `GET /scim/v2/ResourceTypes` and `GET /scim/v2/Schemas` with an empty list. Neither endpoint supports filtering.
1 parent e82b9b0 commit 94251fc

13 files changed

Lines changed: 167 additions & 47 deletions

internal/api/scim/fixtures/fixtures.go

Lines changed: 0 additions & 16 deletions
This file was deleted.

internal/api/scim/fixtures/not_implemented.json

Lines changed: 0 additions & 7 deletions
This file was deleted.

internal/api/scim/protocol/error_test.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,20 @@ import (
77

88
"github.com/stretchr/testify/assert"
99
"github.com/stretchr/testify/require"
10-
"github.com/supabase/auth/internal/api/scim/fixtures"
1110
)
1211

12+
const notFoundError = `{
13+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
14+
"status": "404",
15+
"detail": "Endpoint or resource does not exist"
16+
}`
17+
1318
func TestNewError(t *testing.T) {
1419
t.Run("serializes to JSON correctly", func(t *testing.T) {
1520
body, err := json.Marshal(NewError(http.StatusNotFound, "", "Endpoint or resource does not exist"))
1621

1722
require.NoError(t, err)
18-
assert.JSONEq(t, fixtures.NotFound, string(body))
23+
assert.JSONEq(t, notFoundError, string(body))
1924
})
2025

2126
t.Run("includes the scimType when one is given", func(t *testing.T) {
@@ -48,7 +53,7 @@ func TestErrorStatusCode(t *testing.T) {
4853

4954
t.Run("survives a round trip through JSON", func(t *testing.T) {
5055
var scimErr Error
51-
require.NoError(t, json.Unmarshal([]byte(fixtures.NotFound), &scimErr))
56+
require.NoError(t, json.Unmarshal([]byte(notFoundError), &scimErr))
5257

5358
assert.Equal(t, http.StatusNotFound, scimErr.StatusCode())
5459
assert.Equal(t, "404", scimErr.Status)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package protocol
2+
3+
const SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
4+
5+
type ListResponse[T any] struct {
6+
Schemas []string `json:"schemas"`
7+
TotalResults int `json:"totalResults"`
8+
StartIndex int `json:"startIndex"`
9+
ItemsPerPage int `json:"itemsPerPage"`
10+
Resources []T `json:"Resources"`
11+
}
12+
13+
func NewListResponse[T any](resources []T) *ListResponse[T] {
14+
if resources == nil {
15+
resources = []T{}
16+
}
17+
n := len(resources)
18+
return &ListResponse[T]{
19+
Schemas: []string{SchemaListResponse},
20+
TotalResults: n,
21+
StartIndex: 1,
22+
ItemsPerPage: n,
23+
Resources: resources,
24+
}
25+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package protocol
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
const emptyListResponse = `{
11+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
12+
"totalResults": 0,
13+
"startIndex": 1,
14+
"itemsPerPage": 0,
15+
"Resources": []
16+
}`
17+
18+
func TestNewListResponse(t *testing.T) {
19+
for _, tc := range []struct {
20+
name string
21+
resources []string
22+
expected string
23+
}{
24+
{
25+
name: "nil resources marshal to an empty array",
26+
resources: nil,
27+
expected: emptyListResponse,
28+
},
29+
{
30+
name: "empty resources marshal to an empty array",
31+
resources: []string{},
32+
expected: emptyListResponse,
33+
},
34+
{
35+
name: "populated resources are counted",
36+
resources: []string{"a", "b"},
37+
expected: `{
38+
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],
39+
"totalResults": 2,
40+
"startIndex": 1,
41+
"itemsPerPage": 2,
42+
"Resources": ["a", "b"]
43+
}`,
44+
},
45+
} {
46+
t.Run(tc.name, func(t *testing.T) {
47+
body, err := json.Marshal(NewListResponse(tc.resources))
48+
49+
require.NoError(t, err)
50+
require.JSONEq(t, tc.expected, string(body))
51+
})
52+
}
53+
}

internal/api/scim/server.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,11 @@ func (srv *Server) ServiceProviderConfig(w http.ResponseWriter, r *http.Request)
2929
}
3030

3131
func (srv *Server) ResourceTypes(w http.ResponseWriter, r *http.Request) error {
32-
return notImplemented()
32+
return emptyList(w, r, []any{})
3333
}
3434

3535
func (srv *Server) Schemas(w http.ResponseWriter, r *http.Request) error {
36-
return notImplemented()
36+
return emptyList(w, r, []any{})
3737
}
3838

3939
func (srv *Server) NotFound(w http.ResponseWriter, r *http.Request) error {
@@ -45,6 +45,9 @@ func (srv *Server) MethodNotAllowed(w http.ResponseWriter, r *http.Request) erro
4545
return protocol.NewError(http.StatusMethodNotAllowed, "", "The request method is not supported by this endpoint")
4646
}
4747

48-
func notImplemented() error {
49-
return protocol.NewError(http.StatusNotImplemented, "", "The request endpoint is not implemented")
48+
func emptyList[T any](w http.ResponseWriter, r *http.Request, resources []T) error {
49+
if r.URL.Query().Get("filter") != "" {
50+
return protocol.NewError(http.StatusForbidden, "", "Filtering is not supported on this endpoint")
51+
}
52+
return protocol.Send(w, http.StatusOK, protocol.NewListResponse(resources))
5053
}

internal/api/scim/server_test.go

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,34 @@
11
package scim
22

33
import (
4+
"embed"
5+
"encoding/json"
46
"net/http"
57
"net/http/httptest"
8+
"net/url"
69
"testing"
710

811
"github.com/stretchr/testify/require"
9-
"github.com/supabase/auth/internal/api/scim/fixtures"
1012
"github.com/supabase/auth/internal/api/scim/protocol"
1113
"github.com/supabase/auth/internal/conf"
14+
15+
_ "embed"
1216
)
1317

18+
//go:embed testdata/*
19+
var fixtures embed.FS
20+
1421
func newServerFor(externalURL string) *Server {
1522
return NewServer(&conf.GlobalConfiguration{
1623
API: conf.APIConfiguration{ExternalURL: externalURL},
1724
})
1825
}
1926

27+
func testFixture(file string) string {
28+
data, _ := fixtures.ReadFile("testdata/" + file)
29+
return string(data)
30+
}
31+
2032
func TestServer(t *testing.T) {
2133
srv := newServerFor("http://localhost:9999")
2234
require.NotNil(t, srv)
@@ -35,7 +47,7 @@ func TestServer(t *testing.T) {
3547

3648
require.Equal(t, http.StatusOK, w.Code)
3749
require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type"))
38-
require.JSONEq(t, fixtures.ServiceProviderConfig, w.Body.String())
50+
require.JSONEq(t, testFixture("service_provider_config.json"), w.Body.String())
3951
})
4052

4153
for _, tc := range []struct {
@@ -49,10 +61,23 @@ func TestServer(t *testing.T) {
4961
r := httptest.NewRequest(http.MethodGet, BasePath+"/"+tc.path, nil)
5062
w := httptest.NewRecorder()
5163

64+
require.NoError(t, tc.handler(w, r))
65+
66+
require.Equal(t, http.StatusOK, w.Code)
67+
require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type"))
68+
require.JSONEq(t, testFixture("empty_list_response.json"), w.Body.String())
69+
})
70+
71+
t.Run(tc.path+" rejects filter query parameter", func(t *testing.T) {
72+
filter := url.Values{"filter": {`name eq "User"`}}.Encode()
73+
r := httptest.NewRequest(http.MethodGet, BasePath+"/"+tc.path+"?"+filter, nil)
74+
w := httptest.NewRecorder()
75+
5276
var scimErr *protocol.Error
5377
require.ErrorAs(t, tc.handler(w, r), &scimErr)
5478

55-
require.Equal(t, http.StatusNotImplemented, scimErr.StatusCode())
79+
require.Equal(t, http.StatusForbidden, scimErr.StatusCode())
80+
requireMarshalsTo(t, testFixture("filter_forbidden.json"), scimErr)
5681
})
5782
}
5883

@@ -64,7 +89,7 @@ func TestServer(t *testing.T) {
6489
require.ErrorAs(t, srv.NotFound(w, r), &scimErr)
6590

6691
require.Equal(t, http.StatusNotFound, scimErr.StatusCode())
67-
require.Equal(t, "Endpoint or resource does not exist", scimErr.Detail)
92+
requireMarshalsTo(t, testFixture("not_found.json"), scimErr)
6893
})
6994

7095
t.Run("NotAllowed", func(t *testing.T) {
@@ -76,5 +101,14 @@ func TestServer(t *testing.T) {
76101

77102
require.Equal(t, http.StatusMethodNotAllowed, scimErr.StatusCode())
78103
require.Equal(t, http.MethodGet, w.Header().Get("Allow"))
104+
requireMarshalsTo(t, testFixture("method_not_allowed.json"), scimErr)
79105
})
80106
}
107+
108+
func requireMarshalsTo(t *testing.T, expected string, v any) {
109+
t.Helper()
110+
111+
body, err := json.Marshal(v)
112+
require.NoError(t, err)
113+
require.JSONEq(t, expected, string(body))
114+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"schemas": [
3+
"urn:ietf:params:scim:api:messages:2.0:ListResponse"
4+
],
5+
"totalResults": 0,
6+
"startIndex": 1,
7+
"itemsPerPage": 0,
8+
"Resources": []
9+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"schemas": [
3+
"urn:ietf:params:scim:api:messages:2.0:Error"
4+
],
5+
"detail": "Filtering is not supported on this endpoint",
6+
"status": "403"
7+
}
File renamed without changes.

0 commit comments

Comments
 (0)