auth

Paddy 2015-01-18 Parent:fa8ee6a4507c Child:0a1e16b9c141

116:e000b1c24fc0 Go to Latest

auth/client_test.go

Make all tests that deal with the store interfaces go through the Context. This is mainly important so that pre- and post- save/retrieval/deletion/whatever transforms can be done without doing them in every single implementation of the store. Change the Endpoint URI property to be a string, not a *url.URL. This makes testing easier, JSON responses cleaner, and is all around just a better strategy. Just because we turn it into a URL every now and then doesn't mean that's how we need to store it. Add JSON tags to the Client type and Endpoint type. Create normalizeURI and normalizeURIString methods to... well, normalize the Endpoint URIs. This makes it so that we can compare them, and forgive some arbitrary user behaviour (like slashes, etc.) Add a NormalizedURI property to the Endpoint type. This is where we store the NormalizedURI, which is what we'll be using when we want to check if an endpoint is valid or not. For the sake of tests and predictability, however, we always want to redirect to the URI, not the NormalizedURI. Add checks to the Client creation API endpoint to give better errors. Now leaving out the Type won't be considered an invalid type, it will be considered a missing parameter. An empty name will be reported as a missing parameter, a name with too few characters will be reported as an insufficient name, and a name with too many characters will be reported as an overflow name. We gather as many of these errors as apply before returning. Check if an Endpoint URI is absolute before adding it as an endpoint, or return an invalid value error if it is not. Always return the errors array when creating a client. We could succeed in creating one or more things and still have errors. We should return anything that's created _as well as_ any errors encountered. Add unit testing for our CreateClientHandler. Fix our oauth2 tests so that if there's an error in the body, it's in the test logs. This should help debugging significantly. Fix our oauth2 tests so that the Profile only requires 1 iteration for its password hashing. This means each time we want to validate a session, it doesn't add a full second to our test runs. This is a big speed improvement for our tests. Add test helper methods for comparing API errors, API responses, and filling in server-generated information in a response that it's impossible to have an expectation around (e.g., IDs) so that we can use our comparison helpers to check if a response is as we expect it. Fix a typo in our Context helpers that was reporting no sessionStore being set _only_ when a sessionStore was set. So yes, the opposite of what we wanted. Oops. This was discovered by passing all our tests through the context. methods instead of operating on the stores themselves.

