From b914a9f75c7c1726a710f5d9beddfdcdaa7d33cb Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Tue, 6 Sep 2022 21:05:03 +0930 Subject: [PATCH 1/6] Move go server into docker container and start adding tests --- Backend/Api/Auth/AddProfileImage.go | 6 +- Backend/Api/Auth/ChangeMessageExpiry.go | 4 +- Backend/Api/Auth/ChangePassword.go | 4 +- Backend/Api/Auth/Login.go | 4 +- Backend/Api/Auth/Login_test.go | 140 +++++++++++++++ Backend/Api/Auth/Logout.go | 7 +- Backend/Api/Auth/Logout_test.go | 131 ++++++++++++++ Backend/Api/Auth/Session.go | 6 +- Backend/Api/Auth/Signup.go | 88 ++++----- Backend/Api/Auth/Signup_test.go | 168 ++++++++++++++++++ Backend/Api/Friends/AcceptFriendRequest.go | 4 +- Backend/Api/Friends/EncryptedFriendsList.go | 6 +- Backend/Api/Friends/FriendRequest.go | 6 +- Backend/Api/Friends/Friends.go | 4 +- Backend/Api/Friends/RejectFriendRequest.go | 4 +- .../JsonSerialization/DeserializeUserJson.go | 2 +- Backend/Api/Messages/AddConversationImage.go | 6 +- Backend/Api/Messages/Conversations.go | 6 +- Backend/Api/Messages/CreateConversation.go | 4 +- Backend/Api/Messages/CreateMessage.go | 6 +- Backend/Api/Messages/MessageThread.go | 4 +- Backend/Api/Messages/UpdateConversation.go | 4 +- Backend/Api/Routes.go | 8 +- Backend/Api/Users/SearchUsers.go | 4 +- Backend/Database/Attachments.go | 2 +- Backend/Database/ConversationDetailUsers.go | 2 +- Backend/Database/ConversationDetails.go | 2 +- Backend/Database/FriendRequests.go | 2 +- Backend/Database/Init.go | 6 +- Backend/Database/MessageData.go | 2 +- Backend/Database/Messages.go | 2 +- Backend/Database/Seeder/FriendSeeder.go | 14 +- Backend/Database/Seeder/MessageSeeder.go | 16 +- Backend/Database/Seeder/Seed.go | 55 ++++-- Backend/Database/Seeder/UserSeeder.go | 14 +- Backend/Database/Seeder/encryption.go | 4 +- Backend/Database/Sessions.go | 16 +- Backend/Database/UserConversations.go | 2 +- Backend/Database/Users.go | 2 +- Backend/Dockerfile | 16 ++ Backend/Util/UserHelper.go | 4 +- Backend/go.mod | 2 +- Backend/main.go | 6 +- README.md | 2 +- docker-compose.yml | 49 +++++ .../android/app/src/main/AndroidManifest.xml | 2 +- mobile/lib/components/view_image.dart | 4 +- mobile/lib/main.dart | 2 +- mobile/lib/models/conversation_users.dart | 4 +- mobile/lib/models/conversations.dart | 4 +- mobile/lib/models/image_message.dart | 6 +- mobile/lib/models/my_profile.dart | 2 +- mobile/lib/utils/storage/conversations.dart | 4 +- mobile/lib/utils/storage/database.dart | 4 +- mobile/lib/utils/storage/messages.dart | 4 +- mobile/lib/views/authentication/login.dart | 2 +- mobile/lib/views/authentication/signup.dart | 6 +- .../unauthenticated_landing.dart | 2 +- .../conversation/create_add_users_list.dart | 4 +- .../lib/views/main/conversation/detail.dart | 2 +- .../views/main/conversation/edit_details.dart | 2 +- mobile/lib/views/main/conversation/list.dart | 6 +- .../lib/views/main/conversation/message.dart | 8 +- .../lib/views/main/conversation/settings.dart | 14 +- mobile/lib/views/main/friend/list.dart | 8 +- mobile/lib/views/main/friend/list_item.dart | 12 +- .../views/main/profile/change_server_url.dart | 2 +- mobile/lib/views/main/profile/profile.dart | 10 +- mobile/pubspec.yaml | 4 +- test.sh | 3 + 70 files changed, 740 insertions(+), 227 deletions(-) create mode 100644 Backend/Api/Auth/Login_test.go create mode 100644 Backend/Api/Auth/Logout_test.go create mode 100644 Backend/Api/Auth/Signup_test.go create mode 100644 Backend/Dockerfile create mode 100644 docker-compose.yml create mode 100644 test.sh diff --git a/Backend/Api/Auth/AddProfileImage.go b/Backend/Api/Auth/AddProfileImage.go index 31c7f64..deaea1c 100644 --- a/Backend/Api/Auth/AddProfileImage.go +++ b/Backend/Api/Auth/AddProfileImage.go @@ -5,9 +5,9 @@ import ( "encoding/json" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Util" ) // AddProfileImage adds a profile image diff --git a/Backend/Api/Auth/ChangeMessageExpiry.go b/Backend/Api/Auth/ChangeMessageExpiry.go index acad218..883f7e7 100644 --- a/Backend/Api/Auth/ChangeMessageExpiry.go +++ b/Backend/Api/Auth/ChangeMessageExpiry.go @@ -5,8 +5,8 @@ import ( "io/ioutil" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) type rawChangeMessageExpiry struct { diff --git a/Backend/Api/Auth/ChangePassword.go b/Backend/Api/Auth/ChangePassword.go index f4335cc..2688251 100644 --- a/Backend/Api/Auth/ChangePassword.go +++ b/Backend/Api/Auth/ChangePassword.go @@ -5,8 +5,8 @@ import ( "io/ioutil" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) type rawChangePassword struct { diff --git a/Backend/Api/Auth/Login.go b/Backend/Api/Auth/Login.go index d217493..7c72e07 100644 --- a/Backend/Api/Auth/Login.go +++ b/Backend/Api/Auth/Login.go @@ -6,8 +6,8 @@ import ( "net/http" "time" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) type credentials struct { diff --git a/Backend/Api/Auth/Login_test.go b/Backend/Api/Auth/Login_test.go new file mode 100644 index 0000000..11ea4af --- /dev/null +++ b/Backend/Api/Auth/Login_test.go @@ -0,0 +1,140 @@ +package Auth_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "testing" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "github.com/gorilla/mux" +) + +func Test_Login(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err := Database.CreateUser(&u) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + d := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + Username: "test", + Password: "password", + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/login", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + var session Models.Session + + err = Database.DB.First(&session, "user_id = ?", u.ID.String()).Error + + if err != nil { + t.Errorf("Expected user record, recieved %s", err.Error()) + return + } +} + +func Test_Login_PasswordFails(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err := Database.CreateUser(&u) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + d := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + Username: "test", + Password: "password1", + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/login", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusUnauthorized { + t.Errorf("Expected %d, recieved %d", http.StatusUnauthorized, resp.StatusCode) + return + } +} diff --git a/Backend/Api/Auth/Logout.go b/Backend/Api/Auth/Logout.go index 486b575..484fcc7 100644 --- a/Backend/Api/Auth/Logout.go +++ b/Backend/Api/Auth/Logout.go @@ -5,9 +5,10 @@ import ( "net/http" "time" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" ) +// Logout logs out from system func Logout(w http.ResponseWriter, r *http.Request) { var ( c *http.Cookie @@ -27,7 +28,7 @@ func Logout(w http.ResponseWriter, r *http.Request) { sessionToken = c.Value - err = Database.DeleteSessionById(sessionToken) + err = Database.DeleteSessionByID(sessionToken) if err != nil { log.Println("Could not delete session cookie") } @@ -37,4 +38,6 @@ func Logout(w http.ResponseWriter, r *http.Request) { Value: "", Expires: time.Now(), }) + + w.WriteHeader(http.StatusOK) } diff --git a/Backend/Api/Auth/Logout_test.go b/Backend/Api/Auth/Logout_test.go new file mode 100644 index 0000000..89e2262 --- /dev/null +++ b/Backend/Api/Auth/Logout_test.go @@ -0,0 +1,131 @@ +package Auth_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io/ioutil" + "log" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "sync" + "testing" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "github.com/gorilla/mux" +) + +type Jar struct { + lk sync.Mutex + cookies map[string][]*http.Cookie +} + +func Test_Logout(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err := Database.CreateUser(&u) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + d := struct { + Username string `json:"username"` + Password string `json:"password"` + }{ + Username: "test", + Password: "password", + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/login", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + var session Models.Session + + err = Database.DB.First(&session, "user_id = ?", u.ID.String()).Error + + if err != nil { + t.Errorf("Expected session record, recieved %s", err.Error()) + return + } + + jar, err := cookiejar.New(nil) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + } + + url, _ := url.Parse(ts.URL) + + jar.SetCookies( + url, + []*http.Cookie{ + &http.Cookie{ + Name: "session_token", + Value: session.ID.String(), + MaxAge: 300, + }, + }, + ) + + client = &http.Client{ + Jar: jar, + } + resp, err = client.Get(ts.URL + "/api/v1/logout") + + if err != nil { + t.Errorf("Expected user record, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + err = Database.DB.First(&session, "user_id = ?", u.ID.String()).Error + if err == nil { + t.Errorf("Expected no session record, recieved %s", session.UserID) + return + } +} diff --git a/Backend/Api/Auth/Session.go b/Backend/Api/Auth/Session.go index ffcfae2..4c4c5a1 100644 --- a/Backend/Api/Auth/Session.go +++ b/Backend/Api/Auth/Session.go @@ -4,8 +4,8 @@ import ( "errors" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) func CheckCookie(r *http.Request) (Models.Session, error) { @@ -23,7 +23,7 @@ func CheckCookie(r *http.Request) (Models.Session, error) { sessionToken = c.Value // We then get the session from our session map - userSession, err = Database.GetSessionById(sessionToken) + userSession, err = Database.GetSessionByID(sessionToken) if err != nil { return userSession, errors.New("Cookie not found") } diff --git a/Backend/Api/Auth/Signup.go b/Backend/Api/Auth/Signup.go index b60f880..90252a9 100644 --- a/Backend/Api/Auth/Signup.go +++ b/Backend/Api/Auth/Signup.go @@ -2,95 +2,67 @@ package Auth import ( "encoding/json" - "io/ioutil" - "log" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/JsonSerialization" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) -type signupResponse struct { - Status string `json:"status"` - Message string `json:"message"` -} - -func makeSignupResponse(w http.ResponseWriter, code int, message string) { - var ( - status = "error" - returnJSON []byte - err error - ) - if code > 200 && code < 300 { - status = "success" - } - - returnJSON, err = json.MarshalIndent(signupResponse{ - Status: status, - Message: message, - }, "", " ") - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } - - // Return updated json - w.WriteHeader(code) - w.Write(returnJSON) - +type signup struct { + Username string `json:"username"` + Password string `json:"password"` + ConfirmPassword string `json:"confirm_password"` + PublicKey string `json:"asymmetric_public_key"` + PrivateKey string `json:"asymmetric_private_key"` + SymmetricKey string `json:"symmetric_key"` } // Signup to the platform func Signup(w http.ResponseWriter, r *http.Request) { var ( - userData Models.User - requestBody []byte - err error + user Models.User + err error ) - requestBody, err = ioutil.ReadAll(r.Body) + err = json.NewDecoder(r.Body).Decode(&user) if err != nil { - log.Printf("Error encountered reading POST body: %s\n", err.Error()) - makeSignupResponse(w, http.StatusInternalServerError, "An error occurred") + http.Error(w, "Invalid Data", http.StatusUnprocessableEntity) return } - userData, err = JsonSerialization.DeserializeUser(requestBody, []string{ - "id", - }, false) - if err != nil { - log.Printf("Invalid data provided to Signup: %s\n", err.Error()) - makeSignupResponse(w, http.StatusUnprocessableEntity, "Invalid data provided") + if user.Username == "" || + user.Password == "" || + user.ConfirmPassword == "" || + len(user.AsymmetricPrivateKey) == 0 || + len(user.AsymmetricPublicKey) == 0 || + len(user.SymmetricKey) == 0 { + + http.Error(w, "Invalid Data", http.StatusUnprocessableEntity) return } - if userData.Username == "" || - userData.Password == "" || - userData.ConfirmPassword == "" || - len(userData.AsymmetricPrivateKey) == 0 || - len(userData.AsymmetricPublicKey) == 0 { - makeSignupResponse(w, http.StatusUnprocessableEntity, "Invalid data provided") + if user.Password != user.ConfirmPassword { + http.Error(w, "Invalid Data", http.StatusUnprocessableEntity) return } - err = Database.CheckUniqueUsername(userData.Username) + err = Database.CheckUniqueUsername(user.Username) if err != nil { - makeSignupResponse(w, http.StatusUnprocessableEntity, "Invalid data provided") + http.Error(w, "Invalid Data", http.StatusUnprocessableEntity) return } - userData.Password, err = HashPassword(userData.Password) + user.Password, err = HashPassword(user.Password) if err != nil { - makeSignupResponse(w, http.StatusInternalServerError, "An error occurred") + http.Error(w, "Error", http.StatusInternalServerError) return } - err = Database.CreateUser(&userData) + err = Database.CreateUser(&user) if err != nil { - makeSignupResponse(w, http.StatusInternalServerError, "An error occurred") + http.Error(w, "Error", http.StatusInternalServerError) return } - makeSignupResponse(w, http.StatusCreated, "Successfully signed up") + w.WriteHeader(http.StatusNoContent) } diff --git a/Backend/Api/Auth/Signup_test.go b/Backend/Api/Auth/Signup_test.go new file mode 100644 index 0000000..c57b125 --- /dev/null +++ b/Backend/Api/Auth/Signup_test.go @@ -0,0 +1,168 @@ +package Auth_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "testing" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "github.com/gorilla/mux" +) + +func Test_Signup(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + + pubKey := Seeder.GetPubKey() + + d := struct { + Username string `json:"username"` + Password string `json:"password"` + ConfirmPassword string `json:"confirm_password"` + PubKey string `json:"asymmetric_public_key"` + PrivKey string `json:"asymmetric_private_key"` + SymKey string `json:"symmetric_key"` + }{ + Username: "test", + Password: "password", + ConfirmPassword: "password", + PubKey: Seeder.PublicKey, + PrivKey: Seeder.EncryptedPrivateKey, + SymKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/signup", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Expected %d, recieved %d", http.StatusNoContent, resp.StatusCode) + return + } + + var user Models.User + + err = Database.DB.First(&user, "username = ?", "test").Error + + if err != nil { + t.Errorf("Expected user record, recieved %s", err.Error()) + return + } +} + +func Test_Signup_PasswordMismatchFails(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + + pubKey := Seeder.GetPubKey() + + d := struct { + Username string `json:"username"` + Password string `json:"password"` + ConfirmPassword string `json:"confirm_password"` + PubKey string `json:"asymmetric_public_key"` + PrivKey string `json:"asymmetric_private_key"` + SymKey string `json:"symmetric_key"` + }{ + Username: "test", + Password: "password", + ConfirmPassword: "password1", + PubKey: Seeder.PublicKey, + PrivKey: Seeder.EncryptedPrivateKey, + SymKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/signup", bytes.NewBuffer(jsonStr)) + req.Header.Set("X-Custom-Header", "myvalue") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("Expected %d, recieved %d", http.StatusUnprocessableEntity, resp.StatusCode) + return + } +} + +func Test_Signup_MissingDataFails(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + d := struct { + Username string `json:"username"` + Password string `json:"password"` + ConfirmPassword string `json:"confirm_password"` + PubKey string `json:"asymmetric_public_key"` + PrivKey string `json:"asymmetric_private_key"` + SymKey string `json:"symmetric_key"` + }{ + Username: "test", + Password: "password", + ConfirmPassword: "password", + PubKey: "", + PrivKey: "", + SymKey: "", + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/signup", bytes.NewBuffer(jsonStr)) + req.Header.Set("X-Custom-Header", "myvalue") + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{} + resp, err := client.Do(req) + + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + } + + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("Expected %d, recieved %d", http.StatusUnprocessableEntity, resp.StatusCode) + } +} diff --git a/Backend/Api/Friends/AcceptFriendRequest.go b/Backend/Api/Friends/AcceptFriendRequest.go index aa9e233..9b68bdd 100644 --- a/Backend/Api/Friends/AcceptFriendRequest.go +++ b/Backend/Api/Friends/AcceptFriendRequest.go @@ -6,8 +6,8 @@ import ( "net/http" "time" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "github.com/gorilla/mux" ) diff --git a/Backend/Api/Friends/EncryptedFriendsList.go b/Backend/Api/Friends/EncryptedFriendsList.go index 410c75c..c2ea274 100644 --- a/Backend/Api/Friends/EncryptedFriendsList.go +++ b/Backend/Api/Friends/EncryptedFriendsList.go @@ -4,9 +4,9 @@ import ( "encoding/json" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Auth" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) // EncryptedFriendRequestList gets friend request list diff --git a/Backend/Api/Friends/FriendRequest.go b/Backend/Api/Friends/FriendRequest.go index 126605d..c704800 100644 --- a/Backend/Api/Friends/FriendRequest.go +++ b/Backend/Api/Friends/FriendRequest.go @@ -5,9 +5,9 @@ import ( "io/ioutil" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Util" ) func FriendRequest(w http.ResponseWriter, r *http.Request) { diff --git a/Backend/Api/Friends/Friends.go b/Backend/Api/Friends/Friends.go index a1db196..d7f0b53 100644 --- a/Backend/Api/Friends/Friends.go +++ b/Backend/Api/Friends/Friends.go @@ -6,8 +6,8 @@ import ( "net/http" "time" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) // CreateFriendRequest creates a FriendRequest from post data diff --git a/Backend/Api/Friends/RejectFriendRequest.go b/Backend/Api/Friends/RejectFriendRequest.go index e341858..8ba5829 100644 --- a/Backend/Api/Friends/RejectFriendRequest.go +++ b/Backend/Api/Friends/RejectFriendRequest.go @@ -3,8 +3,8 @@ package Friends import ( "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "github.com/gorilla/mux" ) diff --git a/Backend/Api/JsonSerialization/DeserializeUserJson.go b/Backend/Api/JsonSerialization/DeserializeUserJson.go index 9220be8..4d5af16 100644 --- a/Backend/Api/JsonSerialization/DeserializeUserJson.go +++ b/Backend/Api/JsonSerialization/DeserializeUserJson.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" schema "github.com/Kangaroux/go-map-schema" ) diff --git a/Backend/Api/Messages/AddConversationImage.go b/Backend/Api/Messages/AddConversationImage.go index 1da2866..31c36e9 100644 --- a/Backend/Api/Messages/AddConversationImage.go +++ b/Backend/Api/Messages/AddConversationImage.go @@ -5,9 +5,9 @@ import ( "encoding/json" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Util" "github.com/gorilla/mux" ) diff --git a/Backend/Api/Messages/Conversations.go b/Backend/Api/Messages/Conversations.go index a1681da..4678108 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -6,9 +6,9 @@ import ( "net/url" "strings" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Auth" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) // EncryptedConversationList returns an encrypted list of all Conversations diff --git a/Backend/Api/Messages/CreateConversation.go b/Backend/Api/Messages/CreateConversation.go index 41de38c..728ecb0 100644 --- a/Backend/Api/Messages/CreateConversation.go +++ b/Backend/Api/Messages/CreateConversation.go @@ -6,8 +6,8 @@ import ( "github.com/gofrs/uuid" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) // RawCreateConversationData for holding POST payload diff --git a/Backend/Api/Messages/CreateMessage.go b/Backend/Api/Messages/CreateMessage.go index 052f128..becc0c2 100644 --- a/Backend/Api/Messages/CreateMessage.go +++ b/Backend/Api/Messages/CreateMessage.go @@ -5,9 +5,9 @@ import ( "encoding/json" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Util" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Util" ) type rawMessageData struct { diff --git a/Backend/Api/Messages/MessageThread.go b/Backend/Api/Messages/MessageThread.go index 686f1c1..ff466d3 100644 --- a/Backend/Api/Messages/MessageThread.go +++ b/Backend/Api/Messages/MessageThread.go @@ -4,8 +4,8 @@ import ( "encoding/json" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "github.com/gorilla/mux" ) diff --git a/Backend/Api/Messages/UpdateConversation.go b/Backend/Api/Messages/UpdateConversation.go index 4900ba8..67d2a2c 100644 --- a/Backend/Api/Messages/UpdateConversation.go +++ b/Backend/Api/Messages/UpdateConversation.go @@ -6,8 +6,8 @@ import ( "github.com/gofrs/uuid" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) type rawUpdateConversationData struct { diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 8b0c280..4058644 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -4,10 +4,10 @@ import ( "log" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Auth" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Friends" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Messages" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Users" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Friends" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Messages" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Users" "github.com/gorilla/mux" ) diff --git a/Backend/Api/Users/SearchUsers.go b/Backend/Api/Users/SearchUsers.go index 56ecd89..51f2e62 100644 --- a/Backend/Api/Users/SearchUsers.go +++ b/Backend/Api/Users/SearchUsers.go @@ -5,8 +5,8 @@ import ( "net/http" "net/url" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) // SearchUsers searches a for a user by username diff --git a/Backend/Database/Attachments.go b/Backend/Database/Attachments.go index 3097a04..bda1415 100644 --- a/Backend/Database/Attachments.go +++ b/Backend/Database/Attachments.go @@ -1,7 +1,7 @@ package Database import ( - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/Backend/Database/ConversationDetailUsers.go b/Backend/Database/ConversationDetailUsers.go index 6396acb..9215c0f 100644 --- a/Backend/Database/ConversationDetailUsers.go +++ b/Backend/Database/ConversationDetailUsers.go @@ -1,7 +1,7 @@ package Database import ( - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/Backend/Database/ConversationDetails.go b/Backend/Database/ConversationDetails.go index af04edb..811fa62 100644 --- a/Backend/Database/ConversationDetails.go +++ b/Backend/Database/ConversationDetails.go @@ -1,7 +1,7 @@ package Database import ( - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/Backend/Database/FriendRequests.go b/Backend/Database/FriendRequests.go index 0f6e58a..d93c9e7 100644 --- a/Backend/Database/FriendRequests.go +++ b/Backend/Database/FriendRequests.go @@ -1,7 +1,7 @@ package Database import ( - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/Backend/Database/Init.go b/Backend/Database/Init.go index f4b6fb9..0d1a729 100644 --- a/Backend/Database/Init.go +++ b/Backend/Database/Init.go @@ -3,15 +3,15 @@ package Database import ( "log" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/driver/postgres" "gorm.io/gorm" ) const ( - dbURL = "postgres://postgres:@localhost:5432/envelope" - dbTestURL = "postgres://postgres:@localhost:5432/envelope_test" + dbURL = "postgres://postgres:password@postgres:5432/capsule" + dbTestURL = "postgres://postgres:password@postgres-testing:5432/capsule-testing" ) // DB db diff --git a/Backend/Database/MessageData.go b/Backend/Database/MessageData.go index 80c6515..4198f2a 100644 --- a/Backend/Database/MessageData.go +++ b/Backend/Database/MessageData.go @@ -1,7 +1,7 @@ package Database import ( - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/Backend/Database/Messages.go b/Backend/Database/Messages.go index f415c0e..dd0fbfe 100644 --- a/Backend/Database/Messages.go +++ b/Backend/Database/Messages.go @@ -1,7 +1,7 @@ package Database import ( - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/Backend/Database/Seeder/FriendSeeder.go b/Backend/Database/Seeder/FriendSeeder.go index e317d13..a527004 100644 --- a/Backend/Database/Seeder/FriendSeeder.go +++ b/Backend/Database/Seeder/FriendSeeder.go @@ -6,8 +6,8 @@ import ( "os" "time" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) func seedFriend(userRequestTo, userRequestFrom Models.User, accepted bool) error { @@ -18,12 +18,12 @@ func seedFriend(userRequestTo, userRequestFrom Models.User, accepted bool) error err error ) - symKey, err = generateAesKey() + symKey, err = GenerateAesKey() if err != nil { return err } - encPublicKey, err = symKey.aesEncrypt([]byte(publicKey)) + encPublicKey, err = symKey.aesEncrypt([]byte(PublicKey)) if err != nil { return err } @@ -31,13 +31,13 @@ func seedFriend(userRequestTo, userRequestFrom Models.User, accepted bool) error friendRequest = Models.FriendRequest{ UserID: userRequestTo.ID, FriendID: base64.StdEncoding.EncodeToString( - encryptWithPublicKey( + EncryptWithPublicKey( []byte(userRequestFrom.ID.String()), decodedPublicKey, ), ), FriendUsername: base64.StdEncoding.EncodeToString( - encryptWithPublicKey( + EncryptWithPublicKey( []byte(userRequestFrom.Username), decodedPublicKey, ), @@ -46,7 +46,7 @@ func seedFriend(userRequestTo, userRequestFrom Models.User, accepted bool) error encPublicKey, ), SymmetricKey: base64.StdEncoding.EncodeToString( - encryptWithPublicKey(symKey.Key, decodedPublicKey), + EncryptWithPublicKey(symKey.Key, decodedPublicKey), ), } diff --git a/Backend/Database/Seeder/MessageSeeder.go b/Backend/Database/Seeder/MessageSeeder.go index 1bdffb9..efe4646 100644 --- a/Backend/Database/Seeder/MessageSeeder.go +++ b/Backend/Database/Seeder/MessageSeeder.go @@ -3,8 +3,8 @@ package Seeder import ( "encoding/base64" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "github.com/gofrs/uuid" ) @@ -27,12 +27,12 @@ func seedMessage( plaintext = "Test Message" - userKey, err = generateAesKey() + userKey, err = GenerateAesKey() if err != nil { panic(err) } - key, err = generateAesKey() + key, err = GenerateAesKey() if err != nil { panic(err) } @@ -70,7 +70,7 @@ func seedMessage( message = Models.Message{ MessageData: messageData, SymmetricKey: base64.StdEncoding.EncodeToString( - encryptWithPublicKey(userKey.Key, decodedPublicKey), + EncryptWithPublicKey(userKey.Key, decodedPublicKey), ), AssociationKey: primaryUserAssociationKey, } @@ -83,7 +83,7 @@ func seedMessage( message = Models.Message{ MessageData: messageData, SymmetricKey: base64.StdEncoding.EncodeToString( - encryptWithPublicKey(userKey.Key, decodedPublicKey), + EncryptWithPublicKey(userKey.Key, decodedPublicKey), ), AssociationKey: secondaryUserAssociationKey, } @@ -148,7 +148,7 @@ func seedUserConversation( ConversationDetailID: base64.StdEncoding.EncodeToString(conversationDetailIDCiphertext), Admin: base64.StdEncoding.EncodeToString(adminCiphertext), SymmetricKey: base64.StdEncoding.EncodeToString( - encryptWithPublicKey(key.Key, decodedPublicKey), + EncryptWithPublicKey(key.Key, decodedPublicKey), ), } @@ -233,7 +233,7 @@ func SeedMessages() { err error ) - key, err = generateAesKey() + key, err = GenerateAesKey() if err != nil { panic(err) } diff --git a/Backend/Database/Seeder/Seed.go b/Backend/Database/Seeder/Seed.go index 7bd5c40..bdfc1d0 100644 --- a/Backend/Database/Seeder/Seed.go +++ b/Backend/Database/Seeder/Seed.go @@ -9,11 +9,11 @@ import ( ) const ( - // Encrypted with "password" - encryptedPrivateKey string = `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` + // EncryptedPrivateKey with "password" + EncryptedPrivateKey string = `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` - // Private key for testing server side - privateKey string = `-----BEGIN PRIVATE KEY----- + // PrivateKey for testing server side + PrivateKey string = `-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDJScQQJxWxKwqf FmXH64QnRBVyW7cU25F+O9Zy96dqTjbV4ruWrzb4+txmK20ZPQvMxDLefhEzTXWb HZV1P/XxgmEpaBVHwHnkhaPzzChOa/G18CDoCNrgyVzh5a31OotTCuGlS1bSkR53 @@ -42,7 +42,8 @@ b0XvaLzh1iKG7HZ9tvPt/VhHlKKosNBK/j4fvgMZg7/bhRfHmaDQKoqlGbtyWjEQ mj1b2/Gnbk3VYDR16BFfj7m2 -----END PRIVATE KEY-----` - publicKey string = `-----BEGIN PUBLIC KEY----- + // PublicKey for encryption + PublicKey string = `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyUnEECcVsSsKnxZlx+uE J0QVclu3FNuRfjvWcvenak421eK7lq82+PrcZittGT0LzMQy3n4RM011mx2VdT/1 8YJhKWgVR8B55IWj88woTmvxtfAg6Aja4Mlc4eWt9TqLUwrhpUtW0pEedxMT10Kv @@ -58,35 +59,57 @@ var ( decodedPrivateKey *rsa.PrivateKey ) -// Seed seeds semi random data for use in testing & development -func Seed() { +// GetPubKey for seeding & tests +func GetPubKey() *rsa.PublicKey { var ( - block *pem.Block - decKey any - ok bool - err error + block *pem.Block + decKey any + decPubKey *rsa.PublicKey + ok bool + err error ) - - block, _ = pem.Decode([]byte(publicKey)) + block, _ = pem.Decode([]byte(PublicKey)) decKey, err = x509.ParsePKIXPublicKey(block.Bytes) if err != nil { panic(err) } - decodedPublicKey, ok = decKey.(*rsa.PublicKey) + + decPubKey, ok = decKey.(*rsa.PublicKey) if !ok { panic(errors.New("Invalid decodedPublicKey")) } - block, _ = pem.Decode([]byte(privateKey)) + return decPubKey +} + +// GetPrivKey for seeding & tests +func GetPrivKey() *rsa.PrivateKey { + var ( + block *pem.Block + decKey any + decPrivKey *rsa.PrivateKey + ok bool + err error + ) + block, _ = pem.Decode([]byte(PrivateKey)) decKey, err = x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { panic(err) } - decodedPrivateKey, ok = decKey.(*rsa.PrivateKey) + decPrivKey, ok = decKey.(*rsa.PrivateKey) if !ok { panic(errors.New("Invalid decodedPrivateKey")) } + return decPrivKey +} + +// Seed seeds semi random data for use in testing & development +func Seed() { + + decodedPublicKey = GetPubKey() + decodedPrivateKey = GetPrivKey() + log.Println("Seeding users...") SeedUsers() diff --git a/Backend/Database/Seeder/UserSeeder.go b/Backend/Database/Seeder/UserSeeder.go index c65a94e..e47f983 100644 --- a/Backend/Database/Seeder/UserSeeder.go +++ b/Backend/Database/Seeder/UserSeeder.go @@ -3,9 +3,9 @@ package Seeder import ( "encoding/base64" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api/Auth" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) var userNames = []string{ @@ -30,7 +30,7 @@ func createUser(username string) (Models.User, error) { err error ) - userKey, err = generateAesKey() + userKey, err = GenerateAesKey() if err != nil { panic(err) } @@ -43,10 +43,10 @@ func createUser(username string) (Models.User, error) { userData = Models.User{ Username: username, Password: password, - AsymmetricPrivateKey: encryptedPrivateKey, - AsymmetricPublicKey: publicKey, + AsymmetricPrivateKey: EncryptedPrivateKey, + AsymmetricPublicKey: PublicKey, SymmetricKey: base64.StdEncoding.EncodeToString( - encryptWithPublicKey(userKey.Key, decodedPublicKey), + EncryptWithPublicKey(userKey.Key, decodedPublicKey), ), } diff --git a/Backend/Database/Seeder/encryption.go b/Backend/Database/Seeder/encryption.go index a116134..61f9013 100644 --- a/Backend/Database/Seeder/encryption.go +++ b/Backend/Database/Seeder/encryption.go @@ -71,7 +71,7 @@ func pkcs7strip(data []byte, blockSize int) ([]byte, error) { return data[:length-padLen], nil } -func generateAesKey() (aesKey, error) { +func GenerateAesKey() (aesKey, error) { var ( saltBytes []byte = []byte{} password []byte @@ -157,7 +157,7 @@ func (key aesKey) aesDecrypt(ciphertext []byte) ([]byte, error) { } // EncryptWithPublicKey encrypts data with public key -func encryptWithPublicKey(msg []byte, pub *rsa.PublicKey) []byte { +func EncryptWithPublicKey(msg []byte, pub *rsa.PublicKey) []byte { var ( hash hash.Hash ) diff --git a/Backend/Database/Sessions.go b/Backend/Database/Sessions.go index 1f125df..afee878 100644 --- a/Backend/Database/Sessions.go +++ b/Backend/Database/Sessions.go @@ -1,12 +1,13 @@ package Database import ( - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/gorm/clause" ) -func GetSessionById(id string) (Models.Session, error) { +// GetSessionByID Gets session +func GetSessionByID(id string) (Models.Session, error) { var ( session Models.Session err error @@ -19,6 +20,7 @@ func GetSessionById(id string) (Models.Session, error) { return session, err } +// CreateSession creates session func CreateSession(session *Models.Session) error { var ( err error @@ -29,10 +31,16 @@ func CreateSession(session *Models.Session) error { return err } +// DeleteSession deletes session func DeleteSession(session *Models.Session) error { return DB.Delete(session).Error } -func DeleteSessionById(id string) error { - return DB.Delete(&Models.Session{}, id).Error +// DeleteSessionByID deletes session +func DeleteSessionByID(id string) error { + return DB.Delete( + &Models.Session{}, + "id = ?", + id, + ).Error } diff --git a/Backend/Database/UserConversations.go b/Backend/Database/UserConversations.go index 930a98f..2e77ce7 100644 --- a/Backend/Database/UserConversations.go +++ b/Backend/Database/UserConversations.go @@ -1,7 +1,7 @@ package Database import ( - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/Backend/Database/Users.go b/Backend/Database/Users.go index 2df6a73..c27ba2c 100644 --- a/Backend/Database/Users.go +++ b/Backend/Database/Users.go @@ -3,7 +3,7 @@ package Database import ( "errors" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "gorm.io/gorm" "gorm.io/gorm/clause" diff --git a/Backend/Dockerfile b/Backend/Dockerfile new file mode 100644 index 0000000..9fea42e --- /dev/null +++ b/Backend/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.19-alpine + +RUN mkdir -p /go/src/git.tovijaeschke.xyz/Capsule/Backend + +COPY ./ /go/src/git.tovijaeschke.xyz/Capsule/Backend + +WORKDIR /go/src/git.tovijaeschke.xyz/Capsule/Backend + +# For "go test" +RUN apk add gcc libc-dev + +RUN go mod download + +RUN go build -o /go/bin/capsule-server main.go + +CMD [ "/go/bin/capsule-server" ] diff --git a/Backend/Util/UserHelper.go b/Backend/Util/UserHelper.go index 32616a6..47b2569 100644 --- a/Backend/Util/UserHelper.go +++ b/Backend/Util/UserHelper.go @@ -5,8 +5,8 @@ import ( "log" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" "github.com/gorilla/mux" ) diff --git a/Backend/go.mod b/Backend/go.mod index 127bb75..bebe75f 100644 --- a/Backend/go.mod +++ b/Backend/go.mod @@ -1,4 +1,4 @@ -module git.tovijaeschke.xyz/tovi/Envelope/Backend +module git.tovijaeschke.xyz/tovi/Capsule/Backend go 1.18 diff --git a/Backend/main.go b/Backend/main.go index e9dc701..8001402 100644 --- a/Backend/main.go +++ b/Backend/main.go @@ -5,9 +5,9 @@ import ( "log" "net/http" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Api" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database" - "git.tovijaeschke.xyz/tovi/Envelope/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" "github.com/gorilla/mux" ) diff --git a/README.md b/README.md index d52d837..d43ef78 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Envelope +# Capsule Encrypted messaging app diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6c89730 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +version: "3" + +services: + server: + build: + context: ./Backend + ports: + - "8080:8080" + volumes: + - "./Backend:/app" + links: + - postgres + - postgres-testing + depends_on: + postgres: + condition: service_healthy + depends_on: + postgres-testing: + condition: service_healthy + postgres: + image: postgres:14.5 + ports: + - "54321:5432" + environment: + POSTGRES_DB: capsule + POSTGRES_PASSWORD: password + volumes: + - /var/lib/postgres + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + postgres-testing: + image: postgres:14.5 + ports: + - "54322:5432" + environment: + POSTGRES_DB: capsule-testing + POSTGRES_PASSWORD: password + tmpfs: + - /var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index c3bfaaa..26cb9e0 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -2,7 +2,7 @@ package="com.example.mobile"> deleteDb() async { - final path = join(await getDatabasesPath(), 'envelope.db'); + final path = join(await getDatabasesPath(), 'capsule.db'); deleteDatabase(path); } Future getDatabaseConnection() async { WidgetsFlutterBinding.ensureInitialized(); - final path = join(await getDatabasesPath(), 'envelope.db'); + final path = join(await getDatabasesPath(), 'capsule.db'); final database = openDatabase( path, diff --git a/mobile/lib/utils/storage/messages.dart b/mobile/lib/utils/storage/messages.dart index fd7ced7..8c07669 100644 --- a/mobile/lib/utils/storage/messages.dart +++ b/mobile/lib/utils/storage/messages.dart @@ -1,8 +1,8 @@ import 'dart:convert'; import 'dart:io'; -import 'package:Envelope/models/messages.dart'; -import 'package:Envelope/utils/storage/write_file.dart'; +import 'package:Capsule/models/messages.dart'; +import 'package:Capsule/utils/storage/write_file.dart'; import 'package:http/http.dart' as http; import 'package:sqflite/sqflite.dart'; import 'package:uuid/uuid.dart'; diff --git a/mobile/lib/views/authentication/login.dart b/mobile/lib/views/authentication/login.dart index 6dce978..d4c56a0 100644 --- a/mobile/lib/views/authentication/login.dart +++ b/mobile/lib/views/authentication/login.dart @@ -177,7 +177,7 @@ class _LoginWidgetState extends State { }).catchError((error) { print(error); showMessage( - 'Could not login to Envelope, please try again later.', + 'Could not login to Capsule, please try again later.', context, ); }); diff --git a/mobile/lib/views/authentication/signup.dart b/mobile/lib/views/authentication/signup.dart index 2a190e0..22ac9ab 100644 --- a/mobile/lib/views/authentication/signup.dart +++ b/mobile/lib/views/authentication/signup.dart @@ -1,8 +1,8 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:Envelope/components/flash_message.dart'; -import 'package:Envelope/models/my_profile.dart'; +import 'package:Capsule/components/flash_message.dart'; +import 'package:Capsule/models/my_profile.dart'; import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; @@ -176,7 +176,7 @@ class _SignupWidgetState extends State { .then((dynamic) { Navigator.of(context).popUntil((route) => route.isFirst); }).catchError((error) { - showMessage('Failed to signup to Envelope, please try again later', context); + showMessage('Failed to signup to Capsule, please try again later', context); }); }, child: const Text('Submit'), diff --git a/mobile/lib/views/authentication/unauthenticated_landing.dart b/mobile/lib/views/authentication/unauthenticated_landing.dart index 6bcd086..3a7e432 100644 --- a/mobile/lib/views/authentication/unauthenticated_landing.dart +++ b/mobile/lib/views/authentication/unauthenticated_landing.dart @@ -46,7 +46,7 @@ class _UnauthenticatedLandingWidgetState extends State Date: Thu, 8 Sep 2022 19:13:55 +0930 Subject: [PATCH 2/6] Add more tests for Api/Auth routes --- Backend/Api/Auth/AddProfileImage.go | 10 + Backend/Api/Auth/AddProfileImage_test.go | 143 ++++++++ Backend/Api/Auth/ChangeMessageExpiry.go | 8 +- Backend/Api/Auth/ChangeMessageExpiry_test.go | 212 ++++++++++++ Backend/Api/Auth/ChangePassword.go | 2 +- Backend/Api/Auth/ChangePassword_test.go | 306 ++++++++++++++++++ Backend/Api/Auth/Logout_test.go | 6 - Backend/Api/Auth/profile_picture_test.png | Bin 0 -> 139498 bytes .../JsonSerialization/DeserializeUserJson.go | 76 ----- Backend/Api/JsonSerialization/VerifyJson.go | 109 ------- Backend/Database/Seeder/FriendSeeder.go | 2 +- Backend/Database/Seeder/MessageSeeder.go | 26 +- Backend/Database/Seeder/encryption.go | 4 +- Backend/Models/Users.go | 34 +- Backend/Util/Files.go | 9 +- test.sh | 2 +- 16 files changed, 728 insertions(+), 221 deletions(-) create mode 100644 Backend/Api/Auth/AddProfileImage_test.go create mode 100644 Backend/Api/Auth/ChangeMessageExpiry_test.go create mode 100644 Backend/Api/Auth/ChangePassword_test.go create mode 100644 Backend/Api/Auth/profile_picture_test.png delete mode 100644 Backend/Api/JsonSerialization/DeserializeUserJson.go delete mode 100644 Backend/Api/JsonSerialization/VerifyJson.go diff --git a/Backend/Api/Auth/AddProfileImage.go b/Backend/Api/Auth/AddProfileImage.go index deaea1c..af88ced 100644 --- a/Backend/Api/Auth/AddProfileImage.go +++ b/Backend/Api/Auth/AddProfileImage.go @@ -35,7 +35,17 @@ func AddProfileImage(w http.ResponseWriter, r *http.Request) { } decodedFile, err = base64.StdEncoding.DecodeString(attachment.Data) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + fileName, err = Util.WriteFile(decodedFile) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + attachment.FilePath = fileName user.Attachment = attachment diff --git a/Backend/Api/Auth/AddProfileImage_test.go b/Backend/Api/Auth/AddProfileImage_test.go new file mode 100644 index 0000000..eb0864f --- /dev/null +++ b/Backend/Api/Auth/AddProfileImage_test.go @@ -0,0 +1,143 @@ +package Auth_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io/ioutil" + "log" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "github.com/gorilla/mux" +) + +func Test_AddProfileImage(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err := Database.CreateUser(&u) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + session := Models.Session{ + UserID: u.ID, + Expiry: time.Now().Add(12 * time.Hour), + } + + err = Database.CreateSession(&session) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + jar, err := cookiejar.New(nil) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + url, _ := url.Parse(ts.URL) + + jar.SetCookies( + url, + []*http.Cookie{ + { + Name: "session_token", + Value: session.ID.String(), + MaxAge: 300, + }, + }, + ) + + key, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + dat, err := os.ReadFile("./profile_picture_test.png") + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + encDat, err := key.AesEncrypt(dat) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + a := Models.Attachment{ + Mimetype: "image/png", + Extension: "png", + Data: base64.StdEncoding.EncodeToString(encDat), + } + + jsonStr, _ := json.Marshal(a) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/image", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Jar: jar, + } + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Expected %d, recieved %d", http.StatusNoContent, resp.StatusCode) + return + } + + u, err = Database.GetUserById(u.ID.String()) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if u.AttachmentID.IsNil() { + t.Errorf("Attachment not assigned to user") + } + + err = os.Remove("/app/attachments/" + u.Attachment.FilePath) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } +} diff --git a/Backend/Api/Auth/ChangeMessageExpiry.go b/Backend/Api/Auth/ChangeMessageExpiry.go index 883f7e7..aa2fd5e 100644 --- a/Backend/Api/Auth/ChangeMessageExpiry.go +++ b/Backend/Api/Auth/ChangeMessageExpiry.go @@ -37,7 +37,11 @@ func ChangeMessageExpiry(w http.ResponseWriter, r *http.Request) { return } - user.MessageExpiryDefault.Scan(changeMessageExpiry.MessageExpiry) + err = user.MessageExpiryDefault.Scan(changeMessageExpiry.MessageExpiry) + if err != nil { + http.Error(w, "Error", http.StatusUnprocessableEntity) + return + } err = Database.UpdateUser( user.ID.String(), @@ -48,5 +52,5 @@ func ChangeMessageExpiry(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } diff --git a/Backend/Api/Auth/ChangeMessageExpiry_test.go b/Backend/Api/Auth/ChangeMessageExpiry_test.go new file mode 100644 index 0000000..03012dc --- /dev/null +++ b/Backend/Api/Auth/ChangeMessageExpiry_test.go @@ -0,0 +1,212 @@ +package Auth_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io/ioutil" + "log" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "testing" + "time" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "github.com/gorilla/mux" +) + +func Test_ChangeMessageExpiry(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err := Database.CreateUser(&u) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + session := Models.Session{ + UserID: u.ID, + Expiry: time.Now().Add(12 * time.Hour), + } + + err = Database.CreateSession(&session) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + jar, err := cookiejar.New(nil) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + url, _ := url.Parse(ts.URL) + + jar.SetCookies( + url, + []*http.Cookie{ + { + Name: "session_token", + Value: session.ID.String(), + MaxAge: 300, + }, + }, + ) + + d := struct { + MessageExpiry string `json:"message_expiry"` + }{ + MessageExpiry: "fifteen_min", + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/message_expiry", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Jar: jar, + } + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Expected %d, recieved %d", http.StatusNoContent, resp.StatusCode) + } + + u, err = Database.GetUserById(u.ID.String()) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if u.MessageExpiryDefault.String() != "fifteen_min" { + t.Errorf("Failed to verify the MessageExpiryDefault has been changed") + } +} + +func Test_ChangeMessageExpiryInvalidData(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err := Database.CreateUser(&u) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + session := Models.Session{ + UserID: u.ID, + Expiry: time.Now().Add(12 * time.Hour), + } + + err = Database.CreateSession(&session) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + jar, err := cookiejar.New(nil) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + url, _ := url.Parse(ts.URL) + + jar.SetCookies( + url, + []*http.Cookie{ + { + Name: "session_token", + Value: session.ID.String(), + MaxAge: 300, + }, + }, + ) + + d := struct { + MessageExpiry string `json:"message_expiry"` + }{ + MessageExpiry: "invalid_message_expiry", + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/message_expiry", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Jar: jar, + } + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("Expected %d, recieved %d", http.StatusUnprocessableEntity, resp.StatusCode) + } + + u, err = Database.GetUserById(u.ID.String()) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if u.MessageExpiryDefault.String() != "no_expiry" { + t.Errorf("Failed to verify the MessageExpiryDefault has not been changed") + } +} diff --git a/Backend/Api/Auth/ChangePassword.go b/Backend/Api/Auth/ChangePassword.go index 2688251..6e04bb5 100644 --- a/Backend/Api/Auth/ChangePassword.go +++ b/Backend/Api/Auth/ChangePassword.go @@ -72,5 +72,5 @@ func ChangePassword(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } diff --git a/Backend/Api/Auth/ChangePassword_test.go b/Backend/Api/Auth/ChangePassword_test.go new file mode 100644 index 0000000..53d9491 --- /dev/null +++ b/Backend/Api/Auth/ChangePassword_test.go @@ -0,0 +1,306 @@ +package Auth_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "io/ioutil" + "log" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "testing" + "time" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "github.com/gorilla/mux" +) + +func Test_ChangePassword(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err := Database.CreateUser(&u) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + session := Models.Session{ + UserID: u.ID, + Expiry: time.Now().Add(12 * time.Hour), + } + + err = Database.CreateSession(&session) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + jar, err := cookiejar.New(nil) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + url, _ := url.Parse(ts.URL) + + jar.SetCookies( + url, + []*http.Cookie{ + { + Name: "session_token", + Value: session.ID.String(), + MaxAge: 300, + }, + }, + ) + + d := struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + NewPasswordConfirm string `json:"new_password_confirm"` + PrivateKey string `json:"private_key"` + }{ + OldPassword: "password", + NewPassword: "password1", + NewPasswordConfirm: "password1", + PrivateKey: "", + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/change_password", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Jar: jar, + } + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Expected %d, recieved %d", http.StatusNoContent, resp.StatusCode) + return + } + + u, err = Database.GetUserById(u.ID.String()) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if !Auth.CheckPasswordHash("password1", u.Password) { + t.Errorf("Failed to verify the password has been changed") + } +} + +func Test_ChangePasswordMismatchConfirmFails(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err := Database.CreateUser(&u) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + session := Models.Session{ + UserID: u.ID, + Expiry: time.Now().Add(12 * time.Hour), + } + + err = Database.CreateSession(&session) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + jar, err := cookiejar.New(nil) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + url, _ := url.Parse(ts.URL) + + jar.SetCookies( + url, + []*http.Cookie{ + { + Name: "session_token", + Value: session.ID.String(), + MaxAge: 300, + }, + }, + ) + + d := struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + NewPasswordConfirm string `json:"new_password_confirm"` + PrivateKey string `json:"private_key"` + }{ + OldPassword: "password", + NewPassword: "password1", + NewPasswordConfirm: "password2", + PrivateKey: "", + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/change_password", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Jar: jar, + } + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusUnprocessableEntity { + t.Errorf("Expected %d, recieved %d", http.StatusUnprocessableEntity, resp.StatusCode) + } +} + +func Test_ChangePasswordInvalidCurrentPasswordFails(t *testing.T) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + defer ts.Close() + + userKey, _ := Seeder.GenerateAesKey() + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err := Database.CreateUser(&u) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + session := Models.Session{ + UserID: u.ID, + Expiry: time.Now().Add(12 * time.Hour), + } + + err = Database.CreateSession(&session) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + jar, err := cookiejar.New(nil) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + url, _ := url.Parse(ts.URL) + + jar.SetCookies( + url, + []*http.Cookie{ + { + Name: "session_token", + Value: session.ID.String(), + MaxAge: 300, + }, + }, + ) + + d := struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + NewPasswordConfirm string `json:"new_password_confirm"` + PrivateKey string `json:"private_key"` + }{ + OldPassword: "password2", + NewPassword: "password1", + NewPasswordConfirm: "password1", + PrivateKey: "", + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/change_password", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Jar: jar, + } + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusForbidden { + t.Errorf("Expected %d, recieved %d", http.StatusForbidden, resp.StatusCode) + } +} diff --git a/Backend/Api/Auth/Logout_test.go b/Backend/Api/Auth/Logout_test.go index 89e2262..85c2516 100644 --- a/Backend/Api/Auth/Logout_test.go +++ b/Backend/Api/Auth/Logout_test.go @@ -10,7 +10,6 @@ import ( "net/http/cookiejar" "net/http/httptest" "net/url" - "sync" "testing" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" @@ -21,11 +20,6 @@ import ( "github.com/gorilla/mux" ) -type Jar struct { - lk sync.Mutex - cookies map[string][]*http.Cookie -} - func Test_Logout(t *testing.T) { log.SetOutput(ioutil.Discard) Database.InitTest() diff --git a/Backend/Api/Auth/profile_picture_test.png b/Backend/Api/Auth/profile_picture_test.png new file mode 100644 index 0000000000000000000000000000000000000000..bec7dbc2bf13477e11f6348b821b1e5c69f8006a GIT binary patch literal 139498 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 p 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

