diff --git a/actions/admin/contact.go b/actions/admin/contact.go index ed88612c4..6a8a11615 100644 --- a/actions/admin/contact.go +++ b/actions/admin/contact.go @@ -212,6 +212,44 @@ func contactsAccept(c *gin.Context, _ *reqctx.AdminContext) { c.JSON(http.StatusOK, contract) } +// contactsCreate will perform create contact action for the given paymail +// @Summary Create contact +// @Description Create contact +// @Tags Admin +// @Produce json +// @Param paymail path string false "Contact paymail" +// @Success 200 {object} response.Contact "Changed contact" +// @Failure 400 "Bad request - Error while getting paymail from path" +// @Failure 404 "Not found - Error while getting contact requester by paymail" +// @Failure 409 "Contact already exists - Unable to add duplicate contact" +// @Failure 500 "Internal server error - Error while adding new contact" +// @Router /api/v1/admin/contacts/{paymail} [post] +// @Security x-auth-xpub +func contactsCreate(c *gin.Context, _ *reqctx.AdminContext) { + logger := reqctx.Logger(c) + contactPaymail := c.Param("paymail") + if contactPaymail == "" { + spverrors.ErrorResponse(c, spverrors.ErrMissingContactPaymailParam, logger) + return + } + + var req *CreateContact + if err := c.Bind(&req); err != nil { + spverrors.ErrorResponse(c, spverrors.ErrCannotBindRequest.WithTrace(err), logger) + return + } + + metadata := mappings.MapToMetadata(req.Metadata) + contact, err := reqctx.Engine(c).AdminCreateContact(c, contactPaymail, req.CreatorPaymail, req.FullName, metadata) + if err != nil { + spverrors.ErrorResponse(c, err, logger) + return + } + + contract := mappings.MapToContactContract(contact) + c.JSON(http.StatusOK, contract) +} + // contactsConfirm will perform Confirm action on contacts with the given xpub ids and paymails // Perform confirm action on contacts godoc // @Summary Confirm contacts pair diff --git a/actions/admin/models.go b/actions/admin/models.go index bf10b26be..5a1abbbe7 100644 --- a/actions/admin/models.go +++ b/actions/admin/models.go @@ -41,6 +41,16 @@ type CreateXpub struct { Key string `json:"key" example:"xpub661MyMwAqRbcGpZVrSHU..."` } +// CreateContact is the model for creating a contact by admin +type CreateContact struct { + // Paymail address of the creator (Person A) who owns the contact being added. + CreatorPaymail string `json:"creatorPaymail"` + // The complete name of the contact, including first name, and last name. + FullName string `json:"fullName"` + // Accepts a JSON object for embedding custom metadata, enabling arbitrary additional information to be associated with the resource + Metadata engine.Metadata `json:"metadata" swaggertype:"object,string" example:"key:value,key2:value2"` +} + // UpdateContact is the model for updating a contact type UpdateContact struct { // Accepts a JSON object for embedding custom metadata, enabling arbitrary additional information to be associated with the resource diff --git a/actions/admin/routes.go b/actions/admin/routes.go index db97648fc..e4393d822 100644 --- a/actions/admin/routes.go +++ b/actions/admin/routes.go @@ -14,6 +14,7 @@ func RegisterRoutes(handlersManager *handlers.Manager) { adminGroupOld.POST("/access-keys/count", handlers.AsAdmin(accessKeysCount)) adminGroupOld.POST("/contact/search", handlers.AsAdmin(contactsSearchOld)) adminGroupOld.PATCH("/contact/:id", handlers.AsAdmin(contactsUpdateOld)) + adminGroupOld.POST("/contact/:paymail", handlers.AsAdmin(contactsCreate)) adminGroupOld.DELETE("/contact/:id", handlers.AsAdmin(contactsDeleteOld)) adminGroupOld.PATCH("/contact/accepted/:id", handlers.AsAdmin(contactsAcceptOld)) adminGroupOld.PATCH("/contact/rejected/:id", handlers.AsAdmin(contactsRejectOld)) @@ -54,6 +55,7 @@ func RegisterRoutes(handlersManager *handlers.Manager) { adminGroup.DELETE("/invitations/:id", handlers.AsAdmin(contactsReject)) adminGroup.DELETE("/contacts/:id", handlers.AsAdmin(contactsDelete)) adminGroup.PUT("/contacts/:id", handlers.AsAdmin(contactsUpdate)) + adminGroup.POST("/contacts/:paymail", handlers.AsAdmin(contactsCreate)) adminGroup.POST("/contacts/confirmations", handlers.AsAdmin(contactsConfirm)) // access keys diff --git a/docs/docs.go b/docs/docs.go index daf44928c..8fd1f7b26 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -236,6 +236,51 @@ const docTemplate = `{ } } }, + "/api/v1/admin/contacts/{paymail}": { + "post": { + "security": [ + { + "x-auth-xpub": [] + } + ], + "description": "Create contact", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Create contact", + "parameters": [ + { + "type": "string", + "description": "Contact paymail", + "name": "paymail", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Changed contact", + "schema": { + "$ref": "#/definitions/response.Contact" + } + }, + "400": { + "description": "Bad request - Error while getting paymail from path" + }, + "404": { + "description": "Not found - Error while getting contact requester by paymail" + }, + "409": { + "description": "Contact already exists - Unable to add duplicate contact" + }, + "500": { + "description": "Internal server error - Error while adding new contact" + } + } + } + }, "/api/v1/admin/invitations/{id}": { "delete": { "security": [ diff --git a/docs/swagger.json b/docs/swagger.json index 8c1588206..08adce9b9 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -227,6 +227,51 @@ } } }, + "/api/v1/admin/contacts/{paymail}": { + "post": { + "security": [ + { + "x-auth-xpub": [] + } + ], + "description": "Create contact", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Create contact", + "parameters": [ + { + "type": "string", + "description": "Contact paymail", + "name": "paymail", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Changed contact", + "schema": { + "$ref": "#/definitions/response.Contact" + } + }, + "400": { + "description": "Bad request - Error while getting paymail from path" + }, + "404": { + "description": "Not found - Error while getting contact requester by paymail" + }, + "409": { + "description": "Contact already exists - Unable to add duplicate contact" + }, + "500": { + "description": "Internal server error - Error while adding new contact" + } + } + } + }, "/api/v1/admin/invitations/{id}": { "delete": { "security": [ diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e29e0ac7f..7d72d8b33 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2798,6 +2798,34 @@ paths: summary: Update contact FullName or Metadata tags: - Admin + /api/v1/admin/contacts/{paymail}: + post: + description: Create contact + parameters: + - description: Contact paymail + in: path + name: paymail + type: string + produces: + - application/json + responses: + "200": + description: Changed contact + schema: + $ref: '#/definitions/response.Contact' + "400": + description: Bad request - Error while getting paymail from path + "404": + description: Not found - Error while getting contact requester by paymail + "409": + description: Contact already exists - Unable to add duplicate contact + "500": + description: Internal server error - Error while adding new contact + security: + - x-auth-xpub: [] + summary: Create contact + tags: + - Admin /api/v1/admin/contacts/confirmations: post: description: Marks the contact entries as mutually confirmed, after ensuring diff --git a/engine/action_contact.go b/engine/action_contact.go index b201d92b4..de08b30d7 100644 --- a/engine/action_contact.go +++ b/engine/action_contact.go @@ -3,6 +3,7 @@ package engine import ( "context" "fmt" + "strings" "github.com/bitcoin-sv/go-paymail" "github.com/bitcoin-sv/spv-wallet/engine/datastore" @@ -163,6 +164,79 @@ func (c *Client) UpdateContact(ctx context.Context, id, fullName string, metadat return contact, nil } +// AdminCreateContact creates a new contact - xpubId is retrieved by the creatorPaymail. +func (c *Client) AdminCreateContact(ctx context.Context, contactPaymail, creatorPaymail, fullName string, metadata *Metadata) (*Contact, error) { + if err := validateNewContactReqFields(fullName, creatorPaymail); err != nil { + return nil, err + } + + creatorPaymailAddr, err := getPaymailAddress(ctx, creatorPaymail, c.DefaultModelOptions()...) + if err != nil { + return nil, spverrors.ErrCouldNotFindPaymail.Wrap(err) + } + + if creatorPaymailAddr == nil { + return nil, spverrors.ErrCouldNotFindPaymail + } + + creatorXPub, err := getXpubByID(ctx, creatorPaymailAddr.XpubID, c.DefaultModelOptions()...) + if err != nil { + return nil, spverrors.ErrCouldNotFindXpub.Wrap(err) + } + + newContactSanitisedPaymail, err := c.PaymailService().GetSanitizedPaymail(contactPaymail) + if err != nil { + return nil, spverrors.Wrapf(err, "requested duplicate paymail is invalid") + } + + pkiNewContact, err := c.PaymailService().GetPkiForPaymail(ctx, newContactSanitisedPaymail) + if err != nil { + return nil, spverrors.ErrGettingPKIFailed.Wrap(err) + } + + duplicate, err := getContact(ctx, contactPaymail, creatorXPub.ID, c.DefaultModelOptions()...) + if err != nil { + return nil, err + } + if duplicate != nil { + return nil, spverrors.ErrContactAlreadyExists + } + + opts := c.DefaultModelOptions() + if metadata != nil { + for key, value := range *metadata { + opts = append(opts, WithMetadata(key, value)) + } + } + + contact := newContact( + fullName, + contactPaymail, + pkiNewContact.PubKey, + creatorXPub.ID, + // newly created contact should be in the status of ContactNotConfirmed - initial state + ContactNotConfirmed, + opts..., + ) + if err = contact.Save(ctx); err != nil { + return nil, spverrors.ErrSaveContact.Wrap(err) + } + + return contact, nil +} + +func validateNewContactReqFields(fullName, creatorPaymail string) error { + if strings.TrimSpace(fullName) == "" { + return spverrors.ErrMissingContactFullName + } + + if strings.TrimSpace(creatorPaymail) == "" { + return spverrors.ErrMissingContactCreatorPaymail + } + + return nil +} + // AdminChangeContactStatus changes the status of the contact, should be used only by the admin. func (c *Client) AdminChangeContactStatus(ctx context.Context, id string, status ContactStatus) (*Contact, error) { contact, err := getContactByID(ctx, id, c.DefaultModelOptions()...) diff --git a/engine/contact_service_test.go b/engine/contact_service_test.go index d20b0be02..732c0cd78 100644 --- a/engine/contact_service_test.go +++ b/engine/contact_service_test.go @@ -10,6 +10,7 @@ import ( "github.com/bitcoin-sv/spv-wallet/engine/spverrors" xtester "github.com/bitcoin-sv/spv-wallet/engine/tester/paymailmock" "github.com/bitcoin-sv/spv-wallet/engine/utils" + "github.com/google/uuid" "github.com/jarcoal/httpmock" "github.com/stretchr/testify/require" ) @@ -301,6 +302,173 @@ func TestClientService_AddContactRequest(t *testing.T) { }) } +func Test_ClientService_AdminCreateContact(t *testing.T) { + tests := []struct { + name string + contactPaymail string + creatorPaymail string + fullName string + metadata *Metadata + setupMocks func(pt *paymailTestMock) + expectedError error + expectedStatus ContactStatus + expectedFullName string + }{ + { + name: "Happy path without metadata", + contactPaymail: "user1@example.com", + creatorPaymail: "user2@example.com", + fullName: "John Doe", + metadata: nil, + setupMocks: func(pt *paymailTestMock) { + pt.setup("example.com", true) + pt.mockPki("user2@example.com", "04c85162f06f5391028211a3683d669301fc72085458ce94d0a9e77ba4ff61f90a") + pt.mockPki("user1@example.com", "04c85162f06f5391028211a3683d669301fc72085458ce94d0a9e77ba4ff61f90a") + pt.mockPike("user1@example.com") + }, + expectedError: nil, + expectedStatus: ContactNotConfirmed, + expectedFullName: "John Doe", + }, + { + name: "Happy path with metadata", + contactPaymail: "user1@example.com", + creatorPaymail: "user2@example.com", + fullName: "John Doe", + metadata: &Metadata{ + "key1": "value1", + "key2": 42, + }, + setupMocks: func(pt *paymailTestMock) { + pt.setup("example.com", true) + pt.mockPki("user2@example.com", "04c85162f06f5391028211a3683d669301fc72085458ce94d0a9e77ba4ff61f90a") + pt.mockPki("user1@example.com", "04c85162f06f5391028211a3683d669301fc72085458ce94d0a9e77ba4ff61f90a") + pt.mockPike("user1@example.com") + }, + expectedError: nil, + expectedStatus: ContactNotConfirmed, + expectedFullName: "John Doe", + }, + { + name: "Edge case: Creator paymail not found", + contactPaymail: "user1@example.com", + creatorPaymail: "unknown@example.com", + fullName: "John Doe", + metadata: nil, + setupMocks: func(pt *paymailTestMock) { + pt.setup("example.com", true) + pt.mockPki("unknown@example.com", "") + }, + expectedError: spverrors.ErrCouldNotFindPaymail, + expectedStatus: ContactNotConfirmed, + expectedFullName: "", + }, + { + name: "Edge case: PKI retrieval fails", + contactPaymail: "user1@example.com", + creatorPaymail: "user2@example.com", + fullName: "John Doe", + metadata: nil, + setupMocks: func(pt *paymailTestMock) { + pt.setup("example.com", true) + pt.mockPki("user2@example.com", "04c85162f06f5391028211a3683d669301fc72085458ce94d0a9e77ba4ff61f90a") + }, + expectedError: spverrors.ErrGettingPKIFailed, + expectedStatus: ContactNotConfirmed, + expectedFullName: "", + }, + { + name: "Edge case: missing creator paymail", + contactPaymail: "user1@example.com", + creatorPaymail: "", + fullName: "John Doe", + metadata: nil, + expectedError: spverrors.ErrMissingContactCreatorPaymail, + }, + { + name: "Edge case: missing contact full name", + contactPaymail: "user1@example.com", + creatorPaymail: "user2@example.com", + fullName: "", + metadata: nil, + expectedError: spverrors.ErrMissingContactFullName, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // given + pt := &paymailTestMock{} + if tt.setupMocks != nil { + tt.setupMocks(pt) + } + defer pt.cleanup() + + ctx, client, cleanup := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup(), WithPaymailClient(pt.paymailClient)) + defer cleanup() + + _, err := client.NewXpub(ctx, csXpub, client.DefaultModelOptions()...) + require.NoError(t, err) + + if tt.creatorPaymail != "unknown@example.com" && tt.creatorPaymail != "" { + _, err = client.NewPaymailAddress(ctx, csXpub, tt.creatorPaymail, "Jane Doe", "", client.DefaultModelOptions()...) + require.NoError(t, err) + } + + // when + res, err := client.AdminCreateContact(ctx, tt.contactPaymail, tt.creatorPaymail, tt.fullName, tt.metadata) + + // then + if tt.expectedError != nil { + require.ErrorIs(t, err, tt.expectedError) + require.Nil(t, res) + } else { + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, tt.expectedStatus, res.Status) + require.Equal(t, tt.expectedFullName, res.FullName) + } + }) + } +} + +func Test_ClientService_AdminCreateContact_ContactAlreadyExists(t *testing.T) { + pt := &paymailTestMock{} + pt.setup("example.com", true) + pt.mockPki("user2@example.com", "04c85162f06f5391028211a3683d669301fc72085458ce94d0a9e77ba4ff61f90a") + pt.mockPki("user1@example.com", "04c85162f06f5391028211a3683d669301fc72085458ce94d0a9e77ba4ff61f90a") + pt.mockPike("user1@example.com") + defer pt.cleanup() + + ctx, client, cleanup := CreateTestSQLiteClient(t, false, false, withTaskManagerMockup(), WithPaymailClient(pt.paymailClient)) + defer cleanup() + + _, err := client.NewXpub(ctx, csXpub, client.DefaultModelOptions()...) + require.NoError(t, err) + + _, err = client.NewPaymailAddress(ctx, csXpub, "user2@example.com", "Jane Doe", "", client.DefaultModelOptions()...) + require.NoError(t, err) + + contact := &Contact{ + ID: uuid.NewString(), + Model: *NewBaseModel(ModelContact, client.DefaultModelOptions()...), + FullName: "Existing Contact", + Paymail: "user1@example.com", + OwnerXpubID: csXpubHash, + PubKey: csXpub, + Status: ContactConfirmed, + } + err = contact.Save(ctx) + require.NoError(t, err) + + // when + res, err := client.AdminCreateContact(ctx, "user1@example.com", "user2@example.com", "John Doe", nil) + + // then + require.ErrorIs(t, err, spverrors.ErrContactAlreadyExists) + require.Nil(t, res) +} + type paymailTestMock struct { serverURL string paymailClient paymail.ClientInterface diff --git a/engine/interface.go b/engine/interface.go index 32b9feb82..319c296e1 100644 --- a/engine/interface.go +++ b/engine/interface.go @@ -68,6 +68,7 @@ type ContactService interface { AddContactRequest(ctx context.Context, fullName, paymailAdress, requesterXPubID string, opts ...ModelOps) (*Contact, error) AdminChangeContactStatus(ctx context.Context, id string, status ContactStatus) (*Contact, error) + AdminCreateContact(ctx context.Context, contactPaymail, creatorPaymail, fullName string, metadata *Metadata) (*Contact, error) AdminConfirmContacts(ctx context.Context, paymailA string, paymailB string) error UpdateContact(ctx context.Context, id, fullName string, metadata *Metadata) (*Contact, error) DeleteContactByID(ctx context.Context, id string) error diff --git a/engine/spverrors/definitions.go b/engine/spverrors/definitions.go index 2793f13c8..200aced4b 100644 --- a/engine/spverrors/definitions.go +++ b/engine/spverrors/definitions.go @@ -104,6 +104,9 @@ var ErrContactsNotFound = models.SPVError{Message: "contacts not found", StatusC // ErrCouldNotCountContacts is when contacts cannot be counted var ErrCouldNotCountContacts = models.SPVError{Message: "failed to count contacts", StatusCode: 500, Code: "error-contacts-count-failed"} +// ErrMissingContactPaymailParam is when paymail is missing in contact +var ErrMissingContactPaymailParam = models.SPVError{Message: "missing paymail parameter in request", StatusCode: 400, Code: "error-contact-paymail-missing"} + // ErrInvalidRequesterXpub is when requester xpub is not connected with given paymail var ErrInvalidRequesterXpub = models.SPVError{Message: "invalid requester xpub", StatusCode: 400, Code: "error-contact-invalid-requester-xpub"} @@ -119,6 +122,9 @@ var ErrContactIncorrectStatus = models.SPVError{Message: "contact is in incorrec // ErrMissingContactID is when id is missing in contact var ErrMissingContactID = models.SPVError{Message: "missing id in contact", StatusCode: 400, Code: "error-contact-id-missing"} +// ErrMissingContactCreatorPaymail is when creator paymail is missing in contact +var ErrMissingContactCreatorPaymail = models.SPVError{Message: "missing creator paymail in contact", StatusCode: 400, Code: "error-contact-creator-paymail-missing"} + // ErrMissingContactFullName is when full name is missing in contact var ErrMissingContactFullName = models.SPVError{Message: "missing full name in contact", StatusCode: 400, Code: "error-contact-full-name-missing"} @@ -143,6 +149,9 @@ var ErrGettingPKIFailed = models.SPVError{Message: "getting PKI for contact fail // ErrSaveContact is when saving new contact failed var ErrSaveContact = models.SPVError{Message: "adding contact failed", StatusCode: 400, Code: "error-contact-adding-contact-failed"} +// ErrContactAlreadyExists is when contact already exists +var ErrContactAlreadyExists = models.SPVError{Message: "contact already exists", StatusCode: 409, Code: "error-contact-already-exists"} + // ErrUpdateContact is when updating contact failed var ErrUpdateContact = models.SPVError{Message: "updating contact failed", StatusCode: 500, Code: "error-contact-updating-contact-failed"}