Skip to content

Commit

Permalink
Invoke CreateProject from Tenant (#161)
Browse files Browse the repository at this point in the history
  • Loading branch information
pdeziel committed Feb 3, 2023
1 parent 06e2ac9 commit 6792de1
Show file tree
Hide file tree
Showing 4 changed files with 100 additions and 10 deletions.
16 changes: 16 additions & 0 deletions pkg/quarterdeck/mock/quarterdeck.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const (
AuthenticateEP = "/v1/authenticate"
RefreshEP = "/v1/refresh"
APIKeysEP = "/v1/apikeys"
ProjectsEP = "/v1/projects"
)

// Server embeds an httptest Server and provides additional methods for configuring
Expand Down Expand Up @@ -52,6 +53,11 @@ func (s *Server) URL() string {
return s.Server.URL
}

func (s *Server) Reset() {
s.requests = make(map[string]int)
s.handlers = make(map[string]http.HandlerFunc)
}

func (s *Server) Close() {
s.auth.Close()
s.Server.Close()
Expand All @@ -73,6 +79,8 @@ func (s *Server) routeRequest(w http.ResponseWriter, r *http.Request) {
s.handlers[path](w, r)
case strings.Contains(path, APIKeysEP):
s.handlers[path](w, r)
case path == ProjectsEP:
s.handlers[path](w, r)
default:
w.WriteHeader(http.StatusNotFound)
return
Expand Down Expand Up @@ -201,6 +209,10 @@ func (s *Server) OnAPIKeys(param string, opts ...HandlerOption) {
s.handlers[fullPath(APIKeysEP, param)] = handler(opts...)
}

func (s *Server) OnProjects(opts ...HandlerOption) {
s.handlers[ProjectsEP] = handler(opts...)
}

// Request counters
func (s *Server) StatusCount() int {
return s.requests[StatusEP]
Expand All @@ -225,3 +237,7 @@ func (s *Server) RefreshCount() int {
func (s *Server) APIKeysCount(param string) int {
return s.requests[fullPath(APIKeysEP, param)]
}

func (s *Server) ProjectsCount() int {
return s.requests[ProjectsEP]
}
67 changes: 57 additions & 10 deletions pkg/tenant/projects.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package tenant

import (
"context"
"net/http"

"github.com/gin-gonic/gin"
"github.com/oklog/ulid/v2"
qd "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1"
middleware "github.com/rotationalio/ensign/pkg/quarterdeck/middleware"
"github.com/rotationalio/ensign/pkg/quarterdeck/tokens"
"github.com/rotationalio/ensign/pkg/tenant/api/v1"
Expand Down Expand Up @@ -65,11 +67,19 @@ func (s *Server) TenantProjectList(c *gin.Context) {
func (s *Server) TenantProjectCreate(c *gin.Context) {
var (
err error
ctx context.Context
claims *tokens.Claims
project *api.Project
out *api.Project
)

// User credentials are required for Quarterdeck requests
if ctx, err = middleware.ContextFromRequest(c); err != nil {
log.Error().Err(err).Msg("could not create user context from request")
c.JSON(http.StatusUnauthorized, api.ErrorResponse("could not fetch credentials for authenticated user"))
return
}

// Fetch member from the context.
if claims, err = middleware.GetClaims(c); err != nil {
log.Error().Err(err).Msg("could not fetch member from context")
Expand Down Expand Up @@ -122,16 +132,17 @@ func (s *Server) TenantProjectCreate(c *gin.Context) {
Name: project.Name,
}

// Add project to the database and return a 500 response if it cannot be added.
if err = db.CreateTenantProject(c.Request.Context(), tproject); err != nil {
log.Error().Err(err).Msg("could not create tenant project in the database")
c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add tenant project"))
// Create the project in the database and register it with Quarterdeck.
// TODO: Distinguish between trtl errors and quarterdeck errors.
if err = s.createProject(ctx, tproject); err != nil {
log.Error().Err(err).Msg("could not create project")
c.JSON(qd.ErrorStatus(err), api.ErrorResponse("could not create project"))
return
}

out = &api.Project{
ID: tproject.ID.String(),
Name: project.Name,
Name: tproject.Name,
}

c.JSON(http.StatusCreated, out)
Expand Down Expand Up @@ -192,6 +203,7 @@ func (s *Server) ProjectList(c *gin.Context) {
func (s *Server) ProjectCreate(c *gin.Context) {
var (
err error
ctx context.Context
claims *tokens.Claims
project *api.Project
)
Expand All @@ -203,6 +215,13 @@ func (s *Server) ProjectCreate(c *gin.Context) {
return
}

// User credentials are required for Quarterdeck requests
if ctx, err = middleware.ContextFromRequest(c); err != nil {
log.Error().Err(err).Msg("could not create user context from request")
c.JSON(http.StatusUnauthorized, api.ErrorResponse("could not fetch credentials for authenticated user"))
return
}

// Get the project's organization ID and return a 500 response if it is not a ULID.
var orgID ulid.ULID
if orgID, err = ulid.Parse(claims.OrgID); err != nil {
Expand Down Expand Up @@ -236,16 +255,17 @@ func (s *Server) ProjectCreate(c *gin.Context) {
Name: project.Name,
}

// Add project to the database and return a 500 response if not successful.
if err = db.CreateProject(c.Request.Context(), dbProject); err != nil {
log.Error().Err(err).Msg("could not create project in database")
c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not add project"))
// Create the project in the database and register it with Quarterdeck.
// TODO: Distinguish between trtl errors and quarterdeck errors.
if err = s.createProject(ctx, dbProject); err != nil {
log.Error().Err(err).Msg("could not create project")
c.JSON(qd.ErrorStatus(err), api.ErrorResponse("could not create project"))
return
}

out := &api.Project{
ID: dbProject.ID.String(),
Name: project.Name,
Name: dbProject.Name,
}

c.JSON(http.StatusCreated, out)
Expand Down Expand Up @@ -365,3 +385,30 @@ func (s *Server) ProjectDelete(c *gin.Context) {
}
c.Status(http.StatusOK)
}

// createProject is a helper to create a project in the tenant database as well as
// register the orgid - projectid mapping in Quarterdeck in a single step. Any endpoint
// which allows a user to create a project should use this method to ensure that
// Quarterdeck is aware of the project. If this method returns an error, then the
// caller should return an error response to the client to indicate that the project
// creation has failed.
func (s *Server) createProject(ctx context.Context, project *db.Project) (err error) {
// Create the project in the tenant database
if err = db.CreateProject(ctx, project); err != nil {
return err
}

// Only the project ID is required - Quarterdeck will extract the org ID from the
// user claims.
req := &qd.Project{
ProjectID: project.ID,
}

// See Quarterdeck's ProjectCreate server method for more details
if _, err = s.quarterdeck.ProjectCreate(ctx, req); err != nil {
// TODO: Cleanup unused projects or delete them here
return err
}

return nil
}
24 changes: 24 additions & 0 deletions pkg/tenant/projects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import (
"time"

"github.com/oklog/ulid/v2"
qd "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1"
"github.com/rotationalio/ensign/pkg/quarterdeck/mock"
perms "github.com/rotationalio/ensign/pkg/quarterdeck/permissions"
"github.com/rotationalio/ensign/pkg/quarterdeck/tokens"
"github.com/rotationalio/ensign/pkg/tenant/api/v1"
Expand Down Expand Up @@ -126,6 +128,9 @@ func (suite *tenantTestSuite) TestTenantProjectCreate() {
return &pb.PutReply{}, nil
}

// Quarterdeck server mock expects authentication and returns 200 OK
suite.quarterdeck.OnProjects(mock.UseStatus(http.StatusOK), mock.UseJSONFixture(&qd.Project{}), mock.RequireAuth())

// Set the initial claims fixture
claims := &tokens.Claims{
Name: "Leopold Wentzel",
Expand Down Expand Up @@ -170,7 +175,15 @@ func (suite *tenantTestSuite) TestTenantProjectCreate() {
require.NotEmpty(project.ID, "expected non-zero ulid to be populated")
require.Equal(req.Name, project.Name, "project name should match")

// Should return an error if the Quarterdeck returns an error
suite.quarterdeck.OnProjects(mock.UseStatus(http.StatusInternalServerError), mock.RequireAuth())
_, err = suite.client.TenantProjectCreate(ctx, tenantID, req)
suite.requireError(err, http.StatusInternalServerError, "could not create project", "expected error when quarterdeck returns an error")

// TODO: Return error when orgID is not valid

// Quarterdeck mock should have been called
require.Equal(2, suite.quarterdeck.ProjectsCount(), "expected quarterdeck mock to be called")
}

func (suite *tenantTestSuite) TestProjectList() {
Expand Down Expand Up @@ -289,6 +302,9 @@ func (suite *tenantTestSuite) TestProjectCreate() {
return &pb.PutReply{}, nil
}

// Quarterdeck server mock expects authentication and returns 200 OK
suite.quarterdeck.OnProjects(mock.UseStatus(http.StatusOK), mock.UseJSONFixture(&qd.Project{}), mock.RequireAuth())

// Set the initial claims fixture.
claims := &tokens.Claims{
Name: "Leopold Wentzel",
Expand Down Expand Up @@ -327,6 +343,14 @@ func (suite *tenantTestSuite) TestProjectCreate() {
project, err := suite.client.ProjectCreate(ctx, req)
require.NoError(err, "could not add project")
require.Equal(req.Name, project.Name)

// Should return an error if the Quarterdeck returns an error
suite.quarterdeck.OnProjects(mock.UseStatus(http.StatusInternalServerError), mock.RequireAuth())
_, err = suite.client.ProjectCreate(ctx, req)
suite.requireError(err, http.StatusInternalServerError, "could not create project", "expected error when quarterdeck returns an error")

// Quarterdeck mock should have been called
require.Equal(2, suite.quarterdeck.ProjectsCount(), "expected quarterdeck mock to be called")
}

func (suite *tenantTestSuite) TestProjectDetail() {
Expand Down
3 changes: 3 additions & 0 deletions pkg/tenant/tenant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ func (suite *tenantTestSuite) AfterTest(suiteName, testName string) {
// Ensure any credentials set on the client are reset
suite.client.(*api.APIv1).SetCredentials("")
suite.client.(*api.APIv1).SetCSRFProtect(false)

// Reset the quarterdeck mock server
suite.quarterdeck.Reset()
}

// Helper function to set cookies for CSRF protection on the tenant client
Expand Down

0 comments on commit 6792de1

Please sign in to comment.