Преглед изворни кода

Merge pull request #18 from porter-dev/api-users

Api users
sunguroku пре 5 година
родитељ
комит
0f9bce7c8d

+ 5 - 4
cmd/app/main.go

@@ -10,7 +10,7 @@ import (
 	"github.com/porter-dev/porter/server/api"
 
 	adapter "github.com/porter-dev/porter/internal/adapter"
-	sessionstore "github.com/porter-dev/porter/internal/auth/"
+	sessionstore "github.com/porter-dev/porter/internal/auth"
 	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	vr "github.com/porter-dev/porter/internal/validator"
@@ -28,11 +28,12 @@ func main() {
 		return
 	}
 
-	key = []byte("secret") // TODO: change to os.Getenv("SESSION_KEY")
-	store, _ = sessionstore.NewStore(db, key)
+	repo := gorm.NewRepository(db)
+
+	key := []byte("secret") // TODO: change to os.Getenv("SESSION_KEY")
+	store, _ := sessionstore.NewStore(repo, key)
 
 	validator := vr.New()
-	repo := gorm.NewRepository(db)
 
 	a := api.New(logger, repo, validator, store)
 

+ 369 - 0
docs/API.md

@@ -0,0 +1,369 @@
+**Table of Contents:**
+
+- [Overview](#overview)
+- [Global Errors](#global-errors)
+  - [`ErrorDataWrite`](#errordatawrite)
+  - [`ErrorDataRead`](#errordataread)
+  - [`ErrorInternal`](#errorinternal)
+- [`/api/users`](#apiusers)
+  - [`GET /api/users/{id}`](#get-apiusersid)
+  - [`GET /api/users/{id}/clusters`](#get-apiusersidclusters)
+  - [`GET /api/users/{id}/clusters/all`](#get-apiusersidclustersall)
+  - [`POST /api/users`](#post-apiusers)
+  - [`PUT /api/users/{id}`](#put-apiusersid)
+  - [`DELETE /api/users/{id}`](#delete-apiusersid)
+
+### Overview
+
+This is the API specification that the Go server is implementing. 
+
+**Error handling:**
+
+Errors are passed via both a non-`2xx` status code and an HTTPError response body:
+
+```js
+HTTPError{
+    // The Porter custom error code
+    Code: Number,
+    // A descriptive error message
+    Errors: []String,
+}
+```
+
+Internal server errors are shared across all endpoints and are listed in the [Global Errors](#global-errors) section. 
+
+**Authentication:** The current authentication method is cookie-based sessions--most endpoints require a cookie-based session. 
+
+### Global Errors
+
+#### `ErrorDataWrite`
+
+**Description:** occurs when a write is attempted against the database and fails. 
+
+**Status Code:** `500`
+
+**Response Body:**
+
+```json
+{
+	"Code": 500,
+	"Errors": [{
+		"could not write to database"
+	}],
+}
+```
+
+#### `ErrorDataRead`
+
+**Description:** occurs when a read is attempted against the database and fails. 
+
+**Status Code:** `500`
+
+**Response Body:**
+
+```json
+{
+	"Code": 500,
+	"Errors": [{
+		"could not read from database"
+	}],
+}
+```
+
+#### `ErrorInternal`
+
+**Description:** occurs with a generic internal server error
+
+**Status Code:** `500`
+
+**Response Body:**
+
+```json
+{
+	"Code": 500,
+	"Errors": [{
+		"internal server error"
+	}],
+}
+```
+
+### `/api/users`
+
+#### `GET /api/users/{id}`
+
+**Description:** Gets a user object matching a specific `id`. 
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request Body**: N/A
+
+**Successful Response Body**: 
+
+```js
+User{
+    "id": Number,
+    "email": String,
+    "clusters": []ClusterConfig{
+        "name": String,
+        "server": String,
+        "context": String,
+        "user": String,
+    },
+    "rawKubeConfig": String,
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:**
+
+- User not found
+  - Status Code: `404`
+  - Request Body:
+    ```json
+    {
+        "code":602,
+        "errors":["could not find requested object"]
+    }
+    ```
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+
+#### `GET /api/users/{id}/clusters`
+
+**Description:** Retrieves the clusters that are currently linked to a User account. 
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request Body**: N/A
+
+**Successful Response Body**: 
+
+```js
+[]ClusterConfig{
+  "name": String,
+  "server": String,
+  "context": String,
+  "user": String,
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:** 
+- User not found
+  - Status Code: `404`
+  - Request Body:
+    ```json
+    {
+        "code":602,
+        "errors":["could not find requested object"]
+    }
+    ```
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+
+#### `GET /api/users/{id}/clusters/all`
+
+**Description:** Parses all clusters from the user's kubeconfig and returns a list of viable cluster configs. 
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request Body**: N/A
+
+**Successful Response Body**: 
+
+```js
+[]ClusterConfig{
+  "name": String,
+  "server": String,
+  "context": String,
+  "user": String,
+}
+```
+
+**Successful Status Code**: `200`
+
+**Errors:** 
+- User not found
+  - Status Code: `404`
+  - Request Body:
+    ```json
+    {
+        "code":602,
+        "errors":["could not find requested object"]
+    }
+    ```
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+
+#### `POST /api/users`
+
+**Description:** Creates a new user with a given email and password.
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request Body**: 
+
+```js
+{
+    "email": String,
+    "password": String,
+}
+```
+
+**Successful Response Body**: N/A
+
+**Successful Status Code**: `201`
+
+**Errors:**
+
+- Invalid email (example: `{"email": "notanemail"}`)
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["email validation failed"]
+    }
+    ```
+
+- Missing field
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["required validation failed"]
+    }`
+    ```
+
+- Email already taken 
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["email already taken"]
+    }
+    ```
+
+#### `PUT /api/users/{id}`
+
+**Description:** Updates an existing user.
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request body:**
+
+**Successful Response Body**: N/A
+
+**Successful Status Code**: `204`
+
+**Errors:** 
+
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+
+#### `DELETE /api/users/{id}`
+
+**Description:** Deletes an existing user, requires the password to be sent before deletion. 
+
+**URL parameters:** 
+
+- `id` The user's ID. 
+
+**Query parameters:** N/A
+
+**Request body:**
+
+```js
+{
+    "password": String,
+}
+```
+
+**Successful Response Body**: N/A
+
+**Successful Status Code**: `204`
+
+**Errors:** 
+
+- Invalid `password`
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["invalid password"]
+    }
+    ```
+    
+- Missing field
+  - Status Code: `422`
+  - Request Body:
+    ```json
+    {
+        "code":601,
+        "errors":["required validation failed"]
+    }`
+    ```
+
+- Invalid `id` URL parameter
+  - Status Code: `400`
+  - Request Body:
+    ```json
+    {
+        "code":600,
+        "errors":["could not process request"]
+    }
+    ```
+

+ 2 - 1
internal/auth/example/authExample.go

@@ -5,6 +5,7 @@ import (
 	"net/http"
 
 	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/repository/gorm"
 
 	dbConn "github.com/porter-dev/porter/internal/adapter"
 	sessionstore "github.com/porter-dev/porter/internal/auth"
@@ -16,7 +17,7 @@ var db, dbErr = dbConn.New(&appConf.Db)
 
 var (
 	key      = []byte("secret") // change to os.Getenv("SESSION_KEY")
-	store, _ = sessionstore.NewStore(db, key)
+	store, _ = sessionstore.NewStore(gorm.NewRepository(db), key)
 )
 
 func secret(w http.ResponseWriter, r *http.Request) {

+ 30 - 33
internal/auth/sessionstore.go

@@ -9,14 +9,12 @@ import (
 	"strings"
 	"time"
 
-	"gorm.io/gorm"
-
 	"github.com/gorilla/securecookie"
 	"github.com/gorilla/sessions"
 	"github.com/pkg/errors"
 
 	"github.com/porter-dev/porter/internal/models"
-	rp "github.com/porter-dev/porter/internal/repository/gorm"
+	"github.com/porter-dev/porter/internal/repository"
 )
 
 // structs
@@ -26,7 +24,7 @@ type PGStore struct {
 	Codecs  []securecookie.Codec
 	Options *sessions.Options
 	Path    string
-	DbPool  *gorm.DB
+	Repo    *repository.Repository
 }
 
 // Helpers
@@ -35,8 +33,8 @@ type PGStore struct {
 // If l is 0 there is no limit to the size of a session, use with caution.
 // The default for a new PGStore is 4096. PostgreSQL allows for max
 // value sizes of up to 1GB (http://www.postgresql.org/docs/current/interactive/datatype-character.html)
-func (db *PGStore) MaxLength(l int) {
-	for _, c := range db.Codecs {
+func (store *PGStore) MaxLength(l int) {
+	for _, c := range store.Codecs {
 		if codec, ok := c.(*securecookie.SecureCookie); ok {
 			codec.MaxLength(l)
 		}
@@ -46,11 +44,11 @@ func (db *PGStore) MaxLength(l int) {
 // MaxAge sets the maximum age for the store and the underlying cookie
 // implementation. Individual sessions can be deleted by setting Options.MaxAge
 // = -1 for that session.
-func (db *PGStore) MaxAge(age int) {
-	db.Options.MaxAge = age
+func (store *PGStore) MaxAge(age int) {
+	store.Options.MaxAge = age
 
 	// Set the maxAge for each securecookie instance.
-	for _, codec := range db.Codecs {
+	for _, codec := range store.Codecs {
 		if sc, ok := codec.(*securecookie.SecureCookie); ok {
 			sc.MaxAge(age)
 		}
@@ -59,21 +57,20 @@ func (db *PGStore) MaxAge(age int) {
 
 // load fetches a session by ID from the database and decodes its content
 // into session.Values.
-func (db *PGStore) load(session *sessions.Session) error {
-	repo := rp.NewRepository(db.DbPool)
-	res, err := repo.Session.SelectSession(&models.Session{Key: session.ID})
+func (store *PGStore) load(session *sessions.Session) error {
+	res, err := store.Repo.Session.SelectSession(&models.Session{Key: session.ID})
 
 	if err != nil {
 		return err
 	}
 
-	return securecookie.DecodeMulti(session.Name(), string(res.Data), &session.Values, db.Codecs...)
+	return securecookie.DecodeMulti(session.Name(), string(res.Data), &session.Values, store.Codecs...)
 }
 
 // save writes encoded session.Values to a database record.
 // writes to http_sessions table by default.
-func (db *PGStore) save(session *sessions.Session) error {
-	encoded, err := securecookie.EncodeMulti(session.Name(), session.Values, db.Codecs...)
+func (store *PGStore) save(session *sessions.Session) error {
+	encoded, err := securecookie.EncodeMulti(session.Name(), session.Values, store.Codecs...)
 	if err != nil {
 		return err
 	}
@@ -91,34 +88,34 @@ func (db *PGStore) save(session *sessions.Session) error {
 		}
 	}
 
-	s := models.Session{
+	s := &models.Session{
 		Key:       session.ID,
 		Data:      []byte(encoded),
 		ExpiresAt: expiresOn,
 	}
 
-	repo := rp.NewRepository(db.DbPool)
+	repo := store.Repo
 
 	if session.IsNew {
-		_, createErr := repo.Session.CreateSession(&s)
+		_, createErr := repo.Session.CreateSession(s)
 		return createErr
 	}
 
-	_, updateErr := repo.Session.UpdateSession(&s)
+	_, updateErr := repo.Session.UpdateSession(s)
 	return updateErr
 }
 
 // Implementation of the interface (Get, New, Save)
 
 // NewStore takes an initialized db and session key pairs to create a session-store in postgres db.
-func NewStore(db *gorm.DB, keyPairs ...[]byte) (*PGStore, error) {
+func NewStore(repo *repository.Repository, keyPairs ...[]byte) (*PGStore, error) {
 	dbStore := &PGStore{
 		Codecs: securecookie.CodecsFromPairs(keyPairs...),
 		Options: &sessions.Options{
 			Path:   "/",
 			MaxAge: 86400 * 30,
 		},
-		DbPool: db,
+		Repo: repo,
 	}
 
 	return dbStore, nil
@@ -126,26 +123,26 @@ func NewStore(db *gorm.DB, keyPairs ...[]byte) (*PGStore, error) {
 
 // Get Fetches a session for a given name after it has been added to the
 // registry.
-func (db *PGStore) Get(r *http.Request, name string) (*sessions.Session, error) {
-	return sessions.GetRegistry(r).Get(db, name)
+func (store *PGStore) Get(r *http.Request, name string) (*sessions.Session, error) {
+	return sessions.GetRegistry(r).Get(store, name)
 }
 
 // New returns a new session for the given name without adding it to the registry.
-func (db *PGStore) New(r *http.Request, name string) (*sessions.Session, error) {
-	session := sessions.NewSession(db, name)
+func (store *PGStore) New(r *http.Request, name string) (*sessions.Session, error) {
+	session := sessions.NewSession(store, name)
 	if session == nil {
 		return nil, nil
 	}
 
-	opts := *db.Options
+	opts := *store.Options
 	session.Options = &(opts)
 	session.IsNew = true
 
 	var err error
 	if c, errCookie := r.Cookie(name); errCookie == nil {
-		err = securecookie.DecodeMulti(name, c.Value, &session.ID, db.Codecs...)
+		err = securecookie.DecodeMulti(name, c.Value, &session.ID, store.Codecs...)
 		if err == nil {
-			err = db.load(session)
+			err = store.load(session)
 			if err == nil {
 				session.IsNew = false
 			} else if errors.Cause(err) == sql.ErrNoRows {
@@ -154,14 +151,14 @@ func (db *PGStore) New(r *http.Request, name string) (*sessions.Session, error)
 		}
 	}
 
-	db.MaxAge(db.Options.MaxAge)
+	store.MaxAge(store.Options.MaxAge)
 
 	return session, err
 }
 
 // Save saves the given session into the database and deletes cookies if needed
-func (db *PGStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
-	repo := rp.NewRepository(db.DbPool)
+func (store *PGStore) Save(r *http.Request, w http.ResponseWriter, session *sessions.Session) error {
+	repo := store.Repo
 
 	// Set delete if max-age is < 0
 	if session.Options.MaxAge < 0 {
@@ -180,12 +177,12 @@ func (db *PGStore) Save(r *http.Request, w http.ResponseWriter, session *session
 			), "=")
 	}
 
-	if err := db.save(session); err != nil {
+	if err := store.save(session); err != nil {
 		return err
 	}
 
 	// Keep the session ID key in a cookie so it can be looked up in DB later.
-	encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, db.Codecs...)
+	encoded, err := securecookie.EncodeMulti(session.Name(), session.ID, store.Codecs...)
 	if err != nil {
 		return err
 	}

+ 5 - 6
internal/auth/sessionstore_test.go

@@ -7,8 +7,7 @@ import (
 
 	"github.com/gorilla/securecookie"
 	"github.com/gorilla/sessions"
-
-	dbConn "github.com/porter-dev/porter/internal/adapter"
+	"github.com/porter-dev/porter/internal/repository/test"
 )
 
 type headerOnlyResponseWriter http.Header
@@ -28,9 +27,9 @@ func (ho headerOnlyResponseWriter) WriteHeader(int) {
 var secret = "secret"
 
 func TestPGStore(t *testing.T) {
-	db, _ := dbConn.New()
+	repo := test.NewRepository(true)
 
-	ss, err := NewStore(db, []byte(secret))
+	ss, err := NewStore(repo, []byte(secret))
 	if err != nil {
 		t.Fatal("Failed to get store", err)
 	}
@@ -125,9 +124,9 @@ func TestPGStore(t *testing.T) {
 }
 
 func TestSessionOptionsAreUniquePerSession(t *testing.T) {
-	db, _ := dbConn.New()
+	repo := test.NewRepository(true)
 
-	ss, err := NewStore(db, []byte(secret))
+	ss, err := NewStore(repo, []byte(secret))
 	if err != nil {
 		t.Fatal("Failed to get store", err)
 	}

+ 26 - 5
internal/forms/user.go

@@ -4,7 +4,6 @@ import (
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/models"
 	"golang.org/x/crypto/bcrypt"
-	"gopkg.in/yaml.v2"
 	"gorm.io/gorm"
 )
 
@@ -34,6 +33,31 @@ func (cuf *CreateUserForm) ToUser() (*models.User, error) {
 	}, nil
 }
 
+// LoginUserForm represents the accepted values for logging a user in
+type LoginUserForm struct {
+	WriteUserForm
+	ID       uint   `form:"required"`
+	Email    string `json:"email" form:"required,max=255,email"`
+	Password string `json:"password" form:"required,max=255"`
+}
+
+// ToUser converts a LoginUserForm to models.User
+func (luf *LoginUserForm) ToUser() (*models.User, error) {
+	hashed, err := bcrypt.GenerateFromPassword([]byte(luf.Password), 8)
+
+	if err != nil {
+		return nil, err
+	}
+
+	return &models.User{
+		Model: gorm.Model{
+			ID: luf.ID,
+		},
+		Email:    luf.Email,
+		Password: string(hashed),
+	}, nil
+}
+
 // UpdateUserForm represents the accepted values for updating a user
 //
 // ID is a query parameter, the other two are sent in JSON body
@@ -47,16 +71,13 @@ type UpdateUserForm struct {
 // ToUser converts an UpdateUserForm to models.User by parsing the kubeconfig
 // and the allowed clusters to generate a list of ClusterConfigs.
 func (uuf *UpdateUserForm) ToUser() (*models.User, error) {
-	conf := kubernetes.KubeConfig{}
 	rawBytes := []byte(uuf.RawKubeConfig)
-	err := yaml.Unmarshal(rawBytes, &conf)
+	clusters, err := kubernetes.GetAllowedClusterConfigsFromBytes(rawBytes, uuf.AllowedClusters)
 
 	if err != nil {
 		return nil, err
 	}
 
-	clusters := conf.ToClusterConfigs(uuf.AllowedClusters)
-
 	return &models.User{
 		Model: gorm.Model{
 			ID: uuf.ID,

+ 63 - 8
internal/kubernetes/kubeconfig.go

@@ -1,9 +1,8 @@
 package kubernetes
 
 import (
-	"fmt"
-
 	"github.com/porter-dev/porter/internal/models"
+	"gopkg.in/yaml.v2"
 )
 
 // KubeConfigCluster represents the cluster field in a kubeconfig
@@ -36,11 +35,41 @@ type KubeConfig struct {
 	Users          []KubeConfigUser    `yaml:"users"`
 }
 
-// ToClusterConfigs converts a KubeConfig to a set of ClusterConfigExternals by
+// GetAllowedClusterConfigsFromBytes converts a raw string to a set of ClusterConfigs
+// by unmarshaling and calling (*KubeConfig).ToAllowedClusterConfigs
+func GetAllowedClusterConfigsFromBytes(bytes []byte, allowedClusters []string) ([]models.ClusterConfig, error) {
+	conf := KubeConfig{}
+	err := yaml.Unmarshal(bytes, &conf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	clusters := conf.ToAllowedClusterConfigs(allowedClusters)
+
+	return clusters, nil
+}
+
+// GetAllClusterConfigsFromBytes converts a raw string to a set of ClusterConfigs
+// by unmarshaling and calling (*KubeConfig).ToAllClusterConfigs
+func GetAllClusterConfigsFromBytes(bytes []byte) ([]models.ClusterConfig, error) {
+	conf := KubeConfig{}
+	err := yaml.Unmarshal(bytes, &conf)
+
+	if err != nil {
+		return nil, err
+	}
+
+	clusters := conf.ToAllClusterConfigs()
+
+	return clusters, nil
+}
+
+// ToAllowedClusterConfigs converts a KubeConfig to a set of ClusterConfigs by
 // joining users and clusters on the context.
 //
 // It accepts a list of cluster names that the user wishes to connect to
-func (k *KubeConfig) ToClusterConfigs(allowedClusters []string) []models.ClusterConfig {
+func (k *KubeConfig) ToAllowedClusterConfigs(allowedClusters []string) []models.ClusterConfig {
 	clusters := make([]models.ClusterConfig, 0)
 
 	// convert clusters, contexts, and users to maps for fast lookup
@@ -51,8 +80,6 @@ func (k *KubeConfig) ToClusterConfigs(allowedClusters []string) []models.Cluster
 	// put allowed clusters in map
 	aClusterMap := createAllowedClusterMap(allowedClusters)
 
-	fmt.Println(allowedClusters, aClusterMap)
-
 	// iterate through context maps and link to a user-cluster pair
 	for contextName, context := range contextMap {
 		userName := context.Context.User
@@ -63,8 +90,6 @@ func (k *KubeConfig) ToClusterConfigs(allowedClusters []string) []models.Cluster
 		// make sure the cluster is "allowed"
 		_, aClusterFound := aClusterMap[clusterName]
 
-		fmt.Println(userFound, clusterFound, aClusterFound)
-
 		if userFound && clusterFound && aClusterFound {
 			clusters = append(clusters, models.ClusterConfig{
 				Name:    clusterName,
@@ -78,6 +103,36 @@ func (k *KubeConfig) ToClusterConfigs(allowedClusters []string) []models.Cluster
 	return clusters
 }
 
+// ToAllClusterConfigs converts a KubeConfig to a set of ClusterConfigs by
+// joining users and clusters on the context.
+func (k *KubeConfig) ToAllClusterConfigs() []models.ClusterConfig {
+	clusters := make([]models.ClusterConfig, 0)
+
+	// convert clusters, contexts, and users to maps for fast lookup
+	clusterMap := k.createClusterMap()
+	contextMap := k.createContextMap()
+	userMap := k.createUserMap()
+
+	// iterate through context maps and link to a user-cluster pair
+	for contextName, context := range contextMap {
+		userName := context.Context.User
+		clusterName := context.Context.Cluster
+		_, userFound := userMap[userName]
+		cluster, clusterFound := clusterMap[clusterName]
+
+		if userFound && clusterFound {
+			clusters = append(clusters, models.ClusterConfig{
+				Name:    clusterName,
+				Server:  cluster.Cluster.Server,
+				Context: contextName,
+				User:    userName,
+			})
+		}
+	}
+
+	return clusters
+}
+
 // createAllowedClusterMap creates a map from a cluster name to a KubeConfigCluster object
 func createAllowedClusterMap(clusters []string) map[string]string {
 	aClusterMap := make(map[string]string)

+ 3 - 3
internal/kubernetes/kubeconfig_test.go

@@ -65,7 +65,7 @@ func TestToClusterConfigsMissingFields(t *testing.T) {
 			t.Errorf("Testing: %s, Error: %v\n", c.msg, err)
 		}
 
-		res := conf.ToClusterConfigs(c.allowedClusters)
+		res := conf.ToAllowedClusterConfigs(c.allowedClusters)
 
 		isEqual := reflect.DeepEqual(c.expected, res)
 
@@ -95,7 +95,7 @@ func TestToClusterConfigsNoAllowedClusters(t *testing.T) {
 			t.Errorf("Testing: %s, Error: %v\n", c.msg, err)
 		}
 
-		res := conf.ToClusterConfigs(c.allowedClusters)
+		res := conf.ToAllowedClusterConfigs(c.allowedClusters)
 
 		isEqual := reflect.DeepEqual(c.expected, res)
 
@@ -131,7 +131,7 @@ func TestToClusterConfigsBasic(t *testing.T) {
 			t.Errorf("Testing: %s, Error: %v\n", c.msg, err)
 		}
 
-		res := conf.ToClusterConfigs(c.allowedClusters)
+		res := conf.ToAllowedClusterConfigs(c.allowedClusters)
 
 		isEqual := reflect.DeepEqual(c.expected, res)
 

+ 1 - 1
internal/models/session.go

@@ -10,7 +10,7 @@ import (
 type Session struct {
 	gorm.Model
 	// Session ID
-	Key string
+	Key string `gorm:"unique"`
 	// encrypted cookie
 	Data []byte
 	// Time the session will expire

+ 5 - 9
internal/models/user.go

@@ -7,15 +7,11 @@ import (
 // User type that extends gorm.Model
 type User struct {
 	gorm.Model
-	// Unique email for each user
-	// Email string `gorm:"unique"`
-	Email string
-	// Hashed password
-	Password string
-	// The clusters that this user has linked
-	Clusters []ClusterConfig
-	// The raw kubeconfig uploaded by this user
-	RawKubeConfig []byte
+
+	Email         string          `json:"email" gorm:"unique"`
+	Password      string          `json:"password"`
+	Clusters      []ClusterConfig `json:"clusters"`
+	RawKubeConfig []byte          `json:"rawKubeConfig"`
 }
 
 // UserExternal represents the User type that is sent over REST

+ 7 - 9
internal/repository/gorm/session.go

@@ -6,20 +6,18 @@ import (
 	"gorm.io/gorm"
 )
 
-type sessionrepo struct {
+// SessionRepository uses gorm.DB for querying the database
+type SessionRepository struct {
 	db *gorm.DB
 }
 
 // NewSessionRepository returns pointer to repo along with the db
 func NewSessionRepository(db *gorm.DB) repository.SessionRepository {
-	return &sessionrepo{
-		db: db,
-	}
+	return &SessionRepository{db}
 }
 
 // CreateSession must take in Key, Data, and ExpiresAt as arguments.
-func (s *sessionrepo) CreateSession(session *models.Session) (*models.Session, error) {
-	// TODO: check for duplicate and return error
+func (s *SessionRepository) CreateSession(session *models.Session) (*models.Session, error) {
 	if err := s.db.Create(session).Error; err != nil {
 		return nil, err
 	}
@@ -27,7 +25,7 @@ func (s *sessionrepo) CreateSession(session *models.Session) (*models.Session, e
 }
 
 // UpdateSession updates only the Data field using Key as selector.
-func (s *sessionrepo) UpdateSession(session *models.Session) (*models.Session, error) {
+func (s *SessionRepository) UpdateSession(session *models.Session) (*models.Session, error) {
 	if err := s.db.Model(session).Where("Key = ?", session.Key).Updates(session).Error; err != nil {
 		return nil, err
 	}
@@ -35,7 +33,7 @@ func (s *sessionrepo) UpdateSession(session *models.Session) (*models.Session, e
 }
 
 // DeleteSession deletes a session by Key
-func (s *sessionrepo) DeleteSession(session *models.Session) (*models.Session, error) {
+func (s *SessionRepository) DeleteSession(session *models.Session) (*models.Session, error) {
 
 	if err := s.db.Where("Key = ?", session.Key).Delete(session).Error; err != nil {
 		return nil, err
@@ -45,7 +43,7 @@ func (s *sessionrepo) DeleteSession(session *models.Session) (*models.Session, e
 }
 
 // SelectSession returns a session with matching key
-func (s *sessionrepo) SelectSession(session *models.Session) (*models.Session, error) {
+func (s *SessionRepository) SelectSession(session *models.Session) (*models.Session, error) {
 
 	if err := s.db.Where("Key = ?", session.Key).First(session).Error; err != nil {
 		return nil, err

+ 12 - 10
internal/repository/gorm/session_test.go

@@ -2,17 +2,18 @@ package gorm
 
 import (
 	"database/sql"
-	"regexp"
 	"testing"
 	"time"
 
+	"gorm.io/driver/postgres"
+
 	"github.com/DATA-DOG/go-sqlmock"
 	"github.com/go-test/deep"
-	"github.com/jinzhu/gorm"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/stretchr/testify/require"
 	"github.com/stretchr/testify/suite"
+	"gorm.io/gorm"
 )
 
 type Suite struct {
@@ -36,10 +37,11 @@ func (s *Suite) SetupSuite() {
 
 	require.NoError(s.T(), err)
 
-	s.db, err = gorm.Open("postgres", db)
-	require.NoError(s.T(), err)
+	s.db, err = gorm.Open(postgres.New(postgres.Config{
+		Conn: db,
+	}), &gorm.Config{})
 
-	s.db.LogMode(true)
+	require.NoError(s.T(), err)
 
 	s.repo = NewSessionRepository(s.db)
 }
@@ -62,9 +64,9 @@ func (s *Suite) TestShouldCreateNewSession() {
 	rows := sqlmock.NewRows([]string{"id"}).AddRow("111")
 
 	s.mock.ExpectBegin()
-	s.mock.ExpectQuery(regexp.QuoteMeta(
-		`INSERT INTO "sessions" ("created_at","updated_at","deleted_at","key","data","expires_at")
-		VALUES ($1,$2,$3,$4,$5,$6) RETURNING "sessions"."id"`)).
+	// s.mock.ExpectQuery(`INSERT INTO "sessions" ("created_at","updated_at","deleted_at","key","data","expires_at")
+	// 	VALUES ($1,$2,$3,$4,$5,$6) RETURNING "sessions"."id"`).
+	s.mock.ExpectQuery(`.*`).
 		WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), key, data, expiresAt).
 		WillReturnRows(rows)
 	s.mock.ExpectCommit()
@@ -110,7 +112,7 @@ func (s *Suite) TestShouldUpdateSessionByKey() {
 
 	s.mock.ExpectBegin()
 	s.mock.ExpectExec(`.*`). // do proper regex labor later as meditative exercise
-					WithArgs(data, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), key).
+					WithArgs(sqlmock.AnyArg(), key, data, sqlmock.AnyArg(), key).
 					WillReturnResult(sqlmock.NewResult(1, 1))
 	s.mock.ExpectCommit()
 
@@ -134,7 +136,7 @@ func (s *Suite) TestShouldDeleteSession() {
 
 	s.mock.ExpectBegin()
 	s.mock.ExpectExec(`.*`).
-		WithArgs(sqlmock.AnyArg(), key).
+		WithArgs(key).
 		WillReturnResult(sqlmock.NewResult(1, 1))
 	s.mock.ExpectCommit()
 

+ 4 - 4
internal/repository/gorm/user.go

@@ -35,13 +35,13 @@ func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
 	return user, nil
 }
 
-// ReadUserByEmail finds a single user based on their Email. Used primarily for Login.
+// ReadUserByEmail finds a single user based on their unique email
 func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
-	u := &models.User{}
-	if err := repo.db.Where("email = ?", email).First(&u).Error; err != nil {
+	user := &models.User{}
+	if err := repo.db.Where("email = ?", email).First(&user).Error; err != nil {
 		return nil, err
 	}
-	return u, nil
+	return user, nil
 }
 
 // UpdateUser modifies an existing User in the database

+ 2 - 1
internal/repository/test/repository.go

@@ -8,6 +8,7 @@ import (
 // and accepts a parameter that can trigger read/write errors
 func NewRepository(canQuery bool) *repository.Repository {
 	return &repository.Repository{
-		User: NewUserRepository(canQuery),
+		User:    NewUserRepository(canQuery),
+		Session: NewSessionRepository(canQuery),
 	}
 }

+ 95 - 0
internal/repository/test/session.go

@@ -0,0 +1,95 @@
+package test
+
+import (
+	"errors"
+
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository"
+	"gorm.io/gorm"
+)
+
+// SessionRepository uses gorm.DB for querying the database
+type SessionRepository struct {
+	canQuery bool
+	sessions []*models.Session
+}
+
+// NewSessionRepository returns pointer to repo along with the db
+func NewSessionRepository(canQuery bool) repository.SessionRepository {
+	return &SessionRepository{canQuery, []*models.Session{}}
+}
+
+// CreateSession must take in Key, Data, and ExpiresAt as arguments.
+func (repo *SessionRepository) CreateSession(session *models.Session) (*models.Session, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	// make sure key doesn't exist
+	for _, s := range repo.sessions {
+		if s.Key == session.Key {
+			return nil, errors.New("Cannot write database")
+		}
+	}
+
+	sessions := repo.sessions
+	sessions = append(sessions, session)
+	repo.sessions = sessions
+	session.ID = uint(len(repo.sessions))
+
+	return session, nil
+}
+
+// UpdateSession updates only the Data field using Key as selector.
+func (repo *SessionRepository) UpdateSession(session *models.Session) (*models.Session, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	var oldSession *models.Session
+
+	for _, s := range repo.sessions {
+		if s.Key == session.Key {
+			oldSession = s
+		}
+	}
+
+	if oldSession != nil {
+		oldSession.Data = session.Data
+
+		return oldSession, nil
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
+// DeleteSession deletes a session by Key
+func (repo *SessionRepository) DeleteSession(session *models.Session) (*models.Session, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	if int(session.ID-1) >= len(repo.sessions) || repo.sessions[session.ID-1] == nil {
+		return nil, gorm.ErrRecordNotFound
+	}
+
+	index := int(session.ID - 1)
+	repo.sessions[index] = nil
+
+	return session, nil
+}
+
+// SelectSession returns a session with matching key
+func (repo *SessionRepository) SelectSession(session *models.Session) (*models.Session, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot write database")
+	}
+
+	for _, s := range repo.sessions {
+		if s.Key == session.Key {
+			return s, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}

+ 42 - 6
internal/repository/test/user.go

@@ -2,10 +2,10 @@ package test
 
 import (
 	"errors"
-	"fmt"
 
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
+	"golang.org/x/crypto/bcrypt"
 	"gorm.io/gorm"
 )
 
@@ -28,8 +28,6 @@ func (repo *UserRepository) CreateUser(user *models.User) (*models.User, error)
 		return nil, errors.New("Cannot write database")
 	}
 
-	fmt.Println(len(repo.users))
-
 	// make sure email doesn't exist
 	for _, u := range repo.users {
 		if u.Email == user.Email {
@@ -50,7 +48,7 @@ func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
 		return nil, errors.New("Cannot read from database")
 	}
 
-	if int(id-1) >= len(repo.users) || repo.users[id] == nil {
+	if int(id-1) >= len(repo.users) || repo.users[id-1] == nil {
 		return nil, gorm.ErrRecordNotFound
 	}
 
@@ -58,18 +56,36 @@ func (repo *UserRepository) ReadUser(id uint) (*models.User, error) {
 	return repo.users[index], nil
 }
 
+// ReadUserByEmail finds a single user based on their unique email
+func (repo *UserRepository) ReadUserByEmail(email string) (*models.User, error) {
+	if !repo.canQuery {
+		return nil, errors.New("Cannot read from database")
+	}
+
+	for _, u := range repo.users {
+		if u.Email == email {
+			return u, nil
+		}
+	}
+
+	return nil, gorm.ErrRecordNotFound
+}
+
 // UpdateUser modifies an existing User in the database
 func (repo *UserRepository) UpdateUser(user *models.User) (*models.User, error) {
 	if !repo.canQuery {
 		return nil, errors.New("Cannot write database")
 	}
 
-	if int(user.ID-1) >= len(repo.users) || repo.users[user.ID] == nil {
+	if int(user.ID-1) >= len(repo.users) || repo.users[user.ID-1] == nil {
 		return nil, gorm.ErrRecordNotFound
 	}
 
 	index := int(user.ID - 1)
+	oldUser := *repo.users[index]
 	repo.users[index] = user
+	user.Email = oldUser.Email
+	user.Password = oldUser.Password
 
 	return user, nil
 }
@@ -80,7 +96,7 @@ func (repo *UserRepository) DeleteUser(user *models.User) (*models.User, error)
 		return nil, errors.New("Cannot write database")
 	}
 
-	if int(user.ID-1) >= len(repo.users) || repo.users[user.ID] == nil {
+	if int(user.ID-1) >= len(repo.users) || repo.users[user.ID-1] == nil {
 		return nil, gorm.ErrRecordNotFound
 	}
 
@@ -89,3 +105,23 @@ func (repo *UserRepository) DeleteUser(user *models.User) (*models.User, error)
 
 	return user, nil
 }
+
+// CheckPassword checks the input password is correct for the provided user id.
+func (repo *UserRepository) CheckPassword(id int, pwd string) (bool, error) {
+	if !repo.canQuery {
+		return false, errors.New("Cannot write database")
+	}
+
+	if int(id-1) >= len(repo.users) || repo.users[id-1] == nil {
+		return false, gorm.ErrRecordNotFound
+	}
+
+	index := int(id - 1)
+	user := *repo.users[index]
+
+	if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(pwd)); err != nil {
+		return false, err
+	}
+
+	return true, nil
+}

+ 33 - 32
server/api/errors.go

@@ -8,13 +8,6 @@ import (
 	"gorm.io/gorm"
 )
 
-const (
-	appErrDataWrite    = "data write error"
-	appErrDataRead     = "data read error"
-	appErrFormDecoding = "could not process JSON body"
-	appErrReadNotFound = "could not find requested object"
-)
-
 // HTTPError is the object returned when the API encounters an error: this
 // gets marshaled into JSON
 type HTTPError struct {
@@ -25,6 +18,32 @@ type HTTPError struct {
 // ErrorCode is a custom Porter error code, useful for frontend messages
 type ErrorCode int64
 
+var (
+	// ErrorDataWrite describes an error in writing to the database
+	ErrorDataWrite = HTTPError{
+		Code: 500,
+		Errors: []string{
+			"could not write to database",
+		},
+	}
+
+	// ErrorDataRead describes an error when reading from the database
+	ErrorDataRead = HTTPError{
+		Code: 500,
+		Errors: []string{
+			"could not read from database",
+		},
+	}
+
+	// ErrorInternal describes a generic internal server error
+	ErrorInternal = HTTPError{
+		Code: 500,
+		Errors: []string{
+			"internal server error",
+		},
+	}
+)
+
 // ------------------------ Error helper functions ------------------------ //
 
 // sendExternalError marshals an HTTPError into JSON: this function will return an error if
@@ -38,13 +57,7 @@ func (app *App) sendExternalError(
 	errExt HTTPError,
 	w http.ResponseWriter,
 ) (intErr error) {
-	respBytes, newErr := json.Marshal(errExt)
-
-	if newErr != nil {
-		app.handleErrorInternal(newErr, w)
-		return newErr
-	}
-
+	respBytes, _ := json.Marshal(errExt)
 	respBody := string(respBytes)
 
 	app.logger.Warn().Err(err).
@@ -63,7 +76,7 @@ func (app *App) sendExternalError(
 func (app *App) handleErrorFormDecoding(err error, code ErrorCode, w http.ResponseWriter) {
 	errExt := HTTPError{
 		Code:   code,
-		Errors: []string{appErrFormDecoding},
+		Errors: []string{"could not process request"},
 	}
 
 	app.sendExternalError(err, http.StatusBadRequest, errExt, w)
@@ -99,7 +112,7 @@ func (app *App) handleErrorRead(err error, code ErrorCode, w http.ResponseWriter
 	if err == gorm.ErrRecordNotFound {
 		errExt := HTTPError{
 			Code:   code,
-			Errors: []string{appErrReadNotFound},
+			Errors: []string{"could not find requested object"},
 		}
 
 		app.sendExternalError(err, http.StatusNotFound, errExt, w)
@@ -112,30 +125,18 @@ func (app *App) handleErrorRead(err error, code ErrorCode, w http.ResponseWriter
 
 // handleErrorDataWrite handles a database write error due to either a connection
 // error with the database or failure to write that wasn't caught by the validators
-func (app *App) handleErrorDataWrite(err error, code ErrorCode, w http.ResponseWriter) {
-	errExt := HTTPError{
-		Code:   code,
-		Errors: []string{appErrDataWrite},
-	}
-
-	app.sendExternalError(err, http.StatusInternalServerError, errExt, w)
+func (app *App) handleErrorDataWrite(err error, w http.ResponseWriter) {
+	app.sendExternalError(err, http.StatusInternalServerError, ErrorDataWrite, w)
 }
 
 // handleErrorDataRead handles a database read error due to an internal error, such as
 // the database connection or gorm internals
 func (app *App) handleErrorDataRead(err error, code ErrorCode, w http.ResponseWriter) {
-	errExt := HTTPError{
-		Code:   code,
-		Errors: []string{appErrDataRead},
-	}
-
-	app.sendExternalError(err, http.StatusInternalServerError, errExt, w)
+	app.sendExternalError(err, http.StatusInternalServerError, ErrorDataRead, w)
 }
 
 // handleErrorInternalError is a catch-all for internal errors that occur during the
 // processing of a request
 func (app *App) handleErrorInternal(err error, w http.ResponseWriter) {
-	app.logger.Warn().Err(err).Msg("")
-	w.WriteHeader(http.StatusInternalServerError)
-	w.Write([]byte(`{"error": "Internal server error"}`))
+	app.sendExternalError(err, http.StatusInternalServerError, ErrorInternal, w)
 }

+ 157 - 27
server/api/user_handler.go

@@ -2,8 +2,14 @@ package api
 
 import (
 	"encoding/json"
+	"errors"
 	"net/http"
 	"strconv"
+	"strings"
+
+	"github.com/porter-dev/porter/internal/kubernetes"
+
+	"gorm.io/gorm"
 
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/forms"
@@ -14,9 +20,8 @@ import (
 
 // Enumeration of user API error codes, represented as int64
 const (
-	ErrUserDecode ErrorCode = iota
+	ErrUserDecode ErrorCode = iota + 600
 	ErrUserValidateFields
-	ErrUserDataWrite
 	ErrUserDataRead
 )
 
@@ -25,7 +30,13 @@ const (
 func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 	form := &forms.CreateUserForm{}
 
-	user, err := app.writeUser(form, app.repo.User.CreateUser, w, r)
+	user, err := app.writeUser(
+		form,
+		app.repo.User.CreateUser,
+		w,
+		r,
+		doesUserExist,
+	)
 
 	if err == nil {
 		app.logger.Info().Msgf("New user created: %d", user.ID)
@@ -36,53 +47,77 @@ func (app *App) HandleCreateUser(w http.ResponseWriter, r *http.Request) {
 // HandleLoginUser checks the request header for cookie and validates the user.
 func (app *App) HandleLoginUser(w http.ResponseWriter, r *http.Request) {
 	session, _ := app.store.Get(r, "cookie-name")
+	form := &forms.LoginUserForm{}
 
-	// read in email and password from request
-	email := chi.URLParam(r, "email")
-	password := chi.URLParam(r, "password")
+	// decode from JSON to form value
+	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return
+	}
 
-	// Authentication goes here
-	// Select User by Username (app.repo.User.ReadUserByUsername) and return storedCreds object that has Password.
-	storedUser, readErr := app.repo.User.ReadUserByEmail(email)
+	storedUser, readErr := app.repo.User.ReadUserByEmail(form.Email)
 
 	if readErr != nil {
-		// You're not registered error
-		app.logger.Warn().Err(readErr)
-		w.WriteHeader(http.StatusUnauthorized)
+		app.sendExternalError(readErr, http.StatusUnauthorized, HTTPError{
+			Errors: []string{"email not registered"},
+			Code:   http.StatusUnauthorized,
+		}, w)
+
 		return
 	}
 
-	if err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(password)); err != nil {
-		// If the two passwords don't match, return a 401 status
-		w.WriteHeader(http.StatusUnauthorized)
+	if err := bcrypt.CompareHashAndPassword([]byte(storedUser.Password), []byte(form.Password)); err != nil {
+		app.sendExternalError(readErr, http.StatusUnauthorized, HTTPError{
+			Errors: []string{"incorrect password"},
+			Code:   http.StatusUnauthorized,
+		}, w)
+
 		return
 	}
 
 	// Set user as authenticated
 	session.Values["authenticated"] = true
 	session.Save(r, w)
+	w.WriteHeader(http.StatusOK)
 }
 
 // HandleReadUser returns an externalized User (models.UserExternal)
 // based on an ID
 func (app *App) HandleReadUser(w http.ResponseWriter, r *http.Request) {
-	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+	user, err := app.readUser(w, r)
 
-	if err != nil || id == 0 {
+	// error already handled by helper
+	if err != nil {
+		return
+	}
+
+	extUser := user.Externalize()
+
+	if err := json.NewEncoder(w).Encode(extUser); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
 
-	user, err := app.repo.User.ReadUser(uint(id))
+	w.WriteHeader(http.StatusOK)
+}
+
+// HandleReadUserClusters returns the externalized User.Clusters (models.ClusterConfigs)
+// based on a user ID
+func (app *App) HandleReadUserClusters(w http.ResponseWriter, r *http.Request) {
+	user, err := app.readUser(w, r)
 
+	// error already handled by helper
 	if err != nil {
-		app.handleErrorRead(err, ErrUserDataRead, w)
 		return
 	}
 
-	extUser := user.Externalize()
+	extClusters := make([]models.ClusterConfigExternal, 0)
 
-	if err := json.NewEncoder(w).Encode(extUser); err != nil {
+	for _, cluster := range user.Clusters {
+		extClusters = append(extClusters, *cluster.Externalize())
+	}
+
+	if err := json.NewEncoder(w).Encode(extClusters); err != nil {
 		app.handleErrorFormDecoding(err, ErrUserDecode, w)
 		return
 	}
@@ -90,6 +125,35 @@ func (app *App) HandleReadUser(w http.ResponseWriter, r *http.Request) {
 	w.WriteHeader(http.StatusOK)
 }
 
+// HandleReadUserClustersAll returns all models.ClusterConfigs parsed from a KubeConfig
+// that is attached to a specific user, identified through the user ID
+func (app *App) HandleReadUserClustersAll(w http.ResponseWriter, r *http.Request) {
+	user, err := app.readUser(w, r)
+
+	// if there is an error, it's already handled by helper
+	if err == nil {
+		clusters, err := kubernetes.GetAllClusterConfigsFromBytes(user.RawKubeConfig)
+
+		if err != nil {
+			app.handleErrorFormDecoding(err, ErrUserDecode, w)
+			return
+		}
+
+		extClusters := make([]models.ClusterConfigExternal, 0)
+
+		for _, cluster := range clusters {
+			extClusters = append(extClusters, *cluster.Externalize())
+		}
+
+		if err := json.NewEncoder(w).Encode(extClusters); err != nil {
+			app.handleErrorFormDecoding(err, ErrUserDecode, w)
+			return
+		}
+
+		w.WriteHeader(http.StatusOK)
+	}
+}
+
 // HandleUpdateUser validates an update user form entry, updates the user
 // in the database, and writes status accepted
 func (app *App) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
@@ -108,11 +172,11 @@ func (app *App) HandleUpdateUser(w http.ResponseWriter, r *http.Request) {
 
 	if err == nil {
 		app.logger.Info().Msgf("User updated: %d", user.ID)
-		w.WriteHeader(http.StatusAccepted)
+		w.WriteHeader(http.StatusNoContent)
 	}
 }
 
-// HandleDeleteUser is majestic
+// HandleDeleteUser removes a user after checking that the sent password is correct
 func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
 	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
 
@@ -123,15 +187,14 @@ func (app *App) HandleDeleteUser(w http.ResponseWriter, r *http.Request) {
 
 	// TODO -- HASH AND VERIFY PASSWORD BEFORE USER DELETION
 	form := &forms.DeleteUserForm{
-		ID:       uint(id),
-		Password: "testing",
+		ID: uint(id),
 	}
 
 	user, err := app.writeUser(form, app.repo.User.DeleteUser, w, r)
 
 	if err == nil {
 		app.logger.Info().Msgf("User deleted: %d", user.ID)
-		w.WriteHeader(http.StatusAccepted)
+		w.WriteHeader(http.StatusNoContent)
 	}
 }
 
@@ -145,6 +208,7 @@ func (app *App) writeUser(
 	dbWrite repository.WriteUser,
 	w http.ResponseWriter,
 	r *http.Request,
+	validators ...func(repo *repository.Repository, user *models.User) *HTTPError,
 ) (*models.User, error) {
 	// decode from JSON to form value
 	if err := json.NewDecoder(r.Body).Decode(form); err != nil {
@@ -166,13 +230,79 @@ func (app *App) writeUser(
 		return nil, err
 	}
 
+	// Check any additional validators for any semantic errors
+	// We have completed all syntax checks, so these will be sent
+	// with http.StatusUnprocessableEntity (422), unless this is
+	// an internal server error
+	for _, validator := range validators {
+		err := validator(app.repo, userModel)
+
+		if err != nil {
+			goErr := errors.New(strings.Join(err.Errors, ", "))
+			if err.Code == 500 {
+				app.sendExternalError(
+					goErr,
+					http.StatusInternalServerError,
+					*err,
+					w,
+				)
+			} else {
+				app.sendExternalError(
+					goErr,
+					http.StatusUnprocessableEntity,
+					*err,
+					w,
+				)
+			}
+
+			return nil, goErr
+		}
+	}
+
 	// handle write to the database
 	user, err := dbWrite(userModel)
 
 	if err != nil {
-		app.handleErrorDataWrite(err, ErrUserDataWrite, w)
+		app.handleErrorDataWrite(err, w)
+		return nil, err
+	}
+
+	return user, nil
+}
+
+func (app *App) readUser(w http.ResponseWriter, r *http.Request) (*models.User, error) {
+	id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+
+	if err != nil || id == 0 {
+		app.handleErrorFormDecoding(err, ErrUserDecode, w)
+		return nil, err
+	}
+
+	user, err := app.repo.User.ReadUser(uint(id))
+
+	if err != nil {
+		app.handleErrorRead(err, ErrUserDataRead, w)
 		return nil, err
 	}
 
 	return user, nil
 }
+
+func doesUserExist(repo *repository.Repository, user *models.User) *HTTPError {
+	user, err := repo.User.ReadUserByEmail(user.Email)
+
+	if user != nil && err == nil {
+		return &HTTPError{
+			Code: ErrUserValidateFields,
+			Errors: []string{
+				"email already taken",
+			},
+		}
+	}
+
+	if err != gorm.ErrRecordNotFound {
+		return &ErrorDataRead
+	}
+
+	return nil
+}

+ 505 - 79
server/api/user_handler_test.go

@@ -1,19 +1,23 @@
 package api_test
 
 import (
+	"encoding/json"
 	"net/http"
 	"net/http/httptest"
+	"reflect"
 	"strings"
 	"testing"
 	"time"
 
+	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"github.com/porter-dev/porter/internal/repository/test"
 	"github.com/porter-dev/porter/server/api"
-	"github.com/porter-dev/porter/server/requestlog"
+	"github.com/porter-dev/porter/server/router"
 
+	sessionstore "github.com/porter-dev/porter/internal/auth"
 	lr "github.com/porter-dev/porter/internal/logger"
 	vr "github.com/porter-dev/porter/internal/validator"
 )
@@ -36,7 +40,44 @@ func initApi(canQuery bool) (*api.App, *repository.Repository) {
 
 	repo := test.NewRepository(canQuery)
 
-	return api.New(logger, repo, validator), repo
+	key := []byte("secret") // TODO: change to os.Getenv("SESSION_KEY")
+	store, _ := sessionstore.NewStore(repo, key)
+
+	return api.New(logger, repo, validator, store), repo
+}
+
+func testUserRequest(t *testing.T, c userTest) {
+	// create a mock API
+	api, repo := initApi(c.canQuery)
+	r := router.New(api)
+
+	if c.init != nil {
+		c.init(repo)
+	}
+
+	req, err := http.NewRequest(
+		c.method,
+		c.endpoint,
+		strings.NewReader(c.body),
+	)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+	r.ServeHTTP(rr, req)
+
+	// first, check that the status matches
+	if status := rr.Code; status != c.expStatus {
+		t.Errorf("%s, handler returned wrong status code: got %v want %v",
+			c.msg, status, c.expStatus)
+	}
+
+	// if there's a validator, call it
+	for _, validate := range c.validators {
+		validate(rr, c, r, t)
+	}
 }
 
 type userTest struct {
@@ -45,59 +86,69 @@ type userTest struct {
 	method,
 	endpoint,
 	body string
-	expStatus int
-	expBody   string
-	canQuery  bool
+	expStatus  int
+	expBody    string
+	canQuery   bool
+	validators []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T)
 }
 
 var createUserTests = []userTest{
-	// userTest{
-	// 	msg:      "Create user",
-	// 	method:   "POST",
-	// 	endpoint: "/api/users",
-	// 	body: `{
-	// 		"email": "belanger@getporter.dev",
-	// 		"password": "hello"
-	// 	}`,
-	// 	expStatus: http.StatusCreated,
-	// 	expBody:   "",
-	// 	canQuery:  true,
-	// },
-	// userTest{
-	// 	msg:      "Create user invalid email",
-	// 	method:   "POST",
-	// 	endpoint: "/api/users",
-	// 	body: `{
-	// 		"email": "notanemail",
-	// 		"password": "hello"
-	// 	}`,
-	// 	expStatus: http.StatusUnprocessableEntity,
-	// 	expBody:   `{"code":1,"errors":["email validation failed"]}`,
-	// 	canQuery:  true,
-	// },
-	// userTest{
-	// 	msg:      "Create user missing field",
-	// 	method:   "POST",
-	// 	endpoint: "/api/users",
-	// 	body: `{
-	// 		"password": "hello"
-	// 	}`,
-	// 	expStatus: http.StatusUnprocessableEntity,
-	// 	expBody:   `{"code":1,"errors":["required validation failed"]}`,
-	// 	canQuery:  true,
-	// },
-	// userTest{
-	// 	msg:      "Create user cannot write to db",
-	// 	method:   "POST",
-	// 	endpoint: "/api/users",
-	// 	body: `{
-	// 		"email": "belanger@getporter.dev",
-	// 		"password": "hello"
-	// 	}`,
-	// 	expStatus: http.StatusInternalServerError,
-	// 	expBody:   `{"code":2,"errors":["data write error"]}`,
-	// 	canQuery:  false,
-	// },
+	userTest{
+		msg:      "Create user",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "belanger@getporter.dev",
+			"password": "hello"
+		}`,
+		expStatus: http.StatusCreated,
+		expBody:   "",
+		canQuery:  true,
+	},
+	userTest{
+		msg:      "Create user invalid email",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "notanemail",
+			"password": "hello"
+		}`,
+		expStatus: http.StatusUnprocessableEntity,
+		expBody:   `{"code":601,"errors":["email validation failed"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		msg:      "Create user missing field",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"password": "hello"
+		}`,
+		expStatus: http.StatusUnprocessableEntity,
+		expBody:   `{"code":601,"errors":["required validation failed"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		msg:      "Create user db connection down",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "belanger@getporter.dev",
+			"password": "hello"
+		}`,
+		expStatus: http.StatusInternalServerError,
+		expBody:   `{"code":500,"errors":["could not read from database"]}`,
+		canQuery:  false,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
 	userTest{
 		init: func(repo *repository.Repository) {
 			repo.User.CreateUser(&models.User{
@@ -112,46 +163,421 @@ var createUserTests = []userTest{
 			"email": "belanger@getporter.dev",
 			"password": "hello"
 		}`,
-		expStatus: http.StatusInternalServerError,
-		expBody:   "",
+		expStatus: http.StatusUnprocessableEntity,
+		expBody:   `{"code":601,"errors":["email already taken"]}`,
 		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		msg:      "Create user invalid field type",
+		method:   "POST",
+		endpoint: "/api/users",
+		body: `{
+			"email": "belanger@getporter.dev",
+			"password": 0
+		}`,
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
 	},
 }
 
 func TestHandleCreateUser(t *testing.T) {
 	for _, c := range createUserTests {
-		// create a mock API
-		api, repo := initApi(c.canQuery)
+		testUserRequest(t, c)
+	}
+}
 
-		if c.init != nil {
-			c.init(repo)
-		}
+var readUserTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+				Clusters: []models.ClusterConfig{
+					models.ClusterConfig{
+						Name:    "cluster-test",
+						Server:  "https://localhost",
+						Context: "context-test",
+						User:    "test-admin",
+					},
+				},
+				RawKubeConfig: []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"),
+			})
+		},
+		msg:       "Read user successful",
+		method:    "GET",
+		endpoint:  "/api/users/1",
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   `{"id":1,"email":"belanger@getporter.dev","clusters":[{"name":"cluster-test","server":"https://localhost","context":"context-test","user":"test-admin"}],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			UserModelBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Read user bad id field",
+		method:    "GET",
+		endpoint:  "/api/users/aldkfjas",
+		body:      "",
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Read user not found",
+		method:    "GET",
+		endpoint:  "/api/users/2",
+		body:      "",
+		expStatus: http.StatusNotFound,
+		expBody:   `{"code":602,"errors":["could not find requested object"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+}
 
-		req, err := http.NewRequest(
-			c.method,
-			c.endpoint,
-			strings.NewReader(c.body),
-		)
+func TestHandleReadUser(t *testing.T) {
+	for _, c := range readUserTests {
+		testUserRequest(t, c)
+	}
+}
 
-		if err != nil {
-			t.Fatal(err)
-		}
+var readUserClustersTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+				Clusters: []models.ClusterConfig{
+					models.ClusterConfig{
+						Name:    "cluster-test",
+						Server:  "https://localhost",
+						Context: "context-test",
+						User:    "test-admin",
+					},
+				},
+				RawKubeConfig: []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"),
+			})
+		},
+		msg:       "Read user successful",
+		method:    "GET",
+		endpoint:  "/api/users/1/clusters",
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   `[{"name":"cluster-test","server":"https://localhost","context":"context-test","user":"test-admin"}]`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			ClusterBodyValidator,
+		},
+	},
+}
 
-		rr := httptest.NewRecorder()
-		handler := requestlog.NewHandler(api.HandleCreateUser, api.Logger())
+func TestHandleReadUserClusters(t *testing.T) {
+	for _, c := range readUserClustersTests {
+		testUserRequest(t, c)
+	}
+}
 
-		handler.ServeHTTP(rr, req)
+var readUserClustersAllTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:         "belanger@getporter.dev",
+				Password:      "hello",
+				Clusters:      []models.ClusterConfig{},
+				RawKubeConfig: []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"),
+			})
+		},
+		msg:       "Read user successful",
+		method:    "GET",
+		endpoint:  "/api/users/1/clusters/all",
+		body:      "",
+		expStatus: http.StatusOK,
+		expBody:   `[{"name":"cluster-test","server":"https://localhost","context":"context-test","user":"test-admin"}]`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			ClusterBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:         "belanger@getporter.dev",
+				Password:      "hello",
+				Clusters:      []models.ClusterConfig{},
+				RawKubeConfig: []byte("apiVersion: \xc5\n"),
+			})
+		},
+		msg:       "Read user with invalid utf-8 \xc5 in kubeconfig",
+		method:    "GET",
+		endpoint:  "/api/users/1/clusters/all",
+		body:      "",
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleReadUserClustersAll(t *testing.T) {
+	for _, c := range readUserClustersAllTests {
+		testUserRequest(t, c)
+	}
+}
+
+var updateUserTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Update user successful",
+		method:    "PUT",
+		endpoint:  "/api/users/1",
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedClusters":[]}`,
+		expStatus: http.StatusNoContent,
+		expBody:   "",
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/users/1",
+					strings.NewReader(""),
+				)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+				r.ServeHTTP(rr2, req)
+
+				gotBody := &models.UserExternal{}
+				expBody := &models.UserExternal{}
+
+				json.Unmarshal(rr2.Body.Bytes(), gotBody)
+				json.Unmarshal([]byte(`{"id":1,"email":"belanger@getporter.dev","clusters":[],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin"}`), expBody)
+
+				if !reflect.DeepEqual(gotBody, expBody) {
+					t.Errorf("%s, handler returned wrong body: got %v want %v",
+						"validator failed", gotBody, expBody)
+				}
+			},
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Update user invalid id",
+		method:    "PUT",
+		endpoint:  "/api/users/alsdfjk",
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedClusters":[]}`,
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Update user bad kubeconfig",
+		method:    "PUT",
+		endpoint:  "/api/users/1",
+		body:      `{"rawKubeConfig":"notvalidyaml", "allowedClusters":[]}`,
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Update user db connection down",
+		method:    "PUT",
+		endpoint:  "/api/users/1",
+		body:      `{"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: default\nclusters:\n- cluster:\n    server: https://localhost\n  name: cluster-test\ncontexts:\n- context:\n    cluster: cluster-test\n    user: test-admin\n  name: context-test\nusers:\n- name: test-admin", "allowedClusters":[]}`,
+		expStatus: http.StatusInternalServerError,
+		expBody:   `{"code":500,"errors":["could not write to database"]}`,
+		canQuery:  false,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleUpdateUser(t *testing.T) {
+	for _, c := range updateUserTests {
+		testUserRequest(t, c)
+	}
+}
+
+var deleteUserTests = []userTest{
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Delete user successful",
+		method:    "DELETE",
+		endpoint:  "/api/users/1",
+		body:      `{"password":"hello"}`,
+		expStatus: http.StatusNoContent,
+		expBody:   "",
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+				req, err := http.NewRequest(
+					"GET",
+					"/api/users/1",
+					strings.NewReader(""),
+				)
+
+				if err != nil {
+					t.Fatal(err)
+				}
+
+				rr2 := httptest.NewRecorder()
+
+				r.ServeHTTP(rr2, req)
+
+				gotBody := &models.UserExternal{}
+				expBody := &models.UserExternal{}
+
+				if status := rr2.Code; status != 404 {
+					t.Errorf("DELETE user validation, handler returned wrong status code: got %v want %v",
+						status, 404)
+				}
+
+				json.Unmarshal(rr2.Body.Bytes(), gotBody)
+				json.Unmarshal([]byte(`{"code":602,"errors":["could not find requested object"]}`), expBody)
+
+				if !reflect.DeepEqual(gotBody, expBody) {
+					t.Errorf("%s, handler returned wrong body: got %v want %v",
+						"validator failed", gotBody, expBody)
+				}
+			},
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Delete user invalid id",
+		method:    "DELETE",
+		endpoint:  "/api/users/aldkjf",
+		body:      `{"password":"hello"}`,
+		expStatus: http.StatusBadRequest,
+		expBody:   `{"code":600,"errors":["could not process request"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+	userTest{
+		init: func(repo *repository.Repository) {
+			repo.User.CreateUser(&models.User{
+				Email:    "belanger@getporter.dev",
+				Password: "hello",
+			})
+		},
+		msg:       "Delete user missing password",
+		method:    "DELETE",
+		endpoint:  "/api/users/1",
+		body:      `{}`,
+		expStatus: http.StatusUnprocessableEntity,
+		expBody:   `{"code":601,"errors":["required validation failed"]}`,
+		canQuery:  true,
+		validators: []func(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T){
+			BasicBodyValidator,
+		},
+	},
+}
+
+func TestHandleDeleteUser(t *testing.T) {
+	for _, c := range deleteUserTests {
+		testUserRequest(t, c)
+	}
+}
+
+func BasicBodyValidator(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+	if body := rr.Body.String(); body != c.expBody {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, body, c.expBody)
+	}
+}
 
-		if status := rr.Code; status != c.expStatus {
-			t.Errorf("%s, handler returned wrong status code: got %v want %v",
-				c.msg, status, c.expStatus)
-		}
+func UserModelBodyValidator(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+	gotBody := &models.UserExternal{}
+	expBody := &models.UserExternal{}
 
-		if body := rr.Body.String(); body != c.expBody {
-			t.Errorf("%s, handler returned wrong body: got %v want %v",
-				c.msg, body, c.expBody)
-		}
+	json.Unmarshal(rr.Body.Bytes(), gotBody)
+	json.Unmarshal([]byte(c.expBody), expBody)
+
+	if !reflect.DeepEqual(gotBody, expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody, expBody)
 	}
 }
 
-// var readUserTests = []userTest
+func ClusterBodyValidator(rr *httptest.ResponseRecorder, c userTest, r *chi.Mux, t *testing.T) {
+	// if status is expected to be 200, parse the body for UserExternal
+	gotBody := &[]models.ClusterConfigExternal{}
+	expBody := &[]models.ClusterConfigExternal{}
+
+	json.Unmarshal(rr.Body.Bytes(), gotBody)
+	json.Unmarshal([]byte(c.expBody), expBody)
+
+	if !reflect.DeepEqual(gotBody, expBody) {
+		t.Errorf("%s, handler returned wrong body: got %v want %v",
+			c.msg, gotBody, expBody)
+	}
+}

+ 3 - 1
server/router/router.go

@@ -16,9 +16,11 @@ func New(a *api.App) *chi.Mux {
 		r.Use(middleware.ContentTypeJSON)
 
 		// /api/users routes
+		r.Method("GET", "/users/{id}", requestlog.NewHandler(a.HandleReadUser, l))
+		r.Method("GET", "/users/{id}/clusters", requestlog.NewHandler(a.HandleReadUserClusters, l))
+		r.Method("GET", "/users/{id}/clusters/all", requestlog.NewHandler(a.HandleReadUserClustersAll, l))
 		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
 		r.Method("PUT", "/users/{id}", requestlog.NewHandler(a.HandleUpdateUser, l))
-		r.Method("GET", "/users/{id}", requestlog.NewHandler(a.HandleReadUser, l))
 		r.Method("DELETE", "/users/{id}", requestlog.NewHandler(a.HandleDeleteUser, l))
 		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
 	})