feat: Add project API endpoints (#51)

* feat: Add project models

* Add project query functions

* Add organization parameter query

* Add project URL parameter parse

* Add project create and list endpoints

* Add test for organization provided

* Remove unimplemented routes

* Decrease conn timeout

* Add test for UnbiasedModulo32

* Fix expected value

* Add single user endpoint

* Add query for project versions

* Fix linting errors

* Add comments

* Add test for invalid archive

* Check unauthenticated endpoints

* Add check if no change happened

* Ensure context close ends listener

* Fix parallel test run

* Test empty

* Fix organization param comment
This commit is contained in:
Kyle Carberry
2022-01-24 17:07:42 +00:00
committed by GitHub
parent 52e50fc9ca
commit a44056cff5
37 changed files with 2121 additions and 22 deletions
+12
View File
@@ -35,6 +35,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("NoCookie", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
r = httptest.NewRequest("GET", "/", nil)
@@ -47,6 +48,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("InvalidFormat", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
r = httptest.NewRequest("GET", "/", nil)
@@ -64,6 +66,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("InvalidIDLength", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
r = httptest.NewRequest("GET", "/", nil)
@@ -81,6 +84,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("InvalidSecretLength", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
r = httptest.NewRequest("GET", "/", nil)
@@ -98,6 +102,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
@@ -116,6 +121,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("InvalidSecret", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
@@ -141,6 +147,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("Expired", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
@@ -165,6 +172,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("Valid", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
@@ -203,6 +211,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("ValidUpdateLastUsed", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
@@ -235,6 +244,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("ValidUpdateExpiry", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
@@ -267,6 +277,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("OIDCNotExpired", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
@@ -300,6 +311,7 @@ func TestAPIKey(t *testing.T) {
})
t.Run("OIDCRefresh", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
+86
View File
@@ -0,0 +1,86 @@
package httpmw
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
)
type organizationParamContextKey struct{}
type organizationMemberParamContextKey struct{}
// OrganizationParam returns the organization from the ExtractOrganizationParam handler.
func OrganizationParam(r *http.Request) database.Organization {
organization, ok := r.Context().Value(organizationParamContextKey{}).(database.Organization)
if !ok {
panic("developer error: organization param middleware not provided")
}
return organization
}
// OrganizationMemberParam returns the organization membership that allowed the query
// from the ExtractOrganizationParam handler.
func OrganizationMemberParam(r *http.Request) database.OrganizationMember {
organizationMember, ok := r.Context().Value(organizationMemberParamContextKey{}).(database.OrganizationMember)
if !ok {
panic("developer error: organization param middleware not provided")
}
return organizationMember
}
// ExtractOrganizationParam grabs an organization and user membership from the "organization" URL parameter.
// This middleware requires the API key middleware higher in the call stack for authentication.
func ExtractOrganizationParam(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
apiKey := APIKey(r)
organizationName := chi.URLParam(r, "organization")
if organizationName == "" {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "organization name must be provided",
})
return
}
organization, err := db.GetOrganizationByName(r.Context(), organizationName)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
Message: fmt.Sprintf("organization %q does not exist", organizationName),
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get organization: %s", err.Error()),
})
return
}
organizationMember, err := db.GetOrganizationMemberByUserID(r.Context(), database.GetOrganizationMemberByUserIDParams{
OrganizationID: organization.ID,
UserID: apiKey.UserID,
})
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: "not a member of the organization",
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get organization member: %s", err.Error()),
})
return
}
ctx := context.WithValue(r.Context(), organizationParamContextKey{}, organization)
ctx = context.WithValue(ctx, organizationMemberParamContextKey{}, organizationMember)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
+165
View File
@@ -0,0 +1,165 @@
package httpmw_test
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/database"
"github.com/coder/coder/database/databasefake"
"github.com/coder/coder/httpmw"
)
func TestOrganizationParam(t *testing.T) {
t.Parallel()
setupAuthentication := func(db database.Store) (*http.Request, database.User) {
var (
id, secret = randomAPIKeyParts()
r = httptest.NewRequest("GET", "/", nil)
hashed = sha256.Sum256([]byte(secret))
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Value: fmt.Sprintf("%s-%s", id, secret),
})
userID, err := cryptorand.String(16)
require.NoError(t, err)
username, err := cryptorand.String(8)
require.NoError(t, err)
user, err := db.InsertUser(r.Context(), database.InsertUserParams{
ID: userID,
Email: "testaccount@coder.com",
Name: "example",
LoginType: database.LoginTypeBuiltIn,
HashedPassword: hashed[:],
Username: username,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
require.NoError(t, err)
_, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ID: id,
UserID: user.ID,
HashedSecret: hashed[:],
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
})
require.NoError(t, err)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, chi.NewRouteContext()))
return r, user
}
t.Run("None", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
rw = httptest.NewRecorder()
r, _ = setupAuthentication(db)
rtr = chi.NewRouter()
)
rtr.Use(
httpmw.ExtractAPIKey(db, nil),
httpmw.ExtractOrganizationParam(db),
)
rtr.Get("/", nil)
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
rw = httptest.NewRecorder()
r, _ = setupAuthentication(db)
rtr = chi.NewRouter()
)
chi.RouteContext(r.Context()).URLParams.Add("organization", "nothin")
rtr.Use(
httpmw.ExtractAPIKey(db, nil),
httpmw.ExtractOrganizationParam(db),
)
rtr.Get("/", nil)
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("NotInOrganization", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
rw = httptest.NewRecorder()
r, _ = setupAuthentication(db)
rtr = chi.NewRouter()
)
organization, err := db.InsertOrganization(r.Context(), database.InsertOrganizationParams{
ID: uuid.NewString(),
Name: "test",
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
require.NoError(t, err)
chi.RouteContext(r.Context()).URLParams.Add("organization", organization.Name)
rtr.Use(
httpmw.ExtractAPIKey(db, nil),
httpmw.ExtractOrganizationParam(db),
)
rtr.Get("/", nil)
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusUnauthorized, res.StatusCode)
})
t.Run("Success", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
rw = httptest.NewRecorder()
r, user = setupAuthentication(db)
rtr = chi.NewRouter()
)
organization, err := db.InsertOrganization(r.Context(), database.InsertOrganizationParams{
ID: uuid.NewString(),
Name: "test",
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
require.NoError(t, err)
_, err = db.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{
OrganizationID: organization.ID,
UserID: user.ID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
require.NoError(t, err)
chi.RouteContext(r.Context()).URLParams.Add("organization", organization.Name)
rtr.Use(
httpmw.ExtractAPIKey(db, nil),
httpmw.ExtractOrganizationParam(db),
)
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
_ = httpmw.OrganizationParam(r)
_ = httpmw.OrganizationMemberParam(r)
rw.WriteHeader(http.StatusOK)
})
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
}
+60
View File
@@ -0,0 +1,60 @@
package httpmw
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi"
"github.com/coder/coder/database"
"github.com/coder/coder/httpapi"
)
type projectParamContextKey struct{}
// ProjectParam returns the project from the ExtractProjectParameter handler.
func ProjectParam(r *http.Request) database.Project {
project, ok := r.Context().Value(projectParamContextKey{}).(database.Project)
if !ok {
panic("developer error: project param middleware not provided")
}
return project
}
// ExtractProjectParameter grabs a project from the "project" URL parameter.
func ExtractProjectParameter(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
organization := OrganizationParam(r)
projectName := chi.URLParam(r, "project")
if projectName == "" {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "project name must be provided",
})
return
}
project, err := db.GetProjectByOrganizationAndName(r.Context(), database.GetProjectByOrganizationAndNameParams{
OrganizationID: organization.ID,
Name: projectName,
})
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(rw, http.StatusNotFound, httpapi.Response{
Message: fmt.Sprintf("project %q does not exist", projectName),
})
return
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get project: %s", err.Error()),
})
return
}
ctx := context.WithValue(r.Context(), projectParamContextKey{}, project)
next.ServeHTTP(rw, r.WithContext(ctx))
})
}
}
+151
View File
@@ -0,0 +1,151 @@
package httpmw_test
import (
"context"
"crypto/sha256"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/database"
"github.com/coder/coder/database/databasefake"
"github.com/coder/coder/httpmw"
)
func TestProjectParam(t *testing.T) {
t.Parallel()
setupAuthentication := func(db database.Store) (*http.Request, database.Organization) {
var (
id, secret = randomAPIKeyParts()
hashed = sha256.Sum256([]byte(secret))
)
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Value: fmt.Sprintf("%s-%s", id, secret),
})
userID, err := cryptorand.String(16)
require.NoError(t, err)
username, err := cryptorand.String(8)
require.NoError(t, err)
user, err := db.InsertUser(r.Context(), database.InsertUserParams{
ID: userID,
Email: "testaccount@coder.com",
Name: "example",
LoginType: database.LoginTypeBuiltIn,
HashedPassword: hashed[:],
Username: username,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
require.NoError(t, err)
_, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ID: id,
UserID: user.ID,
HashedSecret: hashed[:],
LastUsed: database.Now(),
ExpiresAt: database.Now().Add(time.Minute),
})
require.NoError(t, err)
orgID, err := cryptorand.String(16)
require.NoError(t, err)
organization, err := db.InsertOrganization(r.Context(), database.InsertOrganizationParams{
ID: orgID,
Name: "banana",
Description: "wowie",
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
require.NoError(t, err)
_, err = db.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{
OrganizationID: orgID,
UserID: user.ID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
require.NoError(t, err)
ctx := chi.NewRouteContext()
ctx.URLParams.Add("organization", organization.Name)
r = r.WithContext(context.WithValue(r.Context(), chi.RouteCtxKey, ctx))
return r, organization
}
t.Run("None", func(t *testing.T) {
t.Parallel()
db := databasefake.New()
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKey(db, nil),
httpmw.ExtractOrganizationParam(db),
httpmw.ExtractProjectParameter(db),
)
rtr.Get("/", nil)
r, _ := setupAuthentication(db)
rw := httptest.NewRecorder()
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusBadRequest, res.StatusCode)
})
t.Run("NotFound", func(t *testing.T) {
t.Parallel()
db := databasefake.New()
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKey(db, nil),
httpmw.ExtractOrganizationParam(db),
httpmw.ExtractProjectParameter(db),
)
rtr.Get("/", nil)
r, _ := setupAuthentication(db)
chi.RouteContext(r.Context()).URLParams.Add("project", "nothin")
rw := httptest.NewRecorder()
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusNotFound, res.StatusCode)
})
t.Run("Project", func(t *testing.T) {
t.Parallel()
db := databasefake.New()
rtr := chi.NewRouter()
rtr.Use(
httpmw.ExtractAPIKey(db, nil),
httpmw.ExtractOrganizationParam(db),
httpmw.ExtractProjectParameter(db),
)
rtr.Get("/", func(rw http.ResponseWriter, r *http.Request) {
_ = httpmw.ProjectParam(r)
rw.WriteHeader(http.StatusOK)
})
r, org := setupAuthentication(db)
project, err := db.InsertProject(context.Background(), database.InsertProjectParams{
ID: uuid.New(),
OrganizationID: org.ID,
Name: "moo",
})
require.NoError(t, err)
chi.RouteContext(r.Context()).URLParams.Add("project", project.Name)
rw := httptest.NewRecorder()
rtr.ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
}
+4
View File
@@ -18,6 +18,7 @@ import (
)
func TestUserParam(t *testing.T) {
t.Parallel()
setup := func(t *testing.T) (database.Store, *httptest.ResponseRecorder, *http.Request) {
var (
db = databasefake.New()
@@ -47,6 +48,7 @@ func TestUserParam(t *testing.T) {
}
t.Run("None", func(t *testing.T) {
t.Parallel()
db, rw, r := setup(t)
httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {
@@ -62,6 +64,7 @@ func TestUserParam(t *testing.T) {
})
t.Run("NotMe", func(t *testing.T) {
t.Parallel()
db, rw, r := setup(t)
httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {
@@ -80,6 +83,7 @@ func TestUserParam(t *testing.T) {
})
t.Run("Me", func(t *testing.T) {
t.Parallel()
db, rw, r := setup(t)
httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, returnedRequest *http.Request) {