diff --git a/satellite/admin/README.md b/satellite/admin/README.md index 0e3c242e3..75295bf0c 100644 --- a/satellite/admin/README.md +++ b/satellite/admin/README.md @@ -4,6 +4,32 @@ Satellite Admin package provides API endpoints for administrative tasks. Requires setting `Authorization` header for requests. +## POST /api/user + +Adds a new user. + +A successful request: + +```json +{ + "email": "alice@mail.test", + "fullName": "Alice Test", + "password": "password" +} +``` + +A successful response: + +```json +{ + "id": "12345678-1234-1234-1234-123456789abc", + "email": "alice@mail.test", + "fullName": "Alice Test", + "shortName": "", + "passwordHash": "" +} +``` + ## GET /api/user/{user-email} This endpoint returns information about user and their projects. @@ -67,7 +93,7 @@ A successful request: ```json { "ownerId": "ca7aa0fb-442a-4d4e-aa36-a49abddae837", - "projectName": "My Second Project", + "projectName": "My Second Project" } ``` @@ -75,6 +101,6 @@ A successful response: ```json { - "projectId": "ca7aa0fb-442a-4d4e-aa36-a49abddae837", + "projectId": "ca7aa0fb-442a-4d4e-aa36-a49abddae837" } ``` diff --git a/satellite/admin/project.go b/satellite/admin/project.go index de6034563..05fd4a6bb 100644 --- a/satellite/admin/project.go +++ b/satellite/admin/project.go @@ -4,9 +4,7 @@ package admin import ( - "database/sql" "encoding/json" - "errors" "fmt" "io/ioutil" "net/http" @@ -21,74 +19,6 @@ import ( "storj.io/storj/satellite/console" ) -func (server *Server) userInfo(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - vars := mux.Vars(r) - userEmail, ok := vars["useremail"] - if !ok { - http.Error(w, "user-email missing", http.StatusBadRequest) - return - } - - user, err := server.db.Console().Users().GetByEmail(ctx, userEmail) - if errors.Is(err, sql.ErrNoRows) { - http.Error(w, fmt.Sprintf("user with email %q not found", userEmail), http.StatusNotFound) - return - } - if err != nil { - http.Error(w, fmt.Sprintf("failed to get user %q: %v", userEmail, err), http.StatusInternalServerError) - return - } - user.PasswordHash = nil - - projects, err := server.db.Console().Projects().GetByUserID(ctx, user.ID) - if err != nil { - http.Error(w, fmt.Sprintf("failed to get user projects %q: %v", userEmail, err), http.StatusInternalServerError) - return - } - - type User struct { - ID uuid.UUID `json:"id"` - FullName string `json:"fullName"` - Email string `json:"email"` - } - type Project struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - OwnerID uuid.UUID `json:"ownerId"` - } - - var output struct { - User User `json:"user"` - Projects []Project `json:"projects"` - } - - output.User = User{ - ID: user.ID, - FullName: user.FullName, - Email: user.Email, - } - for _, p := range projects { - output.Projects = append(output.Projects, Project{ - ID: p.ID, - Name: p.Name, - Description: p.Description, - OwnerID: p.OwnerID, - }) - } - - data, err := json.Marshal(output) - if err != nil { - http.Error(w, fmt.Sprintf("json encoding failed: %v", err), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write(data) // nothing to do with the error response, probably the client requesting disappeared -} - func (server *Server) getProjectLimit(w http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/satellite/admin/project_test.go b/satellite/admin/project_test.go index d4c152eff..76c9ef4dd 100644 --- a/satellite/admin/project_test.go +++ b/satellite/admin/project_test.go @@ -45,15 +45,6 @@ func TestAPI(t *testing.T) { assertGet(t, link, `{"usage":{"amount":"0 B","bytes":0},"bandwidth":{"amount":"0 B","bytes":0},"rate":{"rps":0}}`) }) - t.Run("GetUser", func(t *testing.T) { - userLink := "http://" + address.String() + "/api/user/" + project.Owner.Email - expected := `{` + - fmt.Sprintf(`"user":{"id":"%s","fullName":"User uplink0_0","email":"%s"},`, project.Owner.ID, project.Owner.Email) + - fmt.Sprintf(`"projects":[{"id":"%s","name":"uplink0_0","description":"","ownerId":"%s"}]`, project.ID, project.Owner.ID) + - `}` - assertGet(t, userLink, expected) - }) - t.Run("UpdateUsage", func(t *testing.T) { data := url.Values{"usage": []string{"1TiB"}} req, err := http.NewRequest(http.MethodPost, link, strings.NewReader(data.Encode())) diff --git a/satellite/admin/server.go b/satellite/admin/server.go index 28204f2fa..fcc4b55b3 100644 --- a/satellite/admin/server.go +++ b/satellite/admin/server.go @@ -64,6 +64,7 @@ func NewServer(log *zap.Logger, listener net.Listener, db DB, config Config) *Se } // When adding new options, also update README.md + server.mux.HandleFunc("/api/user", server.addUser).Methods("POST") server.mux.HandleFunc("/api/user/{useremail}", server.userInfo).Methods("GET") server.mux.HandleFunc("/api/project/{project}/limit", server.getProjectLimit).Methods("GET") server.mux.HandleFunc("/api/project/{project}/limit", server.putProjectLimit).Methods("PUT", "POST") diff --git a/satellite/admin/user.go b/satellite/admin/user.go new file mode 100644 index 000000000..bcdf74ce5 --- /dev/null +++ b/satellite/admin/user.go @@ -0,0 +1,168 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +package admin + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + + "github.com/gorilla/mux" + "golang.org/x/crypto/bcrypt" + + "storj.io/common/uuid" + "storj.io/storj/satellite/console" +) + +func (server *Server) addUser(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read body: %v", err), http.StatusInternalServerError) + return + } + + var input console.CreateUser + + err = json.Unmarshal(body, &input) + if err != nil { + http.Error(w, fmt.Sprintf("failed to unmarshal request: %v", err), http.StatusBadRequest) + return + } + + if input.Email == "" { + http.Error(w, "Email is not set", http.StatusBadRequest) + return + } + + if input.Password == "" { + http.Error(w, "Password is not set", http.StatusBadRequest) + return + } + + hash, err := bcrypt.GenerateFromPassword([]byte(input.Password), 0) + if err != nil { + http.Error(w, "Unable to save password hash", http.StatusInternalServerError) + return + } + + userID, err := uuid.New() + if err != nil { + http.Error(w, "Unable to create UUID", http.StatusInternalServerError) + return + } + + user := console.CreateUser{ + Email: input.Email, + FullName: input.FullName, + Password: input.Password, + } + + err = user.IsValid() + if err != nil { + http.Error(w, "User data is not valid", http.StatusBadRequest) + return + } + + newuser, err := server.db.Console().Users().Insert(ctx, &console.User{ + ID: userID, + FullName: user.FullName, + ShortName: user.ShortName, + Email: user.Email, + PasswordHash: hash, + }) + if err != nil { + http.Error(w, fmt.Sprintf("failed to insert user: %v", err), http.StatusInternalServerError) + return + } + //Set User Status to be activated, as we manually created it + newuser.Status = console.Active + newuser.PasswordHash = nil + err = server.db.Console().Users().Update(ctx, newuser) + if err != nil { + http.Error(w, fmt.Sprintf("failed to activate user: %v", err), http.StatusInternalServerError) + return + } + + data, err := json.Marshal(newuser) + if err != nil { + http.Error(w, fmt.Sprintf("json encoding failed: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) // nothing to do with the error response, probably the client requesting disappeared +} + +func (server *Server) userInfo(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + userEmail, ok := vars["useremail"] + if !ok { + http.Error(w, "user-email missing", http.StatusBadRequest) + return + } + + user, err := server.db.Console().Users().GetByEmail(ctx, userEmail) + if errors.Is(err, sql.ErrNoRows) { + http.Error(w, fmt.Sprintf("user with email %q not found", userEmail), http.StatusNotFound) + return + } + if err != nil { + http.Error(w, fmt.Sprintf("failed to get user %q: %v", userEmail, err), http.StatusInternalServerError) + return + } + user.PasswordHash = nil + + projects, err := server.db.Console().Projects().GetByUserID(ctx, user.ID) + if err != nil { + http.Error(w, fmt.Sprintf("failed to get user projects %q: %v", userEmail, err), http.StatusInternalServerError) + return + } + + type User struct { + ID uuid.UUID `json:"id"` + FullName string `json:"fullName"` + Email string `json:"email"` + } + type Project struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + OwnerID uuid.UUID `json:"ownerId"` + } + + var output struct { + User User `json:"user"` + Projects []Project `json:"projects"` + } + + output.User = User{ + ID: user.ID, + FullName: user.FullName, + Email: user.Email, + } + for _, p := range projects { + output.Projects = append(output.Projects, Project{ + ID: p.ID, + Name: p.Name, + Description: p.Description, + OwnerID: p.OwnerID, + }) + } + + data, err := json.Marshal(output) + if err != nil { + http.Error(w, fmt.Sprintf("json encoding failed: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) // nothing to do with the error response, probably the client requesting disappeared +} diff --git a/satellite/admin/user_test.go b/satellite/admin/user_test.go new file mode 100644 index 000000000..d8b5820e2 --- /dev/null +++ b/satellite/admin/user_test.go @@ -0,0 +1,98 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +package admin_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "storj.io/common/testcontext" + "storj.io/storj/private/testplanet" + "storj.io/storj/satellite" + "storj.io/storj/satellite/console" +) + +func TestGetUser(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, + StorageNodeCount: 0, + UplinkCount: 1, + Reconfigure: testplanet.Reconfigure{ + Satellite: func(log *zap.Logger, index int, config *satellite.Config) { + config.Admin.Address = "127.0.0.1:0" + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + sat := planet.Satellites[0] + address := sat.Admin.Admin.Listener.Addr() + project := planet.Uplinks[0].Projects[0] + + t.Run("GetUser", func(t *testing.T) { + userLink := "http://" + address.String() + "/api/user/" + project.Owner.Email + expected := `{` + + fmt.Sprintf(`"user":{"id":"%s","fullName":"User uplink0_0","email":"%s"},`, project.Owner.ID, project.Owner.Email) + + fmt.Sprintf(`"projects":[{"id":"%s","name":"uplink0_0","description":"","ownerId":"%s"}]`, project.ID, project.Owner.ID) + + `}` + + req, err := http.NewRequest(http.MethodGet, userLink, nil) + require.NoError(t, err) + + req.Header.Set("Authorization", "very-secret-token") + + response, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + data, err := ioutil.ReadAll(response.Body) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + + require.Equal(t, http.StatusOK, response.StatusCode, string(data)) + require.Equal(t, expected, string(data)) + }) + }) +} + +func TestAddUser(t *testing.T) { + testplanet.Run(t, testplanet.Config{ + SatelliteCount: 1, + StorageNodeCount: 0, + UplinkCount: 1, + Reconfigure: testplanet.Reconfigure{ + Satellite: func(log *zap.Logger, index int, config *satellite.Config) { + config.Admin.Address = "127.0.0.1:0" + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + address := planet.Satellites[0].Admin.Admin.Listener.Addr() + email := "alice+2@mail.test" + + body := strings.NewReader(fmt.Sprintf(`{"email":"%s","fullName":"Alice Test","password":"123a123"}`, email)) + req, err := http.NewRequest(http.MethodPost, "http://"+address.String()+"/api/user", body) + require.NoError(t, err) + req.Header.Set("Authorization", "very-secret-token") + + response, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + responseBody, err := ioutil.ReadAll(response.Body) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + + var output console.User + + err = json.Unmarshal(responseBody, &output) + require.NoError(t, err) + + user, err := planet.Satellites[0].DB.Console().Users().Get(ctx, output.ID) + require.NoError(t, err) + require.Equal(t, email, user.Email) + }) +}