2019-12-08 07:04:19 +09:00
|
|
|
// Copyright 2019 The Gitea Authors. All rights reserved.
|
2022-11-28 03:20:29 +09:00
|
|
|
// SPDX-License-Identifier: MIT
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-09-03 04:18:23 +09:00
|
|
|
package integration
|
2019-12-08 07:04:19 +09:00
|
|
|
|
|
|
|
import (
|
|
|
|
"fmt"
|
|
|
|
"net/http"
|
|
|
|
"testing"
|
|
|
|
"time"
|
|
|
|
|
2023-01-18 06:46:03 +09:00
|
|
|
auth_model "code.gitea.io/gitea/models/auth"
|
2022-04-08 18:11:15 +09:00
|
|
|
"code.gitea.io/gitea/models/db"
|
2022-06-13 18:37:59 +09:00
|
|
|
issues_model "code.gitea.io/gitea/models/issues"
|
2021-11-16 17:53:21 +09:00
|
|
|
"code.gitea.io/gitea/models/unittest"
|
2021-11-24 18:49:20 +09:00
|
|
|
user_model "code.gitea.io/gitea/models/user"
|
2019-12-08 07:04:19 +09:00
|
|
|
api "code.gitea.io/gitea/modules/structs"
|
2022-12-29 11:57:15 +09:00
|
|
|
"code.gitea.io/gitea/services/convert"
|
2022-09-03 04:18:23 +09:00
|
|
|
"code.gitea.io/gitea/tests"
|
2019-12-08 07:04:19 +09:00
|
|
|
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
)
|
|
|
|
|
|
|
|
func TestAPIIssuesReactions(t *testing.T) {
|
2022-09-03 04:18:23 +09:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-08-16 11:22:25 +09:00
|
|
|
issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{ID: 1})
|
2022-04-08 18:11:15 +09:00
|
|
|
_ = issue.LoadRepo(db.DefaultContext)
|
2022-08-16 11:22:25 +09:00
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID})
|
2019-12-08 07:04:19 +09:00
|
|
|
|
|
|
|
session := loginUser(t, owner.Name)
|
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2023-06-05 03:57:16 +09:00
|
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-08-16 11:22:25 +09:00
|
|
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
2019-12-08 07:04:19 +09:00
|
|
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/reactions?token=%s",
|
|
|
|
owner.Name, issue.Repo.Name, issue.Index, token)
|
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Try to add not allowed reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
|
|
|
|
Reaction: "wrong",
|
|
|
|
})
|
2022-12-02 12:39:42 +09:00
|
|
|
MakeRequest(t, req, http.StatusForbidden)
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Delete not allowed reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{
|
|
|
|
Reaction: "zzz",
|
|
|
|
})
|
2022-12-02 12:39:42 +09:00
|
|
|
MakeRequest(t, req, http.StatusOK)
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Add allowed reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
|
|
|
|
Reaction: "rocket",
|
|
|
|
})
|
2022-12-02 12:39:42 +09:00
|
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
2019-12-31 17:21:21 +09:00
|
|
|
var apiNewReaction api.Reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
DecodeJSON(t, resp, &apiNewReaction)
|
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Add existing reaction
|
2022-12-02 12:39:42 +09:00
|
|
|
MakeRequest(t, req, http.StatusForbidden)
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Get end result of reaction list of issue #1
|
2019-12-08 07:04:19 +09:00
|
|
|
req = NewRequestf(t, "GET", urlStr)
|
2022-12-02 12:39:42 +09:00
|
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
2019-12-31 17:21:21 +09:00
|
|
|
var apiReactions []*api.Reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
DecodeJSON(t, resp, &apiReactions)
|
2019-12-31 17:21:21 +09:00
|
|
|
expectResponse := make(map[int]api.Reaction)
|
|
|
|
expectResponse[0] = api.Reaction{
|
Add context cache as a request level cache (#22294)
To avoid duplicated load of the same data in an HTTP request, we can set
a context cache to do that. i.e. Some pages may load a user from a
database with the same id in different areas on the same page. But the
code is hidden in two different deep logic. How should we share the
user? As a result of this PR, now if both entry functions accept
`context.Context` as the first parameter and we just need to refactor
`GetUserByID` to reuse the user from the context cache. Then it will not
be loaded twice on an HTTP request.
But of course, sometimes we would like to reload an object from the
database, that's why `RemoveContextData` is also exposed.
The core context cache is here. It defines a new context
```go
type cacheContext struct {
ctx context.Context
data map[any]map[any]any
lock sync.RWMutex
}
var cacheContextKey = struct{}{}
func WithCacheContext(ctx context.Context) context.Context {
return context.WithValue(ctx, cacheContextKey, &cacheContext{
ctx: ctx,
data: make(map[any]map[any]any),
})
}
```
Then you can use the below 4 methods to read/write/del the data within
the same context.
```go
func GetContextData(ctx context.Context, tp, key any) any
func SetContextData(ctx context.Context, tp, key, value any)
func RemoveContextData(ctx context.Context, tp, key any)
func GetWithContextCache[T any](ctx context.Context, cacheGroupKey string, cacheTargetID any, f func() (T, error)) (T, error)
```
Then let's take a look at how `system.GetString` implement it.
```go
func GetSetting(ctx context.Context, key string) (string, error) {
return cache.GetWithContextCache(ctx, contextCacheKey, key, func() (string, error) {
return cache.GetString(genSettingCacheKey(key), func() (string, error) {
res, err := GetSettingNoCache(ctx, key)
if err != nil {
return "", err
}
return res.SettingValue, nil
})
})
}
```
First, it will check if context data include the setting object with the
key. If not, it will query from the global cache which may be memory or
a Redis cache. If not, it will get the object from the database. In the
end, if the object gets from the global cache or database, it will be
set into the context cache.
An object stored in the context cache will only be destroyed after the
context disappeared.
2023-02-15 22:37:34 +09:00
|
|
|
User: convert.ToUser(db.DefaultContext, user2, user2),
|
2019-12-08 07:04:19 +09:00
|
|
|
Reaction: "eyes",
|
|
|
|
Created: time.Unix(1573248003, 0),
|
|
|
|
}
|
2019-12-18 22:07:36 +09:00
|
|
|
expectResponse[1] = apiNewReaction
|
|
|
|
assert.Len(t, apiReactions, 2)
|
2019-12-08 07:04:19 +09:00
|
|
|
for i, r := range apiReactions {
|
|
|
|
assert.Equal(t, expectResponse[i].Reaction, r.Reaction)
|
|
|
|
assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix())
|
|
|
|
assert.Equal(t, expectResponse[i].User.ID, r.User.ID)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
func TestAPICommentReactions(t *testing.T) {
|
2022-09-03 04:18:23 +09:00
|
|
|
defer tests.PrepareTestEnv(t)()
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-08-16 11:22:25 +09:00
|
|
|
comment := unittest.AssertExistsAndLoadBean(t, &issues_model.Comment{ID: 2})
|
2022-11-19 17:12:33 +09:00
|
|
|
_ = comment.LoadIssue(db.DefaultContext)
|
2019-12-08 07:04:19 +09:00
|
|
|
issue := comment.Issue
|
2022-04-08 18:11:15 +09:00
|
|
|
_ = issue.LoadRepo(db.DefaultContext)
|
2022-08-16 11:22:25 +09:00
|
|
|
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: issue.Repo.OwnerID})
|
2019-12-08 07:04:19 +09:00
|
|
|
|
|
|
|
session := loginUser(t, owner.Name)
|
Redesign Scoped Access Tokens (#24767)
## Changes
- Adds the following high level access scopes, each with `read` and
`write` levels:
- `activitypub`
- `admin` (hidden if user is not a site admin)
- `misc`
- `notification`
- `organization`
- `package`
- `issue`
- `repository`
- `user`
- Adds new middleware function `tokenRequiresScopes()` in addition to
`reqToken()`
- `tokenRequiresScopes()` is used for each high-level api section
- _if_ a scoped token is present, checks that the required scope is
included based on the section and HTTP method
- `reqToken()` is used for individual routes
- checks that required authentication is present (but does not check
scope levels as this will already have been handled by
`tokenRequiresScopes()`
- Adds migration to convert old scoped access tokens to the new set of
scopes
- Updates the user interface for scope selection
### User interface example
<img width="903" alt="Screen Shot 2023-05-31 at 1 56 55 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/654766ec-2143-4f59-9037-3b51600e32f3">
<img width="917" alt="Screen Shot 2023-05-31 at 1 56 43 PM"
src="https://github.com/go-gitea/gitea/assets/23248839/1ad64081-012c-4a73-b393-66b30352654c">
## tokenRequiresScopes Design Decision
- `tokenRequiresScopes()` was added to more reliably cover api routes.
For an incoming request, this function uses the given scope category
(say `AccessTokenScopeCategoryOrganization`) and the HTTP method (say
`DELETE`) and verifies that any scoped tokens in use include
`delete:organization`.
- `reqToken()` is used to enforce auth for individual routes that
require it. If a scoped token is not present for a request,
`tokenRequiresScopes()` will not return an error
## TODO
- [x] Alphabetize scope categories
- [x] Change 'public repos only' to a radio button (private vs public).
Also expand this to organizations
- [X] Disable token creation if no scopes selected. Alternatively, show
warning
- [x] `reqToken()` is missing from many `POST/DELETE` routes in the api.
`tokenRequiresScopes()` only checks that a given token has the correct
scope, `reqToken()` must be used to check that a token (or some other
auth) is present.
- _This should be addressed in this PR_
- [x] The migration should be reviewed very carefully in order to
minimize access changes to existing user tokens.
- _This should be addressed in this PR_
- [x] Link to api to swagger documentation, clarify what
read/write/delete levels correspond to
- [x] Review cases where more than one scope is needed as this directly
deviates from the api definition.
- _This should be addressed in this PR_
- For example:
```go
m.Group("/users/{username}/orgs", func() {
m.Get("", reqToken(), org.ListUserOrgs)
m.Get("/{org}/permissions", reqToken(), org.GetUserOrgsPermissions)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser,
auth_model.AccessTokenScopeCategoryOrganization),
context_service.UserAssignmentAPI())
```
## Future improvements
- [ ] Add required scopes to swagger documentation
- [ ] Redesign `reqToken()` to be opt-out rather than opt-in
- [ ] Subdivide scopes like `repository`
- [ ] Once a token is created, if it has no scopes, we should display
text instead of an empty bullet point
- [ ] If the 'public repos only' option is selected, should read
categories be selected by default
Closes #24501
Closes #24799
Co-authored-by: Jonathan Tran <jon@allspice.io>
Co-authored-by: Kyle D <kdumontnu@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
2023-06-05 03:57:16 +09:00
|
|
|
token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteIssue)
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-08-16 11:22:25 +09:00
|
|
|
user1 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 1})
|
|
|
|
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
|
2019-12-08 07:04:19 +09:00
|
|
|
urlStr := fmt.Sprintf("/api/v1/repos/%s/%s/issues/comments/%d/reactions?token=%s",
|
|
|
|
owner.Name, issue.Repo.Name, comment.ID, token)
|
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Try to add not allowed reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
req := NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
|
|
|
|
Reaction: "wrong",
|
|
|
|
})
|
2022-12-02 12:39:42 +09:00
|
|
|
MakeRequest(t, req, http.StatusForbidden)
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Delete none existing reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
req = NewRequestWithJSON(t, "DELETE", urlStr, &api.EditReactionOption{
|
|
|
|
Reaction: "eyes",
|
|
|
|
})
|
2022-12-02 12:39:42 +09:00
|
|
|
MakeRequest(t, req, http.StatusOK)
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Add allowed reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
req = NewRequestWithJSON(t, "POST", urlStr, &api.EditReactionOption{
|
|
|
|
Reaction: "+1",
|
|
|
|
})
|
2022-12-02 12:39:42 +09:00
|
|
|
resp := MakeRequest(t, req, http.StatusCreated)
|
2019-12-31 17:21:21 +09:00
|
|
|
var apiNewReaction api.Reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
DecodeJSON(t, resp, &apiNewReaction)
|
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Add existing reaction
|
2022-12-02 12:39:42 +09:00
|
|
|
MakeRequest(t, req, http.StatusForbidden)
|
2019-12-08 07:04:19 +09:00
|
|
|
|
2022-01-21 02:46:10 +09:00
|
|
|
// Get end result of reaction list of issue #1
|
2019-12-08 07:04:19 +09:00
|
|
|
req = NewRequestf(t, "GET", urlStr)
|
2022-12-02 12:39:42 +09:00
|
|
|
resp = MakeRequest(t, req, http.StatusOK)
|
2019-12-31 17:21:21 +09:00
|
|
|
var apiReactions []*api.Reaction
|
2019-12-08 07:04:19 +09:00
|
|
|
DecodeJSON(t, resp, &apiReactions)
|
2019-12-31 17:21:21 +09:00
|
|
|
expectResponse := make(map[int]api.Reaction)
|
|
|
|
expectResponse[0] = api.Reaction{
|
Add context cache as a request level cache (#22294)
To avoid duplicated load of the same data in an HTTP request, we can set
a context cache to do that. i.e. Some pages may load a user from a
database with the same id in different areas on the same page. But the
code is hidden in two different deep logic. How should we share the
user? As a result of this PR, now if both entry functions accept
`context.Context` as the first parameter and we just need to refactor
`GetUserByID` to reuse the user from the context cache. Then it will not
be loaded twice on an HTTP request.
But of course, sometimes we would like to reload an object from the
database, that's why `RemoveContextData` is also exposed.
The core context cache is here. It defines a new context
```go
type cacheContext struct {
ctx context.Context
data map[any]map[any]any
lock sync.RWMutex
}
var cacheContextKey = struct{}{}
func WithCacheContext(ctx context.Context) context.Context {
return context.WithValue(ctx, cacheContextKey, &cacheContext{
ctx: ctx,
data: make(map[any]map[any]any),
})
}
```
Then you can use the below 4 methods to read/write/del the data within
the same context.
```go
func GetContextData(ctx context.Context, tp, key any) any
func SetContextData(ctx context.Context, tp, key, value any)
func RemoveContextData(ctx context.Context, tp, key any)
func GetWithContextCache[T any](ctx context.Context, cacheGroupKey string, cacheTargetID any, f func() (T, error)) (T, error)
```
Then let's take a look at how `system.GetString` implement it.
```go
func GetSetting(ctx context.Context, key string) (string, error) {
return cache.GetWithContextCache(ctx, contextCacheKey, key, func() (string, error) {
return cache.GetString(genSettingCacheKey(key), func() (string, error) {
res, err := GetSettingNoCache(ctx, key)
if err != nil {
return "", err
}
return res.SettingValue, nil
})
})
}
```
First, it will check if context data include the setting object with the
key. If not, it will query from the global cache which may be memory or
a Redis cache. If not, it will get the object from the database. In the
end, if the object gets from the global cache or database, it will be
set into the context cache.
An object stored in the context cache will only be destroyed after the
context disappeared.
2023-02-15 22:37:34 +09:00
|
|
|
User: convert.ToUser(db.DefaultContext, user2, user2),
|
2019-12-08 07:04:19 +09:00
|
|
|
Reaction: "laugh",
|
|
|
|
Created: time.Unix(1573248004, 0),
|
|
|
|
}
|
2019-12-31 17:21:21 +09:00
|
|
|
expectResponse[1] = api.Reaction{
|
Add context cache as a request level cache (#22294)
To avoid duplicated load of the same data in an HTTP request, we can set
a context cache to do that. i.e. Some pages may load a user from a
database with the same id in different areas on the same page. But the
code is hidden in two different deep logic. How should we share the
user? As a result of this PR, now if both entry functions accept
`context.Context` as the first parameter and we just need to refactor
`GetUserByID` to reuse the user from the context cache. Then it will not
be loaded twice on an HTTP request.
But of course, sometimes we would like to reload an object from the
database, that's why `RemoveContextData` is also exposed.
The core context cache is here. It defines a new context
```go
type cacheContext struct {
ctx context.Context
data map[any]map[any]any
lock sync.RWMutex
}
var cacheContextKey = struct{}{}
func WithCacheContext(ctx context.Context) context.Context {
return context.WithValue(ctx, cacheContextKey, &cacheContext{
ctx: ctx,
data: make(map[any]map[any]any),
})
}
```
Then you can use the below 4 methods to read/write/del the data within
the same context.
```go
func GetContextData(ctx context.Context, tp, key any) any
func SetContextData(ctx context.Context, tp, key, value any)
func RemoveContextData(ctx context.Context, tp, key any)
func GetWithContextCache[T any](ctx context.Context, cacheGroupKey string, cacheTargetID any, f func() (T, error)) (T, error)
```
Then let's take a look at how `system.GetString` implement it.
```go
func GetSetting(ctx context.Context, key string) (string, error) {
return cache.GetWithContextCache(ctx, contextCacheKey, key, func() (string, error) {
return cache.GetString(genSettingCacheKey(key), func() (string, error) {
res, err := GetSettingNoCache(ctx, key)
if err != nil {
return "", err
}
return res.SettingValue, nil
})
})
}
```
First, it will check if context data include the setting object with the
key. If not, it will query from the global cache which may be memory or
a Redis cache. If not, it will get the object from the database. In the
end, if the object gets from the global cache or database, it will be
set into the context cache.
An object stored in the context cache will only be destroyed after the
context disappeared.
2023-02-15 22:37:34 +09:00
|
|
|
User: convert.ToUser(db.DefaultContext, user1, user1),
|
2019-12-08 07:04:19 +09:00
|
|
|
Reaction: "laugh",
|
|
|
|
Created: time.Unix(1573248005, 0),
|
|
|
|
}
|
|
|
|
expectResponse[2] = apiNewReaction
|
|
|
|
assert.Len(t, apiReactions, 3)
|
|
|
|
for i, r := range apiReactions {
|
|
|
|
assert.Equal(t, expectResponse[i].Reaction, r.Reaction)
|
|
|
|
assert.Equal(t, expectResponse[i].Created.Unix(), r.Created.Unix())
|
|
|
|
assert.Equal(t, expectResponse[i].User.ID, r.User.ID)
|
|
|
|
}
|
|
|
|
}
|