From 254a82842addb1475611789107c3720e37394879 Mon Sep 17 00:00:00 2001 From: JakobDev Date: Fri, 30 Jun 2023 01:22:55 +0200 Subject: [PATCH] Add API for changing Avatars (#25369) This adds an API for uploading and Deleting Avatars for of Users, Repos and Organisations. I'm not sure, if this should also be added to the Admin API. Resolves #25344 --------- Co-authored-by: silverwind Co-authored-by: Giteabot --- modules/structs/repo.go | 6 + modules/structs/user.go | 6 + routers/api/v1/api.go | 13 ++ routers/api/v1/org/avatar.go | 74 ++++++++ routers/api/v1/repo/avatar.go | 84 ++++++++++ routers/api/v1/swagger/options.go | 6 + routers/api/v1/user/avatar.go | 63 +++++++ templates/swagger/v1_json.tmpl | 195 +++++++++++++++++++++- tests/integration/api_org_avatar_test.go | 72 ++++++++ tests/integration/api_repo_avatar_test.go | 76 +++++++++ tests/integration/api_user_avatar_test.go | 72 ++++++++ tests/integration/avatar.png | Bin 0 -> 7787 bytes 12 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 routers/api/v1/org/avatar.go create mode 100644 routers/api/v1/repo/avatar.go create mode 100644 routers/api/v1/user/avatar.go create mode 100644 tests/integration/api_org_avatar_test.go create mode 100644 tests/integration/api_repo_avatar_test.go create mode 100644 tests/integration/api_user_avatar_test.go create mode 100644 tests/integration/avatar.png diff --git a/modules/structs/repo.go b/modules/structs/repo.go index 3b43f74c79..94992de72e 100644 --- a/modules/structs/repo.go +++ b/modules/structs/repo.go @@ -380,3 +380,9 @@ type NewIssuePinsAllowed struct { Issues bool `json:"issues"` PullRequests bool `json:"pull_requests"` } + +// UpdateRepoAvatarUserOption options when updating the repo avatar +type UpdateRepoAvatarOption struct { + // image must be base64 encoded + Image string `json:"image" binding:"Required"` +} diff --git a/modules/structs/user.go b/modules/structs/user.go index f68b92ac06..0df67894b0 100644 --- a/modules/structs/user.go +++ b/modules/structs/user.go @@ -102,3 +102,9 @@ type RenameUserOption struct { // unique: true NewName string `json:"new_username" binding:"Required"` } + +// UpdateUserAvatarUserOption options when updating the user avatar +type UpdateUserAvatarOption struct { + // image must be base64 encoded + Image string `json:"image" binding:"Required"` +} diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go index 8b7f55976b..0e28bde683 100644 --- a/routers/api/v1/api.go +++ b/routers/api/v1/api.go @@ -899,6 +899,11 @@ func Routes() *web.Route { Patch(bind(api.EditHookOption{}), user.EditHook). Delete(user.DeleteHook) }, reqWebhooksEnabled()) + + m.Group("/avatar", func() { + m.Post("", bind(api.UpdateUserAvatarOption{}), user.UpdateAvatar) + m.Delete("", user.DeleteAvatar) + }, reqToken()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryUser), reqToken()) // Repositories (requires repo scope, org scope) @@ -1134,6 +1139,10 @@ func Routes() *web.Route { m.Get("/languages", reqRepoReader(unit.TypeCode), repo.GetLanguages) m.Get("/activities/feeds", repo.ListRepoActivityFeeds) m.Get("/new_pin_allowed", repo.AreNewIssuePinsAllowed) + m.Group("/avatar", func() { + m.Post("", bind(api.UpdateRepoAvatarOption{}), repo.UpdateAvatar) + m.Delete("", repo.DeleteAvatar) + }, reqAdmin(), reqToken()) }, repoAssignment()) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) @@ -1314,6 +1323,10 @@ func Routes() *web.Route { Patch(bind(api.EditHookOption{}), org.EditHook). Delete(org.DeleteHook) }, reqToken(), reqOrgOwnership(), reqWebhooksEnabled()) + m.Group("/avatar", func() { + m.Post("", bind(api.UpdateUserAvatarOption{}), org.UpdateAvatar) + m.Delete("", org.DeleteAvatar) + }, reqToken(), reqOrgOwnership()) m.Get("/activities/feeds", org.ListOrgActivityFeeds) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryOrganization), orgAssignment(true)) m.Group("/teams/{teamid}", func() { diff --git a/routers/api/v1/org/avatar.go b/routers/api/v1/org/avatar.go new file mode 100644 index 0000000000..b3cb0b81a6 --- /dev/null +++ b/routers/api/v1/org/avatar.go @@ -0,0 +1,74 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package org + +import ( + "encoding/base64" + "net/http" + + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + user_service "code.gitea.io/gitea/services/user" +) + +// UpdateAvatarupdates the Avatar of an Organisation +func UpdateAvatar(ctx *context.APIContext) { + // swagger:operation POST /orgs/{org}/avatar organization orgUpdateAvatar + // --- + // summary: Update Avatar + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateUserAvatarOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + form := web.GetForm(ctx).(*api.UpdateUserAvatarOption) + + content, err := base64.StdEncoding.DecodeString(form.Image) + if err != nil { + ctx.Error(http.StatusBadRequest, "DecodeImage", err) + return + } + + err = user_service.UploadAvatar(ctx.Org.Organization.AsUser(), content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteAvatar deletes the Avatar of an Organisation +func DeleteAvatar(ctx *context.APIContext) { + // swagger:operation DELETE /orgs/{org}/avatar organization orgDeleteAvatar + // --- + // summary: Delete Avatar + // produces: + // - application/json + // parameters: + // - name: org + // in: path + // description: name of the organization + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + err := user_service.DeleteAvatar(ctx.Org.Organization.AsUser()) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/repo/avatar.go b/routers/api/v1/repo/avatar.go new file mode 100644 index 0000000000..48bd143d0c --- /dev/null +++ b/routers/api/v1/repo/avatar.go @@ -0,0 +1,84 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package repo + +import ( + "encoding/base64" + "net/http" + + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + repo_service "code.gitea.io/gitea/services/repository" +) + +// UpdateVatar updates the Avatar of an Repo +func UpdateAvatar(ctx *context.APIContext) { + // swagger:operation POST /repos/{owner}/{repo}/avatar repository repoUpdateAvatar + // --- + // summary: Update avatar + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateRepoAvatarOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + form := web.GetForm(ctx).(*api.UpdateRepoAvatarOption) + + content, err := base64.StdEncoding.DecodeString(form.Image) + if err != nil { + ctx.Error(http.StatusBadRequest, "DecodeImage", err) + return + } + + err = repo_service.UploadAvatar(ctx, ctx.Repo.Repository, content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} + +// UpdateAvatar deletes the Avatar of an Repo +func DeleteAvatar(ctx *context.APIContext) { + // swagger:operation DELETE /repos/{owner}/{repo}/avatar repository repoDeleteAvatar + // --- + // summary: Delete avatar + // produces: + // - application/json + // parameters: + // - name: owner + // in: path + // description: owner of the repo + // type: string + // required: true + // - name: repo + // in: path + // description: name of the repo + // type: string + // required: true + // responses: + // "204": + // "$ref": "#/responses/empty" + err := repo_service.DeleteAvatar(ctx, ctx.Repo.Repository) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/routers/api/v1/swagger/options.go b/routers/api/v1/swagger/options.go index 353d32e214..073d9a19f7 100644 --- a/routers/api/v1/swagger/options.go +++ b/routers/api/v1/swagger/options.go @@ -181,4 +181,10 @@ type swaggerParameterBodies struct { // in:body CreatePushMirrorOption api.CreatePushMirrorOption + + // in:body + UpdateUserAvatarOptions api.UpdateUserAvatarOption + + // in:body + UpdateRepoAvatarOptions api.UpdateRepoAvatarOption } diff --git a/routers/api/v1/user/avatar.go b/routers/api/v1/user/avatar.go new file mode 100644 index 0000000000..84fa129b13 --- /dev/null +++ b/routers/api/v1/user/avatar.go @@ -0,0 +1,63 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package user + +import ( + "encoding/base64" + "net/http" + + "code.gitea.io/gitea/modules/context" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/modules/web" + user_service "code.gitea.io/gitea/services/user" +) + +// UpdateAvatar updates the Avatar of an User +func UpdateAvatar(ctx *context.APIContext) { + // swagger:operation POST /user/avatar user userUpdateAvatar + // --- + // summary: Update Avatar + // produces: + // - application/json + // parameters: + // - name: body + // in: body + // schema: + // "$ref": "#/definitions/UpdateUserAvatarOption" + // responses: + // "204": + // "$ref": "#/responses/empty" + form := web.GetForm(ctx).(*api.UpdateUserAvatarOption) + + content, err := base64.StdEncoding.DecodeString(form.Image) + if err != nil { + ctx.Error(http.StatusBadRequest, "DecodeImage", err) + return + } + + err = user_service.UploadAvatar(ctx.Doer, content) + if err != nil { + ctx.Error(http.StatusInternalServerError, "UploadAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} + +// DeleteAvatar deletes the Avatar of an User +func DeleteAvatar(ctx *context.APIContext) { + // swagger:operation DELETE /user/avatar user userDeleteAvatar + // --- + // summary: Delete Avatar + // produces: + // - application/json + // responses: + // "204": + // "$ref": "#/responses/empty" + err := user_service.DeleteAvatar(ctx.Doer) + if err != nil { + ctx.Error(http.StatusInternalServerError, "DeleteAvatar", err) + } + + ctx.Status(http.StatusNoContent) +} diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 11abeac77c..f98dc12d95 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -1595,6 +1595,63 @@ } } }, + "/orgs/{org}/avatar": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Update Avatar", + "operationId": "orgUpdateAvatar", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateUserAvatarOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "organization" + ], + "summary": "Delete Avatar", + "operationId": "orgDeleteAvatar", + "parameters": [ + { + "type": "string", + "description": "name of the organization", + "name": "org", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/orgs/{org}/hooks": { "get": { "produces": [ @@ -3174,6 +3231,77 @@ } } }, + "/repos/{owner}/{repo}/avatar": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Update avatar", + "operationId": "repoUpdateAvatar", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + }, + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateRepoAvatarOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "repository" + ], + "summary": "Delete avatar", + "operationId": "repoDeleteAvatar", + "parameters": [ + { + "type": "string", + "description": "owner of the repo", + "name": "owner", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "name of the repo", + "name": "repo", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/repos/{owner}/{repo}/branch_protections": { "get": { "produces": [ @@ -13787,6 +13915,47 @@ } } }, + "/user/avatar": { + "post": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Update Avatar", + "operationId": "userUpdateAvatar", + "parameters": [ + { + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/UpdateUserAvatarOption" + } + } + ], + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + }, + "delete": { + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "Delete Avatar", + "operationId": "userDeleteAvatar", + "responses": { + "204": { + "$ref": "#/responses/empty" + } + } + } + }, "/user/emails": { "get": { "produces": [ @@ -21548,6 +21717,30 @@ }, "x-go-package": "code.gitea.io/gitea/modules/structs" }, + "UpdateRepoAvatarOption": { + "description": "UpdateRepoAvatarUserOption options when updating the repo avatar", + "type": "object", + "properties": { + "image": { + "description": "image must be base64 encoded", + "type": "string", + "x-go-name": "Image" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, + "UpdateUserAvatarOption": { + "description": "UpdateUserAvatarUserOption options when updating the user avatar", + "type": "object", + "properties": { + "image": { + "description": "image must be base64 encoded", + "type": "string", + "x-go-name": "Image" + } + }, + "x-go-package": "code.gitea.io/gitea/modules/structs" + }, "User": { "description": "User represents a user", "type": "object", @@ -22837,7 +23030,7 @@ "parameterBodies": { "description": "parameterBodies", "schema": { - "$ref": "#/definitions/CreatePushMirrorOption" + "$ref": "#/definitions/UpdateRepoAvatarOption" } }, "redirect": { diff --git a/tests/integration/api_org_avatar_test.go b/tests/integration/api_org_avatar_test.go new file mode 100644 index 0000000000..e0a4150e9f --- /dev/null +++ b/tests/integration/api_org_avatar_test.go @@ -0,0 +1,72 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "net/http" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUpdateOrgAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + // Test what happens if you use a valid image + avatar, err := os.ReadFile("tests/integration/avatar.png") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open avatar.png") + } + + opts := api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(avatar), + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/orgs/user3/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusNoContent) + + // Test what happens if you don't have a valid Base64 string + opts = api.UpdateUserAvatarOption{ + Image: "Invalid", + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/user3/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusBadRequest) + + // Test what happens if you use a file that is not an image + text, err := os.ReadFile("tests/integration/README.md") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open README.md") + } + + opts = api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(text), + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/orgs/user3/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestAPIDeleteOrgAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + session := loginUser(t, "user1") + + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteOrganization) + + req := NewRequest(t, "DELETE", "/api/v1/orgs/user3/avatar?token="+token) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_repo_avatar_test.go b/tests/integration/api_repo_avatar_test.go new file mode 100644 index 0000000000..58a4fc536c --- /dev/null +++ b/tests/integration/api_repo_avatar_test.go @@ -0,0 +1,76 @@ +// Copyright 2018 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "fmt" + "net/http" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/models/unittest" + user_model "code.gitea.io/gitea/models/user" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUpdateRepoAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository) + + // Test what happens if you use a valid image + avatar, err := os.ReadFile("tests/integration/avatar.png") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open avatar.png") + } + + opts := api.UpdateRepoAvatarOption{ + Image: base64.StdEncoding.EncodeToString(avatar), + } + + req := NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar?token=%s", repo.OwnerName, repo.Name, token), &opts) + MakeRequest(t, req, http.StatusNoContent) + + // Test what happens if you don't have a valid Base64 string + opts = api.UpdateRepoAvatarOption{ + Image: "Invalid", + } + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar?token=%s", repo.OwnerName, repo.Name, token), &opts) + MakeRequest(t, req, http.StatusBadRequest) + + // Test what happens if you use a file that is not an image + text, err := os.ReadFile("tests/integration/README.md") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open README.md") + } + + opts = api.UpdateRepoAvatarOption{ + Image: base64.StdEncoding.EncodeToString(text), + } + + req = NewRequestWithJSON(t, "POST", fmt.Sprintf("/api/v1/repos/%s/%s/avatar?token=%s", repo.OwnerName, repo.Name, token), &opts) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestAPIDeleteRepoAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2}) + token := getUserToken(t, user2.LowerName, auth_model.AccessTokenScopeWriteRepository) + + req := NewRequest(t, "DELETE", fmt.Sprintf("/api/v1/repos/%s/%s/avatar?token=%s", repo.OwnerName, repo.Name, token)) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/api_user_avatar_test.go b/tests/integration/api_user_avatar_test.go new file mode 100644 index 0000000000..807c119e2c --- /dev/null +++ b/tests/integration/api_user_avatar_test.go @@ -0,0 +1,72 @@ +// Copyright 2023 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "encoding/base64" + "net/http" + "os" + "testing" + + auth_model "code.gitea.io/gitea/models/auth" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/tests" + + "github.com/stretchr/testify/assert" +) + +func TestAPIUpdateUserAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + // Test what happens if you use a valid image + avatar, err := os.ReadFile("tests/integration/avatar.png") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open avatar.png") + } + + // Test what happens if you don't have a valid Base64 string + opts := api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(avatar), + } + + req := NewRequestWithJSON(t, "POST", "/api/v1/user/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusNoContent) + + opts = api.UpdateUserAvatarOption{ + Image: "Invalid", + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/user/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusBadRequest) + + // Test what happens if you use a file that is not an image + text, err := os.ReadFile("tests/integration/README.md") + assert.NoError(t, err) + if err != nil { + assert.FailNow(t, "Unable to open README.md") + } + + opts = api.UpdateUserAvatarOption{ + Image: base64.StdEncoding.EncodeToString(text), + } + + req = NewRequestWithJSON(t, "POST", "/api/v1/user/avatar?token="+token, &opts) + MakeRequest(t, req, http.StatusInternalServerError) +} + +func TestAPIDeleteUserAvatar(t *testing.T) { + defer tests.PrepareTestEnv(t)() + + normalUsername := "user2" + session := loginUser(t, normalUsername) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteUser) + + req := NewRequest(t, "DELETE", "/api/v1/user/avatar?token="+token) + MakeRequest(t, req, http.StatusNoContent) +} diff --git a/tests/integration/avatar.png b/tests/integration/avatar.png new file mode 100644 index 0000000000000000000000000000000000000000..dfd2125edc523f52181aade3ab93cfbadb565e0b GIT binary patch literal 7787 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