%7(32Rj*e!Nx}Gksm*iPITbx|#$>S?= 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 literal 0 HcmV?d00001 diff --git a/Backend/Api/JsonSerialization/DeserializeUserJson.go b/Backend/Api/JsonSerialization/DeserializeUserJson.go deleted file mode 100644 index 4d5af16..0000000 --- a/Backend/Api/JsonSerialization/DeserializeUserJson.go +++ /dev/null @@ -1,76 +0,0 @@ -package JsonSerialization - -import ( - "encoding/json" - "errors" - "fmt" - "strings" - - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" - - schema "github.com/Kangaroux/go-map-schema" -) - -func DeserializeUser(data []byte, allowMissing []string, allowAllMissing bool) (Models.User, error) { - var ( - userData Models.User = Models.User{} - jsonStructureTest map[string]interface{} = make(map[string]interface{}) - jsonStructureTestResults *schema.CompareResults - field schema.FieldMissing - allowed string - missingFields []string - i int - err error - ) - - // Verify the JSON has the correct structure - json.Unmarshal(data, &jsonStructureTest) - jsonStructureTestResults, err = schema.CompareMapToStruct( - &userData, - jsonStructureTest, - &schema.CompareOpts{ - ConvertibleFunc: CanConvert, - TypeNameFunc: schema.DetailedTypeName, - }) - if err != nil { - return userData, err - } - - if len(jsonStructureTestResults.MismatchedFields) > 0 { - return userData, errors.New(fmt.Sprintf( - "MismatchedFields found when deserializing data: %s", - jsonStructureTestResults.Errors().Error(), - )) - } - - // Remove allowed missing fields from MissingFields - for _, allowed = range allowMissing { - for i, field = range jsonStructureTestResults.MissingFields { - if allowed == field.String() { - jsonStructureTestResults.MissingFields = append( - jsonStructureTestResults.MissingFields[:i], - jsonStructureTestResults.MissingFields[i+1:]..., - ) - } - } - } - - if !allowAllMissing && len(jsonStructureTestResults.MissingFields) > 0 { - for _, field = range jsonStructureTestResults.MissingFields { - missingFields = append(missingFields, field.String()) - } - - return userData, errors.New(fmt.Sprintf( - "MissingFields found when deserializing data: %s", - strings.Join(missingFields, ", "), - )) - } - - // Deserialize the JSON into the struct - err = json.Unmarshal(data, &userData) - if err != nil { - return userData, err - } - - return userData, err -} diff --git a/Backend/Api/JsonSerialization/VerifyJson.go b/Backend/Api/JsonSerialization/VerifyJson.go deleted file mode 100644 index 3a3ae78..0000000 --- a/Backend/Api/JsonSerialization/VerifyJson.go +++ /dev/null @@ -1,109 +0,0 @@ -package JsonSerialization - -import ( - "math" - "reflect" -) - -// isIntegerType returns whether the type is an integer and if it's unsigned. -// See: https://github.com/Kangaroux/go-map-schema/blob/master/schema.go#L328 -func isIntegerType(t reflect.Type) (bool, bool) { - var ( - yes bool - unsigned bool - ) - switch t.Kind() { - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - yes = true - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - yes = true - unsigned = true - } - - return yes, unsigned -} - -// isFloatType returns true if the type is a floating point. Note that this doesn't -// care about the value -- unmarshaling the number "0" gives a float, not an int. -// See: https://github.com/Kangaroux/go-map-schema/blob/master/schema.go#L319 -func isFloatType(t reflect.Type) bool { - var ( - yes bool - ) - switch t.Kind() { - case reflect.Float32, reflect.Float64: - yes = true - } - - return yes -} - -// CanConvert returns whether value v is convertible to type t. -// -// If t is a pointer and v is not nil, it checks if v is convertible to the type that -// t points to. -// Modified due to not handling slices (DefaultCanConvert fails on PhotoUrls and Tags) -// See: https://github.com/Kangaroux/go-map-schema/blob/master/schema.go#L191 -func CanConvert(t reflect.Type, v reflect.Value) bool { - var ( - isPtr bool - isStruct bool - isArray bool - dstType reflect.Type - dstInt bool - unsigned bool - f float64 - srcInt bool - ) - - isPtr = t.Kind() == reflect.Ptr - isStruct = t.Kind() == reflect.Struct - isArray = t.Kind() == reflect.Array - dstType = t - - // Check if v is a nil value. - if !v.IsValid() || (v.CanAddr() && v.IsNil()) { - return isPtr - } - - // If the dst is a pointer, check if we can convert to the type it's pointing to. - if isPtr { - dstType = t.Elem() - isStruct = t.Elem().Kind() == reflect.Struct - } - - // If the dst is a struct, we should check its nested fields. - if isStruct { - return v.Kind() == reflect.Map - } - - if isArray { - return v.Kind() == reflect.String - } - - if t.Kind() == reflect.Slice { - return v.Kind() == reflect.Slice - } - - if !v.Type().ConvertibleTo(dstType) { - return false - } - - // Handle converting to an integer type. - dstInt, unsigned = isIntegerType(dstType) - if dstInt { - if isFloatType(v.Type()) { - f = v.Float() - - if math.Trunc(f) != f || unsigned && f < 0 { - return false - } - } - srcInt, _ = isIntegerType(v.Type()) - if srcInt && unsigned && v.Int() < 0 { - return false - } - } - - return true -} diff --git a/Backend/Database/Seeder/FriendSeeder.go b/Backend/Database/Seeder/FriendSeeder.go index a527004..b4cacd5 100644 --- a/Backend/Database/Seeder/FriendSeeder.go +++ b/Backend/Database/Seeder/FriendSeeder.go @@ -23,7 +23,7 @@ func seedFriend(userRequestTo, userRequestFrom Models.User, accepted bool) error return err } - encPublicKey, err = symKey.aesEncrypt([]byte(PublicKey)) + encPublicKey, err = symKey.AesEncrypt([]byte(PublicKey)) if err != nil { return err } diff --git a/Backend/Database/Seeder/MessageSeeder.go b/Backend/Database/Seeder/MessageSeeder.go index efe4646..1b4b0a8 100644 --- a/Backend/Database/Seeder/MessageSeeder.go +++ b/Backend/Database/Seeder/MessageSeeder.go @@ -37,24 +37,24 @@ func seedMessage( panic(err) } - dataCiphertext, err = key.aesEncrypt([]byte(plaintext)) + dataCiphertext, err = key.AesEncrypt([]byte(plaintext)) if err != nil { panic(err) } - senderIDCiphertext, err = key.aesEncrypt([]byte(primaryUser.ID.String())) + senderIDCiphertext, err = key.AesEncrypt([]byte(primaryUser.ID.String())) if err != nil { panic(err) } if i%2 == 0 { - senderIDCiphertext, err = key.aesEncrypt([]byte(secondaryUser.ID.String())) + senderIDCiphertext, err = key.AesEncrypt([]byte(secondaryUser.ID.String())) if err != nil { panic(err) } } - keyCiphertext, err = userKey.aesEncrypt( + keyCiphertext, err = userKey.AesEncrypt( []byte(base64.StdEncoding.EncodeToString(key.Key)), ) if err != nil { @@ -102,12 +102,12 @@ func seedConversationDetail(key aesKey) (Models.ConversationDetail, error) { name = "Test Conversation" - nameCiphertext, err = key.aesEncrypt([]byte(name)) + nameCiphertext, err = key.AesEncrypt([]byte(name)) if err != nil { panic(err) } - twoUserCiphertext, err = key.aesEncrypt([]byte("false")) + twoUserCiphertext, err = key.AesEncrypt([]byte("false")) if err != nil { panic(err) } @@ -133,12 +133,12 @@ func seedUserConversation( err error ) - conversationDetailIDCiphertext, err = key.aesEncrypt([]byte(threadID.String())) + conversationDetailIDCiphertext, err = key.AesEncrypt([]byte(threadID.String())) if err != nil { return messageThreadUser, err } - adminCiphertext, err = key.aesEncrypt([]byte("true")) + adminCiphertext, err = key.AesEncrypt([]byte("true")) if err != nil { return messageThreadUser, err } @@ -181,27 +181,27 @@ func seedConversationDetailUser( adminString = "true" } - userIDCiphertext, err = key.aesEncrypt([]byte(user.ID.String())) + userIDCiphertext, err = key.AesEncrypt([]byte(user.ID.String())) if err != nil { return conversationDetailUser, err } - usernameCiphertext, err = key.aesEncrypt([]byte(user.Username)) + usernameCiphertext, err = key.AesEncrypt([]byte(user.Username)) if err != nil { return conversationDetailUser, err } - adminCiphertext, err = key.aesEncrypt([]byte(adminString)) + adminCiphertext, err = key.AesEncrypt([]byte(adminString)) if err != nil { return conversationDetailUser, err } - associationKeyCiphertext, err = key.aesEncrypt([]byte(associationKey.String())) + associationKeyCiphertext, err = key.AesEncrypt([]byte(associationKey.String())) if err != nil { return conversationDetailUser, err } - publicKeyCiphertext, err = key.aesEncrypt([]byte(user.AsymmetricPublicKey)) + publicKeyCiphertext, err = key.AesEncrypt([]byte(user.AsymmetricPublicKey)) if err != nil { return conversationDetailUser, err } diff --git a/Backend/Database/Seeder/encryption.go b/Backend/Database/Seeder/encryption.go index 61f9013..e0f5c74 100644 --- a/Backend/Database/Seeder/encryption.go +++ b/Backend/Database/Seeder/encryption.go @@ -110,7 +110,7 @@ func GenerateAesKey() (aesKey, error) { }, nil } -func (key aesKey) aesEncrypt(plaintext []byte) ([]byte, error) { +func (key aesKey) AesEncrypt(plaintext []byte) ([]byte, error) { var ( bPlaintext []byte ciphertext []byte @@ -134,7 +134,7 @@ func (key aesKey) aesEncrypt(plaintext []byte) ([]byte, error) { return ciphertext, nil } -func (key aesKey) aesDecrypt(ciphertext []byte) ([]byte, error) { +func (key aesKey) AesDecrypt(ciphertext []byte) ([]byte, error) { var ( plaintext []byte iv []byte diff --git a/Backend/Models/Users.go b/Backend/Models/Users.go index 811c3ab..736289e 100644 --- a/Backend/Models/Users.go +++ b/Backend/Models/Users.go @@ -2,6 +2,7 @@ package Models import ( "database/sql/driver" + "errors" "github.com/gofrs/uuid" "gorm.io/gorm" @@ -40,10 +41,35 @@ const ( MessageExpiryNoExpiry = "no_expiry" ) +// MessageExpiryValues list of all expiry values for validation +var MessageExpiryValues = []string{ + MessageExpiryFifteenMin, + MessageExpiryThirtyMin, + MessageExpiryOneHour, + MessageExpiryThreeHour, + MessageExpirySixHour, + MessageExpiryTwelveHour, + MessageExpiryOneDay, + MessageExpiryThreeDay, + MessageExpiryNoExpiry, +} + // Scan new value into MessageExpiry func (e *MessageExpiry) Scan(value interface{}) error { - *e = MessageExpiry(value.(string)) - return nil + var ( + strValue = value.(string) + m string + ) + + for _, m = range MessageExpiryValues { + if strValue != m { + continue + } + *e = MessageExpiry(strValue) + return nil + } + + return errors.New("Invalid MessageExpiry value") } // Value gets value out of MessageExpiry column @@ -51,6 +77,10 @@ func (e MessageExpiry) Value() (driver.Value, error) { return string(e), nil } +func (e MessageExpiry) String() string { + return string(e) +} + // User holds user data type User struct { Base diff --git a/Backend/Util/Files.go b/Backend/Util/Files.go index 4ee8b81..154b1ef 100644 --- a/Backend/Util/Files.go +++ b/Backend/Util/Files.go @@ -10,21 +10,14 @@ func WriteFile(contents []byte) (string, error) { var ( fileName string filePath string - cwd string f *os.File err error ) - cwd, err = os.Getwd() - if err != nil { - return fileName, err - } - fileName = RandomString(32) filePath = fmt.Sprintf( - "%s/attachments/%s", - cwd, + "/app/attachments/%s", fileName, ) diff --git a/test.sh b/test.sh index 153de7f..66de839 100644 --- a/test.sh +++ b/test.sh @@ -1,3 +1,3 @@ #!/bin/sh -docker-compose exec server sh -c "cd /app && go test -v ./Api/Auth" +docker-compose exec server sh -c "cd /app && go test -v ./..." From 5a70720172dc013a2707c99582fb616cbb20a149 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Thu, 8 Sep 2022 19:16:10 +0930 Subject: [PATCH 3/6] Seed rand --- Backend/Util/Strings.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Backend/Util/Strings.go b/Backend/Util/Strings.go index a2d5d0f..879ca23 100644 --- a/Backend/Util/Strings.go +++ b/Backend/Util/Strings.go @@ -2,12 +2,18 @@ package Util import ( "math/rand" + "time" ) var ( letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") ) +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// RandomString generates a random string func RandomString(n int) string { var ( b []rune From 2a368acc44ff4ac878fb31452d599db80b453849 Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Tue, 13 Sep 2022 21:51:58 +0930 Subject: [PATCH 4/6] Update tests --- Backend/Api/Auth/AddProfileImage_test.go | 70 +---- Backend/Api/Auth/ChangeMessageExpiry_test.go | 133 +-------- Backend/Api/Auth/ChangePassword_test.go | 188 +------------ Backend/Api/Auth/Login_test.go | 63 +---- Backend/Api/Auth/Logout_test.go | 90 +------ Backend/Api/Friends/EncryptedFriendsList.go | 4 +- Backend/Api/Messages/Conversations.go | 19 +- Backend/Api/Messages/Conversations_test.go | 255 ++++++++++++++++++ Backend/Api/Messages/CreateConversation.go | 2 +- .../Api/Messages/CreateConversation_test.go | 129 +++++++++ Backend/Api/Messages/CreateMessage.go | 19 +- Backend/Api/Messages/CreateMessage_test.go | 131 +++++++++ Backend/Api/Routes.go | 6 +- Backend/Database/Init.go | 48 ++-- Backend/Database/Seeder/encryption.go | 5 + Backend/Database/UserConversations.go | 10 +- Backend/Models/Conversations.go | 3 + Backend/Models/Messages.go | 3 +- Backend/Tests/Init.go | 87 ++++++ Backend/go.mod | 1 - test.sh | 2 +- 21 files changed, 711 insertions(+), 557 deletions(-) create mode 100644 Backend/Api/Messages/Conversations_test.go create mode 100644 Backend/Api/Messages/CreateConversation_test.go create mode 100644 Backend/Api/Messages/CreateMessage_test.go create mode 100644 Backend/Tests/Init.go diff --git a/Backend/Api/Auth/AddProfileImage_test.go b/Backend/Api/Auth/AddProfileImage_test.go index eb0864f..c0d0dc3 100644 --- a/Backend/Api/Auth/AddProfileImage_test.go +++ b/Backend/Api/Auth/AddProfileImage_test.go @@ -4,84 +4,24 @@ import ( "bytes" "encoding/base64" "encoding/json" - "io/ioutil" - "log" "net/http" - "net/http/cookiejar" - "net/http/httptest" - "net/url" "os" "testing" - "time" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" - "github.com/gorilla/mux" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" ) func Test_AddProfileImage(t *testing.T) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) + client, ts, err := Tests.InitTestEnv() defer ts.Close() - - userKey, _ := Seeder.GenerateAesKey() - pubKey := Seeder.GetPubKey() - - p, _ := Auth.HashPassword("password") - - u := Models.User{ - Username: "test", - Password: p, - AsymmetricPublicKey: Seeder.PublicKey, - AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, - SymmetricKey: base64.StdEncoding.EncodeToString( - Seeder.EncryptWithPublicKey(userKey.Key, pubKey), - ), - } - - err := Database.CreateUser(&u) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - session := Models.Session{ - UserID: u.ID, - Expiry: time.Now().Add(12 * time.Hour), - } - - err = Database.CreateSession(&session) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return } - jar, err := cookiejar.New(nil) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - url, _ := url.Parse(ts.URL) - - jar.SetCookies( - url, - []*http.Cookie{ - { - Name: "session_token", - Value: session.ID.String(), - MaxAge: 300, - }, - }, - ) - key, err := Seeder.GenerateAesKey() if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) @@ -110,10 +50,6 @@ func Test_AddProfileImage(t *testing.T) { req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/image", bytes.NewBuffer(jsonStr)) req.Header.Set("Content-Type", "application/json") - client := &http.Client{ - Jar: jar, - } - resp, err := client.Do(req) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) @@ -125,7 +61,7 @@ func Test_AddProfileImage(t *testing.T) { return } - u, err = Database.GetUserById(u.ID.String()) + u, err := Database.GetUserByUsername("test") if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return diff --git a/Backend/Api/Auth/ChangeMessageExpiry_test.go b/Backend/Api/Auth/ChangeMessageExpiry_test.go index 03012dc..2c48c75 100644 --- a/Backend/Api/Auth/ChangeMessageExpiry_test.go +++ b/Backend/Api/Auth/ChangeMessageExpiry_test.go @@ -2,85 +2,22 @@ package Auth_test import ( "bytes" - "encoding/base64" "encoding/json" - "io/ioutil" - "log" "net/http" - "net/http/cookiejar" - "net/http/httptest" - "net/url" "testing" - "time" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" - "github.com/gorilla/mux" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" ) func Test_ChangeMessageExpiry(t *testing.T) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) + client, ts, err := Tests.InitTestEnv() defer ts.Close() - - userKey, _ := Seeder.GenerateAesKey() - pubKey := Seeder.GetPubKey() - - p, _ := Auth.HashPassword("password") - - u := Models.User{ - Username: "test", - Password: p, - AsymmetricPublicKey: Seeder.PublicKey, - AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, - SymmetricKey: base64.StdEncoding.EncodeToString( - Seeder.EncryptWithPublicKey(userKey.Key, pubKey), - ), - } - - err := Database.CreateUser(&u) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - session := Models.Session{ - UserID: u.ID, - Expiry: time.Now().Add(12 * time.Hour), - } - - err = Database.CreateSession(&session) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return } - jar, err := cookiejar.New(nil) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - url, _ := url.Parse(ts.URL) - - jar.SetCookies( - url, - []*http.Cookie{ - { - Name: "session_token", - Value: session.ID.String(), - MaxAge: 300, - }, - }, - ) - d := struct { MessageExpiry string `json:"message_expiry"` }{ @@ -91,10 +28,6 @@ func Test_ChangeMessageExpiry(t *testing.T) { req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/message_expiry", bytes.NewBuffer(jsonStr)) req.Header.Set("Content-Type", "application/json") - client := &http.Client{ - Jar: jar, - } - resp, err := client.Do(req) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) @@ -105,7 +38,7 @@ func Test_ChangeMessageExpiry(t *testing.T) { t.Errorf("Expected %d, recieved %d", http.StatusNoContent, resp.StatusCode) } - u, err = Database.GetUserById(u.ID.String()) + u, err := Database.GetUserByUsername("test") if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return @@ -117,65 +50,13 @@ func Test_ChangeMessageExpiry(t *testing.T) { } func Test_ChangeMessageExpiryInvalidData(t *testing.T) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) + client, ts, err := Tests.InitTestEnv() defer ts.Close() - - userKey, _ := Seeder.GenerateAesKey() - pubKey := Seeder.GetPubKey() - - p, _ := Auth.HashPassword("password") - - u := Models.User{ - Username: "test", - Password: p, - AsymmetricPublicKey: Seeder.PublicKey, - AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, - SymmetricKey: base64.StdEncoding.EncodeToString( - Seeder.EncryptWithPublicKey(userKey.Key, pubKey), - ), - } - - err := Database.CreateUser(&u) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - session := Models.Session{ - UserID: u.ID, - Expiry: time.Now().Add(12 * time.Hour), - } - - err = Database.CreateSession(&session) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return } - jar, err := cookiejar.New(nil) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - url, _ := url.Parse(ts.URL) - - jar.SetCookies( - url, - []*http.Cookie{ - { - Name: "session_token", - Value: session.ID.String(), - MaxAge: 300, - }, - }, - ) - d := struct { MessageExpiry string `json:"message_expiry"` }{ @@ -186,10 +67,6 @@ func Test_ChangeMessageExpiryInvalidData(t *testing.T) { req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/message_expiry", bytes.NewBuffer(jsonStr)) req.Header.Set("Content-Type", "application/json") - client := &http.Client{ - Jar: jar, - } - resp, err := client.Do(req) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) @@ -200,7 +77,7 @@ func Test_ChangeMessageExpiryInvalidData(t *testing.T) { t.Errorf("Expected %d, recieved %d", http.StatusUnprocessableEntity, resp.StatusCode) } - u, err = Database.GetUserById(u.ID.String()) + u, err := Database.GetUserByUsername("test") if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return diff --git a/Backend/Api/Auth/ChangePassword_test.go b/Backend/Api/Auth/ChangePassword_test.go index 53d9491..29c1e42 100644 --- a/Backend/Api/Auth/ChangePassword_test.go +++ b/Backend/Api/Auth/ChangePassword_test.go @@ -2,85 +2,23 @@ package Auth_test import ( "bytes" - "encoding/base64" "encoding/json" - "io/ioutil" - "log" "net/http" - "net/http/cookiejar" - "net/http/httptest" - "net/url" "testing" - "time" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" - "github.com/gorilla/mux" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" ) func Test_ChangePassword(t *testing.T) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) + client, ts, err := Tests.InitTestEnv() defer ts.Close() - - userKey, _ := Seeder.GenerateAesKey() - pubKey := Seeder.GetPubKey() - - p, _ := Auth.HashPassword("password") - - u := Models.User{ - Username: "test", - Password: p, - AsymmetricPublicKey: Seeder.PublicKey, - AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, - SymmetricKey: base64.StdEncoding.EncodeToString( - Seeder.EncryptWithPublicKey(userKey.Key, pubKey), - ), - } - - err := Database.CreateUser(&u) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return } - session := Models.Session{ - UserID: u.ID, - Expiry: time.Now().Add(12 * time.Hour), - } - - err = Database.CreateSession(&session) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - jar, err := cookiejar.New(nil) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - url, _ := url.Parse(ts.URL) - - jar.SetCookies( - url, - []*http.Cookie{ - { - Name: "session_token", - Value: session.ID.String(), - MaxAge: 300, - }, - }, - ) - d := struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` @@ -97,10 +35,6 @@ func Test_ChangePassword(t *testing.T) { req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/change_password", bytes.NewBuffer(jsonStr)) req.Header.Set("Content-Type", "application/json") - client := &http.Client{ - Jar: jar, - } - resp, err := client.Do(req) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) @@ -112,7 +46,7 @@ func Test_ChangePassword(t *testing.T) { return } - u, err = Database.GetUserById(u.ID.String()) + u, err := Database.GetUserByUsername("test") if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return @@ -124,65 +58,13 @@ func Test_ChangePassword(t *testing.T) { } func Test_ChangePasswordMismatchConfirmFails(t *testing.T) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) + client, ts, err := Tests.InitTestEnv() defer ts.Close() - - userKey, _ := Seeder.GenerateAesKey() - pubKey := Seeder.GetPubKey() - - p, _ := Auth.HashPassword("password") - - u := Models.User{ - Username: "test", - Password: p, - AsymmetricPublicKey: Seeder.PublicKey, - AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, - SymmetricKey: base64.StdEncoding.EncodeToString( - Seeder.EncryptWithPublicKey(userKey.Key, pubKey), - ), - } - - err := Database.CreateUser(&u) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - session := Models.Session{ - UserID: u.ID, - Expiry: time.Now().Add(12 * time.Hour), - } - - err = Database.CreateSession(&session) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return } - jar, err := cookiejar.New(nil) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - url, _ := url.Parse(ts.URL) - - jar.SetCookies( - url, - []*http.Cookie{ - { - Name: "session_token", - Value: session.ID.String(), - MaxAge: 300, - }, - }, - ) - d := struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` @@ -199,10 +81,6 @@ func Test_ChangePasswordMismatchConfirmFails(t *testing.T) { req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/change_password", bytes.NewBuffer(jsonStr)) req.Header.Set("Content-Type", "application/json") - client := &http.Client{ - Jar: jar, - } - resp, err := client.Do(req) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) @@ -215,65 +93,13 @@ func Test_ChangePasswordMismatchConfirmFails(t *testing.T) { } func Test_ChangePasswordInvalidCurrentPasswordFails(t *testing.T) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) + client, ts, err := Tests.InitTestEnv() defer ts.Close() - - userKey, _ := Seeder.GenerateAesKey() - pubKey := Seeder.GetPubKey() - - p, _ := Auth.HashPassword("password") - - u := Models.User{ - Username: "test", - Password: p, - AsymmetricPublicKey: Seeder.PublicKey, - AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, - SymmetricKey: base64.StdEncoding.EncodeToString( - Seeder.EncryptWithPublicKey(userKey.Key, pubKey), - ), - } - - err := Database.CreateUser(&u) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return } - session := Models.Session{ - UserID: u.ID, - Expiry: time.Now().Add(12 * time.Hour), - } - - err = Database.CreateSession(&session) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - jar, err := cookiejar.New(nil) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - return - } - - url, _ := url.Parse(ts.URL) - - jar.SetCookies( - url, - []*http.Cookie{ - { - Name: "session_token", - Value: session.ID.String(), - MaxAge: 300, - }, - }, - ) - d := struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` @@ -290,10 +116,6 @@ func Test_ChangePasswordInvalidCurrentPasswordFails(t *testing.T) { req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/change_password", bytes.NewBuffer(jsonStr)) req.Header.Set("Content-Type", "application/json") - client := &http.Client{ - Jar: jar, - } - resp, err := client.Do(req) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) diff --git a/Backend/Api/Auth/Login_test.go b/Backend/Api/Auth/Login_test.go index 11ea4af..7eef436 100644 --- a/Backend/Api/Auth/Login_test.go +++ b/Backend/Api/Auth/Login_test.go @@ -2,47 +2,18 @@ package Auth_test import ( "bytes" - "encoding/base64" "encoding/json" - "io/ioutil" - "log" "net/http" - "net/http/httptest" "testing" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" - "github.com/gorilla/mux" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" ) func Test_Login(t *testing.T) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) + _, ts, err := Tests.InitTestEnv() defer ts.Close() - - userKey, _ := Seeder.GenerateAesKey() - pubKey := Seeder.GetPubKey() - - p, _ := Auth.HashPassword("password") - - u := Models.User{ - Username: "test", - Password: p, - AsymmetricPublicKey: Seeder.PublicKey, - AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, - SymmetricKey: base64.StdEncoding.EncodeToString( - Seeder.EncryptWithPublicKey(userKey.Key, pubKey), - ), - } - - err := Database.CreateUser(&u) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return @@ -73,6 +44,12 @@ func Test_Login(t *testing.T) { return } + u, err := Database.GetUserByUsername("test") + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + var session Models.Session err = Database.DB.First(&session, "user_id = ?", u.ID.String()).Error @@ -84,30 +61,8 @@ func Test_Login(t *testing.T) { } func Test_Login_PasswordFails(t *testing.T) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) + _, ts, err := Tests.InitTestEnv() defer ts.Close() - - userKey, _ := Seeder.GenerateAesKey() - pubKey := Seeder.GetPubKey() - - p, _ := Auth.HashPassword("password") - - u := Models.User{ - Username: "test", - Password: p, - AsymmetricPublicKey: Seeder.PublicKey, - AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, - SymmetricKey: base64.StdEncoding.EncodeToString( - Seeder.EncryptWithPublicKey(userKey.Key, pubKey), - ), - } - - err := Database.CreateUser(&u) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return diff --git a/Backend/Api/Auth/Logout_test.go b/Backend/Api/Auth/Logout_test.go index 85c2516..2903bb3 100644 --- a/Backend/Api/Auth/Logout_test.go +++ b/Backend/Api/Auth/Logout_test.go @@ -1,72 +1,26 @@ package Auth_test import ( - "bytes" - "encoding/base64" - "encoding/json" - "io/ioutil" - "log" "net/http" - "net/http/cookiejar" - "net/http/httptest" - "net/url" "testing" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" - "github.com/gorilla/mux" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" ) func Test_Logout(t *testing.T) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) + client, ts, err := Tests.InitTestEnv() defer ts.Close() - - userKey, _ := Seeder.GenerateAesKey() - pubKey := Seeder.GetPubKey() - - p, _ := Auth.HashPassword("password") - - u := Models.User{ - Username: "test", - Password: p, - AsymmetricPublicKey: Seeder.PublicKey, - AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, - SymmetricKey: base64.StdEncoding.EncodeToString( - Seeder.EncryptWithPublicKey(userKey.Key, pubKey), - ), - } - - err := Database.CreateUser(&u) if err != nil { t.Errorf("Expected nil, recieved %s", err.Error()) return } - d := struct { - Username string `json:"username"` - Password string `json:"password"` - }{ - Username: "test", - Password: "password", - } - - jsonStr, _ := json.Marshal(d) - req, _ := http.NewRequest("POST", ts.URL+"/api/v1/login", bytes.NewBuffer(jsonStr)) - req.Header.Set("Content-Type", "application/json") - - client := &http.Client{} - resp, err := client.Do(req) + resp, err := client.Get(ts.URL + "/api/v1/logout") if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) + t.Errorf("Expected user record, recieved %s", err.Error()) return } @@ -77,46 +31,12 @@ func Test_Logout(t *testing.T) { var session Models.Session - err = Database.DB.First(&session, "user_id = ?", u.ID.String()).Error - - if err != nil { - t.Errorf("Expected session record, recieved %s", err.Error()) - return - } - - jar, err := cookiejar.New(nil) - if err != nil { - t.Errorf("Expected nil, recieved %s", err.Error()) - } - - url, _ := url.Parse(ts.URL) - - jar.SetCookies( - url, - []*http.Cookie{ - &http.Cookie{ - Name: "session_token", - Value: session.ID.String(), - MaxAge: 300, - }, - }, - ) - - client = &http.Client{ - Jar: jar, - } - resp, err = client.Get(ts.URL + "/api/v1/logout") - + u, err := Database.GetUserByUsername("test") if err != nil { t.Errorf("Expected user record, recieved %s", err.Error()) return } - if resp.StatusCode != http.StatusOK { - t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) - return - } - err = Database.DB.First(&session, "user_id = ?", u.ID.String()).Error if err == nil { t.Errorf("Expected no session record, recieved %s", session.UserID) diff --git a/Backend/Api/Friends/EncryptedFriendsList.go b/Backend/Api/Friends/EncryptedFriendsList.go index c2ea274..79d6113 100644 --- a/Backend/Api/Friends/EncryptedFriendsList.go +++ b/Backend/Api/Friends/EncryptedFriendsList.go @@ -9,8 +9,8 @@ import ( "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) -// EncryptedFriendRequestList gets friend request list -func EncryptedFriendRequestList(w http.ResponseWriter, r *http.Request) { +// FriendRequestList gets friend request list +func FriendRequestList(w http.ResponseWriter, r *http.Request) { var ( userSession Models.Session friends []Models.FriendRequest diff --git a/Backend/Api/Messages/Conversations.go b/Backend/Api/Messages/Conversations.go index 4678108..e4c6e81 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/url" + "strconv" "strings" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" @@ -11,15 +12,24 @@ import ( "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) -// EncryptedConversationList returns an encrypted list of all Conversations -func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { +// ConversationList returns an encrypted list of all Conversations +func ConversationList(w http.ResponseWriter, r *http.Request) { var ( conversationDetails []Models.UserConversation userSession Models.Session returnJSON []byte + values url.Values + page int err error ) + values = r.URL.Query() + + page, err = strconv.Atoi(values.Get("page")) + if err != nil { + page = 0 + } + userSession, err = Auth.CheckCookie(r) if err != nil { http.Error(w, "Forbidden", http.StatusUnauthorized) @@ -28,6 +38,7 @@ func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { conversationDetails, err = Database.GetUserConversationsByUserId( userSession.UserID.String(), + page, ) if err != nil { http.Error(w, "Error", http.StatusInternalServerError) @@ -44,8 +55,8 @@ func EncryptedConversationList(w http.ResponseWriter, r *http.Request) { w.Write(returnJSON) } -// EncryptedConversationDetailsList returns an encrypted list of all ConversationDetails -func EncryptedConversationDetailsList(w http.ResponseWriter, r *http.Request) { +// ConversationDetailsList returns an encrypted list of all ConversationDetails +func ConversationDetailsList(w http.ResponseWriter, r *http.Request) { var ( conversationDetails []Models.ConversationDetail detail Models.ConversationDetail diff --git a/Backend/Api/Messages/Conversations_test.go b/Backend/Api/Messages/Conversations_test.go new file mode 100644 index 0000000..21163ce --- /dev/null +++ b/Backend/Api/Messages/Conversations_test.go @@ -0,0 +1,255 @@ +package Messages_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "testing" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" +) + +func Test_ConversationsList(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + u, err := Database.GetUserByUsername("test") + + key, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + nameCiphertext, err := key.AesEncrypt([]byte("Test conversation")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + twoUserCiphertext, err := key.AesEncrypt([]byte("false")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + messageThread := Models.ConversationDetail{ + Name: base64.StdEncoding.EncodeToString(nameCiphertext), + TwoUser: base64.StdEncoding.EncodeToString(twoUserCiphertext), + } + + err = Database.CreateConversationDetail(&messageThread) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + conversationDetailIDCiphertext, err := key.AesEncrypt([]byte(messageThread.ID.String())) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + adminCiphertext, err := key.AesEncrypt([]byte("true")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + pubKey := Seeder.GetPubKey() + + messageThreadUser := Models.UserConversation{ + UserID: u.ID, + ConversationDetailID: base64.StdEncoding.EncodeToString(conversationDetailIDCiphertext), + Admin: base64.StdEncoding.EncodeToString(adminCiphertext), + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(key.Key, pubKey), + ), + } + + err = Database.CreateUserConversation(&messageThreadUser) + + req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/conversations", nil) + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + requestBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + var conversations []Models.UserConversation + + json.Unmarshal(requestBody, &conversations) + + if len(conversations) != 1 { + t.Errorf("Expected %d, recieved %d", 1, len(conversations)) + } + + conv := conversations[0] + + decodedId, err := base64.StdEncoding.DecodeString(conv.ConversationDetailID) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + decrypedId, err := key.AesDecrypt(decodedId) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + req, _ = http.NewRequest( + "GET", + ts.URL+"/api/v1/auth/conversation_details?conversation_detail_ids="+string(decrypedId), + nil, + ) + + resp, err = client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + var conversationDetails []Models.ConversationDetail + + requestBody, err = ioutil.ReadAll(resp.Body) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + json.Unmarshal(requestBody, &conversationDetails) + + if len(conversationDetails) != 1 { + t.Errorf("Expected %d, recieved %d", 1, len(conversations)) + } + + decodedName, err := base64.StdEncoding.DecodeString(conversationDetails[0].Name) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + decrypedName, err := key.AesDecrypt(decodedName) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + if string(decrypedName) != "Test conversation" { + t.Errorf("Expected %s, recieved %s", "Test converation", string(decrypedName)) + } +} + +func Test_ConversationsListPagination(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + u, err := Database.GetUserByUsername("test") + + key, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + for i := 0; i < 40; i++ { + nameCiphertext, err := key.AesEncrypt([]byte( + fmt.Sprintf("Test conversation %d", i), + )) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + twoUserCiphertext, err := key.AesEncrypt([]byte("false")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + messageThread := Models.ConversationDetail{ + Name: base64.StdEncoding.EncodeToString(nameCiphertext), + TwoUser: base64.StdEncoding.EncodeToString(twoUserCiphertext), + } + + err = Database.CreateConversationDetail(&messageThread) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + conversationDetailIDCiphertext, err := key.AesEncrypt([]byte(messageThread.ID.String())) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + adminCiphertext, err := key.AesEncrypt([]byte("true")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + pubKey := Seeder.GetPubKey() + + messageThreadUser := Models.UserConversation{ + UserID: u.ID, + ConversationDetailID: base64.StdEncoding.EncodeToString(conversationDetailIDCiphertext), + Admin: base64.StdEncoding.EncodeToString(adminCiphertext), + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(key.Key, pubKey), + ), + } + + err = Database.CreateUserConversation(&messageThreadUser) + } + + req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/conversations?page=0", nil) + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + requestBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + var conversations []Models.UserConversation + + json.Unmarshal(requestBody, &conversations) + + if len(conversations) != 20 { + t.Errorf("Expected %d, recieved %d", 1, len(conversations)) + } +} diff --git a/Backend/Api/Messages/CreateConversation.go b/Backend/Api/Messages/CreateConversation.go index 728ecb0..12d54e1 100644 --- a/Backend/Api/Messages/CreateConversation.go +++ b/Backend/Api/Messages/CreateConversation.go @@ -54,5 +54,5 @@ func CreateConversation(w http.ResponseWriter, r *http.Request) { return } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } diff --git a/Backend/Api/Messages/CreateConversation_test.go b/Backend/Api/Messages/CreateConversation_test.go new file mode 100644 index 0000000..7c0ee80 --- /dev/null +++ b/Backend/Api/Messages/CreateConversation_test.go @@ -0,0 +1,129 @@ +package Messages_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" + "github.com/gofrs/uuid" +) + +func Test_CreateConversation(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + u, err := Database.GetUserByUsername("test") + + key, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + nameCiphertext, err := key.AesEncrypt([]byte("Test conversation")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + twoUserCiphertext, err := key.AesEncrypt([]byte("false")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + id, err := uuid.NewV4() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + conversationDetailIDCiphertext, err := key.AesEncrypt([]byte(id.String())) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + adminCiphertext, err := key.AesEncrypt([]byte("true")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + userIDCiphertext, err := key.AesEncrypt([]byte(u.ID.String())) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + usernameCiphertext, err := key.AesEncrypt([]byte(u.Username)) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + pubKey := Seeder.GetPubKey() + + d := struct { + ID string `json:"id"` + Name string `json:"name"` + TwoUser string `json:"two_user"` + Users []Models.ConversationDetailUser `json:"users"` + UserConversations []Models.UserConversation `json:"user_conversations"` + }{ + ID: id.String(), + Name: base64.StdEncoding.EncodeToString(nameCiphertext), + TwoUser: base64.StdEncoding.EncodeToString(twoUserCiphertext), + Users: []Models.ConversationDetailUser{ + { + ConversationDetailID: id, + UserID: base64.StdEncoding.EncodeToString(userIDCiphertext), + Username: base64.StdEncoding.EncodeToString(usernameCiphertext), + AssociationKey: "", + PublicKey: "", + Admin: base64.StdEncoding.EncodeToString(adminCiphertext), + }, + }, + UserConversations: []Models.UserConversation{ + { + UserID: u.ID, + ConversationDetailID: base64.StdEncoding.EncodeToString(conversationDetailIDCiphertext), + Admin: base64.StdEncoding.EncodeToString(adminCiphertext), + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(key.Key, pubKey), + ), + }, + }, + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/conversations", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Expected %d, recieved %d", http.StatusNoContent, resp.StatusCode) + } + + var c Models.ConversationDetail + err = Database.DB.First(&c, "id = ?", id.String()).Error + + if err != nil { + t.Errorf("Expected conversation detail record, received %s", err.Error()) + return + } +} diff --git a/Backend/Api/Messages/CreateMessage.go b/Backend/Api/Messages/CreateMessage.go index becc0c2..04cb15e 100644 --- a/Backend/Api/Messages/CreateMessage.go +++ b/Backend/Api/Messages/CreateMessage.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "net/http" + "time" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" @@ -20,8 +21,11 @@ func CreateMessage(w http.ResponseWriter, r *http.Request) { var ( messagesData []rawMessageData messageData rawMessageData + message Models.Message + t time.Time decodedFile []byte fileName string + i int err error ) @@ -38,6 +42,19 @@ func CreateMessage(w http.ResponseWriter, r *http.Request) { messageData.MessageData.Attachment.FilePath = fileName } + for i, message = range messageData.Messages { + t, err = time.Parse("2006-01-02T15:04:05Z", message.ExpiryRaw) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + err = messageData.Messages[i].Expiry.Scan(t) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + } + err = Database.CreateMessageData(&messageData.MessageData) if err != nil { http.Error(w, "Error", http.StatusInternalServerError) @@ -51,5 +68,5 @@ func CreateMessage(w http.ResponseWriter, r *http.Request) { } } - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusNoContent) } diff --git a/Backend/Api/Messages/CreateMessage_test.go b/Backend/Api/Messages/CreateMessage_test.go new file mode 100644 index 0000000..35cfc51 --- /dev/null +++ b/Backend/Api/Messages/CreateMessage_test.go @@ -0,0 +1,131 @@ +package Messages_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + "time" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" + "github.com/gofrs/uuid" +) + +func Test_CreateMessage(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + u, err := Database.GetUserByUsername("test") + + key, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + dataCiphertext, err := key.AesEncrypt([]byte("Test message...")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + senderIDCiphertext, err := key.AesEncrypt([]byte(u.ID.String())) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + id, err := uuid.NewV4() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + id2, err := uuid.NewV4() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + d := []struct { + MessageData struct { + ID uuid.UUID `json:"id"` + Data string `json:"data"` + SenderID string `json:"sender_id"` + SymmetricKey string `json:"symmetric_key"` + } `json:"message_data"` + Messages []struct { + ID uuid.UUID `json:"id"` + MessageDataID uuid.UUID `json:"message_data_id"` + SymmetricKey string `json:"symmetric_key"` + AssociationKey string `json:"association_key"` + Expiry time.Time `json:"expiry"` + } `json:"message"` + }{ + { + MessageData: struct { + ID uuid.UUID `json:"id"` + Data string `json:"data"` + SenderID string `json:"sender_id"` + SymmetricKey string `json:"symmetric_key"` + }{ + ID: id, + Data: base64.StdEncoding.EncodeToString(dataCiphertext), + SenderID: base64.StdEncoding.EncodeToString(senderIDCiphertext), + SymmetricKey: "", + }, + Messages: []struct { + ID uuid.UUID `json:"id"` + MessageDataID uuid.UUID `json:"message_data_id"` + SymmetricKey string `json:"symmetric_key"` + AssociationKey string `json:"association_key"` + Expiry time.Time `json:"expiry"` + }{ + { + ID: id2, + MessageDataID: id, + SymmetricKey: "", + AssociationKey: "", + Expiry: time.Now(), + }, + }, + }, + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("POST", ts.URL+"/api/v1/auth/message", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Expected %d, recieved %d", http.StatusNoContent, resp.StatusCode) + return + } + + var m Models.Message + err = Database.DB.First(&m).Error + if err != nil { + t.Errorf("Expected conversation detail record, received %s", err.Error()) + return + } + + var md Models.MessageData + err = Database.DB.First(&md).Error + if err != nil { + t.Errorf("Expected conversation detail record, received %s", err.Error()) + return + } + +} diff --git a/Backend/Api/Routes.go b/Backend/Api/Routes.go index 4058644..73d98f4 100644 --- a/Backend/Api/Routes.go +++ b/Backend/Api/Routes.go @@ -68,14 +68,14 @@ func InitAPIEndpoints(router *mux.Router) { authAPI.HandleFunc("/users", Users.SearchUsers).Methods("GET") - authAPI.HandleFunc("/friend_requests", Friends.EncryptedFriendRequestList).Methods("GET") + authAPI.HandleFunc("/friend_requests", Friends.FriendRequestList).Methods("GET") authAPI.HandleFunc("/friend_request", Friends.CreateFriendRequest).Methods("POST") authAPI.HandleFunc("/friend_request/qr_code", Friends.CreateFriendRequestQrCode).Methods("POST") authAPI.HandleFunc("/friend_request/{requestID}", Friends.AcceptFriendRequest).Methods("POST") authAPI.HandleFunc("/friend_request/{requestID}", Friends.RejectFriendRequest).Methods("DELETE") - authAPI.HandleFunc("/conversations", Messages.EncryptedConversationList).Methods("GET") - authAPI.HandleFunc("/conversation_details", Messages.EncryptedConversationDetailsList).Methods("GET") + authAPI.HandleFunc("/conversations", Messages.ConversationList).Methods("GET") + authAPI.HandleFunc("/conversation_details", Messages.ConversationDetailsList).Methods("GET") authAPI.HandleFunc("/conversations", Messages.CreateConversation).Methods("POST") authAPI.HandleFunc("/conversations", Messages.UpdateConversation).Methods("PUT") authAPI.HandleFunc("/conversations/{detailID}/image", Messages.AddConversationImage).Methods("POST") diff --git a/Backend/Database/Init.go b/Backend/Database/Init.go index 0d1a729..982a7d4 100644 --- a/Backend/Database/Init.go +++ b/Backend/Database/Init.go @@ -12,30 +12,29 @@ import ( const ( dbURL = "postgres://postgres:password@postgres:5432/capsule" dbTestURL = "postgres://postgres:password@postgres-testing:5432/capsule-testing" + + PageSize = 20 ) // DB db var DB *gorm.DB -func getModels() []interface{} { - return []interface{}{ - &Models.Session{}, - &Models.Attachment{}, - &Models.User{}, - &Models.FriendRequest{}, - &Models.MessageData{}, - &Models.Message{}, - &Models.ConversationDetail{}, - &Models.ConversationDetailUser{}, - &Models.UserConversation{}, - } +var models = []interface{}{ + &Models.Session{}, + &Models.Attachment{}, + &Models.User{}, + &Models.FriendRequest{}, + &Models.MessageData{}, + &Models.Message{}, + &Models.ConversationDetail{}, + &Models.ConversationDetailUser{}, + &Models.UserConversation{}, } // Init initializes the database connection func Init() { var ( - model interface{} - err error + err error ) log.Println("Initializing database...") @@ -48,19 +47,16 @@ func Init() { log.Println("Running AutoMigrate...") - for _, model = range getModels() { - err = DB.AutoMigrate(model) - if err != nil { - log.Fatalln(err) - } + err = DB.AutoMigrate(models...) + if err != nil { + log.Fatalln(err) } } // InitTest initializes the test datbase func InitTest() { var ( - model interface{} - err error + err error ) DB, err = gorm.Open(postgres.Open(dbTestURL), &gorm.Config{}) @@ -69,8 +65,12 @@ func InitTest() { log.Fatalln(err) } - for _, model = range getModels() { - DB.Migrator().DropTable(model) - DB.AutoMigrate(model) + err = DB.Migrator().DropTable(models...) + if err != nil { + panic(err) + } + err = DB.AutoMigrate(models...) + if err != nil { + panic(err) } } diff --git a/Backend/Database/Seeder/encryption.go b/Backend/Database/Seeder/encryption.go index e0f5c74..101e5a4 100644 --- a/Backend/Database/Seeder/encryption.go +++ b/Backend/Database/Seeder/encryption.go @@ -153,6 +153,11 @@ func (key aesKey) AesDecrypt(ciphertext []byte) ([]byte, error) { decMode := cipher.NewCBCDecrypter(block, iv) decMode.CryptBlocks(plaintext, plaintext) + plaintext, err = pkcs7strip(plaintext, 16) + if err != nil { + return []byte{}, err + } + return plaintext, nil } diff --git a/Backend/Database/UserConversations.go b/Backend/Database/UserConversations.go index 2e77ce7..cc876c7 100644 --- a/Backend/Database/UserConversations.go +++ b/Backend/Database/UserConversations.go @@ -19,13 +19,19 @@ func GetUserConversationById(id string) (Models.UserConversation, error) { return message, err } -func GetUserConversationsByUserId(id string) ([]Models.UserConversation, error) { +func GetUserConversationsByUserId(id string, page int) ([]Models.UserConversation, error) { var ( conversations []Models.UserConversation + offset int err error ) - err = DB.Find(&conversations, "user_id = ?", id). + offset = page * PageSize + + err = DB.Offset(offset). + Limit(PageSize). + Order("created_at DESC"). + Find(&conversations, "user_id = ?", id). Error return conversations, err diff --git a/Backend/Models/Conversations.go b/Backend/Models/Conversations.go index 1c9e53a..6df37ec 100644 --- a/Backend/Models/Conversations.go +++ b/Backend/Models/Conversations.go @@ -1,6 +1,8 @@ package Models import ( + "time" + "github.com/gofrs/uuid" ) @@ -34,4 +36,5 @@ type UserConversation struct { ConversationDetailID string `gorm:"not null" json:"conversation_detail_id"` // Stored encrypted Admin string `gorm:"not null" json:"admin"` // Bool if user is admin of thread, stored encrypted SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted + CreatedAt time.Time `gorm:"not null" json:"created_at"` } diff --git a/Backend/Models/Messages.go b/Backend/Models/Messages.go index bf05e3b..eafac22 100644 --- a/Backend/Models/Messages.go +++ b/Backend/Models/Messages.go @@ -25,6 +25,7 @@ type Message struct { MessageData MessageData ` json:"message_data"` SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted AssociationKey string `gorm:"not null" json:"association_key"` // Stored encrypted - Expiry sql.NullTime ` json:"expiry"` + ExpiryRaw string ` json:"expiry"` + Expiry sql.NullTime ` json:"-"` CreatedAt time.Time `gorm:"not null" json:"created_at"` } diff --git a/Backend/Tests/Init.go b/Backend/Tests/Init.go new file mode 100644 index 0000000..fdb4b48 --- /dev/null +++ b/Backend/Tests/Init.go @@ -0,0 +1,87 @@ +package Tests + +import ( + "encoding/base64" + "io/ioutil" + "log" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "net/url" + "time" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + + "github.com/gorilla/mux" +) + +// InitTestEnv initializes the test environment +// client is used for making authenticated requests +// ts is the testing server +// err, in case it fails ¯\_(ツ)_/¯ +func InitTestEnv() (*http.Client, *httptest.Server, error) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + + userKey, err := Seeder.GenerateAesKey() + if err != nil { + return http.DefaultClient, ts, err + } + pubKey := Seeder.GetPubKey() + + p, _ := Auth.HashPassword("password") + + u := Models.User{ + Username: "test", + Password: p, + AsymmetricPublicKey: Seeder.PublicKey, + AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + } + + err = Database.CreateUser(&u) + if err != nil { + return http.DefaultClient, ts, err + } + + session := Models.Session{ + UserID: u.ID, + Expiry: time.Now().Add(12 * time.Hour), + } + + err = Database.CreateSession(&session) + if err != nil { + return http.DefaultClient, ts, err + } + + jar, err := cookiejar.New(nil) + + url, _ := url.Parse(ts.URL) + + jar.SetCookies( + url, + []*http.Cookie{ + { + Name: "session_token", + Value: session.ID.String(), + MaxAge: 300, + }, + }, + ) + + client := &http.Client{ + Jar: jar, + } + + return client, ts, err +} diff --git a/Backend/go.mod b/Backend/go.mod index bebe75f..9db656c 100644 --- a/Backend/go.mod +++ b/Backend/go.mod @@ -3,7 +3,6 @@ module git.tovijaeschke.xyz/tovi/Capsule/Backend go 1.18 require ( - github.com/Kangaroux/go-map-schema v0.6.1 github.com/gofrs/uuid v4.2.0+incompatible github.com/gorilla/mux v1.8.0 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 diff --git a/test.sh b/test.sh index 66de839..dc285c9 100644 --- a/test.sh +++ b/test.sh @@ -1,3 +1,3 @@ #!/bin/sh -docker-compose exec server sh -c "cd /app && go test -v ./..." +docker-compose exec server sh -c "cd /app && go test -p 1 -v ./..." From 43fcd3b9e84befe3667324e5f4c2d813a7c2ad3a Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Thu, 22 Sep 2022 19:27:40 +0930 Subject: [PATCH 5/6] Add tests for CreateConversation and UpdateConversation --- Backend/Api/Auth/Logout_test.go | 1 - .../Api/Messages/CreateConversation_test.go | 1 - Backend/Api/Messages/CreateMessage_test.go | 2 + Backend/Api/Messages/MessageThread_test.go | 117 +++++++++++ .../Api/Messages/UpdateConversation_test.go | 183 ++++++++++++++++++ Backend/Database/Seeder/FriendSeeder.go | 2 +- Backend/Database/Seeder/MessageSeeder.go | 10 +- Backend/Database/Seeder/UserSeeder.go | 2 +- Backend/Database/Seeder/encryption.go | 18 +- 9 files changed, 318 insertions(+), 18 deletions(-) create mode 100644 Backend/Api/Messages/MessageThread_test.go create mode 100644 Backend/Api/Messages/UpdateConversation_test.go diff --git a/Backend/Api/Auth/Logout_test.go b/Backend/Api/Auth/Logout_test.go index 2903bb3..ca43f0c 100644 --- a/Backend/Api/Auth/Logout_test.go +++ b/Backend/Api/Auth/Logout_test.go @@ -18,7 +18,6 @@ func Test_Logout(t *testing.T) { } resp, err := client.Get(ts.URL + "/api/v1/logout") - if err != nil { t.Errorf("Expected user record, recieved %s", err.Error()) return diff --git a/Backend/Api/Messages/CreateConversation_test.go b/Backend/Api/Messages/CreateConversation_test.go index 7c0ee80..5659dff 100644 --- a/Backend/Api/Messages/CreateConversation_test.go +++ b/Backend/Api/Messages/CreateConversation_test.go @@ -121,7 +121,6 @@ func Test_CreateConversation(t *testing.T) { var c Models.ConversationDetail err = Database.DB.First(&c, "id = ?", id.String()).Error - if err != nil { t.Errorf("Expected conversation detail record, received %s", err.Error()) return diff --git a/Backend/Api/Messages/CreateMessage_test.go b/Backend/Api/Messages/CreateMessage_test.go index 35cfc51..81666c5 100644 --- a/Backend/Api/Messages/CreateMessage_test.go +++ b/Backend/Api/Messages/CreateMessage_test.go @@ -15,6 +15,8 @@ import ( "github.com/gofrs/uuid" ) +// TODO: Write test for message expiry + func Test_CreateMessage(t *testing.T) { client, ts, err := Tests.InitTestEnv() if err != nil { diff --git a/Backend/Api/Messages/MessageThread_test.go b/Backend/Api/Messages/MessageThread_test.go new file mode 100644 index 0000000..3d3dada --- /dev/null +++ b/Backend/Api/Messages/MessageThread_test.go @@ -0,0 +1,117 @@ +package Messages_test + +import ( + "encoding/base64" + "encoding/json" + "io/ioutil" + "net/http" + "testing" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" +) + +func Test_Messages(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + u, err := Database.GetUserByUsername("test") + + userKey, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + key, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + dataCiphertext, err := key.AesEncrypt([]byte("Test message")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + senderIDCiphertext, err := key.AesEncrypt([]byte(u.ID.String())) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + keyCiphertext, err := userKey.AesEncrypt( + []byte(base64.StdEncoding.EncodeToString(key.Key)), + ) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + pubKey := Seeder.GetPubKey() + + message := Models.Message{ + MessageData: Models.MessageData{ + Data: base64.StdEncoding.EncodeToString(dataCiphertext), + SenderID: base64.StdEncoding.EncodeToString(senderIDCiphertext), + SymmetricKey: base64.StdEncoding.EncodeToString(keyCiphertext), + }, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + AssociationKey: "AssociationKey", + } + + err = Database.CreateMessage(&message) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + resp, err := client.Get(ts.URL + "/api/v1/auth/messages/AssociationKey") + if err != nil { + t.Errorf("Expected user record, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + requestBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + var m []Models.Message + err = json.Unmarshal(requestBody, &m) + + if len(m) != 1 { + t.Errorf("Expected %d, recieved %d", 1, len(m)) + } + + msg := m[0] + + decodedData, err := base64.StdEncoding.DecodeString(msg.MessageData.Data) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + decrypedData, err := key.AesDecrypt(decodedData) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + if string(decrypedData) != "Test message" { + t.Errorf("Expected %s, recieved %s", "Test converation", string(decrypedData)) + } +} diff --git a/Backend/Api/Messages/UpdateConversation_test.go b/Backend/Api/Messages/UpdateConversation_test.go new file mode 100644 index 0000000..cdc3684 --- /dev/null +++ b/Backend/Api/Messages/UpdateConversation_test.go @@ -0,0 +1,183 @@ +package Messages_test + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" +) + +func createConversation(key Seeder.AesKey) (Models.ConversationDetail, Models.UserConversation, Models.ConversationDetailUser, error) { + var ( + cd Models.ConversationDetail + uc Models.UserConversation + cdu Models.ConversationDetailUser + ) + + u, err := Database.GetUserByUsername("test") + + nameCiphertext, err := key.AesEncrypt([]byte("Test conversation")) + if err != nil { + return cd, uc, cdu, err + } + + twoUserCiphertext, err := key.AesEncrypt([]byte("false")) + if err != nil { + return cd, uc, cdu, err + } + + cd = Models.ConversationDetail{ + Name: base64.StdEncoding.EncodeToString(nameCiphertext), + TwoUser: base64.StdEncoding.EncodeToString(twoUserCiphertext), + } + + err = Database.CreateConversationDetail(&cd) + if err != nil { + return cd, uc, cdu, err + } + + conversationDetailIDCiphertext, err := key.AesEncrypt([]byte(cd.ID.String())) + if err != nil { + return cd, uc, cdu, err + } + + adminCiphertext, err := key.AesEncrypt([]byte("true")) + if err != nil { + return cd, uc, cdu, err + } + + pubKey := Seeder.GetPubKey() + + uc = Models.UserConversation{ + UserID: u.ID, + ConversationDetailID: base64.StdEncoding.EncodeToString(conversationDetailIDCiphertext), + Admin: base64.StdEncoding.EncodeToString(adminCiphertext), + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(key.Key, pubKey), + ), + } + + err = Database.CreateUserConversation(&uc) + if err != nil { + return cd, uc, cdu, err + } + + userIDCiphertext, err := key.AesEncrypt([]byte(u.ID.String())) + if err != nil { + return cd, uc, cdu, err + } + + usernameCiphertext, err := key.AesEncrypt([]byte(u.Username)) + if err != nil { + return cd, uc, cdu, err + } + + adminCiphertext, err = key.AesEncrypt([]byte("true")) + if err != nil { + return cd, uc, cdu, err + } + + associationKeyCiphertext, err := key.AesEncrypt([]byte("association")) + if err != nil { + return cd, uc, cdu, err + } + + publicKeyCiphertext, err := key.AesEncrypt([]byte(u.AsymmetricPublicKey)) + if err != nil { + return cd, uc, cdu, err + } + + cdu = Models.ConversationDetailUser{ + ConversationDetailID: cd.ID, + UserID: base64.StdEncoding.EncodeToString(userIDCiphertext), + Username: base64.StdEncoding.EncodeToString(usernameCiphertext), + Admin: base64.StdEncoding.EncodeToString(adminCiphertext), + AssociationKey: base64.StdEncoding.EncodeToString(associationKeyCiphertext), + PublicKey: base64.StdEncoding.EncodeToString(publicKeyCiphertext), + } + + err = Database.CreateConversationDetailUser(&cdu) + return cd, uc, cdu, err +} + +func Test_UpdateConversation(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + // u, err := Database.GetUserByUsername("test") + + key, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + cd, uc, cdu, err := createConversation(key) + + nameCiphertext, err := key.AesEncrypt([]byte("Not test conversation")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + } + + d := struct { + ID string `json:"id"` + Name string `json:"name"` + Users []Models.ConversationDetailUser + UserConversations []Models.UserConversation + }{ + ID: cd.ID.String(), + Name: base64.StdEncoding.EncodeToString(nameCiphertext), + Users: []Models.ConversationDetailUser{ + cdu, + }, + UserConversations: []Models.UserConversation{ + uc, + }, + } + + jsonStr, _ := json.Marshal(d) + req, _ := http.NewRequest("PUT", ts.URL+"/api/v1/auth/conversations", bytes.NewBuffer(jsonStr)) + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("Expected %d, recieved %d", http.StatusNoContent, resp.StatusCode) + } + + var ncd Models.ConversationDetail + err = Database.DB.First(&ncd, "id = ?", cd.ID.String()).Error + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + decodedName, err := base64.StdEncoding.DecodeString(ncd.Name) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + decrypedName, err := key.AesDecrypt(decodedName) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + if string(decrypedName) != "Not test conversation" { + t.Errorf("Expected %s, recieved %s", "Not test converation", string(decrypedName)) + } + +} diff --git a/Backend/Database/Seeder/FriendSeeder.go b/Backend/Database/Seeder/FriendSeeder.go index b4cacd5..43cdd0f 100644 --- a/Backend/Database/Seeder/FriendSeeder.go +++ b/Backend/Database/Seeder/FriendSeeder.go @@ -13,7 +13,7 @@ import ( func seedFriend(userRequestTo, userRequestFrom Models.User, accepted bool) error { var ( friendRequest Models.FriendRequest - symKey aesKey + symKey AesKey encPublicKey []byte err error ) diff --git a/Backend/Database/Seeder/MessageSeeder.go b/Backend/Database/Seeder/MessageSeeder.go index 1b4b0a8..a117742 100644 --- a/Backend/Database/Seeder/MessageSeeder.go +++ b/Backend/Database/Seeder/MessageSeeder.go @@ -17,7 +17,7 @@ func seedMessage( var ( message Models.Message messageData Models.MessageData - key, userKey aesKey + key, userKey AesKey keyCiphertext []byte plaintext string dataCiphertext []byte @@ -91,7 +91,7 @@ func seedMessage( return Database.CreateMessage(&message) } -func seedConversationDetail(key aesKey) (Models.ConversationDetail, error) { +func seedConversationDetail(key AesKey) (Models.ConversationDetail, error) { var ( messageThread Models.ConversationDetail name string @@ -124,7 +124,7 @@ func seedConversationDetail(key aesKey) (Models.ConversationDetail, error) { func seedUserConversation( user Models.User, threadID uuid.UUID, - key aesKey, + key AesKey, ) (Models.UserConversation, error) { var ( messageThreadUser Models.UserConversation @@ -161,7 +161,7 @@ func seedConversationDetailUser( conversationDetail Models.ConversationDetail, associationKey uuid.UUID, admin bool, - key aesKey, + key AesKey, ) (Models.ConversationDetailUser, error) { var ( conversationDetailUser Models.ConversationDetailUser @@ -224,7 +224,7 @@ func seedConversationDetailUser( func SeedMessages() { var ( conversationDetail Models.ConversationDetail - key aesKey + key AesKey primaryUser Models.User primaryUserAssociationKey uuid.UUID secondaryUser Models.User diff --git a/Backend/Database/Seeder/UserSeeder.go b/Backend/Database/Seeder/UserSeeder.go index e47f983..eae651f 100644 --- a/Backend/Database/Seeder/UserSeeder.go +++ b/Backend/Database/Seeder/UserSeeder.go @@ -25,7 +25,7 @@ var userNames = []string{ func createUser(username string) (Models.User, error) { var ( userData Models.User - userKey aesKey + userKey AesKey password string err error ) diff --git a/Backend/Database/Seeder/encryption.go b/Backend/Database/Seeder/encryption.go index 101e5a4..16afc11 100644 --- a/Backend/Database/Seeder/encryption.go +++ b/Backend/Database/Seeder/encryption.go @@ -17,12 +17,12 @@ import ( "golang.org/x/crypto/pbkdf2" ) -type aesKey struct { +type AesKey struct { Key []byte Iv []byte } -func (key aesKey) encode() string { +func (key AesKey) encode() string { return base64.StdEncoding.EncodeToString(key.Key) } @@ -71,7 +71,7 @@ func pkcs7strip(data []byte, blockSize int) ([]byte, error) { return data[:length-padLen], nil } -func GenerateAesKey() (aesKey, error) { +func GenerateAesKey() (AesKey, error) { var ( saltBytes []byte = []byte{} password []byte @@ -83,22 +83,22 @@ func GenerateAesKey() (aesKey, error) { password = make([]byte, 64) _, err = rand.Read(password) if err != nil { - return aesKey{}, err + return AesKey{}, err } seed = make([]byte, 64) _, err = rand.Read(seed) if err != nil { - return aesKey{}, err + return AesKey{}, err } iv = make([]byte, 16) _, err = rand.Read(iv) if err != nil { - return aesKey{}, err + return AesKey{}, err } - return aesKey{ + return AesKey{ Key: pbkdf2.Key( password, saltBytes, @@ -110,7 +110,7 @@ func GenerateAesKey() (aesKey, error) { }, nil } -func (key aesKey) AesEncrypt(plaintext []byte) ([]byte, error) { +func (key AesKey) AesEncrypt(plaintext []byte) ([]byte, error) { var ( bPlaintext []byte ciphertext []byte @@ -134,7 +134,7 @@ func (key aesKey) AesEncrypt(plaintext []byte) ([]byte, error) { return ciphertext, nil } -func (key aesKey) AesDecrypt(ciphertext []byte) ([]byte, error) { +func (key AesKey) AesDecrypt(ciphertext []byte) ([]byte, error) { var ( plaintext []byte iv []byte From d3553d5955b07ca1a3b43c0502bed8815cd3b4fc Mon Sep 17 00:00:00 2001 From: Tovi Jaeschke-Rogers Date: Thu, 22 Sep 2022 22:40:46 +0930 Subject: [PATCH 6/6] Add tests for friend list --- Backend/Api/Friends/CreateFriendRequest.go | 87 ++++++++++++++ Backend/Api/Friends/EncryptedFriendsList.go | 41 ------- Backend/Api/Friends/Friends.go | 73 +++--------- Backend/Api/Friends/Friends_test.go | 124 ++++++++++++++++++++ Backend/Api/Messages/Conversations.go | 6 +- Backend/Api/Messages/Conversations_test.go | 1 + Backend/Api/Messages/MessageThread.go | 13 +- Backend/Api/Messages/MessageThread_test.go | 88 ++++++++++++++ Backend/Api/Users/SearchUsers.go | 1 - Backend/Api/Users/SearchUsers_test.go | 106 +++++++++++++++++ Backend/Database/FriendRequests.go | 8 +- Backend/Database/Messages.go | 9 +- Backend/Models/Friends.go | 2 + Backend/Tests/Init.go | 33 +++--- 14 files changed, 471 insertions(+), 121 deletions(-) create mode 100644 Backend/Api/Friends/CreateFriendRequest.go delete mode 100644 Backend/Api/Friends/EncryptedFriendsList.go create mode 100644 Backend/Api/Friends/Friends_test.go create mode 100644 Backend/Api/Users/SearchUsers_test.go diff --git a/Backend/Api/Friends/CreateFriendRequest.go b/Backend/Api/Friends/CreateFriendRequest.go new file mode 100644 index 0000000..d7f0b53 --- /dev/null +++ b/Backend/Api/Friends/CreateFriendRequest.go @@ -0,0 +1,87 @@ +package Friends + +import ( + "encoding/json" + "io/ioutil" + "net/http" + "time" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" +) + +// CreateFriendRequest creates a FriendRequest from post data +func CreateFriendRequest(w http.ResponseWriter, r *http.Request) { + var ( + friendRequest Models.FriendRequest + requestBody []byte + returnJSON []byte + err error + ) + + requestBody, err = ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + err = json.Unmarshal(requestBody, &friendRequest) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + friendRequest.AcceptedAt.Scan(nil) + + err = Database.CreateFriendRequest(&friendRequest) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + returnJSON, err = json.MarshalIndent(friendRequest, "", " ") + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + // Return updated json + w.WriteHeader(http.StatusOK) + w.Write(returnJSON) +} + +// CreateFriendRequestQrCode creates a FriendRequest from post data from qr code scan +func CreateFriendRequestQrCode(w http.ResponseWriter, r *http.Request) { + var ( + friendRequests []Models.FriendRequest + requestBody []byte + i int + err error + ) + + requestBody, err = ioutil.ReadAll(r.Body) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + err = json.Unmarshal(requestBody, &friendRequests) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + for i = range friendRequests { + friendRequests[i].AcceptedAt.Time = time.Now() + friendRequests[i].AcceptedAt.Valid = true + } + + err = Database.CreateFriendRequests(&friendRequests) + if err != nil { + http.Error(w, "Error", http.StatusInternalServerError) + return + } + + // Return updated json + w.WriteHeader(http.StatusOK) +} diff --git a/Backend/Api/Friends/EncryptedFriendsList.go b/Backend/Api/Friends/EncryptedFriendsList.go deleted file mode 100644 index 79d6113..0000000 --- a/Backend/Api/Friends/EncryptedFriendsList.go +++ /dev/null @@ -1,41 +0,0 @@ -package Friends - -import ( - "encoding/json" - "net/http" - - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" - "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" -) - -// FriendRequestList gets friend request list -func FriendRequestList(w http.ResponseWriter, r *http.Request) { - var ( - userSession Models.Session - friends []Models.FriendRequest - returnJSON []byte - err error - ) - - userSession, err = Auth.CheckCookie(r) - if err != nil { - http.Error(w, "Forbidden", http.StatusUnauthorized) - return - } - - friends, err = Database.GetFriendRequestsByUserID(userSession.UserID.String()) - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } - - returnJSON, err = json.MarshalIndent(friends, "", " ") - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - w.Write(returnJSON) -} diff --git a/Backend/Api/Friends/Friends.go b/Backend/Api/Friends/Friends.go index d7f0b53..3bd58ba 100644 --- a/Backend/Api/Friends/Friends.go +++ b/Backend/Api/Friends/Friends.go @@ -2,86 +2,47 @@ package Friends import ( "encoding/json" - "io/ioutil" "net/http" - "time" + "net/url" + "strconv" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Api/Auth" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" ) -// CreateFriendRequest creates a FriendRequest from post data -func CreateFriendRequest(w http.ResponseWriter, r *http.Request) { +// FriendRequestList gets friend request list +func FriendRequestList(w http.ResponseWriter, r *http.Request) { var ( - friendRequest Models.FriendRequest - requestBody []byte - returnJSON []byte - err error + userSession Models.Session + friends []Models.FriendRequest + values url.Values + returnJSON []byte + page int + err error ) - requestBody, err = ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } + values = r.URL.Query() - err = json.Unmarshal(requestBody, &friendRequest) + page, err = strconv.Atoi(values.Get("page")) if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return + page = 0 } - friendRequest.AcceptedAt.Scan(nil) + userSession, _ = Auth.CheckCookie(r) - err = Database.CreateFriendRequest(&friendRequest) + friends, err = Database.GetFriendRequestsByUserID(userSession.UserID.String(), page) if err != nil { http.Error(w, "Error", http.StatusInternalServerError) return } - returnJSON, err = json.MarshalIndent(friendRequest, "", " ") + returnJSON, err = json.MarshalIndent(friends, "", " ") if err != nil { http.Error(w, "Error", http.StatusInternalServerError) return } - // Return updated json w.WriteHeader(http.StatusOK) w.Write(returnJSON) } - -// CreateFriendRequestQrCode creates a FriendRequest from post data from qr code scan -func CreateFriendRequestQrCode(w http.ResponseWriter, r *http.Request) { - var ( - friendRequests []Models.FriendRequest - requestBody []byte - i int - err error - ) - - requestBody, err = ioutil.ReadAll(r.Body) - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } - - err = json.Unmarshal(requestBody, &friendRequests) - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } - - for i = range friendRequests { - friendRequests[i].AcceptedAt.Time = time.Now() - friendRequests[i].AcceptedAt.Valid = true - } - - err = Database.CreateFriendRequests(&friendRequests) - if err != nil { - http.Error(w, "Error", http.StatusInternalServerError) - return - } - - // Return updated json - w.WriteHeader(http.StatusOK) -} diff --git a/Backend/Api/Friends/Friends_test.go b/Backend/Api/Friends/Friends_test.go new file mode 100644 index 0000000..d18de12 --- /dev/null +++ b/Backend/Api/Friends/Friends_test.go @@ -0,0 +1,124 @@ +package Friends_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "testing" + "time" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database/Seeder" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" +) + +func Test_FriendRequestList(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + u, err := Database.GetUserByUsername("test") + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + for i := 0; i < 30; i++ { + u2, err := Tests.InitTestCreateUser(fmt.Sprintf("test%d", i)) + + decodedPublicKey := Seeder.GetPubKey() + + key, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + encPublicKey, err := key.AesEncrypt([]byte(Seeder.PublicKey)) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + friendReq := Models.FriendRequest{ + UserID: u.ID, + FriendID: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey( + []byte(u2.ID.String()), + decodedPublicKey, + ), + ), + FriendUsername: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey( + []byte(u2.Username), + decodedPublicKey, + ), + ), + FriendPublicAsymmetricKey: base64.StdEncoding.EncodeToString( + encPublicKey, + ), + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(key.Key, decodedPublicKey), + ), + } + + if i > 20 { + friendReq.AcceptedAt.Time = time.Now() + friendReq.AcceptedAt.Valid = true + } + + err = Database.CreateFriendRequest(&friendReq) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + } + + req, _ := http.NewRequest("GET", ts.URL+"/api/v1/auth/friend_requests", nil) + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + requestBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + var users []Models.FriendRequest + + json.Unmarshal(requestBody, &users) + + if len(users) != 20 { + t.Errorf("Expected %d, recieved %d", 1, len(users)) + return + } + + for i := 0; i < 20; i++ { + eq := true + if i > 8 { + eq = false + } + if users[i].AcceptedAt.Valid != eq { + t.Errorf( + "Expected %v, recieved %v, on user %d", + eq, users[i].AcceptedAt.Valid, + i, + ) + return + } + } +} diff --git a/Backend/Api/Messages/Conversations.go b/Backend/Api/Messages/Conversations.go index e4c6e81..1639111 100644 --- a/Backend/Api/Messages/Conversations.go +++ b/Backend/Api/Messages/Conversations.go @@ -30,11 +30,7 @@ func ConversationList(w http.ResponseWriter, r *http.Request) { page = 0 } - userSession, err = Auth.CheckCookie(r) - if err != nil { - http.Error(w, "Forbidden", http.StatusUnauthorized) - return - } + userSession, _ = Auth.CheckCookie(r) conversationDetails, err = Database.GetUserConversationsByUserId( userSession.UserID.String(), diff --git a/Backend/Api/Messages/Conversations_test.go b/Backend/Api/Messages/Conversations_test.go index 21163ce..49f3654 100644 --- a/Backend/Api/Messages/Conversations_test.go +++ b/Backend/Api/Messages/Conversations_test.go @@ -102,6 +102,7 @@ func Test_ConversationsList(t *testing.T) { if len(conversations) != 1 { t.Errorf("Expected %d, recieved %d", 1, len(conversations)) + return } conv := conversations[0] diff --git a/Backend/Api/Messages/MessageThread.go b/Backend/Api/Messages/MessageThread.go index ff466d3..1135c20 100644 --- a/Backend/Api/Messages/MessageThread.go +++ b/Backend/Api/Messages/MessageThread.go @@ -3,6 +3,8 @@ package Messages import ( "encoding/json" "net/http" + "net/url" + "strconv" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Database" "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" @@ -17,7 +19,9 @@ func Messages(w http.ResponseWriter, r *http.Request) { message Models.Message urlVars map[string]string associationKey string + values url.Values returnJSON []byte + page int i int ok bool err error @@ -30,7 +34,14 @@ func Messages(w http.ResponseWriter, r *http.Request) { return } - messages, err = Database.GetMessagesByAssociationKey(associationKey) + values = r.URL.Query() + + page, err = strconv.Atoi(values.Get("page")) + if err != nil { + page = 0 + } + + messages, err = Database.GetMessagesByAssociationKey(associationKey, page) if !ok { http.Error(w, "Not Found", http.StatusNotFound) return diff --git a/Backend/Api/Messages/MessageThread_test.go b/Backend/Api/Messages/MessageThread_test.go index 3d3dada..bf4325e 100644 --- a/Backend/Api/Messages/MessageThread_test.go +++ b/Backend/Api/Messages/MessageThread_test.go @@ -115,3 +115,91 @@ func Test_Messages(t *testing.T) { t.Errorf("Expected %s, recieved %s", "Test converation", string(decrypedData)) } } + +func Test_MessagesPagination(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + u, err := Database.GetUserByUsername("test") + + userKey, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + key, err := Seeder.GenerateAesKey() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + dataCiphertext, err := key.AesEncrypt([]byte("Test message")) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + senderIDCiphertext, err := key.AesEncrypt([]byte(u.ID.String())) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + keyCiphertext, err := userKey.AesEncrypt( + []byte(base64.StdEncoding.EncodeToString(key.Key)), + ) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + pubKey := Seeder.GetPubKey() + + for i := 0; i < 50; i++ { + message := Models.Message{ + MessageData: Models.MessageData{ + Data: base64.StdEncoding.EncodeToString(dataCiphertext), + SenderID: base64.StdEncoding.EncodeToString(senderIDCiphertext), + SymmetricKey: base64.StdEncoding.EncodeToString(keyCiphertext), + }, + SymmetricKey: base64.StdEncoding.EncodeToString( + Seeder.EncryptWithPublicKey(userKey.Key, pubKey), + ), + AssociationKey: "AssociationKey", + } + + err = Database.CreateMessage(&message) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + } + + resp, err := client.Get(ts.URL + "/api/v1/auth/messages/AssociationKey") + if err != nil { + t.Errorf("Expected user record, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + requestBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + var m []Models.Message + err = json.Unmarshal(requestBody, &m) + + if len(m) != 20 { + t.Errorf("Expected %d, recieved %d", 20, len(m)) + } +} diff --git a/Backend/Api/Users/SearchUsers.go b/Backend/Api/Users/SearchUsers.go index 51f2e62..a073749 100644 --- a/Backend/Api/Users/SearchUsers.go +++ b/Backend/Api/Users/SearchUsers.go @@ -46,7 +46,6 @@ func SearchUsers(w http.ResponseWriter, r *http.Request) { returnJSON, err = json.MarshalIndent(user, "", " ") if err != nil { - panic(err) http.Error(w, "Not Found", http.StatusNotFound) return } diff --git a/Backend/Api/Users/SearchUsers_test.go b/Backend/Api/Users/SearchUsers_test.go new file mode 100644 index 0000000..1b018f3 --- /dev/null +++ b/Backend/Api/Users/SearchUsers_test.go @@ -0,0 +1,106 @@ +package Users_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "testing" + + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Models" + "git.tovijaeschke.xyz/tovi/Capsule/Backend/Tests" +) + +func Test_SearchUsers(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + u2, err := Tests.InitTestCreateUser("abcd") + + req, _ := http.NewRequest( + "GET", + fmt.Sprintf("%s/api/v1/auth/users?username=%s", ts.URL, u2.Username), + nil, + ) + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusOK { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + requestBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + var user Models.User + + json.Unmarshal(requestBody, &user) + + if user.Username != "abcd" { + t.Errorf("Expected abcd, recieved %s", user.Username) + return + } + + if user.Password != "" { + t.Errorf("Expected \"\", recieved %s", user.Password) + return + } + + if user.AsymmetricPrivateKey != "" { + t.Errorf("Expected \"\", recieved %s", user.AsymmetricPrivateKey) + return + } +} + +func Test_SearchUsersPartialMatchFails(t *testing.T) { + client, ts, err := Tests.InitTestEnv() + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + _, err = Tests.InitTestCreateUser("abcd") + + req, _ := http.NewRequest( + "GET", + fmt.Sprintf("%s/api/v1/auth/users?username=%s", ts.URL, "abc"), + nil, + ) + + resp, err := client.Do(req) + if err != nil { + t.Errorf("Expected nil, recieved %s", err.Error()) + return + } + + if resp.StatusCode != http.StatusNotFound { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + requestBody, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Errorf("Expected %d, recieved %d", http.StatusOK, resp.StatusCode) + return + } + + var user interface{} + + json.Unmarshal(requestBody, &user) + + if user != nil { + t.Errorf("Expected nil, recieved %+v", user) + return + } +} diff --git a/Backend/Database/FriendRequests.go b/Backend/Database/FriendRequests.go index d93c9e7..951a7a1 100644 --- a/Backend/Database/FriendRequests.go +++ b/Backend/Database/FriendRequests.go @@ -22,14 +22,20 @@ func GetFriendRequestByID(id string) (Models.FriendRequest, error) { } // GetFriendRequestsByUserID gets friend request by user id -func GetFriendRequestsByUserID(userID string) ([]Models.FriendRequest, error) { +func GetFriendRequestsByUserID(userID string, page int) ([]Models.FriendRequest, error) { var ( friends []Models.FriendRequest + offset int err error ) + offset = page * PageSize + err = DB.Model(Models.FriendRequest{}). Where("user_id = ?", userID). + Offset(offset). + Limit(PageSize). + Order("created_at DESC"). Find(&friends). Error diff --git a/Backend/Database/Messages.go b/Backend/Database/Messages.go index dd0fbfe..37d0c14 100644 --- a/Backend/Database/Messages.go +++ b/Backend/Database/Messages.go @@ -22,15 +22,20 @@ func GetMessageByID(id string) (Models.Message, error) { } // GetMessagesByAssociationKey for getting whole thread -// TODO: Add pagination -func GetMessagesByAssociationKey(associationKey string) ([]Models.Message, error) { +func GetMessagesByAssociationKey(associationKey string, page int) ([]Models.Message, error) { var ( messages []Models.Message + offset int err error ) + offset = page * PageSize + err = DB.Preload("MessageData"). Preload("MessageData.Attachment"). + Offset(offset). + Limit(PageSize). + Order("created_at DESC"). Find(&messages, "association_key = ?", associationKey). Error diff --git a/Backend/Models/Friends.go b/Backend/Models/Friends.go index 9dc892d..6438d97 100644 --- a/Backend/Models/Friends.go +++ b/Backend/Models/Friends.go @@ -2,6 +2,7 @@ package Models import ( "database/sql" + "time" "github.com/gofrs/uuid" ) @@ -17,4 +18,5 @@ type FriendRequest struct { FriendPublicAsymmetricKey string ` json:"asymmetric_public_key"` // Stored encrypted SymmetricKey string `gorm:"not null" json:"symmetric_key"` // Stored encrypted AcceptedAt sql.NullTime ` json:"accepted_at"` + CreatedAt time.Time `gorm:"not null" json:"created_at"` } diff --git a/Backend/Tests/Init.go b/Backend/Tests/Init.go index fdb4b48..dde5c4f 100644 --- a/Backend/Tests/Init.go +++ b/Backend/Tests/Init.go @@ -19,28 +19,17 @@ import ( "github.com/gorilla/mux" ) -// InitTestEnv initializes the test environment -// client is used for making authenticated requests -// ts is the testing server -// err, in case it fails ¯\_(ツ)_/¯ -func InitTestEnv() (*http.Client, *httptest.Server, error) { - log.SetOutput(ioutil.Discard) - Database.InitTest() - - r := mux.NewRouter() - Api.InitAPIEndpoints(r) - ts := httptest.NewServer(r) - +func InitTestCreateUser(username string) (Models.User, error) { userKey, err := Seeder.GenerateAesKey() if err != nil { - return http.DefaultClient, ts, err + return Models.User{}, err } pubKey := Seeder.GetPubKey() p, _ := Auth.HashPassword("password") u := Models.User{ - Username: "test", + Username: username, Password: p, AsymmetricPublicKey: Seeder.PublicKey, AsymmetricPrivateKey: Seeder.EncryptedPrivateKey, @@ -50,6 +39,22 @@ func InitTestEnv() (*http.Client, *httptest.Server, error) { } err = Database.CreateUser(&u) + return u, err +} + +// InitTestEnv initializes the test environment +// client is used for making authenticated requests +// ts is the testing server +// err, in case it fails ¯\_(ツ)_/¯ +func InitTestEnv() (*http.Client, *httptest.Server, error) { + log.SetOutput(ioutil.Discard) + Database.InitTest() + + r := mux.NewRouter() + Api.InitAPIEndpoints(r) + ts := httptest.NewServer(r) + + u, err := InitTestCreateUser("test") if err != nil { return http.DefaultClient, ts, err }