mirror of https://github.com/dexidp/dex.git
Browse Source
PR contains connector for openstack keystone.
Features:
access tokens
refresh tokens
groups
Requirements:
access to openstack keystone instance
keystone administrative account credentials
Enabling keystone connector specific tests:
make sure docker is running
export DEX_TEST_KEYSTONE=1
make tests
pull/1387/head
v2.14.0
4 changed files with 684 additions and 2 deletions
@ -0,0 +1,271 @@
|
||||
// Package keystone provides authentication strategy using Keystone.
|
||||
package keystone |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"io/ioutil" |
||||
"net/http" |
||||
|
||||
"github.com/sirupsen/logrus" |
||||
|
||||
"github.com/dexidp/dex/connector" |
||||
) |
||||
|
||||
type conn struct { |
||||
Domain string |
||||
Host string |
||||
AdminUsername string |
||||
AdminPassword string |
||||
Logger logrus.FieldLogger |
||||
} |
||||
|
||||
type userKeystone struct { |
||||
Domain domainKeystone `json:"domain"` |
||||
ID string `json:"id"` |
||||
Name string `json:"name"` |
||||
} |
||||
|
||||
type domainKeystone struct { |
||||
ID string `json:"id"` |
||||
Name string `json:"name"` |
||||
} |
||||
|
||||
// Config holds the configuration parameters for Keystone connector.
|
||||
// Keystone should expose API v3
|
||||
// An example config:
|
||||
// connectors:
|
||||
// type: keystone
|
||||
// id: keystone
|
||||
// name: Keystone
|
||||
// config:
|
||||
// keystoneHost: http://example:5000
|
||||
// domain: default
|
||||
// keystoneUsername: demo
|
||||
// keystonePassword: DEMO_PASS
|
||||
type Config struct { |
||||
Domain string `json:"domain"` |
||||
Host string `json:"keystoneHost"` |
||||
AdminUsername string `json:"keystoneUsername"` |
||||
AdminPassword string `json:"keystonePassword"` |
||||
} |
||||
|
||||
type loginRequestData struct { |
||||
auth `json:"auth"` |
||||
} |
||||
|
||||
type auth struct { |
||||
Identity identity `json:"identity"` |
||||
} |
||||
|
||||
type identity struct { |
||||
Methods []string `json:"methods"` |
||||
Password password `json:"password"` |
||||
} |
||||
|
||||
type password struct { |
||||
User user `json:"user"` |
||||
} |
||||
|
||||
type user struct { |
||||
Name string `json:"name"` |
||||
Domain domain `json:"domain"` |
||||
Password string `json:"password"` |
||||
} |
||||
|
||||
type domain struct { |
||||
ID string `json:"id"` |
||||
} |
||||
|
||||
type token struct { |
||||
User userKeystone `json:"user"` |
||||
} |
||||
|
||||
type tokenResponse struct { |
||||
Token token `json:"token"` |
||||
} |
||||
|
||||
type group struct { |
||||
ID string `json:"id"` |
||||
Name string `json:"name"` |
||||
} |
||||
|
||||
type groupsResponse struct { |
||||
Groups []group `json:"groups"` |
||||
} |
||||
|
||||
var ( |
||||
_ connector.PasswordConnector = &conn{} |
||||
_ connector.RefreshConnector = &conn{} |
||||
) |
||||
|
||||
// Open returns an authentication strategy using Keystone.
|
||||
func (c *Config) Open(id string, logger logrus.FieldLogger) (connector.Connector, error) { |
||||
return &conn{ |
||||
c.Domain, |
||||
c.Host, |
||||
c.AdminUsername, |
||||
c.AdminPassword, |
||||
logger}, nil |
||||
} |
||||
|
||||
func (p *conn) Close() error { return nil } |
||||
|
||||
func (p *conn) Login(ctx context.Context, scopes connector.Scopes, username, password string) (identity connector.Identity, validPassword bool, err error) { |
||||
resp, err := p.getTokenResponse(ctx, username, password) |
||||
if err != nil { |
||||
return identity, false, fmt.Errorf("keystone: error %v", err) |
||||
} |
||||
if resp.StatusCode/100 != 2 { |
||||
return identity, false, fmt.Errorf("keystone login: error %v", resp.StatusCode) |
||||
} |
||||
if resp.StatusCode != 201 { |
||||
return identity, false, nil |
||||
} |
||||
token := resp.Header.Get("X-Subject-Token") |
||||
data, err := ioutil.ReadAll(resp.Body) |
||||
if err != nil { |
||||
return identity, false, err |
||||
} |
||||
defer resp.Body.Close() |
||||
var tokenResp = new(tokenResponse) |
||||
err = json.Unmarshal(data, &tokenResp) |
||||
if err != nil { |
||||
return identity, false, fmt.Errorf("keystone: invalid token response: %v", err) |
||||
} |
||||
if scopes.Groups { |
||||
groups, err := p.getUserGroups(ctx, tokenResp.Token.User.ID, token) |
||||
if err != nil { |
||||
return identity, false, err |
||||
} |
||||
identity.Groups = groups |
||||
} |
||||
identity.Username = username |
||||
identity.UserID = tokenResp.Token.User.ID |
||||
return identity, true, nil |
||||
} |
||||
|
||||
func (p *conn) Prompt() string { return "username" } |
||||
|
||||
func (p *conn) Refresh( |
||||
ctx context.Context, scopes connector.Scopes, identity connector.Identity) (connector.Identity, error) { |
||||
|
||||
token, err := p.getAdminToken(ctx) |
||||
if err != nil { |
||||
return identity, fmt.Errorf("keystone: failed to obtain admin token: %v", err) |
||||
} |
||||
ok, err := p.checkIfUserExists(ctx, identity.UserID, token) |
||||
if err != nil { |
||||
return identity, err |
||||
} |
||||
if !ok { |
||||
return identity, fmt.Errorf("keystone: user %q does not exist", identity.UserID) |
||||
} |
||||
if scopes.Groups { |
||||
groups, err := p.getUserGroups(ctx, identity.UserID, token) |
||||
if err != nil { |
||||
return identity, err |
||||
} |
||||
identity.Groups = groups |
||||
} |
||||
return identity, nil |
||||
} |
||||
|
||||
func (p *conn) getTokenResponse(ctx context.Context, username, pass string) (response *http.Response, err error) { |
||||
client := &http.Client{} |
||||
jsonData := loginRequestData{ |
||||
auth: auth{ |
||||
Identity: identity{ |
||||
Methods: []string{"password"}, |
||||
Password: password{ |
||||
User: user{ |
||||
Name: username, |
||||
Domain: domain{ID: p.Domain}, |
||||
Password: pass, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
jsonValue, err := json.Marshal(jsonData) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
// https://developer.openstack.org/api-ref/identity/v3/#password-authentication-with-unscoped-authorization
|
||||
authTokenURL := p.Host + "/v3/auth/tokens/" |
||||
req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(jsonValue)) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
req.Header.Set("Content-Type", "application/json") |
||||
req = req.WithContext(ctx) |
||||
|
||||
return client.Do(req) |
||||
} |
||||
|
||||
func (p *conn) getAdminToken(ctx context.Context) (string, error) { |
||||
resp, err := p.getTokenResponse(ctx, p.AdminUsername, p.AdminPassword) |
||||
if err != nil { |
||||
return "", err |
||||
} |
||||
token := resp.Header.Get("X-Subject-Token") |
||||
return token, nil |
||||
} |
||||
|
||||
func (p *conn) checkIfUserExists(ctx context.Context, userID string, token string) (bool, error) { |
||||
// https://developer.openstack.org/api-ref/identity/v3/#show-user-details
|
||||
userURL := p.Host + "/v3/users/" + userID |
||||
client := &http.Client{} |
||||
req, err := http.NewRequest("GET", userURL, nil) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
req.Header.Set("X-Auth-Token", token) |
||||
req = req.WithContext(ctx) |
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
return false, err |
||||
} |
||||
|
||||
if resp.StatusCode == 200 { |
||||
return true, nil |
||||
} |
||||
return false, err |
||||
} |
||||
|
||||
func (p *conn) getUserGroups(ctx context.Context, userID string, token string) ([]string, error) { |
||||
client := &http.Client{} |
||||
// https://developer.openstack.org/api-ref/identity/v3/#list-groups-to-which-a-user-belongs
|
||||
groupsURL := p.Host + "/v3/users/" + userID + "/groups" |
||||
req, err := http.NewRequest("GET", groupsURL, nil) |
||||
req.Header.Set("X-Auth-Token", token) |
||||
req = req.WithContext(ctx) |
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
p.Logger.Errorf("keystone: error while fetching user %q groups\n", userID) |
||||
return nil, err |
||||
} |
||||
|
||||
data, err := ioutil.ReadAll(resp.Body) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
var groupsResp = new(groupsResponse) |
||||
|
||||
err = json.Unmarshal(data, &groupsResp) |
||||
if err != nil { |
||||
return nil, err |
||||
} |
||||
|
||||
groups := make([]string, len(groupsResp.Groups)) |
||||
for i, group := range groupsResp.Groups { |
||||
groups[i] = group.Name |
||||
} |
||||
return groups, nil |
||||
} |
||||
@ -0,0 +1,404 @@
|
||||
package keystone |
||||
|
||||
import ( |
||||
"bytes" |
||||
"context" |
||||
"encoding/json" |
||||
"fmt" |
||||
"io/ioutil" |
||||
"net/http" |
||||
"os" |
||||
"reflect" |
||||
"strings" |
||||
"testing" |
||||
|
||||
"github.com/dexidp/dex/connector" |
||||
) |
||||
|
||||
const ( |
||||
invalidPass = "WRONG_PASS" |
||||
|
||||
testUser = "test_user" |
||||
testPass = "test_pass" |
||||
testEmail = "test@example.com" |
||||
testGroup = "test_group" |
||||
testDomain = "default" |
||||
) |
||||
|
||||
var ( |
||||
keystoneURL = "" |
||||
keystoneAdminURL = "" |
||||
adminUser = "" |
||||
adminPass = "" |
||||
authTokenURL = "" |
||||
usersURL = "" |
||||
groupsURL = "" |
||||
) |
||||
|
||||
type userResponse struct { |
||||
User struct { |
||||
ID string `json:"id"` |
||||
} `json:"user"` |
||||
} |
||||
|
||||
type groupResponse struct { |
||||
Group struct { |
||||
ID string `json:"id"` |
||||
} `json:"group"` |
||||
} |
||||
|
||||
func getAdminToken(t *testing.T, adminName, adminPass string) (token, id string) { |
||||
t.Helper() |
||||
client := &http.Client{} |
||||
|
||||
jsonData := loginRequestData{ |
||||
auth: auth{ |
||||
Identity: identity{ |
||||
Methods: []string{"password"}, |
||||
Password: password{ |
||||
User: user{ |
||||
Name: adminName, |
||||
Domain: domain{ID: testDomain}, |
||||
Password: adminPass, |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
} |
||||
|
||||
body, err := json.Marshal(jsonData) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
req, err := http.NewRequest("POST", authTokenURL, bytes.NewBuffer(body)) |
||||
if err != nil { |
||||
t.Fatalf("keystone: failed to obtain admin token: %v\n", err) |
||||
} |
||||
|
||||
req.Header.Set("Content-Type", "application/json") |
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
token = resp.Header.Get("X-Subject-Token") |
||||
|
||||
data, err := ioutil.ReadAll(resp.Body) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
var tokenResp = new(tokenResponse) |
||||
err = json.Unmarshal(data, &tokenResp) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
return token, tokenResp.Token.User.ID |
||||
} |
||||
|
||||
func createUser(t *testing.T, token, userName, userEmail, userPass string) string { |
||||
t.Helper() |
||||
client := &http.Client{} |
||||
|
||||
createUserData := map[string]interface{}{ |
||||
"user": map[string]interface{}{ |
||||
"name": userName, |
||||
"email": userEmail, |
||||
"enabled": true, |
||||
"password": userPass, |
||||
"roles": []string{"admin"}, |
||||
}, |
||||
} |
||||
|
||||
body, err := json.Marshal(createUserData) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
req, err := http.NewRequest("POST", usersURL, bytes.NewBuffer(body)) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
req.Header.Set("X-Auth-Token", token) |
||||
req.Header.Add("Content-Type", "application/json") |
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
data, err := ioutil.ReadAll(resp.Body) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
var userResp = new(userResponse) |
||||
err = json.Unmarshal(data, &userResp) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
return userResp.User.ID |
||||
} |
||||
|
||||
// delete group or user
|
||||
func delete(t *testing.T, token, id, uri string) { |
||||
t.Helper() |
||||
client := &http.Client{} |
||||
|
||||
deleteURI := uri + id |
||||
req, err := http.NewRequest("DELETE", deleteURI, nil) |
||||
if err != nil { |
||||
t.Fatalf("error: %v", err) |
||||
} |
||||
req.Header.Set("X-Auth-Token", token) |
||||
client.Do(req) |
||||
} |
||||
|
||||
func createGroup(t *testing.T, token, description, name string) string { |
||||
t.Helper() |
||||
client := &http.Client{} |
||||
|
||||
createGroupData := map[string]interface{}{ |
||||
"group": map[string]interface{}{ |
||||
"name": name, |
||||
"description": description, |
||||
}, |
||||
} |
||||
|
||||
body, err := json.Marshal(createGroupData) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
req, err := http.NewRequest("POST", groupsURL, bytes.NewBuffer(body)) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
req.Header.Set("X-Auth-Token", token) |
||||
req.Header.Add("Content-Type", "application/json") |
||||
resp, err := client.Do(req) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
data, err := ioutil.ReadAll(resp.Body) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
defer resp.Body.Close() |
||||
|
||||
var groupResp = new(groupResponse) |
||||
err = json.Unmarshal(data, &groupResp) |
||||
if err != nil { |
||||
t.Fatal(err) |
||||
} |
||||
|
||||
return groupResp.Group.ID |
||||
} |
||||
|
||||
func addUserToGroup(t *testing.T, token, groupID, userID string) error { |
||||
t.Helper() |
||||
uri := groupsURL + groupID + "/users/" + userID |
||||
client := &http.Client{} |
||||
req, err := http.NewRequest("PUT", uri, nil) |
||||
if err != nil { |
||||
return err |
||||
} |
||||
req.Header.Set("X-Auth-Token", token) |
||||
client.Do(req) |
||||
return nil |
||||
} |
||||
|
||||
func TestIncorrectCredentialsLogin(t *testing.T) { |
||||
setupVariables(t) |
||||
c := conn{Host: keystoneURL, Domain: testDomain, |
||||
AdminUsername: adminUser, AdminPassword: adminPass} |
||||
s := connector.Scopes{OfflineAccess: true, Groups: true} |
||||
_, validPW, err := c.Login(context.Background(), s, adminUser, invalidPass) |
||||
|
||||
if validPW { |
||||
t.Fatal("Incorrect password check") |
||||
} |
||||
|
||||
if err == nil { |
||||
t.Fatal("Error should be returned when invalid password is provided") |
||||
} |
||||
|
||||
if !strings.Contains(err.Error(), "401") { |
||||
t.Fatal("Unrecognized error, expecting 401") |
||||
} |
||||
} |
||||
|
||||
func TestValidUserLogin(t *testing.T) { |
||||
setupVariables(t) |
||||
token, _ := getAdminToken(t, adminUser, adminPass) |
||||
userID := createUser(t, token, testUser, testEmail, testPass) |
||||
c := conn{Host: keystoneURL, Domain: testDomain, |
||||
AdminUsername: adminUser, AdminPassword: adminPass} |
||||
s := connector.Scopes{OfflineAccess: true, Groups: true} |
||||
identity, validPW, err := c.Login(context.Background(), s, testUser, testPass) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
t.Log(identity) |
||||
|
||||
if !validPW { |
||||
t.Fatal("Valid password was not accepted") |
||||
} |
||||
delete(t, token, userID, usersURL) |
||||
} |
||||
|
||||
func TestUseRefreshToken(t *testing.T) { |
||||
setupVariables(t) |
||||
token, adminID := getAdminToken(t, adminUser, adminPass) |
||||
groupID := createGroup(t, token, "Test group description", testGroup) |
||||
addUserToGroup(t, token, groupID, adminID) |
||||
|
||||
c := conn{Host: keystoneURL, Domain: testDomain, |
||||
AdminUsername: adminUser, AdminPassword: adminPass} |
||||
s := connector.Scopes{OfflineAccess: true, Groups: true} |
||||
|
||||
identityLogin, _, err := c.Login(context.Background(), s, adminUser, adminPass) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
|
||||
identityRefresh, err := c.Refresh(context.Background(), s, identityLogin) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
|
||||
delete(t, token, groupID, groupsURL) |
||||
|
||||
expectEquals(t, 1, len(identityRefresh.Groups)) |
||||
expectEquals(t, testGroup, string(identityRefresh.Groups[0])) |
||||
} |
||||
|
||||
func TestUseRefreshTokenUserDeleted(t *testing.T) { |
||||
setupVariables(t) |
||||
token, _ := getAdminToken(t, adminUser, adminPass) |
||||
userID := createUser(t, token, testUser, testEmail, testPass) |
||||
|
||||
c := conn{Host: keystoneURL, Domain: testDomain, |
||||
AdminUsername: adminUser, AdminPassword: adminPass} |
||||
s := connector.Scopes{OfflineAccess: true, Groups: true} |
||||
|
||||
identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
|
||||
_, err = c.Refresh(context.Background(), s, identityLogin) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
|
||||
delete(t, token, userID, usersURL) |
||||
_, err = c.Refresh(context.Background(), s, identityLogin) |
||||
|
||||
if !strings.Contains(err.Error(), "does not exist") { |
||||
t.Errorf("unexpected error: %s", err.Error()) |
||||
} |
||||
} |
||||
|
||||
func TestUseRefreshTokenGroupsChanged(t *testing.T) { |
||||
setupVariables(t) |
||||
token, _ := getAdminToken(t, adminUser, adminPass) |
||||
userID := createUser(t, token, testUser, testEmail, testPass) |
||||
|
||||
c := conn{Host: keystoneURL, Domain: testDomain, |
||||
AdminUsername: adminUser, AdminPassword: adminPass} |
||||
s := connector.Scopes{OfflineAccess: true, Groups: true} |
||||
|
||||
identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
|
||||
identityRefresh, err := c.Refresh(context.Background(), s, identityLogin) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
|
||||
expectEquals(t, 0, len(identityRefresh.Groups)) |
||||
|
||||
groupID := createGroup(t, token, "Test group", testGroup) |
||||
addUserToGroup(t, token, groupID, userID) |
||||
|
||||
identityRefresh, err = c.Refresh(context.Background(), s, identityLogin) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
|
||||
delete(t, token, groupID, groupsURL) |
||||
delete(t, token, userID, usersURL) |
||||
|
||||
expectEquals(t, 1, len(identityRefresh.Groups)) |
||||
} |
||||
|
||||
func TestNoGroupsInScope(t *testing.T) { |
||||
setupVariables(t) |
||||
token, _ := getAdminToken(t, adminUser, adminPass) |
||||
userID := createUser(t, token, testUser, testEmail, testPass) |
||||
|
||||
c := conn{Host: keystoneURL, Domain: testDomain, |
||||
AdminUsername: adminUser, AdminPassword: adminPass} |
||||
s := connector.Scopes{OfflineAccess: true, Groups: false} |
||||
|
||||
groupID := createGroup(t, token, "Test group", testGroup) |
||||
addUserToGroup(t, token, groupID, userID) |
||||
|
||||
identityLogin, _, err := c.Login(context.Background(), s, testUser, testPass) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
expectEquals(t, 0, len(identityLogin.Groups)) |
||||
|
||||
identityRefresh, err := c.Refresh(context.Background(), s, identityLogin) |
||||
if err != nil { |
||||
t.Fatal(err.Error()) |
||||
} |
||||
expectEquals(t, 0, len(identityRefresh.Groups)) |
||||
|
||||
delete(t, token, groupID, groupsURL) |
||||
delete(t, token, userID, usersURL) |
||||
} |
||||
|
||||
func setupVariables(t *testing.T) { |
||||
keystoneURLEnv := "DEX_KEYSTONE_URL" |
||||
keystoneAdminURLEnv := "DEX_KEYSTONE_ADMIN_URL" |
||||
keystoneAdminUserEnv := "DEX_KEYSTONE_ADMIN_USER" |
||||
keystoneAdminPassEnv := "DEX_KEYSTONE_ADMIN_PASS" |
||||
keystoneURL = os.Getenv(keystoneURLEnv) |
||||
if keystoneURL == "" { |
||||
t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneURLEnv)) |
||||
return |
||||
} |
||||
keystoneAdminURL = os.Getenv(keystoneAdminURLEnv) |
||||
if keystoneAdminURL == "" { |
||||
t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneAdminURLEnv)) |
||||
return |
||||
} |
||||
adminUser = os.Getenv(keystoneAdminUserEnv) |
||||
if adminUser == "" { |
||||
t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneAdminUserEnv)) |
||||
return |
||||
} |
||||
adminPass = os.Getenv(keystoneAdminPassEnv) |
||||
if adminPass == "" { |
||||
t.Skip(fmt.Sprintf("variable %q not set, skipping keystone connector tests\n", keystoneAdminPassEnv)) |
||||
return |
||||
} |
||||
authTokenURL = keystoneURL + "/v3/auth/tokens/" |
||||
usersURL = keystoneAdminURL + "/v3/users/" |
||||
groupsURL = keystoneAdminURL + "/v3/groups/" |
||||
} |
||||
|
||||
func expectEquals(t *testing.T, a interface{}, b interface{}) { |
||||
if !reflect.DeepEqual(a, b) { |
||||
t.Errorf("Expected %v to be equal %v", a, b) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue