diff --git a/README.md b/README.md index d815eb1..a94f965 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ You absolutely can, so long as you follow the license in `LICENSE.md`. I am real - [ ] Passwords can be reset - [x] Passwords can be compared to find if passwords are correct - [ ] Accounts can be logged into (and will provide a valid JWT token / session for future calls) [I need to research JWTs vs session-based auth for this task] -- [ ] Account updates require proof of ownership +- [x] Account updates require proof of ownership - [ ] All account endpoints are rate-limited appropriately ### Lobbies (/lobby) @@ -199,7 +199,7 @@ You absolutely can, so long as you follow the license in `LICENSE.md`. I am real - [ ] "Player connected" event is sent when players join the lobby - [ ] Chats can be sent in lobbies - [ ] All lobby endpoints are rate-limited appropriately -- [ ] Lobby updates require proof of ownership +- [x] Lobby updates require proof of ownership ### Games (/game) *Note: profiles can be changed in the game (as seen in the UI), but this should be handled client-side using the account endpoints. diff --git a/docs/docs.go b/docs/docs.go index 8855b71..7698600 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -64,6 +64,52 @@ const docTemplate = `{ } } }, + "/account/delete_account": { + "delete": { + "description": "This endpoint deletes a player account.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "account" + ], + "summary": "Deletes an account", + "parameters": [ + { + "description": "account deletion request body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/account.DeleteAccountArgs" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted account!", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "403": { + "description": "Forbidden", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/account/get_account": { "get": { "description": "This endpoint gets a multiplayer account's info.", @@ -599,6 +645,20 @@ const docTemplate = `{ } } }, + "account.DeleteAccountArgs": { + "description": "Structure for the account deletion request payload.", + "type": "object", + "properties": { + "account_id": { + "description": "The account ID for the account that will be deleted.", + "type": "integer" + }, + "session_id": { + "description": "A valid session ID for the account (so we know they are signed in)", + "type": "integer" + } + } + }, "account.ExperienceLevel": { "type": "integer", "enum": [ diff --git a/docs/swagger.json b/docs/swagger.json index 5b0438a..bd51f9e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -55,6 +55,52 @@ } } }, + "/account/delete_account": { + "delete": { + "description": "This endpoint deletes a player account.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "account" + ], + "summary": "Deletes an account", + "parameters": [ + { + "description": "account deletion request body", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/account.DeleteAccountArgs" + } + } + ], + "responses": { + "200": { + "description": "Successfully deleted account!", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "403": { + "description": "Forbidden", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/account/get_account": { "get": { "description": "This endpoint gets a multiplayer account's info.", @@ -590,6 +636,20 @@ } } }, + "account.DeleteAccountArgs": { + "description": "Structure for the account deletion request payload.", + "type": "object", + "properties": { + "account_id": { + "description": "The account ID for the account that will be deleted.", + "type": "integer" + }, + "session_id": { + "description": "A valid session ID for the account (so we know they are signed in)", + "type": "integer" + } + } + }, "account.ExperienceLevel": { "type": "integer", "enum": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 270947f..a597264 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -52,6 +52,17 @@ definitions: description: The password for the account to be created type: string type: object + account.DeleteAccountArgs: + description: Structure for the account deletion request payload. + properties: + account_id: + description: The account ID for the account that will be deleted. + type: integer + session_id: + description: A valid session ID for the account (so we know they are signed + in) + type: integer + type: object account.ExperienceLevel: enum: - 0 @@ -246,6 +257,37 @@ paths: summary: Create a new account tags: - account + /account/delete_account: + delete: + consumes: + - application/json + description: This endpoint deletes a player account. + parameters: + - description: account deletion request body + in: body + name: body + required: true + schema: + $ref: '#/definitions/account.DeleteAccountArgs' + produces: + - application/json + responses: + "200": + description: Successfully deleted account! + schema: + type: string + "400": + description: Bad Request + schema: {} + "403": + description: Forbidden + schema: {} + "500": + description: Internal Server Error + schema: {} + summary: Deletes an account + tags: + - account /account/get_account: get: consumes: diff --git a/internal/account/delete_account.go b/internal/account/delete_account.go new file mode 100644 index 0000000..122bef3 --- /dev/null +++ b/internal/account/delete_account.go @@ -0,0 +1,99 @@ +package account + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + + "github.com/jmoiron/sqlx" + "github.com/justinfarrelldev/open-ctp-server/internal/auth" +) + +// DeleteAccountArgs represents the expected structure of the request body for deleting an account. +// +// @Description Structure for the account deletion request payload. +type DeleteAccountArgs struct { + // The account ID for the account that will be deleted. + AccountId int64 `json:"account_id"` + // A valid session ID for the account (so we know they are signed in) + SessionId *int64 `json:"session_id,omitempty"` +} + +// DeleteAccount deletes an account by the account ID. +// +// @Summary Deletes an account +// @Description This endpoint deletes a player account. +// @Tags account +// @Accept json +// @Produce json +// @Param body body DeleteAccountArgs true "account deletion request body" +// @Success 200 {string} string "Successfully deleted account!" +// @Failure 400 {object} error "Bad Request" +// @Failure 403 {object} error "Forbidden" +// @Failure 500 {object} error "Internal Server Error" +// @Router /account/delete_account [delete] +func DeleteAccount(w http.ResponseWriter, r *http.Request, db *sqlx.DB, store *auth.SessionStore) error { + + if r.Method != http.MethodDelete { + return errors.New("invalid request; request must be a DELETE request") + } + + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + + args := DeleteAccountArgs{} + err := decoder.Decode(&args) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return errors.New("an error occurred while decoding the request body: " + err.Error()) + } + + if args.AccountId == 0 { + w.WriteHeader(http.StatusBadRequest) + return errors.New("account_id must be specified") + } + + if args.SessionId == nil { + w.WriteHeader(http.StatusBadRequest) + return errors.New("a valid session_id must be specified") + } + + session, err := store.GetSession(strconv.FormatInt(*args.SessionId, 10)) + + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return errors.New("an error occurred while retrieving the session: " + err.Error()) + } + + if session == nil { + w.WriteHeader(http.StatusInternalServerError) + return errors.New("session not found") + } + + if session.IsExpired() { + w.WriteHeader(http.StatusForbidden) + return errors.New("session has expired") + } + + query := "DELETE FROM account WHERE id = $1" + result, err := db.Exec(query, args.AccountId) + if err != nil { + return fmt.Errorf("an error occurred while deleting the account with the ID %d: %v", args.AccountId, err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("an error occurred while checking the affected rows: %v", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("no account exists with the ID %d", args.AccountId) + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte("Successfully deleted account!")) + return nil +} diff --git a/internal/account/delete_account_test.go b/internal/account/delete_account_test.go new file mode 100644 index 0000000..125e8e4 --- /dev/null +++ b/internal/account/delete_account_test.go @@ -0,0 +1,405 @@ +package account + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/jmoiron/sqlx" + + auth "github.com/justinfarrelldev/open-ctp-server/internal/auth" +) + +func TestDeleteAccount_InvalidMethod(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + req, err := http.NewRequest("POST", "/account/delete_account", nil) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + mockStore := &auth.SessionStore{ + DB: sqlxDB, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := DeleteAccount(w, r, sqlxDB, mockStore) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError) + } + + expectedError := "invalid request; request must be a DELETE request" + if strings.TrimSpace(rr.Body.String()) != expectedError { + t.Errorf("handler returned unexpected body: got %v want %v", strings.TrimSpace(rr.Body.String()), expectedError) + } +} + +func TestDeleteAccount_DecodeError(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + req, err := http.NewRequest("DELETE", "/account/delete_account", strings.NewReader("invalid json")) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + mockStore := &auth.SessionStore{ + DB: sqlxDB, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := DeleteAccount(w, r, sqlxDB, mockStore) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError) + } + + expectedError := "an error occurred while decoding the request body: invalid character 'i' looking for beginning of value" + if strings.TrimSpace(rr.Body.String()) != expectedError { + t.Errorf("handler returned unexpected body: got %v want %v", strings.TrimSpace(rr.Body.String()), expectedError) + } +} + +func TestDeleteAccount_MissingAccountID(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + sessionID := int64(1) + deleteArgs := DeleteAccountArgs{ + SessionId: &sessionID, + } + + body, _ := json.Marshal(deleteArgs) + req, err := http.NewRequest("DELETE", "/account/delete_account", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + mockStore := &auth.SessionStore{ + DB: sqlxDB, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := DeleteAccount(w, r, sqlxDB, mockStore) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest) + } + + expectedError := "account_id must be specified" + if strings.TrimSpace(rr.Body.String()) != expectedError { + t.Errorf("handler returned unexpected body: got %v want %v", strings.TrimSpace(rr.Body.String()), expectedError) + } +} + +func TestDeleteAccount_MissingSessionID(t *testing.T) { + db, _, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + deleteArgs := DeleteAccountArgs{ + AccountId: 1, + } + + body, _ := json.Marshal(deleteArgs) + req, err := http.NewRequest("DELETE", "/account/delete_account", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + mockStore := &auth.SessionStore{ + DB: sqlxDB, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := DeleteAccount(w, r, sqlxDB, mockStore) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusBadRequest { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusBadRequest) + } + + expectedError := "a valid session_id must be specified" + if strings.TrimSpace(rr.Body.String()) != expectedError { + t.Errorf("handler returned unexpected body: got %v want %v", strings.TrimSpace(rr.Body.String()), expectedError) + } +} + +func TestDeleteAccount_SessionNotFound(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + accountID := int64(1) + sessionID := int64(1) + deleteArgs := DeleteAccountArgs{ + AccountId: accountID, + SessionId: &sessionID, + } + + body, _ := json.Marshal(deleteArgs) + req, err := http.NewRequest("DELETE", "/account/delete_account", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM sessions WHERE id = $1")). + WithArgs(fmt.Sprint(sessionID)). + WillReturnRows(sqlmock.NewRows(nil)) + + mockStore := &auth.SessionStore{ + DB: sqlxDB, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := DeleteAccount(w, r, sqlxDB, mockStore) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError) + } + + expectedError := "session not found" + if strings.TrimSpace(rr.Body.String()) != expectedError { + t.Errorf("handler returned unexpected body: got %v want %v", strings.TrimSpace(rr.Body.String()), expectedError) + } +} + +func TestDeleteAccount_SessionExpired(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + accountID := int64(1) + sessionID := int64(1) + deleteArgs := DeleteAccountArgs{ + AccountId: accountID, + SessionId: &sessionID, + } + + body, _ := json.Marshal(deleteArgs) + req, err := http.NewRequest("DELETE", "/account/delete_account", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + createdAt := time.Now().Add(-2 * time.Hour) + expiresAt := time.Now().Add(-1 * time.Hour) + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM sessions WHERE id = $1")). + WithArgs(fmt.Sprintf("%d", sessionID)). + WillReturnRows(sqlmock.NewRows([]string{"id", "account_id", "created_at", "expires_at"}). + AddRow(sessionID, accountID, createdAt, expiresAt)) + + mockStore := &auth.SessionStore{ + DB: sqlxDB, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := DeleteAccount(w, r, sqlxDB, mockStore) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusForbidden { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusForbidden) + } + + expectedError := "session has expired" + if strings.TrimSpace(rr.Body.String()) != expectedError { + t.Errorf("handler returned unexpected body: got %v want %v", strings.TrimSpace(rr.Body.String()), expectedError) + } +} + +func TestDeleteAccount_Success(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + accountID := int64(1) + sessionID := int64(1) + deleteArgs := DeleteAccountArgs{ + AccountId: accountID, + SessionId: &sessionID, + } + + body, _ := json.Marshal(deleteArgs) + req, err := http.NewRequest("DELETE", "/account/delete_account", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + createdAt := time.Now() + expiresAt := time.Now().Add(6 * time.Hour) + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM sessions WHERE id = $1")). + WithArgs(fmt.Sprintf("%d", sessionID)). + WillReturnRows(sqlmock.NewRows([]string{"id", "account_id", "created_at", "expires_at"}). + AddRow(sessionID, accountID, createdAt, expiresAt)) + + mock.ExpectExec("DELETE FROM account WHERE id = \\$1"). + WithArgs(accountID). + WillReturnResult(sqlmock.NewResult(1, 1)) + + mockStore := &auth.SessionStore{ + DB: sqlxDB, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := DeleteAccount(w, r, sqlxDB, mockStore) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusOK { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK) + } + + expectedResponse := "Successfully deleted account!" + if strings.TrimSpace(rr.Body.String()) != expectedResponse { + t.Errorf("handler returned unexpected body: got %v want %v", strings.TrimSpace(rr.Body.String()), expectedResponse) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Errorf("there were unfulfilled expectations: %s", err) + } +} + +func TestDeleteAccount_NoAccountExists(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("an error '%s' was not expected when opening a stub database connection", err) + } + defer db.Close() + + accountID := int64(1) + sessionID := int64(1) + deleteArgs := DeleteAccountArgs{ + AccountId: accountID, + SessionId: &sessionID, + } + + body, _ := json.Marshal(deleteArgs) + req, err := http.NewRequest("DELETE", "/account/delete_account", bytes.NewBuffer(body)) + if err != nil { + t.Fatal(err) + } + + rr := httptest.NewRecorder() + sqlxDB := sqlx.NewDb(db, "sqlmock") + + createdAt := time.Now() + expiresAt := time.Now().Add(6 * time.Hour) + + mock.ExpectQuery(regexp.QuoteMeta("SELECT * FROM sessions WHERE id = $1")). + WithArgs(fmt.Sprintf("%d", sessionID)). + WillReturnRows(sqlmock.NewRows([]string{"id", "account_id", "created_at", "expires_at"}). + AddRow(sessionID, accountID, createdAt, expiresAt)) + + mock.ExpectExec("DELETE FROM account WHERE id = \\$1"). + WithArgs(accountID). + WillReturnResult(sqlmock.NewResult(0, 0)) + + mockStore := &auth.SessionStore{ + DB: sqlxDB, + } + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := DeleteAccount(w, r, sqlxDB, mockStore) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }) + + handler.ServeHTTP(rr, req) + + if status := rr.Code; status != http.StatusInternalServerError { + t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusInternalServerError) + } + + expectedError := "no account exists with the ID 1" + if strings.TrimSpace(rr.Body.String()) != expectedError { + t.Errorf("handler returned unexpected body: got %v want %v", strings.TrimSpace(rr.Body.String()), expectedError) + } +}