History
     1.1 --- a/client_test.go	Wed Jan 14 00:23:30 2015 -0500
     1.2 +++ b/client_test.go	Sun Jan 18 01:02:14 2015 -0500
     1.3 @@ -2,6 +2,7 @@
     1.4  
     1.5  import (
     1.6  	"bytes"
     1.7 +	"encoding/json"
     1.8  	"fmt"
     1.9  	"io/ioutil"
    1.10  	"net/http"
    1.11 @@ -60,7 +61,7 @@
    1.12  	if !endpoint1.Added.Equal(endpoint2.Added) {
    1.13  		return false, "Added", endpoint1.Added, endpoint2.Added
    1.14  	}
    1.15 -	if endpoint1.URI.String() != endpoint2.URI.String() {
    1.16 +	if endpoint1.URI != endpoint2.URI {
    1.17  		return false, "URI", endpoint1.URI, endpoint2.URI
    1.18  	}
    1.19  	return true, "", nil, nil
    1.20 @@ -77,15 +78,16 @@
    1.21  		Website: "website",
    1.22  	}
    1.23  	for _, store := range clientStores {
    1.24 -		err := store.saveClient(client)
    1.25 +		context := Context{clients: store}
    1.26 +		err := context.SaveClient(client)
    1.27  		if err != nil {
    1.28  			t.Fatalf("Error saving client to %T: %s", store, err)
    1.29  		}
    1.30 -		err = store.saveClient(client)
    1.31 +		err = context.SaveClient(client)
    1.32  		if err != ErrClientAlreadyExists {
    1.33  			t.Fatalf("Expected ErrClientAlreadyExists, got %v from %T", err, store)
    1.34  		}
    1.35 -		retrieved, err := store.getClient(client.ID)
    1.36 +		retrieved, err := context.GetClient(client.ID)
    1.37  		if err != nil {
    1.38  			t.Fatalf("Error retrieving client from %T: %s", store, err)
    1.39  		}
    1.40 @@ -93,7 +95,7 @@
    1.41  		if !success {
    1.42  			t.Fatalf("Expected field %s to be %v, but %T returned %v", field, expectation, store, result)
    1.43  		}
    1.44 -		clients, err := store.listClientsByOwner(client.OwnerID, 25, 0)
    1.45 +		clients, err := context.ListClientsByOwner(client.OwnerID, 25, 0)
    1.46  		if err != nil {
    1.47  			t.Fatalf("Error retrieving clients by owner from %T: %s", store, err)
    1.48  		}
    1.49 @@ -104,19 +106,19 @@
    1.50  		if !success {
    1.51  			t.Fatalf("Expected field %s to be %v, but %T returned %v", field, expectation, store, result)
    1.52  		}
    1.53 -		err = store.deleteClient(client.ID)
    1.54 +		err = context.DeleteClient(client.ID)
    1.55  		if err != nil {
    1.56  			t.Fatalf("Error deleting client from %T: %s", store, err)
    1.57  		}
    1.58 -		err = store.deleteClient(client.ID)
    1.59 +		err = context.DeleteClient(client.ID)
    1.60  		if err != ErrClientNotFound {
    1.61  			t.Fatalf("Expected ErrClientNotFound, got %s from %T", err, store)
    1.62  		}
    1.63 -		retrieved, err = store.getClient(client.ID)
    1.64 +		retrieved, err = context.GetClient(client.ID)
    1.65  		if err != ErrClientNotFound {
    1.66  			t.Fatalf("Expected ErrClientNotFound from %T, got %+v and %s", store, retrieved, err)
    1.67  		}
    1.68 -		clients, err = store.listClientsByOwner(client.OwnerID, 25, 0)
    1.69 +		clients, err = context.ListClientsByOwner(client.OwnerID, 25, 0)
    1.70  		if err != nil {
    1.71  			t.Fatalf("Error listing clients by owner from %T: %s", store, err)
    1.72  		}
    1.73 @@ -136,30 +138,29 @@
    1.74  		Logo:    "logo",
    1.75  		Website: "website",
    1.76  	}
    1.77 -	uri1, _ := url.Parse("https://www.example.com/")
    1.78 -	uri2, _ := url.Parse("https://www.example.com/my/full/path")
    1.79  	endpoint1 := Endpoint{
    1.80  		ID:       uuid.NewID(),
    1.81  		ClientID: client.ID,
    1.82  		Added:    time.Now(),
    1.83 -		URI:      *uri1,
    1.84 +		URI:      "https://www.example.com/",
    1.85  	}
    1.86  	endpoint2 := Endpoint{
    1.87  		ID:       uuid.NewID(),
    1.88  		ClientID: client.ID,
    1.89  		Added:    time.Now(),
    1.90 -		URI:      *uri2,
    1.91 +		URI:      "https://www.example.com/my/full/path",
    1.92  	}
    1.93  	for _, store := range clientStores {
    1.94 -		err := store.saveClient(client)
    1.95 +		context := Context{clients: store}
    1.96 +		err := context.SaveClient(client)
    1.97  		if err != nil {
    1.98  			t.Fatalf("Error saving client to %T: %s", store, err)
    1.99  		}
   1.100 -		err = store.addEndpoints(client.ID, []Endpoint{endpoint1})
   1.101 +		err = context.AddEndpoints(client.ID, []Endpoint{endpoint1})
   1.102  		if err != nil {
   1.103  			t.Fatalf("Error adding endpoint to client in %T: %s", store, err)
   1.104  		}
   1.105 -		endpoints, err := store.listEndpoints(client.ID, 10, 0)
   1.106 +		endpoints, err := context.ListEndpoints(client.ID, 10, 0)
   1.107  		if err != nil {
   1.108  			t.Fatalf("Error retrieving endpoints from %T: %s", store, err)
   1.109  		}
   1.110 @@ -170,11 +171,11 @@
   1.111  		if !success {
   1.112  			t.Fatalf("Expected field %s to be %v, but %T returned %v", field, expectation, store, result)
   1.113  		}
   1.114 -		err = store.addEndpoints(client.ID, []Endpoint{endpoint2})
   1.115 +		err = context.AddEndpoints(client.ID, []Endpoint{endpoint2})
   1.116  		if err != nil {
   1.117  			t.Fatalf("Error adding endpoint to client in %T: %s", store, err)
   1.118  		}
   1.119 -		endpoints, err = store.listEndpoints(client.ID, 10, 0)
   1.120 +		endpoints, err = context.ListEndpoints(client.ID, 10, 0)
   1.121  		if err != nil {
   1.122  			t.Fatalf("Error retrieving endpoints from %T: %s", store, err)
   1.123  		}
   1.124 @@ -192,11 +193,11 @@
   1.125  		if !success {
   1.126  			t.Fatalf("Expected field %s to be %v, but %T returned %v", field, expectation, store, result)
   1.127  		}
   1.128 -		err = store.removeEndpoint(client.ID, endpoint1.ID)
   1.129 +		err = context.RemoveEndpoint(client.ID, endpoint1.ID)
   1.130  		if err != nil {
   1.131  			t.Fatalf("Error removing endpoint from client in %T: %s", store, err)
   1.132  		}
   1.133 -		endpoints, err = store.listEndpoints(client.ID, 10, 0)
   1.134 +		endpoints, err = context.ListEndpoints(client.ID, 10, 0)
   1.135  		if err != nil {
   1.136  			t.Fatalf("Error listing endpoints in %T: %s", store, err)
   1.137  		}
   1.138 @@ -207,11 +208,11 @@
   1.139  		if !success {
   1.140  			t.Fatalf("Expected field %s to be %v, but %T returned %v", field, expectation, store, result)
   1.141  		}
   1.142 -		err = store.removeEndpoint(client.ID, endpoint2.ID)
   1.143 +		err = context.RemoveEndpoint(client.ID, endpoint2.ID)
   1.144  		if err != nil {
   1.145  			t.Fatalf("Error removing endpoint from client in %T: %s", store, err)
   1.146  		}
   1.147 -		endpoints, err = store.listEndpoints(client.ID, 10, 0)
   1.148 +		endpoints, err = context.ListEndpoints(client.ID, 10, 0)
   1.149  		if err != nil {
   1.150  			t.Fatalf("Error listing endpoints in %T: %s", store, err)
   1.151  		}
   1.152 @@ -267,27 +268,28 @@
   1.153  			t.Fatalf("Expected field `%s` to be `%v`, got `%v`", field, expected, got)
   1.154  		}
   1.155  		for _, store := range clientStores {
   1.156 -			err := store.saveClient(client)
   1.157 +			context := Context{clients: store}
   1.158 +			err := context.SaveClient(client)
   1.159  			if err != nil {
   1.160  				t.Fatalf("Error saving client in %T: %s", store, err)
   1.161  			}
   1.162 -			err = store.updateClient(client.ID, change)
   1.163 +			err = context.UpdateClient(client.ID, change)
   1.164  			if err != nil {
   1.165  				t.Fatalf("Error updating client in %T: %s", store, err)
   1.166  			}
   1.167 -			retrieved, err := store.getClient(client.ID)
   1.168 +			retrieved, err := context.GetClient(client.ID)
   1.169  			if err != nil {
   1.170 -				t.Fatalf("Error getting profile from %T: %s", store, err)
   1.171 +				t.Fatalf("Error getting client from %T: %s", store, err)
   1.172  			}
   1.173  			match, field, expected, got = compareClients(expectation, retrieved)
   1.174  			if !match {
   1.175  				t.Fatalf("Expected field `%s` to be `%v`, got `%v` from %T", field, expected, got, store)
   1.176  			}
   1.177 -			err = store.deleteClient(client.ID)
   1.178 +			err = context.DeleteClient(client.ID)
   1.179  			if err != nil {
   1.180  				t.Fatalf("Error deleting client from %T: %s", store, err)
   1.181  			}
   1.182 -			err = store.updateClient(client.ID, change)
   1.183 +			err = context.UpdateClient(client.ID, change)
   1.184  			if err != ErrClientNotFound {
   1.185  				t.Fatalf("Expected ErrClientNotFound, got %v from %T", err, store)
   1.186  			}
   1.187 @@ -305,19 +307,17 @@
   1.188  		Logo:    "logo",
   1.189  		Website: "website",
   1.190  	}
   1.191 -	uri1, _ := url.Parse("https://www.example.com/first")
   1.192 -	uri2, _ := url.Parse("https://www.example.com/my/full/path")
   1.193  	endpoint1 := Endpoint{
   1.194  		ID:       uuid.NewID(),
   1.195  		ClientID: client.ID,
   1.196  		Added:    time.Now(),
   1.197 -		URI:      *uri1,
   1.198 +		URI:      "https://www.example.com/first",
   1.199  	}
   1.200  	endpoint2 := Endpoint{
   1.201  		ID:       uuid.NewID(),
   1.202  		ClientID: client.ID,
   1.203  		Added:    time.Now(),
   1.204 -		URI:      *uri2,
   1.205 +		URI:      "https://www.example.com/my/full/path",
   1.206  	}
   1.207  	candidates := map[string]bool{
   1.208  		"https://www.example.com/":                 false,
   1.209 @@ -327,20 +327,21 @@
   1.210  		"https://www.example.com/my/full/path":     true,
   1.211  	}
   1.212  	for _, store := range clientStores {
   1.213 -		err := store.saveClient(client)
   1.214 +		context := Context{clients: store}
   1.215 +		err := context.SaveClient(client)
   1.216  		if err != nil {
   1.217  			t.Fatalf("Error saving client in %T: %s", store, err)
   1.218  		}
   1.219 -		err = store.addEndpoints(client.ID, []Endpoint{endpoint1})
   1.220 +		err = context.AddEndpoints(client.ID, []Endpoint{endpoint1})
   1.221  		if err != nil {
   1.222  			t.Fatalf("Error saving endpoint in %T: %s", store, err)
   1.223  		}
   1.224 -		err = store.addEndpoints(client.ID, []Endpoint{endpoint2})
   1.225 +		err = context.AddEndpoints(client.ID, []Endpoint{endpoint2})
   1.226  		if err != nil {
   1.227  			t.Fatalf("Error saving endpoint in %T: %s", store, err)
   1.228  		}
   1.229  		for candidate, expectation := range candidates {
   1.230 -			result, err := store.checkEndpoint(client.ID, candidate)
   1.231 +			result, err := context.CheckEndpoint(client.ID, candidate)
   1.232  			if err != nil {
   1.233  				t.Fatalf("Error checking endpoint %s in %T: %s", candidate, store, err)
   1.234  			}
   1.235 @@ -367,19 +368,17 @@
   1.236  		Logo:    "logo",
   1.237  		Website: "website",
   1.238  	}
   1.239 -	uri1, _ := url.Parse("https://www.example.com/first")
   1.240 -	uri2, _ := url.Parse("https://www.example.com/my/full/path")
   1.241  	endpoint1 := Endpoint{
   1.242  		ID:       uuid.NewID(),
   1.243  		ClientID: client.ID,
   1.244  		Added:    time.Now(),
   1.245 -		URI:      *uri1,
   1.246 +		URI:      "https://www.example.com/first",
   1.247  	}
   1.248  	endpoint2 := Endpoint{
   1.249  		ID:       uuid.NewID(),
   1.250  		ClientID: client.ID,
   1.251  		Added:    time.Now(),
   1.252 -		URI:      *uri2,
   1.253 +		URI:      "https://www.example.com/my/full/path",
   1.254  	}
   1.255  	candidates := map[string]bool{
   1.256  		"https://www.example.com/":                 false,
   1.257 @@ -389,20 +388,21 @@
   1.258  		"https://www.example.com/my/full/path":     true,
   1.259  	}
   1.260  	for _, store := range clientStores {
   1.261 -		err := store.saveClient(client)
   1.262 +		context := Context{clients: store}
   1.263 +		err := context.SaveClient(client)
   1.264  		if err != nil {
   1.265  			t.Fatalf("Error saving client in %T: %s", store, err)
   1.266  		}
   1.267 -		err = store.addEndpoints(client.ID, []Endpoint{endpoint1})
   1.268 +		err = context.AddEndpoints(client.ID, []Endpoint{endpoint1})
   1.269  		if err != nil {
   1.270  			t.Fatalf("Error saving endpoint in %T: %s", store, err)
   1.271  		}
   1.272 -		err = store.addEndpoints(client.ID, []Endpoint{endpoint2})
   1.273 +		err = context.AddEndpoints(client.ID, []Endpoint{endpoint2})
   1.274  		if err != nil {
   1.275  			t.Fatalf("Error saving endpoint in %T: %s", store, err)
   1.276  		}
   1.277  		for candidate, expectation := range candidates {
   1.278 -			result, err := store.checkEndpoint(client.ID, candidate)
   1.279 +			result, err := context.CheckEndpoint(client.ID, candidate)
   1.280  			if err != nil {
   1.281  				t.Fatalf("Error checking endpoint %s in %T: %s", candidate, store, err)
   1.282  			}
   1.283 @@ -863,3 +863,143 @@
   1.284  		t.Errorf(`Expected empty body, got "%s"`, w.Body.String())
   1.285  	}
   1.286  }
   1.287 +
   1.288 +func TestCreateClientHandler(t *testing.T) {
   1.289 +	t.Parallel()
   1.290 +	memstore := NewMemstore()
   1.291 +	c := Context{
   1.292 +		clients:  memstore,
   1.293 +		profiles: memstore,
   1.294 +	}
   1.295 +	w := httptest.NewRecorder()
   1.296 +	r, err := http.NewRequest("POST", "https://test.auth.secondbit.org/clients", nil)
   1.297 +	if err != nil {
   1.298 +		t.Fatal("Can't build request:", err)
   1.299 +	}
   1.300 +	r.Header.Set("Content-Type", "application/json")
   1.301 +	CreateClientHandler(w, r, c)
   1.302 +	if w.Code != http.StatusUnauthorized {
   1.303 +		t.Errorf("Expected status of %d, got status %d", http.StatusUnauthorized, w.Code)
   1.304 +	}
   1.305 +	expected := `{"errors":[{"error":"access_denied"}]}`
   1.306 +	result := strings.TrimSpace(w.Body.String())
   1.307 +	if result != expected {
   1.308 +		t.Errorf("Expected response to be `%s`, got `%s`", expected, result)
   1.309 +	}
   1.310 +	w = httptest.NewRecorder()
   1.311 +	r.Header.Set("Authorization", "Not basic at all...")
   1.312 +	CreateClientHandler(w, r, c)
   1.313 +	if w.Code != http.StatusUnauthorized {
   1.314 +		t.Errorf("Expected status of %d, got status %d", http.StatusUnauthorized, w.Code)
   1.315 +	}
   1.316 +	expected = `{"errors":[{"error":"access_denied"}]}`
   1.317 +	result = strings.TrimSpace(w.Body.String())
   1.318 +	if result != expected {
   1.319 +		t.Errorf("Expected response to be `%s`, got `%s`", expected, result)
   1.320 +	}
   1.321 +	w = httptest.NewRecorder()
   1.322 +	r.Header.Set("Authorization", "Basic TotallyNotBase64Encoded")
   1.323 +	CreateClientHandler(w, r, c)
   1.324 +	if w.Code != http.StatusUnauthorized {
   1.325 +		t.Errorf("Expected status of %d, got status %d", http.StatusUnauthorized, w.Code)
   1.326 +	}
   1.327 +	expected = `{"errors":[{"error":"access_denied"}]}`
   1.328 +	result = strings.TrimSpace(w.Body.String())
   1.329 +	if result != expected {
   1.330 +		t.Errorf("Expected response to be `%s`, got `%s`", expected, result)
   1.331 +	}
   1.332 +	w = httptest.NewRecorder()
   1.333 +	r.Header.Set("Authorization", "Basic dGhpc2hhc25vY29sb24=")
   1.334 +	CreateClientHandler(w, r, c)
   1.335 +	if w.Code != http.StatusUnauthorized {
   1.336 +		t.Errorf("Expected status of %d, got status %d", http.StatusUnauthorized, w.Code)
   1.337 +	}
   1.338 +	expected = `{"errors":[{"error":"access_denied"}]}`
   1.339 +	result = strings.TrimSpace(w.Body.String())
   1.340 +	if result != expected {
   1.341 +		t.Errorf("Expected response to be `%s`, got `%s`", expected, result)
   1.342 +	}
   1.343 +	profile := Profile{
   1.344 +		ID:                     uuid.NewID(),
   1.345 +		Name:                   "Test User",
   1.346 +		Passphrase:             "f3a4ac4f1d657b2e6e776d24213e39406d50a87a52691a2a78891425af1271d0",
   1.347 +		Iterations:             1,
   1.348 +		Salt:                   "d82d92cfa8bfb5a08270ebbf39a3710d24b352b937fcc8959ebcb40384cc616b",
   1.349 +		PassphraseScheme:       1,
   1.350 +		Compromised:            false,
   1.351 +		LockedUntil:            time.Time{},
   1.352 +		PassphraseReset:        "",
   1.353 +		PassphraseResetCreated: time.Time{},
   1.354 +		Created:                time.Now(),
   1.355 +		LastSeen:               time.Time{},
   1.356 +	}
   1.357 +	login := Login{
   1.358 +		Type:      "email",
   1.359 +		Value:     "test@example.com",
   1.360 +		ProfileID: profile.ID,
   1.361 +		Created:   time.Now(),
   1.362 +		LastUsed:  time.Time{},
   1.363 +	}
   1.364 +	w = httptest.NewRecorder()
   1.365 +	r.SetBasicAuth("test@example.com", "mysecurepassphrase")
   1.366 +	CreateClientHandler(w, r, c)
   1.367 +	if w.Code != http.StatusUnauthorized {
   1.368 +		t.Errorf("Expected status of %d, got status %d", http.StatusUnauthorized, w.Code)
   1.369 +	}
   1.370 +	expected = `{"errors":[{"error":"access_denied"}]}`
   1.371 +	result = strings.TrimSpace(w.Body.String())
   1.372 +	if result != expected {
   1.373 +		t.Errorf("Expected response to be `%s`, got `%s`", expected, result)
   1.374 +	}
   1.375 +	err = c.SaveProfile(profile)
   1.376 +	if err != nil {
   1.377 +		t.Error("Error saving profile:", err)
   1.378 +	}
   1.379 +	err = c.AddLogin(login)
   1.380 +	if err != nil {
   1.381 +		t.Error("Error adding login:", err)
   1.382 +	}
   1.383 +	r.SetBasicAuth("test@example.com", "mysecurepassphrase")
   1.384 +	type testStruct struct {
   1.385 +		request string
   1.386 +		code    int
   1.387 +		resp    response
   1.388 +	}
   1.389 +	tests := []testStruct{
   1.390 +		{``, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidFormat, Field: "/"}}}},
   1.391 +		{`{}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrMissing, Field: "/type"}, {Slug: requestErrMissing, Field: "/name"}}}},
   1.392 +		{`{"type":"notarealtype"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidValue, Field: "/type"}, {Slug: requestErrMissing, Field: "/name"}}}},
   1.393 +		{`{"type":"notarealtype","name":"myreallylongnameislongerthatthemaximumnamelength"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidValue, Field: "/type"}, {Slug: requestErrOverflow, Field: "/name"}}}},
   1.394 +		{`{"type":"notarealtype","name":"a"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidValue, Field: "/type"}, {Slug: requestErrInsufficient, Field: "/name"}}}},
   1.395 +		{`{"type":"public"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrMissing, Field: "/name"}}}},
   1.396 +		{`{"type":"public","name":"myreallylongnameislongerthatthemaximumnamelength"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrOverflow, Field: "/name"}}}},
   1.397 +		{`{"type":"public","name":"a"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInsufficient, Field: "/name"}}}},
   1.398 +		{`{"name":"My Client"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrMissing, Field: "/type"}}}},
   1.399 +		{`{"type":"notarealtype","name":"My Client"}`, http.StatusBadRequest, response{Errors: []requestError{{Slug: requestErrInvalidValue, Field: "/type"}}}},
   1.400 +		{`{"type":"public","name":"My Client"}`, http.StatusCreated, response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}}},
   1.401 +		{`{"type":"public","name":"My Client", "endpoints": ["https://test.secondbit.org/", "https://paddy.io"]}`, http.StatusCreated, response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}, Endpoints: []Endpoint{{URI: "https://test.secondbit.org/"}, {URI: "https://paddy.io"}}}},
   1.402 +		{`{"type":"public","name":"My Client", "endpoints": [":/not a url", "https://paddy.io"]}`, http.StatusCreated, response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}, Endpoints: []Endpoint{{URI: "https://paddy.io"}}, Errors: []requestError{{Slug: requestErrInvalidFormat, Field: "/endpoints/0"}}}},
   1.403 +		{`{"type":"public","name":"My Client", "endpoints": [":/not a url", "/relative/uri", "https://paddy.io"]}`, http.StatusCreated, response{Clients: []Client{{Name: "My Client", OwnerID: profile.ID, Type: "public"}}, Endpoints: []Endpoint{{URI: "https://paddy.io"}}, Errors: []requestError{{Slug: requestErrInvalidFormat, Field: "/endpoints/0"}, {Slug: requestErrInvalidValue, Field: "/endpoints/1"}}}},
   1.404 +	}
   1.405 +	for pos, test := range tests {
   1.406 +		t.Logf("Test #%d: `%s`", pos, test.request)
   1.407 +		w = httptest.NewRecorder()
   1.408 +		body := bytes.NewBufferString(test.request)
   1.409 +		r.Body = ioutil.NopCloser(body)
   1.410 +		CreateClientHandler(w, r, c)
   1.411 +		if w.Code != test.code {
   1.412 +			t.Errorf("Expected response code to be %d, got %d", test.code, w.Code)
   1.413 +		}
   1.414 +		t.Logf("Response: %s", w.Body.String())
   1.415 +		var res response
   1.416 +		err = json.Unmarshal(w.Body.Bytes(), &res)
   1.417 +		if err != nil {
   1.418 +			t.Error("Unexpected error unmarshalling response:", err)
   1.419 +		}
   1.420 +		fillInServerGenerated(test.resp, res)
   1.421 +		success, field, expectation, result := compareResponses(test.resp, res)
   1.422 +		if !success {
   1.423 +			t.Errorf("Unexpected result for %s in response: expected %v, got %v", field, expectation, result)
   1.424 +		}
   1.425 +	}
   1.426 +}