Skip to content

Commit

Permalink
feat: Add route to fetch OAuth clients usage (#4087)
Browse files Browse the repository at this point in the history
This will be useful to know how many OAuth clients a user has already
connected to their Cozy and how many they're allowed to create,
especially for the flagship app to decide whether it should complete
its onboarding flow or present the user with a modal requesting to
either remove some clients or increase the limit if they're able to.
  • Loading branch information
taratatach authored Sep 12, 2023
2 parents 6d89745 + 3d3b20c commit 6b4764b
Show file tree
Hide file tree
Showing 5 changed files with 248 additions and 1 deletion.
41 changes: 40 additions & 1 deletion docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ Content-Type: application/vnd.api+json
"type": "io.cozy.settings",
"id": "io.cozy.settings.disk-usage",
"attributes": {
"is_limited": true,
"quota": "123456789",
"used": "12345678",
"files": "10305070",
Expand All @@ -47,6 +46,46 @@ Content-Type: application/vnd.api+json
}
```

## OAuth clients usage

### GET /settings/clients-usage

This endpoint returns the number of user-connected OAuth clients, the limit set
on the Cozy and if this limit has been reached or even exceeded.
If there is no limit, the `limit` attribute won't be present in the response.

#### Request

```
GET /settings/clients-usage HTTP/1.1
Host: alice.example.com
Accept: application/vnd.api+json
Authorization: Bearer ...
```

#### Response

```http
HTTP/1.1 200 OK
Content-Type: application/vnd.api+json
```

```json
{
"data": {
"type": "io.cozy.settings",
"id": "io.cozy.settings.clients-usage",
"attributes": {
"limit": 3,
"count": 3,
"limitReached": true,
"limitExceeded": false
}
}
}
```


## Email update

### POST /settings/email
Expand Down
2 changes: 2 additions & 0 deletions pkg/consts/consts.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const (
BitwardenSettingsID = "io.cozy.settings.bitwarden"
// ContextSettingsID is the id of the settings JSON-API response for the context
ContextSettingsID = "io.cozy.settings.context"
// ClientsUsageID is the id of the settings JSON-API response for clients-usage
ClientsUsageID = "io.cozy.settings.clients-usage"
// DiskUsageID is the id of the settings JSON-API response for disk-usage
DiskUsageID = "io.cozy.settings.disk-usage"
// InstanceSettingsID is the id of settings document for the instance
Expand Down
75 changes: 75 additions & 0 deletions web/settings/clients_usage.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package settings

import (
"fmt"
"net/http"

"github.com/cozy/cozy-stack/model/feature"
"github.com/cozy/cozy-stack/model/oauth"
"github.com/cozy/cozy-stack/model/permission"
"github.com/cozy/cozy-stack/pkg/consts"
"github.com/cozy/cozy-stack/pkg/couchdb"
"github.com/cozy/cozy-stack/pkg/jsonapi"
"github.com/cozy/cozy-stack/web/middlewares"
"github.com/labstack/echo/v4"
)

type apiClientsUsage struct {
Limit *int `json:"limit,omitempty"`
Count int `json:"count"`
LimitReached bool `json:"limitReached"`
LimitExceeded bool `json:"limitExceeded"`
}

func (j *apiClientsUsage) ID() string { return consts.ClientsUsageID }
func (j *apiClientsUsage) Rev() string { return "" }
func (j *apiClientsUsage) DocType() string { return consts.Settings }
func (j *apiClientsUsage) Clone() couchdb.Doc { return j }
func (j *apiClientsUsage) SetID(_ string) {}
func (j *apiClientsUsage) SetRev(_ string) {}
func (j *apiClientsUsage) Relationships() jsonapi.RelationshipMap { return nil }
func (j *apiClientsUsage) Included() []jsonapi.Object { return nil }
func (j *apiClientsUsage) Links() *jsonapi.LinksList {
return &jsonapi.LinksList{Self: "/settings/clients-usage"}
}

// Settings objects permissions are only on ID
func (j *apiClientsUsage) Fetch(field string) []string { return nil }

func (h *HTTPHandler) clientsUsage(c echo.Context) error {
inst := middlewares.GetInstance(c)
var result apiClientsUsage

if err := middlewares.Allow(c, permission.GET, &result); err != nil {
return err
}

flags, err := feature.GetFlags(inst)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Errorf("Could not get flags: %w", err))
}

limit := -1
if clientsLimit, ok := flags.M["cozy.oauthclients.max"].(float64); ok && clientsLimit >= 0 {
limit = int(clientsLimit)
}

clients, _, err := oauth.GetConnectedUserClients(inst, 100, "")
if err != nil {
return fmt.Errorf("Could not get user OAuth clients: %w", err)
}
count := len(clients)

if limit != -1 {
result.Limit = &limit

if count >= limit {
result.LimitReached = true
}
if count > limit {
result.LimitExceeded = true
}
}
result.Count = count
return jsonapi.Data(c, http.StatusOK, &result, nil)
}
130 changes: 130 additions & 0 deletions web/settings/clients_usage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package settings_test

import (
"testing"

"github.com/cozy/cozy-stack/model/instance"
"github.com/cozy/cozy-stack/model/instance/lifecycle"
"github.com/cozy/cozy-stack/model/oauth"
csettings "github.com/cozy/cozy-stack/model/settings"
"github.com/cozy/cozy-stack/pkg/config/config"
"github.com/cozy/cozy-stack/pkg/consts"
"github.com/cozy/cozy-stack/tests/testutils"
"github.com/gavv/httpexpect/v2"
"github.com/stretchr/testify/require"
)

func setClientsLimit(t *testing.T, inst *instance.Instance, limit float64) {
inst.FeatureFlags = map[string]interface{}{"cozy.oauthclients.max": limit}
require.NoError(t, instance.Update(inst))
}

func TestClientsUsage(t *testing.T) {
config.UseTestFile(t)
testutils.NeedCouchdb(t)
setup := testutils.NewSetup(t, t.Name())
testInstance := setup.GetTestInstance(&lifecycle.Options{
Locale: "en",
Timezone: "Europe/Berlin",
Email: "[email protected]",
ContextName: "test-context",
})
scope := consts.Settings + " " + consts.OAuthClients
_, token := setup.GetTestClient(scope)

svc := csettings.NewServiceMock(t)
ts := setupRouter(t, testInstance, svc)

flagship := oauth.Client{
RedirectURIs: []string{"cozy://flagship"},
ClientName: "flagship-app",
ClientKind: "mobile",
SoftwareID: "github.com/cozy/cozy-stack/testing/flagship",
Flagship: true,
}
require.Nil(t, flagship.Create(testInstance, oauth.NotPending))

t.Run("WithoutLimit", func(t *testing.T) {
setClientsLimit(t, testInstance, -1)

e := testutils.CreateTestClient(t, ts.URL)
obj := e.GET("/settings/clients-usage").
WithHeader("Authorization", "Bearer "+token).
Expect().Status(200).
JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
Object()

data := obj.Value("data").Object()
data.ValueEqual("type", "io.cozy.settings")
data.ValueEqual("id", "io.cozy.settings.clients-usage")

attrs := data.Value("attributes").Object()
attrs.NotContainsKey("limit")
attrs.ValueEqual("count", 1)
attrs.ValueEqual("limitReached", false)
attrs.ValueEqual("limitExceeded", false)
})

t.Run("WithLimitNotReached", func(t *testing.T) {
setClientsLimit(t, testInstance, 2)

e := testutils.CreateTestClient(t, ts.URL)
obj := e.GET("/settings/clients-usage").
WithHeader("Authorization", "Bearer "+token).
Expect().Status(200).
JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
Object()

data := obj.Value("data").Object()
data.ValueEqual("type", "io.cozy.settings")
data.ValueEqual("id", "io.cozy.settings.clients-usage")

attrs := data.Value("attributes").Object()
attrs.ValueEqual("limit", 2)
attrs.ValueEqual("count", 1)
attrs.ValueEqual("limitReached", false)
attrs.ValueEqual("limitExceeded", false)
})

t.Run("WithLimitReached", func(t *testing.T) {
setClientsLimit(t, testInstance, 1)

e := testutils.CreateTestClient(t, ts.URL)
obj := e.GET("/settings/clients-usage").
WithHeader("Authorization", "Bearer "+token).
Expect().Status(200).
JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
Object()

data := obj.Value("data").Object()
data.ValueEqual("type", "io.cozy.settings")
data.ValueEqual("id", "io.cozy.settings.clients-usage")

attrs := data.Value("attributes").Object()
attrs.ValueEqual("limit", 1)
attrs.ValueEqual("count", 1)
attrs.ValueEqual("limitReached", true)
attrs.ValueEqual("limitExceeded", false)
})

t.Run("WithLimitExceeded", func(t *testing.T) {
setClientsLimit(t, testInstance, 0)

e := testutils.CreateTestClient(t, ts.URL)
obj := e.GET("/settings/clients-usage").
WithHeader("Authorization", "Bearer "+token).
Expect().Status(200).
JSON(httpexpect.ContentOpts{MediaType: "application/vnd.api+json"}).
Object()

data := obj.Value("data").Object()
data.ValueEqual("type", "io.cozy.settings")
data.ValueEqual("id", "io.cozy.settings.clients-usage")

attrs := data.Value("attributes").Object()
attrs.ValueEqual("limit", 0)
attrs.ValueEqual("count", 1)
attrs.ValueEqual("limitReached", true)
attrs.ValueEqual("limitExceeded", true)
})
}
1 change: 1 addition & 0 deletions web/settings/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ func isMovedError(err error) bool {
// Register all the `/settings` routes to the given router.
func (h *HTTPHandler) Register(router *echo.Group) {
router.GET("/disk-usage", h.diskUsage)
router.GET("/clients-usage", h.clientsUsage)

router.POST("/email", h.postEmail)
router.POST("/email/resend", h.postEmailResend)
Expand Down

0 comments on commit 6b4764b

Please sign in to comment.