Browse Source

[wip] created generalized nested router w/ simple endpoints

Alexander Belanger 5 years ago
parent
commit
ed4bec1e7a

+ 2 - 15
api/server/authn/handler_test.go

@@ -1,7 +1,6 @@
 package authn_test
 
 import (
-	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
@@ -201,22 +200,10 @@ func loadHandlers(t *testing.T) (*shared.Config, http.Handler, *testHandler) {
 func assertForbiddenError(t *testing.T, next *testHandler, rr *httptest.ResponseRecorder) {
 	assert := assert.New(t)
 
+	// first assert that that the next middleware was not called
 	assert.False(next.WasCalled, "next handler should not have been called")
-	assert.Equal(http.StatusForbidden, rr.Result().StatusCode, "status code should be forbidden")
 
-	// json error should be forbidden
-	reqErr := &types.ExternalError{}
-	err := json.NewDecoder(rr.Result().Body).Decode(reqErr)
-
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	expReqErr := &types.ExternalError{
-		Error: "Forbidden",
-	}
-
-	assert.Equal(expReqErr, reqErr, "body should be forbidden error")
+	apitest.AssertResponseForbidden(t, rr)
 }
 
 func assertNextHandlerCalled(

+ 21 - 8
api/server/handlers/project/create.go

@@ -39,15 +39,30 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 		Name: request.Name,
 	}
 
-	proj, err := p.config.Repo.Project().CreateProject(proj)
+	var err error
+	proj, err = CreateProjectWithUser(p.config, proj, user)
 
 	if err != nil {
 		apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrInternal(err))
 		return
 	}
 
+	p.writer.WriteResult(w, proj.ToProjectType())
+}
+
+func CreateProjectWithUser(
+	config *shared.Config,
+	proj *models.Project,
+	user *models.User,
+) (*models.Project, error) {
+	proj, err := config.Repo.Project().CreateProject(proj)
+
+	if err != nil {
+		return nil, err
+	}
+
 	// create a new Role with the user as the admin
-	_, err = p.config.Repo.Project().CreateProjectRole(proj, &models.Role{
+	_, err = config.Repo.Project().CreateProjectRole(proj, &models.Role{
 		Role: types.Role{
 			UserID:    user.ID,
 			ProjectID: proj.ID,
@@ -56,17 +71,15 @@ func (p *ProjectCreateHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
 	})
 
 	if err != nil {
-		apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrInternal(err))
-		return
+		return nil, err
 	}
 
 	// read the project again to get the model with the role attached
-	proj, err = p.config.Repo.Project().ReadProject(proj.ID)
+	proj, err = config.Repo.Project().ReadProject(proj.ID)
 
 	if err != nil {
-		apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrInternal(err))
-		return
+		return nil, err
 	}
 
-	p.writer.WriteResult(w, proj.ToProjectType())
+	return proj, nil
 }

+ 82 - 44
api/server/handlers/project/create_test.go

@@ -1,63 +1,32 @@
 package project_test
 
 import (
-	"context"
-	"encoding/json"
-	"net/http"
-	"net/http/httptest"
-	"strings"
 	"testing"
 
 	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/server/shared/apitest"
-	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
-	"github.com/stretchr/testify/assert"
+	"github.com/porter-dev/porter/internal/repository/test"
 )
 
 func TestCreateProjectSuccessful(t *testing.T) {
-	// create request for create project
-	data, err := json.Marshal(&types.CreateProjectRequest{
+	req, rr := apitest.GetRequestAndRecorder(t, &types.CreateProjectRequest{
 		Name: "test-project",
 	})
 
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	req, err := http.NewRequest("POST", "/api/projects", strings.NewReader(string(data)))
-
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	rr := httptest.NewRecorder()
-
-	// attach authenticated user to context
 	config := apitest.LoadConfig(t)
-
 	user := apitest.CreateTestUser(t, config)
+	req = apitest.WithAuthenticatedUser(t, req, user)
 
-	ctx := req.Context()
-	ctx = context.WithValue(ctx, types.UserScope, user)
-
-	req = req.WithContext(ctx)
-
-	// create the project
 	handler := project.NewProjectCreateHandler(
 		config,
-		shared.NewDefaultRequestDecoderValidator(
-			config,
-			requestutils.NewDefaultValidator(),
-			requestutils.NewDefaultDecoder(),
-		),
+		shared.NewDefaultRequestDecoderValidator(config),
 		shared.NewDefaultResultWriter(config),
 	)
 
 	handler.ServeHTTP(rr, req)
 
-	// ensure the API response is correct
 	expProject := &types.CreateProjectResponse{
 		ID:   1,
 		Name: "test-project",
@@ -72,16 +41,85 @@ func TestCreateProjectSuccessful(t *testing.T) {
 
 	gotProject := &types.CreateProjectResponse{}
 
-	err = json.NewDecoder(rr.Body).Decode(gotProject)
+	apitest.AssertResponseExpected(t, rr, expProject, gotProject)
+}
 
-	if err != nil {
-		t.Fatal(err)
-	}
+func TestFailingDecoderValidator(t *testing.T) {
+	req, rr := apitest.GetRequestAndRecorder(t, &types.CreateProjectRequest{
+		Name: "test-project",
+	})
+
+	config := apitest.LoadConfig(t)
+	user := apitest.CreateTestUser(t, config)
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler := project.NewProjectCreateHandler(
+		config,
+		apitest.NewFailingDecoderValidator(config),
+		shared.NewDefaultResultWriter(config),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	apitest.AssertResponseInternalServerError(t, rr)
+}
+
+func TestFailingCreateMethod(t *testing.T) {
+	req, rr := apitest.GetRequestAndRecorder(t, &types.CreateProjectRequest{
+		Name: "test-project",
+	})
+
+	config := apitest.LoadConfig(t, test.CreateProjectMethod)
+	user := apitest.CreateTestUser(t, config)
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler := project.NewProjectCreateHandler(
+		config,
+		shared.NewDefaultRequestDecoderValidator(config),
+		shared.NewDefaultResultWriter(config),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	apitest.AssertResponseInternalServerError(t, rr)
+}
+
+func TestFailingCreateRoleMethod(t *testing.T) {
+	req, rr := apitest.GetRequestAndRecorder(t, &types.CreateProjectRequest{
+		Name: "test-project",
+	})
+
+	config := apitest.LoadConfig(t, test.CreateProjectRoleMethod)
+	user := apitest.CreateTestUser(t, config)
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler := project.NewProjectCreateHandler(
+		config,
+		shared.NewDefaultRequestDecoderValidator(config),
+		shared.NewDefaultResultWriter(config),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	apitest.AssertResponseInternalServerError(t, rr)
+}
+
+func TestFailingReadMethod(t *testing.T) {
+	req, rr := apitest.GetRequestAndRecorder(t, &types.CreateProjectRequest{
+		Name: "test-project",
+	})
 
-	assert.Equal(
-		t,
-		expProject,
-		gotProject,
-		"incorrect response data",
+	config := apitest.LoadConfig(t, test.ReadProjectMethod)
+	user := apitest.CreateTestUser(t, config)
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler := project.NewProjectCreateHandler(
+		config,
+		shared.NewDefaultRequestDecoderValidator(config),
+		shared.NewDefaultResultWriter(config),
 	)
+
+	handler.ServeHTTP(rr, req)
+
+	apitest.AssertResponseInternalServerError(t, rr)
 }

+ 27 - 0
api/server/handlers/project/get.go

@@ -0,0 +1,27 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ProjectGetHandler struct {
+	config *shared.Config
+	writer shared.ResultWriter
+}
+
+func NewProjectGetHandler(
+	config *shared.Config,
+	writer shared.ResultWriter,
+) *ProjectGetHandler {
+	return &ProjectGetHandler{config, writer}
+}
+
+func (p *ProjectGetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	proj, _ := r.Context().Value(types.ProjectScope).(*models.Project)
+
+	p.writer.WriteResult(w, proj.ToProjectType())
+}

+ 41 - 0
api/server/handlers/project/get_test.go

@@ -0,0 +1,41 @@
+package project_test
+
+import (
+	"testing"
+
+	"github.com/porter-dev/porter/api/server/handlers/project"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apitest"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func TestGetProjectSuccessful(t *testing.T) {
+	// create a test project
+	config := apitest.LoadConfig(t)
+	user := apitest.CreateTestUser(t, config)
+	proj, err := project.CreateProjectWithUser(config, &models.Project{
+		Name: "test-project",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, rr := apitest.GetRequestAndRecorder(t, nil)
+
+	req = apitest.WithAuthenticatedUser(t, req, user)
+	req = apitest.WithProject(t, req, proj)
+
+	handler := project.NewProjectGetHandler(
+		config,
+		shared.NewDefaultResultWriter(config),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	expProject := proj.ToProjectType()
+	gotProject := &types.Project{}
+
+	apitest.AssertResponseExpected(t, rr, expProject, gotProject)
+}

+ 43 - 0
api/server/handlers/project/list.go

@@ -0,0 +1,43 @@
+package project
+
+import (
+	"net/http"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+type ProjectListHandler struct {
+	config *shared.Config
+	writer shared.ResultWriter
+}
+
+func NewProjectListHandler(
+	config *shared.Config,
+	writer shared.ResultWriter,
+) *ProjectListHandler {
+	return &ProjectListHandler{config, writer}
+}
+
+func (p *ProjectListHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+	// read the user from context
+	user, _ := r.Context().Value(types.UserScope).(*models.User)
+
+	// read all projects for this user
+	projects, err := p.config.Repo.Project().ListProjectsByUserID(user.ID)
+
+	if err != nil {
+		apierrors.HandleAPIError(w, p.config.Logger, apierrors.NewErrInternal(err))
+		return
+	}
+
+	res := make([]*types.Project, len(projects))
+
+	for i, proj := range projects {
+		res[i] = proj.ToProjectType()
+	}
+
+	p.writer.WriteResult(w, res)
+}

+ 71 - 0
api/server/handlers/project/list_test.go

@@ -0,0 +1,71 @@
+package project_test
+
+import (
+	"testing"
+
+	"github.com/porter-dev/porter/api/server/handlers/project"
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apitest"
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+	"github.com/porter-dev/porter/internal/repository/test"
+)
+
+func TestListProjectsSuccessful(t *testing.T) {
+	// create a test project
+	config := apitest.LoadConfig(t)
+	user := apitest.CreateTestUser(t, config)
+	proj1, err := project.CreateProjectWithUser(config, &models.Project{
+		Name: "test-project",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	proj2, err := project.CreateProjectWithUser(config, &models.Project{
+		Name: "test-project-2",
+	}, user)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	req, rr := apitest.GetRequestAndRecorder(t, nil)
+
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler := project.NewProjectListHandler(
+		config,
+		shared.NewDefaultResultWriter(config),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	expProjects := make([]*types.Project, 0)
+
+	expProjects = append(expProjects, proj1.ToProjectType())
+	expProjects = append(expProjects, proj2.ToProjectType())
+	gotProjects := []*types.Project{}
+
+	apitest.AssertResponseExpected(t, rr, &expProjects, &gotProjects)
+}
+
+func TestFailingListMethod(t *testing.T) {
+	req, rr := apitest.GetRequestAndRecorder(t, &types.CreateProjectRequest{
+		Name: "test-project",
+	})
+
+	config := apitest.LoadConfig(t, test.ListProjectsByUserIDMethod)
+	user := apitest.CreateTestUser(t, config)
+	req = apitest.WithAuthenticatedUser(t, req, user)
+
+	handler := project.NewProjectListHandler(
+		config,
+		shared.NewDefaultResultWriter(config),
+	)
+
+	handler.ServeHTTP(rr, req)
+
+	apitest.AssertResponseInternalServerError(t, rr)
+}

+ 52 - 2
api/server/router/project.go

@@ -3,15 +3,24 @@ package router
 import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/types"
 )
 
+func NewProjectScopedRegisterer(children ...*Registerer) *Registerer {
+	return &Registerer{
+		Func:     RegisterProjectScopedRoutes,
+		Children: children,
+	}
+}
+
 func RegisterProjectScopedRoutes(
 	r chi.Router,
 	config *shared.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
+	children ...*Registerer,
 ) chi.Router {
 	// Create a new "project-scoped" factory which will create a new project-scoped request
 	// after authorization. Each subsequent http.Handler can lookup the project in context.
@@ -20,7 +29,15 @@ func RegisterProjectScopedRoutes(
 	// attach middleware to router
 	r.Use(projFactory.NewProjectScoped)
 
-	registerProjectEndpoints(r, config, basePath, factory)
+	projPath := registerProjectEndpoints(r, config, basePath, factory)
+
+	if len(children) > 0 {
+		r.Route(projPath.RelativePath, func(r chi.Router) {
+			for _, child := range children {
+				child.Func(r, config, basePath, factory, child.Children...)
+			}
+		})
+	}
 
 	return r
 }
@@ -30,6 +47,39 @@ func registerProjectEndpoints(
 	config *shared.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
-) {
+) *types.Path {
+	relPath := "/projects/{project_id}"
+
+	newPath := &types.Path{
+		Parent:       basePath,
+		RelativePath: relPath,
+	}
+
+	routes := make([]*Route, 0)
+
+	// POST /api/projects -> project.NewProjectCreateHandler
+	getEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbCreate,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: relPath,
+			},
+		},
+	)
+
+	createHandler := project.NewProjectGetHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: getEndpoint,
+		Handler:  createHandler,
+	})
+
+	registerRoutes(r, routes)
 
+	return newPath
 }

+ 24 - 27
api/server/router/router.go

@@ -4,42 +4,27 @@ import (
 	"net/http"
 
 	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/api/server/authn"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/types"
 )
 
 func NewAPIRouter(config *shared.Config) *chi.Mux {
 	r := chi.NewRouter()
+
 	endpointFactory := shared.NewAPIObjectEndpointFactory(config)
-	authNFactory := authn.NewAuthNFactory(config)
+	projRegisterer := NewProjectScopedRegisterer()
+	userRegisterer := NewUserScopedRegisterer(projRegisterer)
 
 	r.Route("/api", func(r chi.Router) {
-		// create a group of authenticated endpoints
-		r.Group(func(r chi.Router) {
-			// all authenticated endpoints use the authn middleware
-			r.Use(authNFactory.NewAuthenticated)
-
-			// register all user-scoped routes
-			RegisterUserScopedRoutes(
-				r,
-				config,
-				&types.Path{
-					RelativePath: "",
-				},
-				endpointFactory,
-			)
-
-			// register all project-scoped routes
-			RegisterProjectScopedRoutes(
-				r,
-				config,
-				&types.Path{
-					RelativePath: "/projects",
-				},
-				endpointFactory,
-			)
-		})
+		userRegisterer.Func(
+			r,
+			config,
+			&types.Path{
+				RelativePath: "",
+			},
+			endpointFactory,
+			userRegisterer.Children...,
+		)
 	})
 
 	return r
@@ -50,6 +35,18 @@ type Route struct {
 	Handler  http.Handler
 }
 
+type Registerer struct {
+	Func func(
+		r chi.Router,
+		config *shared.Config,
+		basePath *types.Path,
+		factory shared.APIEndpointFactory,
+		children ...*Registerer,
+	) chi.Router
+
+	Children []*Registerer
+}
+
 func registerRoutes(r chi.Router, routes []*Route) {
 	for _, route := range routes {
 		r.Method(

+ 26 - 0
api/server/router/router_test.go

@@ -0,0 +1,26 @@
+package router_test
+
+import (
+	"net/http"
+	"strings"
+	"testing"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/api/server/router"
+	"github.com/porter-dev/porter/api/server/shared/apitest"
+)
+
+func TestRouter(t *testing.T) {
+	walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
+		route = strings.Replace(route, "/*/", "/", -1)
+		t.Errorf("%s %s\n", method, route)
+		return nil
+	}
+
+	config := apitest.LoadConfig(t)
+	r := router.NewAPIRouter(config)
+
+	if err := chi.Walk(r, walkFunc); err != nil {
+		t.Fatalf("Logging err: %s\n", err.Error())
+	}
+}

+ 43 - 6
api/server/router/user.go

@@ -2,26 +2,40 @@ package router
 
 import (
 	"github.com/go-chi/chi"
-	"github.com/porter-dev/porter/api/server/authz"
+	"github.com/porter-dev/porter/api/server/authn"
 	"github.com/porter-dev/porter/api/server/handlers/project"
 	"github.com/porter-dev/porter/api/server/shared"
 	"github.com/porter-dev/porter/api/types"
 )
 
+func NewUserScopedRegisterer(children ...*Registerer) *Registerer {
+	return &Registerer{
+		Func:     RegisterUserScopedRoutes,
+		Children: children,
+	}
+}
+
 func RegisterUserScopedRoutes(
 	r chi.Router,
 	config *shared.Config,
 	basePath *types.Path,
 	factory shared.APIEndpointFactory,
+	children ...*Registerer,
 ) chi.Router {
-	// Create a new "project-scoped" factory which will create a new project-scoped request
-	// after authorization. Each subsequent http.Handler can lookup the project in context.
-	projFactory := authz.NewProjectScopedFactory(config.Repo.Project(), config)
+	// Create a new "user-scoped" factory which will create a new user-scoped request
+	// after authentication. Each subsequent http.Handler can lookup the user in context.
+	authNFactory := authn.NewAuthNFactory(config)
 
 	// attach middleware to router
-	r.Use(projFactory.NewProjectScoped)
+	r.Use(authNFactory.NewAuthenticated)
+
+	registerUserRoutes(r, config, basePath, factory)
 
-	registerProjectEndpoints(r, config, basePath, factory)
+	for _, child := range children {
+		r.Group(func(r chi.Router) {
+			child.Func(r, config, basePath, factory, child.Children...)
+		})
+	}
 
 	return r
 }
@@ -34,6 +48,7 @@ func registerUserRoutes(
 ) {
 	routes := make([]*Route, 0)
 
+	// POST /api/projects -> project.NewProjectCreateHandler
 	createEndpoint := factory.NewAPIEndpoint(
 		&types.APIRequestMetadata{
 			Verb:   types.APIVerbCreate,
@@ -56,5 +71,27 @@ func registerUserRoutes(
 		Handler:  createHandler,
 	})
 
+	// GET /api/projects -> project.NewProjectListHandler
+	listEndpoint := factory.NewAPIEndpoint(
+		&types.APIRequestMetadata{
+			Verb:   types.APIVerbList,
+			Method: types.HTTPVerbGet,
+			Path: &types.Path{
+				Parent:       basePath,
+				RelativePath: "/projects",
+			},
+		},
+	)
+
+	listHandler := project.NewProjectListHandler(
+		config,
+		factory.GetResultWriter(),
+	)
+
+	routes = append(routes, &Route{
+		Endpoint: listEndpoint,
+		Handler:  listHandler,
+	})
+
 	registerRoutes(r, routes)
 }

+ 10 - 0
api/server/shared/apitest/authn.go

@@ -1,12 +1,14 @@
 package apitest
 
 import (
+	"context"
 	"fmt"
 	"net/http"
 	"net/http/httptest"
 	"testing"
 
 	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/types"
 	"github.com/porter-dev/porter/internal/auth/token"
 	"github.com/porter-dev/porter/internal/models"
 )
@@ -71,3 +73,11 @@ func AuthenticateUserWithToken(t *testing.T, config *shared.Config, userID uint)
 
 	return res
 }
+
+func WithAuthenticatedUser(t *testing.T, req *http.Request, user *models.User) *http.Request {
+	ctx := req.Context()
+	ctx = context.WithValue(ctx, types.UserScope, user)
+	req = req.WithContext(ctx)
+
+	return req
+}

+ 18 - 0
api/server/shared/apitest/authz.go

@@ -0,0 +1,18 @@
+package apitest
+
+import (
+	"context"
+	"net/http"
+	"testing"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/porter-dev/porter/internal/models"
+)
+
+func WithProject(t *testing.T, req *http.Request, proj *models.Project) *http.Request {
+	ctx := req.Context()
+	ctx = context.WithValue(ctx, types.ProjectScope, proj)
+	req = req.WithContext(ctx)
+
+	return req
+}

+ 7 - 6
api/server/shared/apitest/config.go

@@ -13,16 +13,17 @@ import (
 )
 
 type TestConfigLoader struct {
-	canQuery bool
+	canQuery           bool
+	failingRepoMethods []string
 }
 
-func NewTestConfigLoader(canQuery bool) shared.ConfigLoader {
-	return &TestConfigLoader{canQuery}
+func NewTestConfigLoader(canQuery bool, failingRepoMethods ...string) shared.ConfigLoader {
+	return &TestConfigLoader{canQuery, failingRepoMethods}
 }
 
 func (t *TestConfigLoader) LoadConfig() (*shared.Config, error) {
 	l := logger.New(true, os.Stdout)
-	repo := test.NewRepository(t.canQuery)
+	repo := test.NewRepository(t.canQuery, t.failingRepoMethods...)
 	configFromEnv := config.FromEnv()
 	store, err := sessionstore.NewStore(repo, configFromEnv.Server)
 
@@ -43,8 +44,8 @@ func (t *TestConfigLoader) LoadConfig() (*shared.Config, error) {
 	}, nil
 }
 
-func LoadConfig(t *testing.T) *shared.Config {
-	configLoader := NewTestConfigLoader(true)
+func LoadConfig(t *testing.T, failingRepoMethods ...string) *shared.Config {
+	configLoader := NewTestConfigLoader(true, failingRepoMethods...)
 
 	config, err := configLoader.LoadConfig()
 

+ 56 - 0
api/server/shared/apitest/request.go

@@ -0,0 +1,56 @@
+package apitest
+
+import (
+	"encoding/json"
+	"fmt"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/porter-dev/porter/api/server/shared"
+	"github.com/porter-dev/porter/api/server/shared/apierrors"
+)
+
+func GetRequestAndRecorder(t *testing.T, requestObj interface{}) (*http.Request, *httptest.ResponseRecorder) {
+	var reader io.Reader = nil
+
+	if requestObj != nil {
+		data, err := json.Marshal(requestObj)
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		reader = strings.NewReader(string(data))
+	}
+
+	// method and route don't actually matter since this is meant to test handlers
+	req, err := http.NewRequest("POST", "/fake-route", reader)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	rr := httptest.NewRecorder()
+
+	return req, rr
+}
+
+type failingDecoderValidator struct {
+	config *shared.Config
+}
+
+func (f *failingDecoderValidator) DecodeAndValidate(
+	w http.ResponseWriter,
+	r *http.Request,
+	v interface{},
+) (ok bool) {
+	apierrors.HandleAPIError(w, f.config.Logger, apierrors.NewErrInternal(fmt.Errorf("fake error")))
+	return false
+}
+
+func NewFailingDecoderValidator(config *shared.Config) shared.RequestDecoderValidator {
+	return &failingDecoderValidator{config}
+}

+ 58 - 0
api/server/shared/apitest/response.go

@@ -0,0 +1,58 @@
+package apitest
+
+import (
+	"encoding/json"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/porter-dev/porter/api/types"
+	"github.com/stretchr/testify/assert"
+)
+
+func AssertResponseExpected(t *testing.T, rr *httptest.ResponseRecorder, expResponse interface{}, gotTarget interface{}) {
+	err := json.NewDecoder(rr.Body).Decode(gotTarget)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	assert.Equal(
+		t,
+		expResponse,
+		gotTarget,
+		"incorrect response data",
+	)
+}
+
+func AssertResponseForbidden(t *testing.T, rr *httptest.ResponseRecorder) {
+	reqErr := &types.ExternalError{}
+	err := json.NewDecoder(rr.Result().Body).Decode(reqErr)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expReqErr := &types.ExternalError{
+		Error: "Forbidden",
+	}
+
+	assert.Equal(t, http.StatusForbidden, rr.Result().StatusCode, "status code should be forbidden")
+	assert.Equal(t, expReqErr, reqErr, "body should be forbidden error")
+}
+
+func AssertResponseInternalServerError(t *testing.T, rr *httptest.ResponseRecorder) {
+	reqErr := &types.ExternalError{}
+	err := json.NewDecoder(rr.Result().Body).Decode(reqErr)
+
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	expReqErr := &types.ExternalError{
+		Error: "An internal error occurred.",
+	}
+
+	assert.Equal(t, http.StatusInternalServerError, rr.Result().StatusCode, "status code should be internal server error")
+	assert.Equal(t, expReqErr, reqErr, "body should be internal server error")
+}

+ 1 - 5
api/server/shared/endpoints.go

@@ -1,7 +1,6 @@
 package shared
 
 import (
-	"github.com/porter-dev/porter/api/server/shared/requestutils"
 	"github.com/porter-dev/porter/api/types"
 )
 
@@ -23,10 +22,7 @@ type APIObjectEndpointFactory struct {
 }
 
 func NewAPIObjectEndpointFactory(config *Config) APIEndpointFactory {
-	validator := requestutils.NewDefaultValidator()
-	decoder := requestutils.NewDefaultDecoder()
-
-	decoderValidator := NewDefaultRequestDecoderValidator(config, validator, decoder)
+	decoderValidator := NewDefaultRequestDecoderValidator(config)
 	resultWriter := NewDefaultResultWriter(config)
 
 	return &APIObjectEndpointFactory{

+ 3 - 2
api/server/shared/reader.go

@@ -19,9 +19,10 @@ type DefaultRequestDecoderValidator struct {
 
 func NewDefaultRequestDecoderValidator(
 	config *Config,
-	validator requestutils.Validator,
-	decoder requestutils.Decoder,
 ) RequestDecoderValidator {
+	validator := requestutils.NewDefaultValidator()
+	decoder := requestutils.NewDefaultDecoder()
+
 	return &DefaultRequestDecoderValidator{config, validator, decoder}
 }
 

+ 1 - 1
api/types/project.go

@@ -17,7 +17,7 @@ type CreateProjectRoleRequest struct {
 	UserID uint   `json:"user_id" form:"required"`
 }
 
-type CreateProjectRoleResponse Role
+type ReadProjectResponse Project
 
 type ListProjectsRequest struct{}
 

+ 17 - 8
internal/repository/test/project.go

@@ -2,28 +2,37 @@ package test
 
 import (
 	"errors"
+	"strings"
 
 	"github.com/porter-dev/porter/internal/models"
 	"github.com/porter-dev/porter/internal/repository"
 	"gorm.io/gorm"
 )
 
+const (
+	CreateProjectMethod        string = "create_project_0"
+	CreateProjectRoleMethod    string = "create_project_role_0"
+	ReadProjectMethod          string = "read_project_0"
+	ListProjectsByUserIDMethod string = "list_projects_by_user_id_0"
+)
+
 // ProjectRepository will return errors on queries if canQuery is false
 // and only stores a small set of projects in-memory that are indexed by their
 // array index + 1
 type ProjectRepository struct {
-	canQuery bool
-	projects []*models.Project
+	canQuery       bool
+	failingMethods string
+	projects       []*models.Project
 }
 
 // NewProjectRepository will return errors if canQuery is false
-func NewProjectRepository(canQuery bool) repository.ProjectRepository {
-	return &ProjectRepository{canQuery, []*models.Project{}}
+func NewProjectRepository(canQuery bool, failingMethods ...string) repository.ProjectRepository {
+	return &ProjectRepository{canQuery, strings.Join(failingMethods, ","), []*models.Project{}}
 }
 
 // CreateProject appends a new project to the in-memory projects array
 func (repo *ProjectRepository) CreateProject(project *models.Project) (*models.Project, error) {
-	if !repo.canQuery {
+	if !repo.canQuery || strings.Contains(repo.failingMethods, CreateProjectMethod) {
 		return nil, errors.New("Cannot write database")
 	}
 
@@ -35,7 +44,7 @@ func (repo *ProjectRepository) CreateProject(project *models.Project) (*models.P
 
 // CreateProjectRole appends a role to the existing array of roles
 func (repo *ProjectRepository) CreateProjectRole(project *models.Project, role *models.Role) (*models.Role, error) {
-	if !repo.canQuery {
+	if !repo.canQuery || strings.Contains(repo.failingMethods, CreateProjectRoleMethod) {
 		return nil, errors.New("Cannot write database")
 	}
 
@@ -75,7 +84,7 @@ func (repo *ProjectRepository) ReadProjectRole(userID, projID uint) (*models.Rol
 
 // ReadProject gets a projects specified by a unique id
 func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
-	if !repo.canQuery {
+	if !repo.canQuery || strings.Contains(repo.failingMethods, ReadProjectMethod) {
 		return nil, errors.New("Cannot read from database")
 	}
 
@@ -89,7 +98,7 @@ func (repo *ProjectRepository) ReadProject(id uint) (*models.Project, error) {
 
 // ListProjectsByUserID lists projects where a user has an associated role
 func (repo *ProjectRepository) ListProjectsByUserID(userID uint) ([]*models.Project, error) {
-	if !repo.canQuery {
+	if !repo.canQuery || strings.Contains(repo.failingMethods, ListProjectsByUserIDMethod) {
 		return nil, errors.New("Cannot read from database")
 	}
 

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

@@ -109,11 +109,11 @@ func (t *TestRepository) AWSIntegration() repository.AWSIntegrationRepository {
 
 // NewRepository returns a Repository which persists users in memory
 // and accepts a parameter that can trigger read/write errors
-func NewRepository(canQuery bool) repository.Repository {
+func NewRepository(canQuery bool, failingMethods ...string) repository.Repository {
 	return &TestRepository{
 		user:             NewUserRepository(canQuery),
 		session:          NewSessionRepository(canQuery),
-		project:          NewProjectRepository(canQuery),
+		project:          NewProjectRepository(canQuery, failingMethods...),
 		cluster:          NewClusterRepository(canQuery),
 		helmRepo:         NewHelmRepoRepository(canQuery),
 		registry:         NewRegistryRepository(canQuery),