Skip to content

Commit fe67628

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 fe67628

13 files changed

Lines changed: 166 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: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,33 @@
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"
1214
)
1315

16+
//go:embed testdata/*
17+
var fixtures embed.FS
18+
1419
func newServerFor(externalURL string) *Server {
1520
return NewServer(&conf.GlobalConfiguration{
1621
API: conf.APIConfiguration{ExternalURL: externalURL},
1722
})
1823
}
1924

25+
func testFixture(t *testing.T, file string) string {
26+
data, err := fixtures.ReadFile("testdata/" + file)
27+
require.NoError(t, err)
28+
return string(data)
29+
}
30+
2031
func TestServer(t *testing.T) {
2132
srv := newServerFor("http://localhost:9999")
2233
require.NotNil(t, srv)
@@ -35,7 +46,7 @@ func TestServer(t *testing.T) {
3546

3647
require.Equal(t, http.StatusOK, w.Code)
3748
require.Equal(t, protocol.MediaType, w.Header().Get("Content-Type"))
38-
require.JSONEq(t, fixtures.ServiceProviderConfig, w.Body.String())
49+
require.JSONEq(t, testFixture(t, "service_provider_config.json"), w.Body.String())
3950
})
4051

4152
for _, tc := range []struct {
@@ -49,10 +60,23 @@ func TestServer(t *testing.T) {
4960
r := httptest.NewRequest(http.MethodGet, BasePath+"/"+tc.path, nil)
5061
w := httptest.NewRecorder()
5162

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

55-
require.Equal(t, http.StatusNotImplemented, scimErr.StatusCode())
78+
require.Equal(t, http.StatusForbidden, scimErr.StatusCode())
79+
requireMarshalsTo(t, testFixture(t, "filter_forbidden.json"), scimErr)
5680
})
5781
}
5882

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

6690
require.Equal(t, http.StatusNotFound, scimErr.StatusCode())
67-
require.Equal(t, "Endpoint or resource does not exist", scimErr.Detail)
91+
requireMarshalsTo(t, testFixture(t, "not_found.json"), scimErr)
6892
})
6993

7094
t.Run("NotAllowed", func(t *testing.T) {
@@ -76,5 +100,14 @@ func TestServer(t *testing.T) {
76100

77101
require.Equal(t, http.StatusMethodNotAllowed, scimErr.StatusCode())
78102
require.Equal(t, http.MethodGet, w.Header().Get("Allow"))
103+
requireMarshalsTo(t, testFixture(t, "method_not_allowed.json"), scimErr)
79104
})
80105
}
106+
107+
func requireMarshalsTo(t *testing.T, expected string, v any) {
108+
t.Helper()
109+
110+
body, err := json.Marshal(v)
111+
require.NoError(t, err)
112+
require.JSONEq(t, expected, string(body))
113+
}
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)