Jelajahi Sumber

list charts functionality tested

Alexander Belanger 5 tahun lalu
induk
melakukan
8ff3b832a0

+ 2 - 0
go.mod

@@ -41,12 +41,14 @@ require (
 	gopkg.in/yaml.v2 v2.3.0
 	gorm.io/driver/postgres v1.0.2
 	gorm.io/gorm v1.20.2
+	helm.sh/helm v2.16.12+incompatible
 	helm.sh/helm/v3 v3.3.4
 	k8s.io/api v0.18.8
 	k8s.io/apimachinery v0.18.8
 	k8s.io/cli-runtime v0.18.8
 	k8s.io/client-go v0.18.8
 	k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac // indirect
+	k8s.io/helm v2.16.12+incompatible // indirect
 	k8s.io/klog v1.0.0 // indirect
 	k8s.io/klog/v2 v2.2.0 // indirect
 	k8s.io/utils v0.0.0-20200912215256-4140de9c8800 // indirect

+ 4 - 0
go.sum

@@ -1263,6 +1263,7 @@ gorm.io/gorm v1.20.2/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
 gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 helm.sh/helm v1.2.1 h1:Jrn7kKQqQ/hnFWZEX+9pMFvYqFexkzrBnGqYBmIph7c=
 helm.sh/helm v2.16.12+incompatible h1:nQfifk10KcpAGD1RJaNZVW/fWiqluV0JMuuDwdba4rw=
+helm.sh/helm v2.16.12+incompatible/go.mod h1:0Xbc6ErzwWH9qC55X1+hE3ZwhM3atbhCm/NbFZw5i+4=
 helm.sh/helm/v3 v3.3.4 h1:tbad6WQVMxEw1HlVBvI2rQqOblmI5lgXOrWAMwJ198M=
 helm.sh/helm/v3 v3.3.4/go.mod h1:CyCGQa53/k1JFxXvXveGwtfJ4cuB9zkaBSGa5rnAiHU=
 honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
@@ -1364,6 +1365,9 @@ k8s.io/component-base v0.18.8/go.mod h1:00frPRDas29rx58pPCxNkhUfPbwajlyyvu8ruNgS
 k8s.io/gengo v0.0.0-20190128074634-0689ccc1d7d6/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
 k8s.io/gengo v0.0.0-20200114144118-36b2048a9120/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
 k8s.io/gengo v0.0.0-20200413195148-3a45101e95ac/go.mod h1:ezvh/TsK7cY6rbqRK0oQQ8IAqLxYwwyPxAX1Pzy0ii0=
+k8s.io/helm v1.2.1 h1:Ny4wgW4p7X3tFXR34PziNkUxw2pV0G1DIFmI1QRDdo0=
+k8s.io/helm v2.16.12+incompatible h1:K2zhF8+B85Ya1n7n3eH34xwwp5qNUM42TBFENDZJT7w=
+k8s.io/helm v2.16.12+incompatible/go.mod h1:LZzlS4LQBHfciFOurYBFkCMTaZ0D1l+p0teMg7TSULI=
 k8s.io/klog v0.0.0-20181102134211-b9b56d5dfc92/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
 k8s.io/klog v0.3.0/go.mod h1:Gq+BEi5rUBO/HRz0bTSXDUcqjScdoY3a9IHpCEIOOfk=
 k8s.io/klog v1.0.0 h1:Pt+yjF5aB1xDSVbau4VsWe+dQNzA0qv1LlXdC2dF6Q8=

+ 6 - 0
internal/config/config.go

@@ -12,6 +12,7 @@ type Conf struct {
 	Debug  bool `env:"DEBUG,default=false"`
 	Server ServerConf
 	Db     DBConf
+	Helm   HelmGlobalConf
 }
 
 // ServerConf is the server configuration
@@ -34,6 +35,11 @@ type DBConf struct {
 	DbName   string `env:"DB_NAME,default=porter"`
 }
 
+// HelmGlobalConf is the global configuration for the Helm agent
+type HelmGlobalConf struct {
+	IsTesting bool `env:"HELM_IS_TESTING,default=false"`
+}
+
 // FromEnv generates a configuration from environment variables
 func FromEnv() *Conf {
 	var c Conf

+ 1 - 1
internal/forms/chart.go

@@ -7,7 +7,7 @@ import (
 
 // ListChartForm represents the accepted values for listing Helm charts
 type ListChartForm struct {
-	HelmOptions *helm.HelmForm   `json:"helm" form:"required"`
+	HelmOptions *helm.Form       `json:"helm" form:"required"`
 	ListFilter  *helm.ListFilter `json:"filter" form:"required"`
 	UserID      uint             `json:"user_id"`
 }

+ 23 - 10
internal/helm/agent.go

@@ -3,10 +3,12 @@ package helm
 import (
 	"io/ioutil"
 
+	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/kubernetes"
 	"github.com/porter-dev/porter/internal/logger"
 	"helm.sh/helm/v3/pkg/action"
 	"helm.sh/helm/v3/pkg/release"
+	"helm.sh/helm/v3/pkg/storage"
 
 	"helm.sh/helm/v3/pkg/chartutil"
 	kubefake "helm.sh/helm/v3/pkg/kube/fake"
@@ -17,21 +19,32 @@ type Agent struct {
 	ActionConfig *action.Configuration
 }
 
-type HelmForm struct {
-	KubeConfig      []byte   `form:"required"`
-	AllowedContexts []string `form:"required"`
-	Context         string   `json:"context" form:"required"`
-	Storage         string   `json:"storage" form:"oneof=secret configmap memory"`
-	Namespace       string   `json:"namespace"`
+// Form represents the options for connecting to a cluster and
+// creating a Helm agent
+type Form struct {
+	KubeConfig      []byte
+	AllowedContexts []string
+	Context         string `json:"context" form:"required"`
+	Storage         string `json:"storage" form:"oneof=secret configmap memory"`
+	Namespace       string `json:"namespace"`
 }
 
-func (h *HelmForm) ToAgent(
+// ToAgent uses the Form to generate an agent. Setting testing=true will create
+// a test agent with in-memory storage
+func (h *Form) ToAgent(
 	l *logger.Logger,
-	testing bool,
+	helmConf *config.HelmGlobalConf,
+	storage *storage.Storage,
 ) (*Agent, error) {
-	if testing {
+	if helmConf.IsTesting {
+		testStorage := storage
+
+		if testStorage == nil {
+			testStorage = StorageMap["memory"](nil, h.Namespace, nil)
+		}
+
 		return &Agent{&action.Configuration{
-			Releases: StorageMap["memory"](l, h.Namespace, nil),
+			Releases: testStorage,
 			KubeClient: &kubefake.FailingKubeClient{
 				PrintingKubeClient: kubefake.PrintingKubeClient{
 					Out: ioutil.Discard,

+ 35 - 22
internal/helm/agent_test.go

@@ -3,6 +3,9 @@ package helm_test
 import (
 	"testing"
 
+	"helm.sh/helm/v3/pkg/storage/driver"
+
+	"github.com/porter-dev/porter/internal/config"
 	"github.com/porter-dev/porter/internal/helm"
 	"github.com/porter-dev/porter/internal/logger"
 
@@ -14,11 +17,13 @@ func newAgentFixture(t *testing.T, namespace string) *helm.Agent {
 	t.Helper()
 
 	l := logger.NewConsole(true)
-	opts := &helm.HelmForm{
+	opts := &helm.Form{
 		Namespace: namespace,
 	}
 
-	agent, _ := opts.ToAgent(l, true)
+	agent, _ := opts.ToAgent(l, &config.HelmGlobalConf{
+		IsTesting: true,
+	}, nil)
 
 	return agent
 }
@@ -35,6 +40,7 @@ type releaseStub struct {
 func makeReleases(t *testing.T, agent *helm.Agent, rels []releaseStub) {
 	t.Helper()
 	storage := agent.ActionConfig.Releases
+
 	for _, r := range rels {
 		rel := &release.Release{
 			Name:      r.name,
@@ -50,7 +56,9 @@ func makeReleases(t *testing.T, agent *helm.Agent, rels []releaseStub) {
 				},
 			},
 		}
+
 		err := storage.Create(rel)
+
 		if err != nil {
 			t.Fatal(err)
 		}
@@ -125,26 +133,26 @@ var listReleaseTests = []listReleaseTest{
 			releaseStub{"wordpress", "default", 1, "1.0.1", release.StatusDeployed},
 		},
 	},
-	// listReleaseTest{
-	// 	name:      "simple test limit",
-	// 	namespace: "",
-	// 	filter: &helm.ListFilter{
-	// 		Namespace:    "",
-	// 		Limit:        2,
-	// 		Skip:         0,
-	// 		ByDate:       false,
-	// 		StatusFilter: []string{"deployed"},
-	// 	},
-	// 	releases: []releaseStub{
-	// 		releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
-	// 		releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
-	// 		releaseStub{"wordpress", "default", 1, "1.0.2", release.StatusDeployed},
-	// 	},
-	// 	expRes: []releaseStub{
-	// 		releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
-	// 		releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
-	// 	},
-	// },
+	listReleaseTest{
+		name:      "simple test limit",
+		namespace: "",
+		filter: &helm.ListFilter{
+			Namespace:    "",
+			Limit:        2,
+			Skip:         0,
+			ByDate:       false,
+			StatusFilter: []string{"deployed"},
+		},
+		releases: []releaseStub{
+			releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+			releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
+			releaseStub{"wordpress", "default", 1, "1.0.2", release.StatusDeployed},
+		},
+		expRes: []releaseStub{
+			releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+			releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
+		},
+	},
 }
 
 func TestListReleases(t *testing.T) {
@@ -152,7 +160,12 @@ func TestListReleases(t *testing.T) {
 		agent := newAgentFixture(t, tc.namespace)
 		makeReleases(t, agent, tc.releases)
 
+		// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
+		// namespace, so we have to reset the namespace of the storage driver
+		agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace(tc.namespace)
+
 		releases, err := agent.ListReleases(tc.namespace, tc.filter)
+
 		if err != nil {
 			t.Errorf("%v", err)
 		}

+ 1 - 4
internal/helm/driver.go

@@ -59,8 +59,5 @@ func newMemoryStorageDriver(
 	_ *kubernetes.Clientset,
 ) *storage.Storage {
 	d := driver.NewMemory()
-	store := storage.Init(d)
-
-	store.Driver.(*driver.Memory).SetNamespace(namespace)
-	return store
+	return storage.Init(d)
 }

+ 9 - 1
server/api/api.go

@@ -5,8 +5,10 @@ import (
 	ut "github.com/go-playground/universal-translator"
 	"github.com/go-playground/validator/v10"
 	sessionstore "github.com/porter-dev/porter/internal/auth"
+	"github.com/porter-dev/porter/internal/config"
 	lr "github.com/porter-dev/porter/internal/logger"
 	"github.com/porter-dev/porter/internal/repository"
+	"helm.sh/helm/v3/pkg/storage"
 )
 
 // App represents an API instance with handler methods attached, a DB connection
@@ -17,7 +19,11 @@ type App struct {
 	validator  *validator.Validate
 	store      *sessionstore.PGStore
 	translator *ut.Translator
-	cookieName string
+	helmConf   *config.HelmGlobalConf
+	// HelmTestStorageDriver is used by testing libraries to query the in-memory
+	// Helm storage driver
+	HelmTestStorageDriver *storage.Storage
+	cookieName            string
 }
 
 // New returns a new App instance
@@ -26,6 +32,7 @@ func New(
 	repo *repository.Repository,
 	validator *validator.Validate,
 	store *sessionstore.PGStore,
+	helmConf *config.HelmGlobalConf,
 	cookieName string,
 ) *App {
 	// for now, will just support the english translator from the
@@ -40,6 +47,7 @@ func New(
 		validator:  validator,
 		store:      store,
 		translator: &trans,
+		helmConf:   helmConf,
 		cookieName: cookieName,
 	}
 }

+ 1 - 1
server/api/chart_handler.go

@@ -33,7 +33,7 @@ func (app *App) HandleListCharts(w http.ResponseWriter, r *http.Request) {
 	}
 
 	// create a new agent
-	agent, err := form.HelmOptions.ToAgent(app.logger, false)
+	agent, err := form.HelmOptions.ToAgent(app.logger, app.helmConf, app.HelmTestStorageDriver)
 
 	releases, err := agent.ListReleases(form.HelmOptions.Namespace, form.ListFilter)
 

+ 230 - 0
server/api/chart_handler_test.go

@@ -0,0 +1,230 @@
+package api_test
+
+import (
+	"encoding/json"
+	"net/http"
+	"reflect"
+	"strings"
+	"testing"
+
+	"github.com/porter-dev/porter/internal/config"
+	"github.com/porter-dev/porter/internal/helm"
+	"github.com/porter-dev/porter/internal/logger"
+
+	"helm.sh/helm/v3/pkg/chart"
+	"helm.sh/helm/v3/pkg/release"
+	"helm.sh/helm/v3/pkg/storage"
+	"helm.sh/helm/v3/pkg/storage/driver"
+)
+
+type releaseStub struct {
+	name         string
+	namespace    string
+	version      int
+	chartVersion string
+	status       release.Status
+}
+
+// type ListFilter struct {
+// 	Namespace    string   `json:"namespace"`
+// 	Limit        int      `json:"limit"`
+// 	Skip         int      `json:"skip"`
+// 	ByDate       bool     `json:"byDate"`
+// 	StatusFilter []string `json:"statusFilter"`
+// }
+
+// type Form struct {
+// 	KubeConfig      []byte   `form:"required"`
+// 	AllowedContexts []string `form:"required"`
+// 	Context         string   `json:"context" form:"required"`
+// 	Storage         string   `json:"storage" form:"oneof=secret configmap memory"`
+// 	Namespace       string   `json:"namespace"`
+// }
+
+// type ListChartForm struct {
+// 	HelmOptions *helm.Form       `json:"helm" form:"required"`
+// 	ListFilter  *helm.ListFilter `json:"filter" form:"required"`
+// 	UserID      uint             `json:"user_id"`
+// }
+
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
+
+type chartTest struct {
+	initializers []func(tester *tester)
+	msg          string
+	method       string
+	endpoint     string
+	body         string
+	expStatus    int
+	expBody      string
+	useCookie    bool
+	validators   []func(c *chartTest, tester *tester, t *testing.T)
+}
+
+func testChartRequests(t *testing.T, tests []*chartTest, canQuery bool) {
+	for _, c := range tests {
+		// create a new tester
+		storage := helm.StorageMap["memory"](nil, "", nil)
+		tester := newTester(canQuery, storage)
+
+		// if there's an initializer, call it
+		for _, init := range c.initializers {
+			init(tester)
+		}
+
+		req, err := http.NewRequest(
+			c.method,
+			c.endpoint,
+			strings.NewReader(c.body),
+		)
+
+		tester.req = req
+
+		if c.useCookie {
+			req.AddCookie(tester.cookie)
+		}
+
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		tester.execute()
+		rr := tester.rr
+
+		// 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(c, tester, t)
+		}
+	}
+}
+
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
+var listChartsTests = []*chartTest{
+	&chartTest{
+		initializers: []func(tester *tester){
+			initDefaultCharts,
+		},
+		msg:      "List charts",
+		method:   "GET",
+		endpoint: "/api/charts",
+		body: `{
+			"user_id": 1,
+			"helm": {
+				"namespace": "",
+				"context": "context-test",
+				"storage": "memory"
+			},
+			"filter": {
+				"namespace": "",
+				"limit": 20,
+				"skip": 0,
+				"byDate": false,
+				"statusFilter": ["deployed"]
+			}
+		}`,
+		expStatus: http.StatusOK,
+		expBody:   releaseStubsToChartJSON(sampleReleaseStubs),
+		useCookie: true,
+		validators: []func(c *chartTest, tester *tester, t *testing.T){
+			chartReleaseBodyValidator,
+		},
+	},
+}
+
+func TestHandleListCharts(t *testing.T) {
+	testChartRequests(t, listChartsTests, true)
+}
+
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initDefaultCharts(tester *tester) {
+	initUserDefault(tester)
+
+	agent := newAgentFixture("default", tester.app.HelmTestStorageDriver)
+
+	makeReleases(agent, sampleReleaseStubs)
+
+	// calling agent.ActionConfig.Releases.Create in makeReleases will automatically set the
+	// namespace, so we have to reset the namespace of the storage driver
+	agent.ActionConfig.Releases.Driver.(*driver.Memory).SetNamespace("")
+}
+
+func newAgentFixture(namespace string, storage *storage.Storage) *helm.Agent {
+	l := logger.NewConsole(true)
+	opts := &helm.Form{
+		Namespace: namespace,
+	}
+
+	agent, _ := opts.ToAgent(l, &config.HelmGlobalConf{
+		IsTesting: true,
+	}, storage)
+
+	return agent
+}
+
+var sampleReleaseStubs = []releaseStub{
+	releaseStub{"airwatch", "default", 1, "1.0.0", release.StatusDeployed},
+	releaseStub{"not-in-default-namespace", "other", 1, "1.0.1", release.StatusDeployed},
+	releaseStub{"wordpress", "default", 1, "1.0.2", release.StatusDeployed},
+}
+
+func releaseStubsToChartJSON(rels []releaseStub) string {
+	releases := make([]*release.Release, 0)
+
+	for _, r := range rels {
+		rel := releaseStubToRelease(r)
+
+		releases = append(releases, rel)
+	}
+
+	str, _ := json.Marshal(releases)
+
+	return string(str)
+}
+
+func releaseStubToRelease(r releaseStub) *release.Release {
+	return &release.Release{
+		Name:      r.name,
+		Namespace: r.namespace,
+		Version:   r.version,
+		Info: &release.Info{
+			Status: r.status,
+		},
+		Chart: &chart.Chart{
+			Metadata: &chart.Metadata{
+				Version: r.chartVersion,
+				Icon:    "https://example.com/icon.png",
+			},
+		},
+	}
+}
+
+func makeReleases(agent *helm.Agent, rels []releaseStub) {
+	storage := agent.ActionConfig.Releases
+
+	for _, r := range rels {
+		rel := releaseStubToRelease(r)
+
+		storage.Create(rel)
+	}
+}
+
+func chartReleaseBodyValidator(c *chartTest, tester *tester, t *testing.T) {
+	gotBody := &[]release.Release{}
+	expBody := &[]release.Release{}
+
+	json.Unmarshal(tester.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)
+	}
+}

+ 96 - 0
server/api/helpers_test.go

@@ -0,0 +1,96 @@
+package api_test
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"strings"
+	"time"
+
+	"github.com/go-chi/chi"
+	"github.com/porter-dev/porter/internal/config"
+	lr "github.com/porter-dev/porter/internal/logger"
+	"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/router"
+	"helm.sh/helm/v3/pkg/storage"
+
+	sessionstore "github.com/porter-dev/porter/internal/auth"
+	vr "github.com/porter-dev/porter/internal/validator"
+)
+
+type tester struct {
+	app    *api.App
+	repo   *repository.Repository
+	store  *sessionstore.PGStore
+	router *chi.Mux
+	req    *http.Request
+	rr     *httptest.ResponseRecorder
+	cookie *http.Cookie
+}
+
+func (t *tester) execute() {
+	t.router.ServeHTTP(t.rr, t.req)
+}
+
+func (t *tester) reset() {
+	t.rr = httptest.NewRecorder()
+	t.req = nil
+}
+
+func (t *tester) createUserSession(email string, pw string) {
+	req, _ := http.NewRequest(
+		"POST",
+		"/api/users",
+		strings.NewReader(`{"email":"`+email+`","password":"`+pw+`"}`),
+	)
+
+	t.req = req
+	t.execute()
+
+	if cookies := t.rr.Result().Cookies(); len(cookies) > 0 {
+		t.cookie = cookies[0]
+	}
+
+	t.reset()
+}
+
+func newTester(canQuery bool, storage *storage.Storage) *tester {
+	appConf := config.Conf{
+		Debug: true,
+		Server: config.ServerConf{
+			Port:         8080,
+			CookieName:   "porter",
+			CookieSecret: []byte("secret"),
+			TimeoutRead:  time.Second * 5,
+			TimeoutWrite: time.Second * 10,
+			TimeoutIdle:  time.Second * 15,
+		},
+		// unimportant here
+		Db: config.DBConf{},
+		// set the helm config to testing
+		Helm: config.HelmGlobalConf{
+			IsTesting: true,
+		},
+	}
+
+	logger := lr.NewConsole(appConf.Debug)
+	validator := vr.New()
+
+	repo := test.NewRepository(canQuery)
+
+	store, _ := sessionstore.NewStore(repo, appConf.Server)
+	app := api.New(logger, repo, validator, store, &appConf.Helm, appConf.Server.CookieName)
+	app.HelmTestStorageDriver = storage
+	r := router.New(app, store, appConf.Server.CookieName)
+
+	return &tester{
+		app:    app,
+		repo:   repo,
+		store:  store,
+		router: r,
+		req:    nil,
+		rr:     httptest.NewRecorder(),
+		cookie: nil,
+	}
+}

+ 40 - 116
server/api/user_handler_test.go

@@ -7,30 +7,11 @@ import (
 	"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/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"
 )
 
-type tester struct {
-	app    *api.App
-	repo   *repository.Repository
-	store  *sessionstore.PGStore
-	router *chi.Mux
-	req    *http.Request
-	rr     *httptest.ResponseRecorder
-	cookie *http.Cookie
-}
+// ------------------------- TEST TYPES AND MAIN LOOP ------------------------- //
 
 type userTest struct {
 	initializers []func(t *tester)
@@ -44,86 +25,10 @@ type userTest struct {
 	validators   []func(c *userTest, tester *tester, t *testing.T)
 }
 
-func (t *tester) execute() {
-	t.router.ServeHTTP(t.rr, t.req)
-}
-
-func (t *tester) reset() {
-	t.rr = httptest.NewRecorder()
-	t.req = nil
-}
-
-func (t *tester) createUserSession(email string, pw string) {
-	req, _ := http.NewRequest(
-		"POST",
-		"/api/users",
-		strings.NewReader(`{"email":"`+email+`","password":"`+pw+`"}`),
-	)
-
-	t.req = req
-	t.execute()
-
-	if cookies := t.rr.Result().Cookies(); len(cookies) > 0 {
-		t.cookie = cookies[0]
-	}
-
-	t.reset()
-}
-
-func initUserDefault(tester *tester) {
-	tester.createUserSession("belanger@getporter.dev", "hello")
-}
-
-func initUserWithContexts(tester *tester) {
-	initUserDefault(tester)
-
-	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
-	user.Contexts = []string{"context-test"}
-
-	user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\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")
-
-	tester.repo.User.UpdateUser(user)
-}
-
-func newTester(canQuery bool) *tester {
-	appConf := config.Conf{
-		Debug: true,
-		Server: config.ServerConf{
-			Port:         8080,
-			CookieName:   "porter",
-			CookieSecret: []byte("secret"),
-			TimeoutRead:  time.Second * 5,
-			TimeoutWrite: time.Second * 10,
-			TimeoutIdle:  time.Second * 15,
-		},
-		// unimportant here
-		Db: config.DBConf{},
-	}
-
-	logger := lr.NewConsole(appConf.Debug)
-	validator := vr.New()
-
-	repo := test.NewRepository(canQuery)
-
-	store, _ := sessionstore.NewStore(repo, appConf.Server)
-	app := api.New(logger, repo, validator, store, appConf.Server.CookieName)
-	r := router.New(app, store, appConf.Server.CookieName)
-
-	return &tester{
-		app:    app,
-		repo:   repo,
-		store:  store,
-		router: r,
-		req:    nil,
-		rr:     httptest.NewRecorder(),
-		cookie: nil,
-	}
-}
-
 func testUserRequests(t *testing.T, tests []*userTest, canQuery bool) {
 	for _, c := range tests {
 		// create a new tester
-		tester := newTester(canQuery)
+		tester := newTester(canQuery, nil)
 
 		// if there's an initializer, call it
 		for _, init := range c.initializers {
@@ -162,6 +67,8 @@ func testUserRequests(t *testing.T, tests []*userTest, canQuery bool) {
 	}
 }
 
+// ------------------------- TEST FIXTURES AND FUNCTIONS  ------------------------- //
+
 var createUserTests = []*userTest{
 	&userTest{
 		msg:      "Create user",
@@ -185,7 +92,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusUnprocessableEntity,
 		expBody:   `{"code":601,"errors":["email validation failed"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -198,7 +105,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusUnprocessableEntity,
 		expBody:   `{"code":601,"errors":["required validation failed"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -215,7 +122,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusUnprocessableEntity,
 		expBody:   `{"code":601,"errors":["email already taken"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -229,7 +136,7 @@ var createUserTests = []*userTest{
 		expStatus: http.StatusBadRequest,
 		expBody:   `{"code":600,"errors":["could not process request"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -250,7 +157,7 @@ var createUserTestsWriteFail = []*userTest{
 		expStatus: http.StatusInternalServerError,
 		expBody:   `{"code":500,"errors":["could not read from database"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -274,7 +181,7 @@ var loginUserTests = []*userTest{
 		expStatus: http.StatusOK,
 		expBody:   ``,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -292,7 +199,7 @@ var loginUserTests = []*userTest{
 		expBody:   ``,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -306,7 +213,7 @@ var loginUserTests = []*userTest{
 		expStatus: http.StatusUnauthorized,
 		expBody:   `{"code":401,"errors":["email not registered"]}`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -324,7 +231,7 @@ var loginUserTests = []*userTest{
 		expBody:   `{"code":401,"errors":["incorrect password"]}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -391,7 +298,7 @@ var readUserTests = []*userTest{
 		expBody:   `{"id":1,"email":"belanger@getporter.dev","contexts":["context-test"],"rawKubeConfig":"apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\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"}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			UserModelBodyValidator,
+			userModelBodyValidator,
 		},
 	},
 	&userTest{
@@ -405,7 +312,7 @@ var readUserTests = []*userTest{
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -427,7 +334,7 @@ var readUserClustersTests = []*userTest{
 		useCookie: true,
 		expBody:   `[{"name":"context-test","server":"https://localhost","cluster":"cluster-test","user":"test-admin","selected":true}]`,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			ContextBodyValidator,
+			userContextBodyValidator,
 		},
 	},
 }
@@ -612,7 +519,7 @@ var updateUserTests = []*userTest{
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -627,7 +534,7 @@ var updateUserTests = []*userTest{
 		expBody:   `{"code":600,"errors":["could not process request"]}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -695,7 +602,7 @@ var deleteUserTests = []*userTest{
 		expStatus: http.StatusForbidden,
 		expBody:   http.StatusText(http.StatusForbidden) + "\n",
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 	&userTest{
@@ -710,7 +617,7 @@ var deleteUserTests = []*userTest{
 		expBody:   `{"code":601,"errors":["required validation failed"]}`,
 		useCookie: true,
 		validators: []func(c *userTest, tester *tester, t *testing.T){
-			BasicBodyValidator,
+			userBasicBodyValidator,
 		},
 	},
 }
@@ -719,14 +626,31 @@ func TestHandleDeleteUser(t *testing.T) {
 	testUserRequests(t, deleteUserTests, true)
 }
 
-func BasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
+// ------------------------- INITIALIZERS AND VALIDATORS ------------------------- //
+
+func initUserDefault(tester *tester) {
+	tester.createUserSession("belanger@getporter.dev", "hello")
+}
+
+func initUserWithContexts(tester *tester) {
+	initUserDefault(tester)
+
+	user, _ := tester.repo.User.ReadUserByEmail("belanger@getporter.dev")
+	user.Contexts = []string{"context-test"}
+
+	user.RawKubeConfig = []byte("apiVersion: v1\nkind: Config\npreferences: {}\ncurrent-context: context-test\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")
+
+	tester.repo.User.UpdateUser(user)
+}
+
+func userBasicBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	if body := tester.rr.Body.String(); body != c.expBody {
 		t.Errorf("%s, handler returned wrong body: got %v want %v",
 			c.msg, body, c.expBody)
 	}
 }
 
-func UserModelBodyValidator(c *userTest, tester *tester, t *testing.T) {
+func userModelBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	gotBody := &models.UserExternal{}
 	expBody := &models.UserExternal{}
 
@@ -739,7 +663,7 @@ func UserModelBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	}
 }
 
-func ContextBodyValidator(c *userTest, tester *tester, t *testing.T) {
+func userContextBodyValidator(c *userTest, tester *tester, t *testing.T) {
 	gotBody := &[]models.Context{}
 	expBody := &[]models.Context{}
 

+ 34 - 2
server/router/middleware/auth.go

@@ -1,6 +1,9 @@
 package middleware
 
 import (
+	"bytes"
+	"encoding/json"
+	"io/ioutil"
 	"net/http"
 	"strconv"
 
@@ -8,11 +11,13 @@ import (
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 )
 
+// Auth implements the authorization functions
 type Auth struct {
 	store      *sessionstore.PGStore
 	cookieName string
 }
 
+// NewAuth returns a new Auth instance
 func NewAuth(
 	store *sessionstore.PGStore,
 	cookieName string,
@@ -34,11 +39,38 @@ func (auth *Auth) BasicAuthenticate(next http.Handler) http.Handler {
 	})
 }
 
+// IDLocation represents the location of the ID to use for authentication
+type IDLocation uint
+
+const (
+	// URLParam location looks for {id} in the URL
+	URLParam IDLocation = iota
+	// BodyParam location looks for user_id in the body
+	BodyParam
+)
+
+type bodyID struct {
+	UserID uint64 `json:"user_id"`
+}
+
 // DoesUserIDMatch checks the id URL parameter and verifies that it matches
 // the one stored in the session
-func (auth *Auth) DoesUserIDMatch(next http.Handler) http.Handler {
+func (auth *Auth) DoesUserIDMatch(next http.Handler, loc IDLocation) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		id, err := strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+		var id uint64
+		var err error
+
+		if loc == URLParam {
+			id, err = strconv.ParseUint(chi.URLParam(r, "id"), 0, 64)
+		} else if loc == BodyParam {
+			form := &bodyID{}
+			body, _ := ioutil.ReadAll(r.Body)
+			err = json.Unmarshal(body, form)
+			id = form.UserID
+
+			// need to create a new stream for the body
+			r.Body = ioutil.NopCloser(bytes.NewReader(body))
+		}
 
 		if err == nil && auth.doesSessionMatchID(r, uint(id)) {
 			next.ServeHTTP(w, r)

+ 10 - 7
server/router/router.go

@@ -4,7 +4,7 @@ import (
 	"github.com/go-chi/chi"
 	"github.com/porter-dev/porter/server/api"
 	"github.com/porter-dev/porter/server/requestlog"
-	"github.com/porter-dev/porter/server/router/middleware"
+	mw "github.com/porter-dev/porter/server/router/middleware"
 
 	sessionstore "github.com/porter-dev/porter/internal/auth"
 )
@@ -13,20 +13,23 @@ import (
 func New(a *api.App, store *sessionstore.PGStore, cookieName string) *chi.Mux {
 	l := a.Logger()
 	r := chi.NewRouter()
-	auth := middleware.NewAuth(store, cookieName)
+	auth := mw.NewAuth(store, cookieName)
 
 	r.Route("/api", func(r chi.Router) {
-		r.Use(middleware.ContentTypeJSON)
+		r.Use(mw.ContentTypeJSON)
 
 		// /api/users routes
-		r.Method("GET", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l)))
-		r.Method("GET", "/users/{id}/contexts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUserContexts, l)))
+		r.Method("GET", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUser, l), mw.URLParam))
+		r.Method("GET", "/users/{id}/contexts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleReadUserContexts, l), mw.URLParam))
 		r.Method("POST", "/users", requestlog.NewHandler(a.HandleCreateUser, l))
-		r.Method("PUT", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, l)))
-		r.Method("DELETE", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l)))
+		r.Method("PUT", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleUpdateUser, l), mw.URLParam))
+		r.Method("DELETE", "/users/{id}", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleDeleteUser, l), mw.URLParam))
 		r.Method("POST", "/login", requestlog.NewHandler(a.HandleLoginUser, l))
 		r.Method("GET", "/auth/check", requestlog.NewHandler(a.HandleAuthCheck, l))
 		r.Method("POST", "/logout", auth.BasicAuthenticate(requestlog.NewHandler(a.HandleLogoutUser, l)))
+
+		// /api/charts routes
+		r.Method("GET", "/charts", auth.DoesUserIDMatch(requestlog.NewHandler(a.HandleListCharts, l), mw.BodyParam))
 	})
 
 	return r