diff --git a/.travis.yml b/.travis.yml index 67e0aaa..84412c2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,7 +2,7 @@ sudo: required language: go go: - - 1.9 + - 1.12 services: - docker diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c1a303c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Added +- `CHANGELOG.md` file. +- [#1](https://github.com/elyby/chrly/issues/1): Restored Mojang skins proxy. +- New StatsD metrics: + - Counters: + - `ely.skinsystem.{hostname}.app.mojang_textures.invalid_username` + - `ely.skinsystem.{hostname}.app.mojang_textures.request` + - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit_nil` + - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queued` + - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.cache_hit` + - `ely.skinsystem.{hostname}.app.mojang_textures.already_in_queue` + - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_miss` + - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.uuid_hit` + - `ely.skinsystem.{hostname}.app.mojang_textures.textures.cache_hit` + - `ely.skinsystem.{hostname}.app.mojang_textures.textures.request` + - Gauges: + - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.iteration_size` + - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.queue_size` + - Timers: + - `ely.skinsystem.{hostname}.app.mojang_textures.result_time` + - `ely.skinsystem.{hostname}.app.mojang_textures.usernames.round_time` + - `ely.skinsystem.{hostname}.app.mojang_textures.textures.request_time` + +### Changed +- Bumped Go version to 1.12. + +### Fixed +- `/textures` request no longer proxies request to Mojang in a case when there is no information about the skin, + but there is a cape. + +### Removed +- `hash` field from `/textures` response because the game doesn't use it and calculates hash by getting the filename + from the textures link instead. + +[Unreleased]: https://github.com/elyby/chrly/compare/4.1.1...HEAD diff --git a/Gopkg.lock b/Gopkg.lock index 4ac5f62..e802372 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -227,12 +227,33 @@ version = "v1.0.0" [[projects]] - digest = "1:3926a4ec9a4ff1a072458451aa2d9b98acd059a45b38f7335d31e06c3d6a0159" + digest = "1:711eebe744c0151a9d09af2315f0bb729b2ec7637ef4c410fa90a18ef74b65b6" + name = "github.com/stretchr/objx" + packages = ["."] + pruneopts = "" + revision = "477a77ecc69700c7cdeb1fa9e129548e1c1c393c" + version = "v0.1.1" + +[[projects]] + digest = "1:381bcbeb112a51493d9d998bbba207a529c73dbb49b3fd789e48c63fac1f192c" name = "github.com/stretchr/testify" - packages = ["assert"] + packages = [ + "assert", + "mock", + "require", + "suite", + ] + pruneopts = "" + revision = "ffdc059bfe9ce6a4e144ba849dbedead332c6053" + version = "v1.3.0" + +[[projects]] + branch = "master" + digest = "1:86e6712cfd4070a2120c03fcec41cfcbbc51813504a74e28d74479edfaf669ee" + name = "github.com/tevino/abool" + packages = ["."] pruneopts = "" - revision = "69483b4bd14f5845b5a1e55bca19e954e827f1d0" - version = "v1.1.4" + revision = "9b9efcf221b50905aab9bbabd3daed56dc10f339" [[projects]] branch = "issue-18" @@ -303,6 +324,9 @@ "github.com/spf13/cobra", "github.com/spf13/viper", "github.com/stretchr/testify/assert", + "github.com/stretchr/testify/mock", + "github.com/stretchr/testify/suite", + "github.com/tevino/abool", "github.com/thedevsaddam/govalidator", "gopkg.in/h2non/gock.v1", ] diff --git a/Gopkg.toml b/Gopkg.toml index 868b732..8d33262 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -29,11 +29,15 @@ ignored = ["github.com/elyby/chrly"] source = "https://github.com/erickskrauch/govalidator.git" branch = "issue-18" +[[constraint]] + branch = "master" + name = "github.com/tevino/abool" + # Testing dependencies [[constraint]] name = "github.com/stretchr/testify" - version = "^1.1.4" + version = "^1.3.0" [[constraint]] name = "github.com/golang/mock" diff --git a/README.md b/README.md index 878629a..41d3c25 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,14 @@ # Chrly -Chrly is a lightweight implementation of Minecraft skins system server. It's packaged and distributed as a Docker -image and can be downloaded from [Dockerhub](https://hub.docker.com/r/elyby/chrly/). App is written in Go, can -withstand heavy loads and is production ready. +[![Written in Go][ico-lang]][link-go] +[![Build Status][ico-build]][link-build] +[![Keep a Changelog][ico-changelog]](CHANGELOG.md) +[![Software License][ico-license]](LICENSE) + +Chrly is a lightweight implementation of Minecraft skins system server with ability to proxy requests to Mojang's +skins system. It's packaged and distributed as a Docker image and can be downloaded from +[Dockerhub](https://hub.docker.com/r/elyby/chrly/). App is written in Go, can withstand heavy loads and is +production ready. ## Installation @@ -33,7 +39,8 @@ services: - ./data/redis:/data ``` -Chrly will mount some volumes on the host machine to persist storage for capes and Redis database. +Chrly uses some volumes to persist storage for capes and Redis database. The configuration above mounts them to +the host machine to do not lose data on container recreations. ### Config @@ -64,13 +71,13 @@ Each endpoint that accepts `username` as a part of an url takes it case insensit #### `GET /skins/{username}.png` This endpoint responds to requested `username` with a skin texture. If user's skin was set as texture's link, then it'll -respond with the `301` redirect to that url. If there is no record for requested username, it'll redirect to the -Mojang skins system as: `http://skins.minecraft.net/MinecraftSkins/{username}.png` with the original username's case. +respond with the `301` redirect to that url. If the skin entry isn't found, it'll request textures information from +Mojang's API and if it has a skin, than it'll return a `301` redirect to it. #### `GET /cloaks/{username}.png` -It responds to requested `username` with a cape texture. If user's cape file doesn't exists, then it'll redirect to the -Mojang skins system as: `http://skins.minecraft.net/MinecraftCloaks/{username}.png` with the original username's case. +It responds to requested `username` with a cape texture. If the cape entry isn't found, it'll request textures +information from Mojang's API and if it has a cape, than it'll return a `301` redirect to it. #### `GET /textures/{username}` @@ -79,22 +86,19 @@ This endpoint forms response payloads as if it was the `textures`' property, but ```json { "SKIN": { - "url": "http://ely.by/minecraft/skins/skin.png", - "hash": "55d2a8848764f5ff04012cdb093458bd", + "url": "http://example.com/skin.png", "metadata": { "model": "slim" } }, "CAPE": { - "url": "http://skinsystem.ely.by/cloaks/username", - "hash": "424ff79dce9940af89c28ad80de8aaad" + "url": "http://example.com/cape.png" } } ``` -If record for the requested username wasn't found, cape would be omitted and skin would be formed for Mojang skins -system. Hash would be formed as the username plus the half-hour-ranged time of request, which is needed to improve -caching of Mojang skins inside Minecraft. +If both the skin and the cape entries aren't found, it'll request textures information from Mojang's API and if it has +a textures property, than it'll return decoded contents. That request is handy in case when your server implements authentication for a game server (e.g. join/hasJoined operation) and you have to respond with hasJoined request with an actual user textures. You have to simply send request @@ -103,8 +107,8 @@ to the Chrly server and put the result in your hasJoined response. #### `GET /textures/signed/{username}` Actually, it's [Ely.by](http://ely.by) feature called [Server Skins System](http://ely.by/server-skins-system), but if -you have your own source of the Mojang signatures, then you can pass it with textures and it'll be displayed in this -method. Received response should be directly sent to the client without any modification via game server API. +you have your own source of Mojang's signatures, then you can pass it with textures and it'll be displayed in response +of this endpoint. Received response should be directly sent to the client without any modification via game server API. Response example: @@ -128,6 +132,10 @@ Response example: If there is no requested `username` or `mojangSignature` field isn't set, `204` status code will be sent. +You can adjust URL to `/textures/signed/{username}?proxy=true` to obtain textures information for provided username +from Mojang's API. The textures will contain unmodified json with addition property with name "chrly" as shown in +the example above. + #### `GET /skins?name={username}` Equivalent of the `GET /skins/{username}.png`, but constructed especially for old Minecraft versions, where username @@ -250,3 +258,11 @@ If your Redis instance isn't located at the `localhost`, you can change host by After all of that `go run main.go serve` should successfully start the application. To run tests execute `go test ./...`. If your Go version is older than 1.9, then run a `/script/test`. + +[ico-lang]: https://img.shields.io/badge/lang-go%201.12-blue.svg?style=flat-square +[ico-build]: https://img.shields.io/travis/elyby/chrly.svg?style=flat-square +[ico-changelog]: https://img.shields.io/badge/keep%20a-changelog-orange.svg?style=flat-square +[ico-license]: https://img.shields.io/github/license/elyby/chrly.svg?style=flat-square + +[link-go]: https://golang.org +[link-build]: https://travis-ci.org/elyby/chrly diff --git a/api/mojang/mojang.go b/api/mojang/mojang.go new file mode 100644 index 0000000..a10ad53 --- /dev/null +++ b/api/mojang/mojang.go @@ -0,0 +1,191 @@ +package mojang + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "net/http" + "time" +) + +var HttpClient = &http.Client{ + Timeout: 3 * time.Second, +} + +type SignedTexturesResponse struct { + Id string `json:"id"` + Name string `json:"name"` + Props []*Property `json:"properties"` + decodedTextures *TexturesProp +} + +func (t *SignedTexturesResponse) DecodeTextures() *TexturesProp { + if t.decodedTextures == nil { + var texturesProp string + for _, prop := range t.Props { + if prop.Name == "textures" { + texturesProp = prop.Value + break + } + } + + if texturesProp == "" { + return nil + } + + decodedTextures, _ := DecodeTextures(texturesProp) + t.decodedTextures = decodedTextures + } + + return t.decodedTextures +} + +type Property struct { + Name string `json:"name"` + Signature string `json:"signature,omitempty"` + Value string `json:"value"` +} + +type ProfileInfo struct { + Id string `json:"id"` + Name string `json:"name"` + IsLegacy bool `json:"legacy,omitempty"` + IsDemo bool `json:"demo,omitempty"` +} + +// Exchanges usernames array to array of uuids +// See https://wiki.vg/Mojang_API#Playernames_-.3E_UUIDs +func UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) { + requestBody, _ := json.Marshal(usernames) + request, _ := http.NewRequest("POST", "https://api.mojang.com/profiles/minecraft", bytes.NewBuffer(requestBody)) + + request.Header.Set("Content-Type", "application/json") + + response, err := HttpClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if responseErr := validateResponse(response); responseErr != nil { + return nil, responseErr + } + + var result []*ProfileInfo + + body, _ := ioutil.ReadAll(response.Body) + _ = json.Unmarshal(body, &result) + + return result, nil +} + +// Obtains textures information for provided uuid +// See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape +func UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) { + url := "https://sessionserver.mojang.com/session/minecraft/profile/" + uuid + if signed { + url += "?unsigned=false" + } + + request, _ := http.NewRequest("GET", url, nil) + + response, err := HttpClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if responseErr := validateResponse(response); responseErr != nil { + return nil, responseErr + } + + var result *SignedTexturesResponse + + body, _ := ioutil.ReadAll(response.Body) + _ = json.Unmarshal(body, &result) + + return result, nil +} + +func validateResponse(response *http.Response) error { + switch { + case response.StatusCode == 204: + return &EmptyResponse{} + case response.StatusCode == 400: + type errorResponse struct { + Error string `json:"error"` + Message string `json:"errorMessage"` + } + + var decodedError *errorResponse + body, _ := ioutil.ReadAll(response.Body) + _ = json.Unmarshal(body, &decodedError) + + return &BadRequestError{ErrorType: decodedError.Error, Message: decodedError.Message} + case response.StatusCode == 429: + return &TooManyRequestsError{} + case response.StatusCode >= 500: + return &ServerError{Status: response.StatusCode} + } + + return nil +} + +type ResponseError interface { + IsMojangError() bool +} + +// Mojang API doesn't return a 404 Not Found error for non-existent data identifiers +// Instead, they return 204 with an empty body +type EmptyResponse struct { +} + +func (*EmptyResponse) Error() string { + return "Empty Response" +} + +func (*EmptyResponse) IsMojangError() bool { + return true +} + +// When passed request params are invalid, Mojang returns 400 Bad Request error +type BadRequestError struct { + ResponseError + ErrorType string + Message string +} + +func (e *BadRequestError) Error() string { + return e.Message +} + +func (*BadRequestError) IsMojangError() bool { + return true +} + +// When you exceed the set limit of requests, this error will be returned +type TooManyRequestsError struct { + ResponseError +} + +func (*TooManyRequestsError) Error() string { + return "Too Many Requests" +} + +func (*TooManyRequestsError) IsMojangError() bool { + return true +} + +// ServerError happens when Mojang's API returns any response with 50* status +type ServerError struct { + ResponseError + Status int +} + +func (e *ServerError) Error() string { + return "Server error" +} + +func (*ServerError) IsMojangError() bool { + return true +} diff --git a/api/mojang/mojang_test.go b/api/mojang/mojang_test.go new file mode 100644 index 0000000..76348dd --- /dev/null +++ b/api/mojang/mojang_test.go @@ -0,0 +1,289 @@ +package mojang + +import ( + "net/http" + "testing" + + testify "github.com/stretchr/testify/assert" + "gopkg.in/h2non/gock.v1" +) + +func TestSignedTexturesResponse(t *testing.T) { + t.Run("DecodeTextures", func(t *testing.T) { + obj := &SignedTexturesResponse{ + Id: "00000000000000000000000000000000", + Name: "mock", + Props: []*Property{ + { + Name: "textures", + Value: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=", + }, + }, + } + textures := obj.DecodeTextures() + testify.Equal(t, "3e3ee6c35afa48abb61e8cd8c42fc0d9", textures.ProfileID) + }) + + t.Run("DecodedTextures without textures prop", func(t *testing.T) { + obj := &SignedTexturesResponse{ + Id: "00000000000000000000000000000000", + Name: "mock", + Props: []*Property{}, + } + textures := obj.DecodeTextures() + testify.Nil(t, textures) + }) +} + +func TestUsernamesToUuids(t *testing.T) { + t.Run("exchange usernames to uuids", func(t *testing.T) { + assert := testify.New(t) + + defer gock.Off() + gock.New("https://api.mojang.com"). + Post("/profiles/minecraft"). + JSON([]string{"Thinkofdeath", "maksimkurb"}). + Reply(200). + JSON([]map[string]interface{}{ + { + "id": "4566e69fc90748ee8d71d7ba5aa00d20", + "name": "Thinkofdeath", + "legacy": false, + "demo": true, + }, + { + "id": "0d252b7218b648bfb86c2ae476954d32", + "name": "maksimkurb", + // There is no legacy or demo fields + }, + }) + + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client + + result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"}) + if assert.NoError(err) { + assert.Len(result, 2) + assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result[0].Id) + assert.Equal("Thinkofdeath", result[0].Name) + assert.False(result[0].IsLegacy) + assert.True(result[0].IsDemo) + + assert.Equal("0d252b7218b648bfb86c2ae476954d32", result[1].Id) + assert.Equal("maksimkurb", result[1].Name) + assert.False(result[1].IsLegacy) + assert.False(result[1].IsDemo) + } + }) + + t.Run("handle bad request response", func(t *testing.T) { + assert := testify.New(t) + + defer gock.Off() + gock.New("https://api.mojang.com"). + Post("/profiles/minecraft"). + Reply(400). + JSON(map[string]interface{}{ + "error": "IllegalArgumentException", + "errorMessage": "profileName can not be null or empty.", + }) + + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client + + result, err := UsernamesToUuids([]string{""}) + assert.Nil(result) + assert.IsType(&BadRequestError{}, err) + assert.EqualError(err, "profileName can not be null or empty.") + assert.Implements((*ResponseError)(nil), err) + }) + + t.Run("handle too many requests response", func(t *testing.T) { + assert := testify.New(t) + + defer gock.Off() + gock.New("https://api.mojang.com"). + Post("/profiles/minecraft"). + Reply(429). + JSON(map[string]interface{}{ + "error": "TooManyRequestsException", + "errorMessage": "The client has sent too many requests within a certain amount of time", + }) + + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client + + result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"}) + assert.Nil(result) + assert.IsType(&TooManyRequestsError{}, err) + assert.EqualError(err, "Too Many Requests") + assert.Implements((*ResponseError)(nil), err) + }) + + t.Run("handle server error", func(t *testing.T) { + assert := testify.New(t) + + defer gock.Off() + gock.New("https://api.mojang.com"). + Post("/profiles/minecraft"). + Reply(500). + BodyString("500 Internal Server Error") + + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client + + result, err := UsernamesToUuids([]string{"Thinkofdeath", "maksimkurb"}) + assert.Nil(result) + assert.IsType(&ServerError{}, err) + assert.EqualError(err, "Server error") + assert.Equal(500, err.(*ServerError).Status) + assert.Implements((*ResponseError)(nil), err) + }) +} + +func TestUuidToTextures(t *testing.T) { + t.Run("obtain not signed textures", func(t *testing.T) { + assert := testify.New(t) + + defer gock.Off() + gock.New("https://sessionserver.mojang.com"). + Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). + Reply(200). + JSON(map[string]interface{}{ + "id": "4566e69fc90748ee8d71d7ba5aa00d20", + "name": "Thinkofdeath", + "properties": []interface{}{ + map[string]interface{}{ + "name": "textures", + "value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=", + }, + }, + }) + + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client + + result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false) + if assert.NoError(err) { + assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id) + assert.Equal("Thinkofdeath", result.Name) + assert.Equal(1, len(result.Props)) + assert.Equal("textures", result.Props[0].Name) + assert.Equal(476, len(result.Props[0].Value)) + assert.Equal("", result.Props[0].Signature) + } + }) + + t.Run("obtain signed textures", func(t *testing.T) { + assert := testify.New(t) + + defer gock.Off() + gock.New("https://sessionserver.mojang.com"). + Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). + MatchParam("unsigned", "false"). + Reply(200). + JSON(map[string]interface{}{ + "id": "4566e69fc90748ee8d71d7ba5aa00d20", + "name": "Thinkofdeath", + "properties": []interface{}{ + map[string]interface{}{ + "name": "textures", + "signature": "signature string", + "value": "eyJ0aW1lc3RhbXAiOjE1NDMxMDczMDExODUsInByb2ZpbGVJZCI6IjQ1NjZlNjlmYzkwNzQ4ZWU4ZDcxZDdiYTVhYTAwZDIwIiwicHJvZmlsZU5hbWUiOiJUaGlua29mZGVhdGgiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzRkMWUwOGIwYmI3ZTlmNTkwYWYyNzc1ODEyNWJiZWQxNzc4YWM2Y2VmNzI5YWVkZmNiOTYxM2U5OTExYWU3NSJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvYjBjYzA4ODQwNzAwNDQ3MzIyZDk1M2EwMmI5NjVmMWQ2NWExM2E2MDNiZjY0YjE3YzgwM2MyMTQ0NmZlMTYzNSJ9fX0=", + }, + }, + }) + + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client + + result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", true) + if assert.NoError(err) { + assert.Equal("4566e69fc90748ee8d71d7ba5aa00d20", result.Id) + assert.Equal("Thinkofdeath", result.Name) + assert.Equal(1, len(result.Props)) + assert.Equal("textures", result.Props[0].Name) + assert.Equal(476, len(result.Props[0].Value)) + assert.Equal("signature string", result.Props[0].Signature) + } + }) + + t.Run("handle empty response", func(t *testing.T) { + assert := testify.New(t) + + defer gock.Off() + gock.New("https://sessionserver.mojang.com"). + Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). + Reply(204). + BodyString("") + + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client + + result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false) + assert.Nil(result) + assert.IsType(&EmptyResponse{}, err) + assert.EqualError(err, "Empty Response") + assert.Implements((*ResponseError)(nil), err) + }) + + t.Run("handle too many requests response", func(t *testing.T) { + assert := testify.New(t) + + defer gock.Off() + gock.New("https://sessionserver.mojang.com"). + Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). + Reply(429). + JSON(map[string]interface{}{ + "error": "TooManyRequestsException", + "errorMessage": "The client has sent too many requests within a certain amount of time", + }) + + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client + + result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false) + assert.Nil(result) + assert.IsType(&TooManyRequestsError{}, err) + assert.EqualError(err, "Too Many Requests") + assert.Implements((*ResponseError)(nil), err) + }) + + t.Run("handle server error", func(t *testing.T) { + assert := testify.New(t) + + defer gock.Off() + gock.New("https://sessionserver.mojang.com"). + Get("/session/minecraft/profile/4566e69fc90748ee8d71d7ba5aa00d20"). + Reply(500). + BodyString("500 Internal Server Error") + + client := &http.Client{} + gock.InterceptClient(client) + + HttpClient = client + + result, err := UuidToTextures("4566e69fc90748ee8d71d7ba5aa00d20", false) + assert.Nil(result) + assert.IsType(&ServerError{}, err) + assert.EqualError(err, "Server error") + assert.Equal(500, err.(*ServerError).Status) + assert.Implements((*ResponseError)(nil), err) + }) +} diff --git a/api/mojang/queue/broadcast.go b/api/mojang/queue/broadcast.go new file mode 100644 index 0000000..56c3b5e --- /dev/null +++ b/api/mojang/queue/broadcast.go @@ -0,0 +1,53 @@ +package queue + +import ( + "sync" + + "github.com/elyby/chrly/api/mojang" +) + +type broadcastMap struct { + lock sync.Mutex + listeners map[string][]chan *mojang.SignedTexturesResponse +} + +func newBroadcaster() *broadcastMap { + return &broadcastMap{ + listeners: make(map[string][]chan *mojang.SignedTexturesResponse), + } +} + +// Returns a boolean value, which will be true if the username passed didn't exist before +func (c *broadcastMap) AddListener(username string, resultChan chan *mojang.SignedTexturesResponse) bool { + c.lock.Lock() + defer c.lock.Unlock() + + val, alreadyHasSource := c.listeners[username] + if alreadyHasSource { + c.listeners[username] = append(val, resultChan) + return false + } + + c.listeners[username] = []chan *mojang.SignedTexturesResponse{resultChan} + + return true +} + +func (c *broadcastMap) BroadcastAndRemove(username string, result *mojang.SignedTexturesResponse) { + c.lock.Lock() + defer c.lock.Unlock() + + val, ok := c.listeners[username] + if !ok { + return + } + + for _, channel := range val { + go func(channel chan *mojang.SignedTexturesResponse) { + channel <- result + close(channel) + }(channel) + } + + delete(c.listeners, username) +} diff --git a/api/mojang/queue/broadcast_test.go b/api/mojang/queue/broadcast_test.go new file mode 100644 index 0000000..910ea8b --- /dev/null +++ b/api/mojang/queue/broadcast_test.go @@ -0,0 +1,75 @@ +package queue + +import ( + "github.com/elyby/chrly/api/mojang" + + testify "github.com/stretchr/testify/assert" + "testing" +) + +func TestBroadcastMap_GetOrAppend(t *testing.T) { + t.Run("first call when username didn't exist before should return true", func(t *testing.T) { + assert := testify.New(t) + + broadcaster := newBroadcaster() + channel := make(chan *mojang.SignedTexturesResponse) + isFirstListener := broadcaster.AddListener("mock", channel) + + assert.True(isFirstListener) + listeners, ok := broadcaster.listeners["mock"] + assert.True(ok) + assert.Len(listeners, 1) + assert.Equal(channel, listeners[0]) + }) + + t.Run("subsequent calls should return false", func(t *testing.T) { + assert := testify.New(t) + + broadcaster := newBroadcaster() + channel1 := make(chan *mojang.SignedTexturesResponse) + isFirstListener := broadcaster.AddListener("mock", channel1) + + assert.True(isFirstListener) + + channel2 := make(chan *mojang.SignedTexturesResponse) + isFirstListener = broadcaster.AddListener("mock", channel2) + + assert.False(isFirstListener) + + channel3 := make(chan *mojang.SignedTexturesResponse) + isFirstListener = broadcaster.AddListener("mock", channel3) + + assert.False(isFirstListener) + }) +} + +func TestBroadcastMap_BroadcastAndRemove(t *testing.T) { + t.Run("should broadcast to all listeners and remove the key", func(t *testing.T) { + assert := testify.New(t) + + broadcaster := newBroadcaster() + channel1 := make(chan *mojang.SignedTexturesResponse) + channel2 := make(chan *mojang.SignedTexturesResponse) + broadcaster.AddListener("mock", channel1) + broadcaster.AddListener("mock", channel2) + + result := &mojang.SignedTexturesResponse{Id: "mockUuid"} + broadcaster.BroadcastAndRemove("mock", result) + + assert.Equal(result, <-channel1) + assert.Equal(result, <-channel2) + + channel3 := make(chan *mojang.SignedTexturesResponse) + isFirstListener := broadcaster.AddListener("mock", channel3) + assert.True(isFirstListener) + }) + + t.Run("call on not exists username", func(t *testing.T) { + assert := testify.New(t) + + assert.NotPanics(func() { + broadcaster := newBroadcaster() + broadcaster.BroadcastAndRemove("mock", &mojang.SignedTexturesResponse{}) + }) + }) +} diff --git a/api/mojang/queue/in_memory_textures_storage.go b/api/mojang/queue/in_memory_textures_storage.go new file mode 100644 index 0000000..0abc05b --- /dev/null +++ b/api/mojang/queue/in_memory_textures_storage.go @@ -0,0 +1,94 @@ +package queue + +import ( + "sync" + "time" + + "github.com/elyby/chrly/api/mojang" + + "github.com/tevino/abool" +) + +var inMemoryStorageGCPeriod = time.Second +var inMemoryStoragePersistPeriod = time.Second * 60 +var now = time.Now + +type inMemoryItem struct { + textures *mojang.SignedTexturesResponse + timestamp int64 +} + +type inMemoryTexturesStorage struct { + lock sync.Mutex + data map[string]*inMemoryItem + working *abool.AtomicBool +} + +func CreateInMemoryTexturesStorage() *inMemoryTexturesStorage { + return &inMemoryTexturesStorage{ + data: make(map[string]*inMemoryItem), + } +} + +func (s *inMemoryTexturesStorage) Start() { + if s.working == nil { + s.working = abool.New() + } + + if !s.working.IsSet() { + go func() { + time.Sleep(inMemoryStorageGCPeriod) + // TODO: this can be reimplemented in future with channels, but right now I have no idea how to make it right + for s.working.IsSet() { + start := time.Now() + s.gc() + time.Sleep(inMemoryStorageGCPeriod - time.Since(start)) + } + }() + } + + s.working.Set() +} + +func (s *inMemoryTexturesStorage) Stop() { + s.working.UnSet() +} + +func (s *inMemoryTexturesStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) { + s.lock.Lock() + defer s.lock.Unlock() + + item, exists := s.data[uuid] + if !exists || now().Add(inMemoryStoragePersistPeriod*time.Duration(-1)).UnixNano()/10e5 > item.timestamp { + return nil, &ValueNotFound{} + } + + return item.textures, nil +} + +func (s *inMemoryTexturesStorage) StoreTextures(textures *mojang.SignedTexturesResponse) { + s.lock.Lock() + defer s.lock.Unlock() + + decoded := textures.DecodeTextures() + if decoded == nil { + panic("unable to decode textures") + } + + s.data[textures.Id] = &inMemoryItem{ + textures: textures, + timestamp: decoded.Timestamp, + } +} + +func (s *inMemoryTexturesStorage) gc() { + s.lock.Lock() + defer s.lock.Unlock() + + maxTime := now().Add(inMemoryStoragePersistPeriod*time.Duration(-1)).UnixNano() / 10e5 + for uuid, value := range s.data { + if maxTime > value.timestamp { + delete(s.data, uuid) + } + } +} diff --git a/api/mojang/queue/in_memory_textures_storage_test.go b/api/mojang/queue/in_memory_textures_storage_test.go new file mode 100644 index 0000000..27bce9f --- /dev/null +++ b/api/mojang/queue/in_memory_textures_storage_test.go @@ -0,0 +1,189 @@ +package queue + +import ( + "time" + + "github.com/elyby/chrly/api/mojang" + + testify "github.com/stretchr/testify/assert" + "testing" +) + +var texturesWithSkin = &mojang.SignedTexturesResponse{ + Id: "dead24f9a4fa4877b7b04c8c6c72bb46", + Name: "mock", + Props: []*mojang.Property{ + { + Name: "textures", + Value: mojang.EncodeTextures(&mojang.TexturesProp{ + Timestamp: time.Now().UnixNano() / 10e5, + ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46", + ProfileName: "mock", + Textures: &mojang.TexturesResponse{ + Skin: &mojang.SkinTexturesResponse{ + Url: "http://textures.minecraft.net/texture/74d1e08b0bb7e9f590af27758125bbed1778ac6cef729aedfcb9613e9911ae75", + }, + }, + }), + }, + }, +} +var texturesWithoutSkin = &mojang.SignedTexturesResponse{ + Id: "dead24f9a4fa4877b7b04c8c6c72bb46", + Name: "mock", + Props: []*mojang.Property{ + { + Name: "textures", + Value: mojang.EncodeTextures(&mojang.TexturesProp{ + Timestamp: time.Now().UnixNano() / 10e5, + ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46", + ProfileName: "mock", + Textures: &mojang.TexturesResponse{}, + }), + }, + }, +} + +func TestInMemoryTexturesStorage_GetTextures(t *testing.T) { + t.Run("get error when uuid is not exists", func(t *testing.T) { + assert := testify.New(t) + + storage := CreateInMemoryTexturesStorage() + result, err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579") + + assert.Nil(result) + assert.Error(err, "value not found in the storage") + }) + + t.Run("get textures object, when uuid is stored in the storage", func(t *testing.T) { + assert := testify.New(t) + + storage := CreateInMemoryTexturesStorage() + storage.StoreTextures(texturesWithSkin) + result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") + + assert.Equal(texturesWithSkin, result) + assert.Nil(err) + }) + + t.Run("get error when uuid is exists, but textures are expired", func(t *testing.T) { + assert := testify.New(t) + + storage := CreateInMemoryTexturesStorage() + storage.StoreTextures(texturesWithSkin) + + now = func() time.Time { + return time.Now().Add(time.Minute * 2) + } + + result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") + + assert.Nil(result) + assert.Error(err, "value not found in the storage") + + now = time.Now + }) +} + +func TestInMemoryTexturesStorage_StoreTextures(t *testing.T) { + t.Run("store textures for previously not existed uuid", func(t *testing.T) { + assert := testify.New(t) + + storage := CreateInMemoryTexturesStorage() + storage.StoreTextures(texturesWithSkin) + result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") + + assert.Equal(texturesWithSkin, result) + assert.Nil(err) + }) + + t.Run("override already existed textures for uuid", func(t *testing.T) { + assert := testify.New(t) + + storage := CreateInMemoryTexturesStorage() + storage.StoreTextures(texturesWithoutSkin) + storage.StoreTextures(texturesWithSkin) + result, err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") + + assert.NotEqual(texturesWithoutSkin, result) + assert.Equal(texturesWithSkin, result) + assert.Nil(err) + }) + + t.Run("should panic if textures prop is not decoded", func(t *testing.T) { + assert := testify.New(t) + + toStore := &mojang.SignedTexturesResponse{ + Id: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", + Name: "mock", + Props: []*mojang.Property{}, + } + + assert.PanicsWithValue("unable to decode textures", func() { + storage := CreateInMemoryTexturesStorage() + storage.StoreTextures(toStore) + }) + }) +} + +func TestInMemoryTexturesStorage_GarbageCollection(t *testing.T) { + assert := testify.New(t) + + inMemoryStorageGCPeriod = 10 * time.Millisecond + inMemoryStoragePersistPeriod = 10 * time.Millisecond + + textures1 := &mojang.SignedTexturesResponse{ + Id: "dead24f9a4fa4877b7b04c8c6c72bb46", + Name: "mock1", + Props: []*mojang.Property{ + { + Name: "textures", + Value: mojang.EncodeTextures(&mojang.TexturesProp{ + Timestamp: time.Now().Add(inMemoryStorageGCPeriod-time.Millisecond*time.Duration(5)).UnixNano() / 10e5, + ProfileID: "dead24f9a4fa4877b7b04c8c6c72bb46", + ProfileName: "mock1", + Textures: &mojang.TexturesResponse{}, + }), + }, + }, + } + textures2 := &mojang.SignedTexturesResponse{ + Id: "b5d58475007d4f9e9ddd1403e2497579", + Name: "mock2", + Props: []*mojang.Property{ + { + Name: "textures", + Value: mojang.EncodeTextures(&mojang.TexturesProp{ + Timestamp: time.Now().Add(inMemoryStorageGCPeriod-time.Millisecond*time.Duration(15)).UnixNano() / 10e5, + ProfileID: "b5d58475007d4f9e9ddd1403e2497579", + ProfileName: "mock2", + Textures: &mojang.TexturesResponse{}, + }), + }, + }, + } + + storage := CreateInMemoryTexturesStorage() + storage.StoreTextures(textures1) + storage.StoreTextures(textures2) + + storage.Start() + + time.Sleep(inMemoryStorageGCPeriod + time.Millisecond) // Let it start first iteration + + _, textures1Err := storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") + _, textures2Err := storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579") + + assert.Nil(textures1Err) + assert.Error(textures2Err) + + time.Sleep(inMemoryStorageGCPeriod + time.Millisecond) // Let another iteration happen + + _, textures1Err = storage.GetTextures("dead24f9a4fa4877b7b04c8c6c72bb46") + _, textures2Err = storage.GetTextures("b5d58475007d4f9e9ddd1403e2497579") + + assert.Error(textures1Err) + assert.Error(textures2Err) + + storage.Stop() +} diff --git a/api/mojang/queue/jobs_structure.go b/api/mojang/queue/jobs_structure.go new file mode 100644 index 0000000..987be3d --- /dev/null +++ b/api/mojang/queue/jobs_structure.go @@ -0,0 +1,56 @@ +// Based on the implementation from https://flaviocopes.com/golang-data-structure-queue/ + +package queue + +import ( + "sync" + + "github.com/elyby/chrly/api/mojang" +) + +type jobItem struct { + Username string + RespondTo chan *mojang.SignedTexturesResponse +} + +type jobsQueue struct { + lock sync.Mutex + items []*jobItem +} + +func (s *jobsQueue) New() *jobsQueue { + s.items = []*jobItem{} + return s +} + +func (s *jobsQueue) Enqueue(t *jobItem) { + s.lock.Lock() + defer s.lock.Unlock() + + s.items = append(s.items, t) +} + +func (s *jobsQueue) Dequeue(n int) []*jobItem { + s.lock.Lock() + defer s.lock.Unlock() + + if n > s.Size() { + n = s.Size() + } + + items := s.items[0:n] + s.items = s.items[n:len(s.items)] + + return items +} + +func (s *jobsQueue) IsEmpty() bool { + s.lock.Lock() + defer s.lock.Unlock() + + return len(s.items) == 0 +} + +func (s *jobsQueue) Size() int { + return len(s.items) +} diff --git a/api/mojang/queue/jobs_structure_test.go b/api/mojang/queue/jobs_structure_test.go new file mode 100644 index 0000000..b179d26 --- /dev/null +++ b/api/mojang/queue/jobs_structure_test.go @@ -0,0 +1,47 @@ +package queue + +import ( + "testing" + + testify "github.com/stretchr/testify/assert" +) + +func TestEnqueue(t *testing.T) { + assert := testify.New(t) + + s := createQueue() + s.Enqueue(&jobItem{Username: "username1"}) + s.Enqueue(&jobItem{Username: "username2"}) + s.Enqueue(&jobItem{Username: "username3"}) + + assert.Equal(3, s.Size()) +} + +func TestDequeueN(t *testing.T) { + assert := testify.New(t) + + s := createQueue() + s.Enqueue(&jobItem{Username: "username1"}) + s.Enqueue(&jobItem{Username: "username2"}) + s.Enqueue(&jobItem{Username: "username3"}) + s.Enqueue(&jobItem{Username: "username4"}) + + items := s.Dequeue(2) + assert.Len(items, 2) + assert.Equal("username1", items[0].Username) + assert.Equal("username2", items[1].Username) + assert.Equal(2, s.Size()) + + items = s.Dequeue(40) + assert.Len(items, 2) + assert.Equal("username3", items[0].Username) + assert.Equal("username4", items[1].Username) + assert.True(s.IsEmpty()) +} + +func createQueue() *jobsQueue { + queue := &jobsQueue{} + queue.New() + + return queue +} diff --git a/api/mojang/queue/queue.go b/api/mojang/queue/queue.go new file mode 100644 index 0000000..4a67992 --- /dev/null +++ b/api/mojang/queue/queue.go @@ -0,0 +1,207 @@ +package queue + +import ( + "net" + "regexp" + "strings" + "sync" + "syscall" + "time" + + "github.com/mono83/slf/wd" + + "github.com/elyby/chrly/api/mojang" +) + +var usernamesToUuids = mojang.UsernamesToUuids +var uuidToTextures = mojang.UuidToTextures +var uuidsQueuePeriod = time.Second +var forever = func() bool { + return true +} + +// https://help.mojang.com/customer/portal/articles/928638 +var allowedUsernamesRegex = regexp.MustCompile(`^[\w_]{3,16}$`) + +type JobsQueue struct { + Storage Storage + Logger wd.Watchdog + + onFirstCall sync.Once + queue jobsQueue + broadcast *broadcastMap +} + +func (ctx *JobsQueue) GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse { + // TODO: convert username to lower case + ctx.onFirstCall.Do(func() { + ctx.queue.New() + ctx.broadcast = newBroadcaster() + ctx.startQueue() + }) + + responseChan := make(chan *mojang.SignedTexturesResponse) + if !allowedUsernamesRegex.MatchString(username) { + ctx.Logger.IncCounter("mojang_textures.invalid_username", 1) + go func() { + responseChan <- nil + close(responseChan) + }() + + return responseChan + } + + ctx.Logger.IncCounter("mojang_textures.request", 1) + + uuid, err := ctx.Storage.GetUuid(username) + if err == nil && uuid == "" { + ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit_nil", 1) + + go func() { + responseChan <- nil + close(responseChan) + }() + + return responseChan + } + + isFirstListener := ctx.broadcast.AddListener(username, responseChan) + if isFirstListener { + start := time.Now() + // TODO: respond nil if processing takes more than 5 seconds + + resultChan := make(chan *mojang.SignedTexturesResponse) + if uuid == "" { + ctx.Logger.IncCounter("mojang_textures.usernames.queued", 1) + ctx.queue.Enqueue(&jobItem{username, resultChan}) + } else { + ctx.Logger.IncCounter("mojang_textures.usernames.cache_hit", 1) + go func() { + resultChan <- ctx.getTextures(uuid) + }() + } + + go func() { + result := <-resultChan + close(resultChan) + ctx.broadcast.BroadcastAndRemove(username, result) + ctx.Logger.RecordTimer("mojang_textures.result_time", time.Since(start)) + }() + } else { + ctx.Logger.IncCounter("mojang_textures.already_in_queue", 1) + } + + return responseChan +} + +func (ctx *JobsQueue) startQueue() { + go func() { + time.Sleep(uuidsQueuePeriod) + for forever() { + start := time.Now() + ctx.queueRound() + elapsed := time.Since(start) + ctx.Logger.RecordTimer("mojang_textures.usernames.round_time", elapsed) + time.Sleep(uuidsQueuePeriod - elapsed) + } + }() +} + +func (ctx *JobsQueue) queueRound() { + if ctx.queue.IsEmpty() { + return + } + + queueSize := ctx.queue.Size() + jobs := ctx.queue.Dequeue(100) + ctx.Logger.UpdateGauge("mojang_textures.usernames.iteration_size", int64(len(jobs))) + ctx.Logger.UpdateGauge("mojang_textures.usernames.queue_size", int64(queueSize-len(jobs))) + var usernames []string + for _, job := range jobs { + usernames = append(usernames, job.Username) + } + + profiles, err := usernamesToUuids(usernames) + if err != nil { + ctx.handleResponseError(err) + for _, job := range jobs { + job.RespondTo <- nil + } + + return + } + + for _, job := range jobs { + go func(job *jobItem) { + var uuid string + // Profiles in response not ordered, so we must search each username over full array + for _, profile := range profiles { + if strings.EqualFold(job.Username, profile.Name) { + uuid = profile.Id + break + } + } + + ctx.Storage.StoreUuid(job.Username, uuid) + if uuid == "" { + job.RespondTo <- nil + ctx.Logger.IncCounter("mojang_textures.usernames.uuid_miss", 1) + } else { + job.RespondTo <- ctx.getTextures(uuid) + ctx.Logger.IncCounter("mojang_textures.usernames.uuid_hit", 1) + } + }(job) + } +} + +func (ctx *JobsQueue) getTextures(uuid string) *mojang.SignedTexturesResponse { + existsTextures, err := ctx.Storage.GetTextures(uuid) + if err == nil { + ctx.Logger.IncCounter("mojang_textures.textures.cache_hit", 1) + return existsTextures + } + + ctx.Logger.IncCounter("mojang_textures.textures.request", 1) + + start := time.Now() + result, err := uuidToTextures(uuid, true) + ctx.Logger.RecordTimer("mojang_textures.textures.request_time", time.Since(start)) + shouldCache := true + if err != nil { + ctx.handleResponseError(err) + shouldCache = false + } + + if shouldCache && result != nil { + ctx.Storage.StoreTextures(result) + } + + return result +} + +func (ctx *JobsQueue) handleResponseError(err error) { + ctx.Logger.Debug("Got response error :err", wd.ErrParam(err)) + + switch err.(type) { + case mojang.ResponseError: + if _, ok := err.(*mojang.TooManyRequestsError); ok { + ctx.Logger.Warning("Got 429 Too Many Requests :err", wd.ErrParam(err)) + } + + return + case net.Error: + if err.(net.Error).Timeout() { + return + } + + if opErr, ok := err.(*net.OpError); ok && (opErr.Op == "dial" || opErr.Op == "read") { + return + } + + if err == syscall.ECONNREFUSED { + return + } + } + + ctx.Logger.Emergency("Unknown Mojang response error: :err", wd.ErrParam(err)) +} diff --git a/api/mojang/queue/queue_test.go b/api/mojang/queue/queue_test.go new file mode 100644 index 0000000..4372203 --- /dev/null +++ b/api/mojang/queue/queue_test.go @@ -0,0 +1,515 @@ +package queue + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "net" + "strings" + "syscall" + "time" + + "github.com/elyby/chrly/api/mojang" + + mocks "github.com/elyby/chrly/tests" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + "testing" +) + +type mojangApiMocks struct { + mock.Mock +} + +func (o *mojangApiMocks) UsernamesToUuids(usernames []string) ([]*mojang.ProfileInfo, error) { + args := o.Called(usernames) + var result []*mojang.ProfileInfo + if casted, ok := args.Get(0).([]*mojang.ProfileInfo); ok { + result = casted + } + + return result, args.Error(1) +} + +func (o *mojangApiMocks) UuidToTextures(uuid string, signed bool) (*mojang.SignedTexturesResponse, error) { + args := o.Called(uuid, signed) + var result *mojang.SignedTexturesResponse + if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok { + result = casted + } + + return result, args.Error(1) +} + +type mockStorage struct { + mock.Mock +} + +func (m *mockStorage) GetUuid(username string) (string, error) { + args := m.Called(username) + return args.String(0), args.Error(1) +} + +func (m *mockStorage) StoreUuid(username string, uuid string) { + m.Called(username, uuid) +} + +func (m *mockStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) { + args := m.Called(uuid) + var result *mojang.SignedTexturesResponse + if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok { + result = casted + } + + return result, args.Error(1) +} + +func (m *mockStorage) StoreTextures(textures *mojang.SignedTexturesResponse) { + m.Called(textures) +} + +type queueTestSuite struct { + suite.Suite + Queue *JobsQueue + Storage *mockStorage + MojangApi *mojangApiMocks + Logger *mocks.WdMock + Iterate func() + + iterateChan chan bool + done func() +} + +func (suite *queueTestSuite) SetupSuite() { + uuidsQueuePeriod = 0 +} + +func (suite *queueTestSuite) SetupTest() { + suite.Storage = &mockStorage{} + suite.Logger = &mocks.WdMock{} + + suite.Queue = &JobsQueue{Storage: suite.Storage, Logger: suite.Logger} + + suite.iterateChan = make(chan bool) + forever = func() bool { + return <-suite.iterateChan + } + + suite.Iterate = func() { + suite.iterateChan <- true + } + + suite.done = func() { + suite.iterateChan <- false + } + + suite.MojangApi = new(mojangApiMocks) + usernamesToUuids = suite.MojangApi.UsernamesToUuids + uuidToTextures = suite.MojangApi.UuidToTextures +} + +func (suite *queueTestSuite) TearDownTest() { + suite.done() + time.Sleep(10 * time.Millisecond) // Add delay to let finish all goroutines before assert mocks calls + suite.MojangApi.AssertExpectations(suite.T()) + suite.Storage.AssertExpectations(suite.T()) + suite.Logger.AssertExpectations(suite.T()) +} + +func (suite *queueTestSuite) TestReceiveTexturesForOneUsernameWithoutAnyCache() { + expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"} + + suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once() + suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything) + suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once() + suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{}) + suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once() + suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{}) + suite.Storage.On("StoreTextures", expectedResult).Once() + + suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{ + {Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}, + }, nil) + suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil) + + resultChan := suite.Queue.GetTexturesForUsername("maksimkurb") + + suite.Iterate() + + result := <-resultChan + suite.Assert().Equal(expectedResult, result) +} + +func (suite *queueTestSuite) TestReceiveTexturesForFewUsernamesWithoutAnyCache() { + expectedResult1 := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"} + expectedResult2 := &mojang.SignedTexturesResponse{Id: "4566e69fc90748ee8d71d7ba5aa00d20", Name: "Thinkofdeath"} + + suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice() + suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Twice() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(2)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything) + suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Twice() + suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Twice() + suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Twice() + suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Twice() + + suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{}) + suite.Storage.On("GetUuid", "Thinkofdeath").Once().Return("", &ValueNotFound{}) + suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once() + suite.Storage.On("StoreUuid", "Thinkofdeath", "4566e69fc90748ee8d71d7ba5aa00d20").Once() + suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{}) + suite.Storage.On("GetTextures", "4566e69fc90748ee8d71d7ba5aa00d20").Once().Return(nil, &ValueNotFound{}) + suite.Storage.On("StoreTextures", expectedResult1).Once() + suite.Storage.On("StoreTextures", expectedResult2).Once() + + suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb", "Thinkofdeath"}).Once().Return([]*mojang.ProfileInfo{ + {Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}, + {Id: "4566e69fc90748ee8d71d7ba5aa00d20", Name: "Thinkofdeath"}, + }, nil) + suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult1, nil) + suite.MojangApi.On("UuidToTextures", "4566e69fc90748ee8d71d7ba5aa00d20", true).Once().Return(expectedResult2, nil) + + resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb") + resultChan2 := suite.Queue.GetTexturesForUsername("Thinkofdeath") + + suite.Iterate() + + suite.Assert().Equal(expectedResult1, <-resultChan1) + suite.Assert().Equal(expectedResult2, <-resultChan2) +} + +func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithCachedUuid() { + expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"} + + suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once() + suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once() + suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once() + suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Once().Return("0d252b7218b648bfb86c2ae476954d32", nil) + // Storage.StoreUuid shouldn't be called + suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{}) + suite.Storage.On("StoreTextures", expectedResult).Once() + + // MojangApi.UsernamesToUuids shouldn't be called + suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil) + + resultChan := suite.Queue.GetTexturesForUsername("maksimkurb") + + // Note that there is no iteration + + result := <-resultChan + suite.Assert().Equal(expectedResult, result) +} + +func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithFullyCachedResult() { + expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"} + + suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once() + suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit", int64(1)).Once() + suite.Logger.On("IncCounter", "mojang_textures.textures.cache_hit", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Once().Return("0d252b7218b648bfb86c2ae476954d32", nil) + // Storage.StoreUuid shouldn't be called + suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(expectedResult, nil) + // Storage.StoreTextures shouldn't be called + + // MojangApi.UsernamesToUuids shouldn't be called + // MojangApi.UuidToTextures shouldn't be called + + resultChan := suite.Queue.GetTexturesForUsername("maksimkurb") + + // Note that there is no iteration + + result := <-resultChan + suite.Assert().Equal(expectedResult, result) +} + +func (suite *queueTestSuite) TestReceiveTexturesForUsernameWithCachedUnknownUuid() { + suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once() + suite.Logger.On("IncCounter", "mojang_textures.usernames.cache_hit_nil", int64(1)).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", nil) + // Storage.StoreUuid shouldn't be called + // Storage.GetTextures shouldn't be called + // Storage.StoreTextures shouldn't be called + + // MojangApi.UsernamesToUuids shouldn't be called + // MojangApi.UuidToTextures shouldn't be called + + resultChan := suite.Queue.GetTexturesForUsername("maksimkurb") + + // Note that there is no iteration + + suite.Assert().Nil(<-resultChan) +} + +func (suite *queueTestSuite) TestReceiveTexturesForMoreThan100Usernames() { + usernames := make([]string, 120) + for i := 0; i < 120; i++ { + usernames[i] = randStr(8) + } + + suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Times(120) + suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Times(120) + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(100)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(20)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(20)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything).Twice() + suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Times(120) + suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Times(120) + + suite.Storage.On("GetUuid", mock.Anything).Times(120).Return("", &ValueNotFound{}) + suite.Storage.On("StoreUuid", mock.Anything, "").Times(120) // if username is not compared to uuid, then receive "" + // Storage.GetTextures and Storage.SetTextures shouldn't be called + + suite.MojangApi.On("UsernamesToUuids", usernames[0:100]).Once().Return([]*mojang.ProfileInfo{}, nil) + suite.MojangApi.On("UsernamesToUuids", usernames[100:120]).Once().Return([]*mojang.ProfileInfo{}, nil) + + channels := make([]chan *mojang.SignedTexturesResponse, 120) + for i, username := range usernames { + channels[i] = suite.Queue.GetTexturesForUsername(username) + } + + suite.Iterate() + suite.Iterate() + + for _, channel := range channels { + <-channel + } +} + +func (suite *queueTestSuite) TestReceiveTexturesForTheSameUsernames() { + expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"} + + suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice() + suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once() + suite.Logger.On("IncCounter", "mojang_textures.already_in_queue", int64(1)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything) + suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once() + suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Twice().Return("", &ValueNotFound{}) + suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once() + suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{}) + suite.Storage.On("StoreTextures", expectedResult).Once() + suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{ + {Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}, + }, nil) + suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(expectedResult, nil) + + resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb") + resultChan2 := suite.Queue.GetTexturesForUsername("maksimkurb") + + suite.Iterate() + + suite.Assert().Equal(expectedResult, <-resultChan1) + suite.Assert().Equal(expectedResult, <-resultChan2) +} + +func (suite *queueTestSuite) TestReceiveTexturesForUsernameThatAlreadyProcessing() { + expectedResult := &mojang.SignedTexturesResponse{Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"} + + suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Twice() + suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once() + suite.Logger.On("IncCounter", "mojang_textures.already_in_queue", int64(1)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything) + suite.Logger.On("IncCounter", "mojang_textures.textures.request", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.textures.request_time", mock.Anything).Once() + suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_hit", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Twice().Return("", &ValueNotFound{}) + suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32").Once() + suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Once().Return(nil, &ValueNotFound{}) + suite.Storage.On("StoreTextures", expectedResult).Once() + + suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{ + {Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}, + }, nil) + suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true). + Once(). + After(10*time.Millisecond). // Simulate long round trip + Return(expectedResult, nil) + + resultChan1 := suite.Queue.GetTexturesForUsername("maksimkurb") + + // Note that for entire test there is only one iteration + suite.Iterate() + + // Let it meet delayed UuidToTextures request + time.Sleep(5 * time.Millisecond) + + resultChan2 := suite.Queue.GetTexturesForUsername("maksimkurb") + + suite.Assert().Equal(expectedResult, <-resultChan1) + suite.Assert().Equal(expectedResult, <-resultChan2) +} + +func (suite *queueTestSuite) TestDoNothingWhenNoTasks() { + suite.Logger.On("IncCounter", "mojang_textures.request", int64(1)).Once() + suite.Logger.On("IncCounter", "mojang_textures.usernames.queued", int64(1)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.iteration_size", int64(1)).Once() + suite.Logger.On("UpdateGauge", "mojang_textures.usernames.queue_size", int64(0)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.usernames.round_time", mock.Anything) + suite.Logger.On("IncCounter", "mojang_textures.usernames.uuid_miss", int64(1)).Once() + suite.Logger.On("RecordTimer", "mojang_textures.result_time", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Once().Return("", &ValueNotFound{}) + suite.Storage.On("StoreUuid", "maksimkurb", "").Once() + // Storage.GetTextures and Storage.StoreTextures shouldn't be called + + suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{}, nil) + + // Perform first iteration and await it finish + resultChan := suite.Queue.GetTexturesForUsername("maksimkurb") + + suite.Iterate() + + suite.Assert().Nil(<-resultChan) + + // Let it to perform a few more iterations to ensure, that there is no calls to external APIs + suite.Iterate() + suite.Iterate() +} + +type timeoutError struct { +} + +func (*timeoutError) Error() string { return "timeout error" } +func (*timeoutError) Timeout() bool { return true } +func (*timeoutError) Temporary() bool { return false } + +var expectedErrors = []error{ + &mojang.BadRequestError{}, + &mojang.TooManyRequestsError{}, + &mojang.ServerError{}, + &timeoutError{}, + &net.OpError{Op: "read"}, + &net.OpError{Op: "dial"}, + syscall.ECONNREFUSED, +} + +func (suite *queueTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUsernameToUuidRequest() { + suite.Logger.On("IncCounter", mock.Anything, mock.Anything) + suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything) + suite.Logger.On("RecordTimer", mock.Anything, mock.Anything) + suite.Logger.On("Debug", "Got response error :err", mock.Anything).Times(len(expectedErrors)) + suite.Logger.On("Warning", "Got 429 Too Many Requests :err", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{}) + + for _, err := range expectedErrors { + suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return(nil, err) + + resultChan := suite.Queue.GetTexturesForUsername("maksimkurb") + suite.Iterate() + suite.Assert().Nil(<-resultChan) + suite.MojangApi.AssertExpectations(suite.T()) + suite.MojangApi.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364 + } +} + +func (suite *queueTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUsernameToUuidRequest() { + suite.Logger.On("IncCounter", mock.Anything, mock.Anything) + suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything) + suite.Logger.On("RecordTimer", mock.Anything, mock.Anything) + suite.Logger.On("Debug", "Got response error :err", mock.Anything).Once() + suite.Logger.On("Emergency", "Unknown Mojang response error: :err", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{}) + + suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return(nil, errors.New("unexpected error")) + + resultChan := suite.Queue.GetTexturesForUsername("maksimkurb") + suite.Iterate() + suite.Assert().Nil(<-resultChan) +} + +func (suite *queueTestSuite) TestShouldNotLogErrorWhenExpectedErrorReturnedFromUuidToTexturesRequest() { + suite.Logger.On("IncCounter", mock.Anything, mock.Anything) + suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything) + suite.Logger.On("RecordTimer", mock.Anything, mock.Anything) + suite.Logger.On("Debug", "Got response error :err", mock.Anything).Times(len(expectedErrors)) + suite.Logger.On("Warning", "Got 429 Too Many Requests :err", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{}) + suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32") + suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Return(nil, &ValueNotFound{}) + // Storage.StoreTextures shouldn't be called + + for _, err := range expectedErrors { + suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{ + {Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}, + }, nil) + suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(nil, err) + + resultChan := suite.Queue.GetTexturesForUsername("maksimkurb") + suite.Iterate() + suite.Assert().Nil(<-resultChan) + suite.MojangApi.AssertExpectations(suite.T()) + suite.MojangApi.ExpectedCalls = nil // https://github.com/stretchr/testify/issues/558#issuecomment-372112364 + } +} + +func (suite *queueTestSuite) TestShouldLogEmergencyOnUnexpectedErrorReturnedFromUuidToTexturesRequest() { + suite.Logger.On("IncCounter", mock.Anything, mock.Anything) + suite.Logger.On("UpdateGauge", mock.Anything, mock.Anything) + suite.Logger.On("RecordTimer", mock.Anything, mock.Anything) + suite.Logger.On("Debug", "Got response error :err", mock.Anything).Once() + suite.Logger.On("Emergency", "Unknown Mojang response error: :err", mock.Anything).Once() + + suite.Storage.On("GetUuid", "maksimkurb").Return("", &ValueNotFound{}) + suite.Storage.On("StoreUuid", "maksimkurb", "0d252b7218b648bfb86c2ae476954d32") + suite.Storage.On("GetTextures", "0d252b7218b648bfb86c2ae476954d32").Return(nil, &ValueNotFound{}) + // Storage.StoreTextures shouldn't be called + + suite.MojangApi.On("UsernamesToUuids", []string{"maksimkurb"}).Once().Return([]*mojang.ProfileInfo{ + {Id: "0d252b7218b648bfb86c2ae476954d32", Name: "maksimkurb"}, + }, nil) + suite.MojangApi.On("UuidToTextures", "0d252b7218b648bfb86c2ae476954d32", true).Once().Return(nil, errors.New("unexpected error")) + + resultChan := suite.Queue.GetTexturesForUsername("maksimkurb") + suite.Iterate() + suite.Assert().Nil(<-resultChan) +} + +func (suite *queueTestSuite) TestReceiveTexturesForNotAllowedMojangUsername() { + suite.Logger.On("IncCounter", "mojang_textures.invalid_username", int64(1)).Once() + + resultChan := suite.Queue.GetTexturesForUsername("Not allowed") + suite.Assert().Nil(<-resultChan) +} + +func TestJobsQueueSuite(t *testing.T) { + suite.Run(t, new(queueTestSuite)) +} + +var replacer = strings.NewReplacer("-", "_", "=", "") + +// https://stackoverflow.com/a/50581165 +func randStr(len int) string { + buff := make([]byte, len) + _, _ = rand.Read(buff) + str := replacer.Replace(base64.URLEncoding.EncodeToString(buff)) + + // Base 64 can be longer than len + return str[:len] +} diff --git a/api/mojang/queue/storage.go b/api/mojang/queue/storage.go new file mode 100644 index 0000000..f0ffd50 --- /dev/null +++ b/api/mojang/queue/storage.go @@ -0,0 +1,53 @@ +package queue + +import "github.com/elyby/chrly/api/mojang" + +type UuidsStorage interface { + GetUuid(username string) (string, error) + StoreUuid(username string, uuid string) +} + +type TexturesStorage interface { + // nil can be returned to indicate that there is no textures for uuid + // and we know about it. Return err only in case, when storage completely + // don't know anything about uuid + GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) + StoreTextures(textures *mojang.SignedTexturesResponse) +} + +type Storage interface { + UuidsStorage + TexturesStorage +} + +// SplittedStorage allows you to use separate storage engines to satisfy +// the Storage interface +type SplittedStorage struct { + UuidsStorage + TexturesStorage +} + +func (s *SplittedStorage) GetUuid(username string) (string, error) { + return s.UuidsStorage.GetUuid(username) +} + +func (s *SplittedStorage) StoreUuid(username string, uuid string) { + s.UuidsStorage.StoreUuid(username, uuid) +} + +func (s *SplittedStorage) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) { + return s.TexturesStorage.GetTextures(uuid) +} + +func (s *SplittedStorage) StoreTextures(textures *mojang.SignedTexturesResponse) { + s.TexturesStorage.StoreTextures(textures) +} + +// This error can be used to indicate, that requested +// value doesn't exists in the storage +type ValueNotFound struct { +} + +func (*ValueNotFound) Error() string { + return "value not found in the storage" +} diff --git a/api/mojang/queue/storage_test.go b/api/mojang/queue/storage_test.go new file mode 100644 index 0000000..fb93767 --- /dev/null +++ b/api/mojang/queue/storage_test.go @@ -0,0 +1,88 @@ +package queue + +import ( + "github.com/elyby/chrly/api/mojang" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "testing" +) + +type uuidsStorageMock struct { + mock.Mock +} + +func (m *uuidsStorageMock) GetUuid(username string) (string, error) { + args := m.Called(username) + return args.String(0), args.Error(1) +} + +func (m *uuidsStorageMock) StoreUuid(username string, uuid string) { + m.Called(username, uuid) +} + +type texturesStorageMock struct { + mock.Mock +} + +func (m *texturesStorageMock) GetTextures(uuid string) (*mojang.SignedTexturesResponse, error) { + args := m.Called(uuid) + var result *mojang.SignedTexturesResponse + if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok { + result = casted + } + + return result, args.Error(1) +} + +func (m *texturesStorageMock) StoreTextures(textures *mojang.SignedTexturesResponse) { + m.Called(textures) +} + +func TestSplittedStorage(t *testing.T) { + createMockedStorage := func() (*SplittedStorage, *uuidsStorageMock, *texturesStorageMock) { + uuidsStorage := &uuidsStorageMock{} + texturesStorage := &texturesStorageMock{} + + return &SplittedStorage{uuidsStorage, texturesStorage}, uuidsStorage, texturesStorage + } + + t.Run("GetUuid", func(t *testing.T) { + storage, uuidsMock, _ := createMockedStorage() + uuidsMock.On("GetUuid", "username").Once().Return("find me", nil) + result, err := storage.GetUuid("username") + assert.Nil(t, err) + assert.Equal(t, "find me", result) + uuidsMock.AssertExpectations(t) + }) + + t.Run("StoreUuid", func(t *testing.T) { + storage, uuidsMock, _ := createMockedStorage() + uuidsMock.On("StoreUuid", "username", "result").Once() + storage.StoreUuid("username", "result") + uuidsMock.AssertExpectations(t) + }) + + t.Run("GetTextures", func(t *testing.T) { + result := &mojang.SignedTexturesResponse{Id: "mock id"} + storage, _, texturesMock := createMockedStorage() + texturesMock.On("GetTextures", "uuid").Once().Return(result, nil) + returned, err := storage.GetTextures("uuid") + assert.Nil(t, err) + assert.Equal(t, result, returned) + texturesMock.AssertExpectations(t) + }) + + t.Run("StoreTextures", func(t *testing.T) { + toStore := &mojang.SignedTexturesResponse{Id: "mock id"} + storage, _, texturesMock := createMockedStorage() + texturesMock.On("StoreTextures", toStore).Once() + storage.StoreTextures(toStore) + texturesMock.AssertExpectations(t) + }) +} + +func TestValueNotFound_Error(t *testing.T) { + err := &ValueNotFound{} + assert.Equal(t, "value not found in the storage", err.Error()) +} diff --git a/api/mojang/textures.go b/api/mojang/textures.go new file mode 100644 index 0000000..7ac1c4e --- /dev/null +++ b/api/mojang/textures.go @@ -0,0 +1,51 @@ +package mojang + +import ( + "encoding/base64" + "encoding/json" +) + +type TexturesProp struct { + Timestamp int64 `json:"timestamp"` + ProfileID string `json:"profileId"` + ProfileName string `json:"profileName"` + Textures *TexturesResponse `json:"textures"` +} + +type TexturesResponse struct { + Skin *SkinTexturesResponse `json:"SKIN,omitempty"` + Cape *CapeTexturesResponse `json:"CAPE,omitempty"` +} + +type SkinTexturesResponse struct { + Url string `json:"url"` + Metadata *SkinTexturesMetadata `json:"metadata,omitempty"` +} + +type SkinTexturesMetadata struct { + Model string `json:"model"` +} + +type CapeTexturesResponse struct { + Url string `json:"url"` +} + +func DecodeTextures(encodedTextures string) (*TexturesProp, error) { + jsonStr, err := base64.URLEncoding.DecodeString(encodedTextures) + if err != nil { + return nil, err + } + + var result *TexturesProp + err = json.Unmarshal(jsonStr, &result) + if err != nil { + return nil, err + } + + return result, nil +} + +func EncodeTextures(textures *TexturesProp) string { + jsonSerialized, _ := json.Marshal(textures) + return base64.URLEncoding.EncodeToString(jsonSerialized) +} diff --git a/api/mojang/textures_test.go b/api/mojang/textures_test.go new file mode 100644 index 0000000..7d88e8d --- /dev/null +++ b/api/mojang/textures_test.go @@ -0,0 +1,112 @@ +package mojang + +import ( + testify "github.com/stretchr/testify/assert" + "testing" +) + +type texturesTestCase struct { + Name string + Encoded string + Decoded *TexturesProp +} + +var texturesTestCases = []*texturesTestCase{ + { + Name: "property without textures", + Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYwMTA0OTQsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6e319", + Decoded: &TexturesProp{ + ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9", + ProfileName: "ErickSkrauch", + Timestamp: int64(1555856010494), + Textures: &TexturesResponse{}, + }, + }, + { + Name: "property with classic skin textures", + Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTYzMDc0MTIsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvZmMxNzU3NjMzN2ExMDZkOWMyMmFjNzgyZTM2MmMxNmM0ZTBlNDliZTUzZmFhNDE4NTdiZmYzMzJiNzc5MjgxZSJ9fX0=", + Decoded: &TexturesProp{ + ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9", + ProfileName: "ErickSkrauch", + Timestamp: int64(1555856307412), + Textures: &TexturesResponse{ + Skin: &SkinTexturesResponse{ + Url: "http://textures.minecraft.net/texture/fc17576337a106d9c22ac782e362c16c4e0e49be53faa41857bff332b779281e", + }, + }, + }, + }, + { + Name: "property with alex skin textures", + Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTY0OTQ3OTEsInByb2ZpbGVJZCI6IjNlM2VlNmMzNWFmYTQ4YWJiNjFlOGNkOGM0MmZjMGQ5IiwicHJvZmlsZU5hbWUiOiJFcmlja1NrcmF1Y2giLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNjlmNzUzNWY4YzNhMjE1ZDFkZTc3MmIyODdmMTc3M2IzNTg5OGVmNzUyZDI2YmRkZjRhMjVhZGFiNjVjMTg1OSIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19", + Decoded: &TexturesProp{ + ProfileID: "3e3ee6c35afa48abb61e8cd8c42fc0d9", + ProfileName: "ErickSkrauch", + Timestamp: int64(1555856494791), + Textures: &TexturesResponse{ + Skin: &SkinTexturesResponse{ + Url: "http://textures.minecraft.net/texture/69f7535f8c3a215d1de772b287f1773b35898ef752d26bddf4a25adab65c1859", + Metadata: &SkinTexturesMetadata{ + Model: "slim", + }, + }, + }, + }, + }, + { + Name: "property with skin and cape textures", + Encoded: "eyJ0aW1lc3RhbXAiOjE1NTU4NTc2NzUzMzUsInByb2ZpbGVJZCI6ImQ5MGI2OGJjODE3MjQzMjlhMDQ3ZjExODZkY2Q0MzM2IiwicHJvZmlsZU5hbWUiOiJha3Jvbm1hbjEiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvM2U2ZGVmY2I3ZGU1YTBlMDVjNzUyNWM2Y2Q0NmU0YjliNDE2YjkyZTBjZjRiYWExZTBhOWUyMTJhODg3ZjNmNyJ9LCJDQVBFIjp7InVybCI6Imh0dHA6Ly90ZXh0dXJlcy5taW5lY3JhZnQubmV0L3RleHR1cmUvNzBlZmZmYWY4NmZlNWJjMDg5NjA4ZDNjYjI5N2QzZTI3NmI5ZWI3YThmOWYyZmU2NjU5YzIzYTJkOGIxOGVkZiJ9fX0=", + Decoded: &TexturesProp{ + ProfileID: "d90b68bc81724329a047f1186dcd4336", + ProfileName: "akronman1", + Timestamp: int64(1555857675335), + Textures: &TexturesResponse{ + Skin: &SkinTexturesResponse{ + Url: "http://textures.minecraft.net/texture/3e6defcb7de5a0e05c7525c6cd46e4b9b416b92e0cf4baa1e0a9e212a887f3f7", + }, + Cape: &CapeTexturesResponse{ + Url: "http://textures.minecraft.net/texture/70efffaf86fe5bc089608d3cb297d3e276b9eb7a8f9f2fe6659c23a2d8b18edf", + }, + }, + }, + }, +} + +func TestDecodeTextures(t *testing.T) { + for _, testCase := range texturesTestCases { + t.Run("decode "+testCase.Name, func(t *testing.T) { + assert := testify.New(t) + + result, err := DecodeTextures(testCase.Encoded) + assert.Nil(err) + assert.Equal(testCase.Decoded, result) + }) + } + + t.Run("should return error if invalid base64 passed", func(t *testing.T) { + assert := testify.New(t) + + result, err := DecodeTextures("invalid base64") + assert.Error(err) + assert.Nil(result) + }) + + t.Run("should return error if invalid json found inside base64", func(t *testing.T) { + assert := testify.New(t) + + result, err := DecodeTextures("aW52YWxpZCBqc29u") // encoded "invalid json" + assert.Error(err) + assert.Nil(result) + }) +} + +func TestEncodeTextures(t *testing.T) { + for _, testCase := range texturesTestCases { + t.Run("encode "+testCase.Name, func(t *testing.T) { + assert := testify.New(t) + + result := EncodeTextures(testCase.Decoded) + assert.Equal(testCase.Encoded, result) + }) + } +} diff --git a/cmd/serve.go b/cmd/serve.go index 32794eb..bc72be8 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -4,11 +4,11 @@ import ( "fmt" "log" - "github.com/elyby/chrly/auth" - "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/elyby/chrly/api/mojang/queue" + "github.com/elyby/chrly/auth" "github.com/elyby/chrly/bootstrap" "github.com/elyby/chrly/db" "github.com/elyby/chrly/http" @@ -27,7 +27,8 @@ var serveCmd = &cobra.Command{ storageFactory := db.StorageFactory{Config: viper.GetViper()} logger.Info("Initializing skins repository") - skinsRepo, err := storageFactory.CreateFactory("redis").CreateSkinsRepository() + redisFactory := storageFactory.CreateFactory("redis") + skinsRepo, err := redisFactory.CreateSkinsRepository() if err != nil { logger.Emergency(fmt.Sprintf("Error on creating skins repo: %+v", err)) return @@ -35,19 +36,37 @@ var serveCmd = &cobra.Command{ logger.Info("Skins repository successfully initialized") logger.Info("Initializing capes repository") - capesRepo, err := storageFactory.CreateFactory("filesystem").CreateCapesRepository() + filesystemFactory := storageFactory.CreateFactory("filesystem") + capesRepo, err := filesystemFactory.CreateCapesRepository() if err != nil { logger.Emergency(fmt.Sprintf("Error on creating capes repo: %v", err)) return } logger.Info("Capes repository successfully initialized") + logger.Info("Preparing Mojang's textures queue") + mojangUuidsRepository, err := redisFactory.CreateMojangUuidsRepository() + if err != nil { + logger.Emergency(fmt.Sprintf("Error on creating mojang uuids repo: %v", err)) + return + } + + mojangTexturesQueue := &queue.JobsQueue{ + Logger: logger, + Storage: &queue.SplittedStorage{ + UuidsStorage: mojangUuidsRepository, + TexturesStorage: queue.CreateInMemoryTexturesStorage(), + }, + } + logger.Info("Mojang's textures queue is successfully initialized") + cfg := &http.Config{ - ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")), - SkinsRepo: skinsRepo, - CapesRepo: capesRepo, - Logger: logger, - Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))}, + ListenSpec: fmt.Sprintf("%s:%d", viper.GetString("server.host"), viper.GetInt("server.port")), + SkinsRepo: skinsRepo, + CapesRepo: capesRepo, + MojangTexturesQueue: mojangTexturesQueue, + Logger: logger, + Auth: &auth.JwtAuth{Key: []byte(viper.GetString("chrly.secret"))}, } if err := cfg.Run(); err != nil { diff --git a/db/factory.go b/db/factory.go index 93f88c8..2ba75a7 100644 --- a/db/factory.go +++ b/db/factory.go @@ -3,6 +3,7 @@ package db import ( "github.com/spf13/viper" + "github.com/elyby/chrly/api/mojang/queue" "github.com/elyby/chrly/interfaces" ) @@ -13,6 +14,7 @@ type StorageFactory struct { type RepositoriesCreator interface { CreateSkinsRepository() (interfaces.SkinsRepository, error) CreateCapesRepository() (interfaces.CapesRepository, error) + CreateMojangUuidsRepository() (queue.UuidsStorage, error) } func (factory *StorageFactory) CreateFactory(backend string) RepositoriesCreator { @@ -25,7 +27,7 @@ func (factory *StorageFactory) CreateFactory(backend string) RepositoriesCreator } case "filesystem": return &FilesystemFactory{ - BasePath : factory.Config.GetString("storage.filesystem.basePath"), + BasePath: factory.Config.GetString("storage.filesystem.basePath"), CapesDirName: factory.Config.GetString("storage.filesystem.capesDirName"), } } diff --git a/db/filesystem.go b/db/filesystem.go index cbc6251..4674652 100644 --- a/db/filesystem.go +++ b/db/filesystem.go @@ -5,12 +5,13 @@ import ( "path" "strings" + "github.com/elyby/chrly/api/mojang/queue" "github.com/elyby/chrly/interfaces" "github.com/elyby/chrly/model" ) type FilesystemFactory struct { - BasePath string + BasePath string CapesDirName string } @@ -26,6 +27,10 @@ func (f FilesystemFactory) CreateCapesRepository() (interfaces.CapesRepository, return &filesStorage{path: path.Join(f.BasePath, f.CapesDirName)}, nil } +func (f FilesystemFactory) CreateMojangUuidsRepository() (queue.UuidsStorage, error) { + panic("implement me") +} + func (f FilesystemFactory) validateFactoryConfig() error { if f.BasePath == "" { return &ParamRequired{"basePath"} @@ -47,7 +52,7 @@ func (repository *filesStorage) FindByUsername(username string) (*model.Cape, er return nil, &CapeNotFoundError{username} } - capePath := path.Join(repository.path, strings.ToLower(username) + ".png") + capePath := path.Join(repository.path, strings.ToLower(username)+".png") file, err := os.Open(capePath) if err != nil { return nil, &CapeNotFoundError{username} diff --git a/db/redis.go b/db/redis.go index 08f1359..0a8253b 100644 --- a/db/redis.go +++ b/db/redis.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "log" + "strconv" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/mediocregopher/radix.v2/redis" "github.com/mediocregopher/radix.v2/util" + "github.com/elyby/chrly/api/mojang/queue" "github.com/elyby/chrly/interfaces" "github.com/elyby/chrly/model" ) @@ -27,7 +29,20 @@ type RedisFactory struct { // TODO: maybe we should manually return connection to the pool? +// TODO: Why isn't a pointer used here? func (f RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error) { + return f.createInstance() +} + +func (f RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error) { + panic("capes repository not supported for this storage type") +} + +func (f RedisFactory) CreateMojangUuidsRepository() (queue.UuidsStorage, error) { + return f.createInstance() +} + +func (f RedisFactory) createInstance() (*redisDb, error) { connection, err := f.getConnection() if err != nil { return nil, err @@ -36,10 +51,6 @@ func (f RedisFactory) CreateSkinsRepository() (interfaces.SkinsRepository, error return &redisDb{connection}, nil } -func (f RedisFactory) CreateCapesRepository() (interfaces.CapesRepository, error) { - panic("capes repository not supported for this storage type") -} - func (f RedisFactory) getConnection() (*pool.Pool, error) { if f.connection == nil { if f.Host == "" { @@ -89,7 +100,9 @@ type redisDb struct { } const accountIdToUsernameKey = "hash:username-to-account-id" +const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid" +// TODO: return connection to the pool after usage func (db *redisDb) FindByUsername(username string) (*model.Skin, error) { return findByUsername(username, db.getConn()) } @@ -110,6 +123,14 @@ func (db *redisDb) RemoveByUsername(username string) error { return removeByUsername(username, db.getConn()) } +func (db *redisDb) GetUuid(username string) (string, error) { + return findMojangUuidByUsername(username, db.getConn()) +} + +func (db *redisDb) StoreUuid(username string, uuid string) { + storeMojangUuid(username, uuid, db.getConn()) +} + func (db *redisDb) getConn() util.Cmder { conn, _ := db.conn.Get() return conn @@ -221,6 +242,28 @@ func save(skin *model.Skin, conn util.Cmder) error { return nil } +func findMojangUuidByUsername(username string, conn util.Cmder) (string, error) { + response := conn.Cmd("HGET", mojangUsernameToUuidKey, strings.ToLower(username)) + if response.IsType(redis.Nil) { + return "", &queue.ValueNotFound{} + } + + data, _ := response.Str() + parts := strings.Split(data, ":") + timestamp, _ := strconv.ParseInt(parts[1], 10, 64) + storedAt := time.Unix(timestamp, 0) + if storedAt.Add(time.Hour * 24 * 30).Before(time.Now()) { + return "", &queue.ValueNotFound{} + } + + return parts[0], nil +} + +func storeMojangUuid(username string, uuid string, conn util.Cmder) { + value := uuid + ":" + strconv.FormatInt(time.Now().Unix(), 10) + conn.Cmd("HSET", mojangUsernameToUuidKey, strings.ToLower(username), value) +} + func buildUsernameKey(username string) string { return "username:" + strings.ToLower(username) } diff --git a/http/api_test.go b/http/api_test.go index cc2cf6f..61c622d 100644 --- a/http/api_test.go +++ b/http/api_test.go @@ -17,474 +17,482 @@ import ( testify "github.com/stretchr/testify/assert" ) -func TestConfig_PostSkin_Valid(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - resultModel := createSkinModel("mock_user", false) - resultModel.SkinId = 5 - resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" - resultModel.Url = "http://ely.by/minecraft/skins/default.png" - resultModel.MojangTextures = "" - resultModel.MojangSignature = "" - - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil) - mocks.Skins.EXPECT().Save(resultModel).Return(nil) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1)) - - form := url.Values{ - "identityId": {"1"}, - "username": {"mock_user"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, - "is1_8": {"0"}, - "isSlim": {"0"}, - "url": {"http://ely.by/minecraft/skins/default.png"}, - } - - req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - assert.Equal(201, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.Empty(response) -} - -func TestConfig_PostSkin_ChangedIdentityId(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - resultModel := createSkinModel("mock_user", false) - resultModel.UserId = 2 - resultModel.SkinId = 5 - resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" - resultModel.Url = "http://ely.by/minecraft/skins/default.png" - resultModel.MojangTextures = "" - resultModel.MojangSignature = "" - - form := url.Values{ - "identityId": {"2"}, - "username": {"mock_user"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, - "is1_8": {"0"}, - "isSlim": {"0"}, - "url": {"http://ely.by/minecraft/skins/default.png"}, - } - - req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{"unknown"}) - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - mocks.Skins.EXPECT().RemoveByUsername("mock_user").Return(nil) - mocks.Skins.EXPECT().Save(resultModel).Return(nil) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1)) - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - assert.Equal(201, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.Empty(response) -} +func TestConfig_PostSkin(t *testing.T) { + t.Run("Upload new identity with textures info", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + resultModel := createSkinModel("mock_user", false) + resultModel.SkinId = 5 + resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" + resultModel.Url = "http://ely.by/minecraft/skins/default.png" + resultModel.MojangTextures = "" + resultModel.MojangSignature = "" + + form := url.Values{ + "identityId": {"1"}, + "username": {"mock_user"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://ely.by/minecraft/skins/default.png"}, + } -func TestConfig_PostSkin_ChangedUsername(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - resultModel := createSkinModel("changed_username", false) - resultModel.SkinId = 5 - resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" - resultModel.Url = "http://ely.by/minecraft/skins/default.png" - resultModel.MojangTextures = "" - resultModel.MojangSignature = "" - - form := url.Values{ - "identityId": {"1"}, - "username": {"changed_username"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, - "is1_8": {"0"}, - "isSlim": {"0"}, - "url": {"http://ely.by/minecraft/skins/default.png"}, - } + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() - req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil) - mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil) - mocks.Skins.EXPECT().Save(resultModel).Return(nil) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1)) - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - assert.Equal(201, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.Empty(response) -} + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{Who: "unknown"}) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{Who: "mock_user"}) + mocks.Skins.EXPECT().Save(resultModel).Return(nil) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1)) -func TestConfig_PostSkin_CompletelyNewIdentity(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - resultModel := createSkinModel("mock_user", false) - resultModel.SkinId = 5 - resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" - resultModel.Url = "http://ely.by/minecraft/skins/default.png" - resultModel.MojangTextures = "" - resultModel.MojangSignature = "" - - form := url.Values{ - "identityId": {"1"}, - "username": {"mock_user"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, - "is1_8": {"0"}, - "isSlim": {"0"}, - "url": {"http://ely.by/minecraft/skins/default.png"}, - } + config.CreateHandler().ServeHTTP(w, req) - req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Skins.EXPECT().FindByUserId(1).Return(nil, &db.SkinNotFoundError{"unknown"}) - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{"mock_user"}) - mocks.Skins.EXPECT().Save(resultModel).Return(nil) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1)) - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - assert.Equal(201, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.Empty(response) -} + resp := w.Result() + defer resp.Body.Close() + assert.Equal(201, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(response) + }) -func TestConfig_PostSkin_UploadSkin(t *testing.T) { - assert := testify.New(t) + t.Run("Upload new identity with skin file", func(t *testing.T) { + assert := testify.New(t) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - config, mocks := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - body := &bytes.Buffer{} - writer := multipart.NewWriter(body) + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) - part, _ := writer.CreateFormFile("skin", "char.png") - part.Write(loadSkinFile()) + part, _ := writer.CreateFormFile("skin", "char.png") + _, _ = part.Write(loadSkinFile()) - _ = writer.WriteField("identityId", "1") - _ = writer.WriteField("username", "mock_user") - _ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3") - _ = writer.WriteField("skinId", "5") + _ = writer.WriteField("identityId", "1") + _ = writer.WriteField("username", "mock_user") + _ = writer.WriteField("uuid", "0f657aa8-bfbe-415d-b700-5750090d3af3") + _ = writer.WriteField("skinId", "5") - err := writer.Close() - if err != nil { - panic(err) - } - - req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", body) - req.Header.Add("Content-Type", writer.FormDataContentType()) - w := httptest.NewRecorder() - - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1)) - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - assert.Equal(400, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`{ - "errors": { - "skin": [ - "Skin uploading is temporary unavailable" - ] + err := writer.Close() + if err != nil { + panic(err) } - }`, string(response)) -} -func TestConfig_PostSkin_RequiredFields(t *testing.T) { - assert := testify.New(t) + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", body) + req.Header.Add("Content-Type", writer.FormDataContentType()) + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1)) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(400, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "errors": { + "skin": [ + "Skin uploading is temporary unavailable" + ] + } + }`, string(response)) + }) + + t.Run("Keep the same identityId, uuid and username, but change textures information", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + resultModel := createSkinModel("mock_user", false) + resultModel.SkinId = 5 + resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" + resultModel.Url = "http://textures-server.com/skin.png" + resultModel.MojangTextures = "" + resultModel.MojangSignature = "" + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil) + mocks.Skins.EXPECT().Save(resultModel).Return(nil) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1)) + + form := url.Values{ + "identityId": {"1"}, + "username": {"mock_user"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://textures-server.com/skin.png"}, + } - ctrl := gomock.NewController(t) - defer ctrl.Finish() + req := httptest.NewRequest("POST", "http://chrly/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(201, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(response) + }) + + t.Run("Keep the same uuid and username, but change identityId", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + resultModel := createSkinModel("mock_user", false) + resultModel.UserId = 2 + resultModel.SkinId = 5 + resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" + resultModel.Url = "http://ely.by/minecraft/skins/default.png" + resultModel.MojangTextures = "" + resultModel.MojangSignature = "" + + form := url.Values{ + "identityId": {"2"}, + "username": {"mock_user"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://ely.by/minecraft/skins/default.png"}, + } - config, mocks := setupMocks(ctrl) + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{Who: "unknown"}) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) + mocks.Skins.EXPECT().RemoveByUsername("mock_user").Return(nil) + mocks.Skins.EXPECT().Save(resultModel).Return(nil) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1)) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(201, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(response) + }) + + t.Run("Keep the same identityId and uuid, but change username", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + resultModel := createSkinModel("changed_username", false) + resultModel.SkinId = 5 + resultModel.Hash = "94a457d92a61460cb9cb5d6f29732d2a" + resultModel.Url = "http://ely.by/minecraft/skins/default.png" + resultModel.MojangTextures = "" + resultModel.MojangSignature = "" + + form := url.Values{ + "identityId": {"1"}, + "username": {"changed_username"}, + "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, + "skinId": {"5"}, + "hash": {"94a457d92a61460cb9cb5d6f29732d2a"}, + "is1_8": {"0"}, + "isSlim": {"0"}, + "url": {"http://ely.by/minecraft/skins/default.png"}, + } - form := url.Values{ - "mojangTextures": {"someBase64EncodedString"}, - } + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() - req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1)) - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - defer resp.Body.Close() - assert.Equal(400, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`{ - "errors": { - "identityId": [ - "The identityId field is required", - "The identityId field must be numeric", - "The identityId field must be minimum 1 char" - ], - "skinId": [ - "The skinId field is required", - "The skinId field must be numeric", - "The skinId field must be minimum 1 char" - ], - "username": [ - "The username field is required" - ], - "uuid": [ - "The uuid field is required", - "The uuid field must contain valid UUID" - ], - "url": [ - "One of url or skin should be provided, but not both" - ], - "skin": [ - "One of url or skin should be provided, but not both" - ], - "mojangSignature": [ - "The mojangSignature field is required" - ] - } - }`, string(response)) -} + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil) + mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil) + mocks.Skins.EXPECT().Save(resultModel).Return(nil) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.success", int64(1)) -func TestConfig_PostSkin_Unauthorized(t *testing.T) { - assert := testify.New(t) + config.CreateHandler().ServeHTTP(w, req) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + resp := w.Result() + defer resp.Body.Close() + assert.Equal(201, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(response) + }) - config, mocks := setupMocks(ctrl) + t.Run("Get errors about required fields", func(t *testing.T) { + assert := testify.New(t) - req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", nil) - req.Header.Add("Authorization", "Bearer invalid.jwt.token") - w := httptest.NewRecorder() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{"Cannot parse passed JWT token"}) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1)) + config, mocks := setupMocks(ctrl) - config.CreateHandler().ServeHTTP(w, req) + form := url.Values{ + "mojangTextures": {"someBase64EncodedString"}, + } - resp := w.Result() - defer resp.Body.Close() - assert.Equal(403, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`{ - "error": "Cannot parse passed JWT token" - }`, string(response)) + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", bytes.NewBufferString(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.post.validation_failed", int64(1)) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(400, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "errors": { + "identityId": [ + "The identityId field is required", + "The identityId field must be numeric", + "The identityId field must be minimum 1 char" + ], + "skinId": [ + "The skinId field is required", + "The skinId field must be numeric", + "The skinId field must be minimum 1 char" + ], + "username": [ + "The username field is required" + ], + "uuid": [ + "The uuid field is required", + "The uuid field must contain valid UUID" + ], + "url": [ + "One of url or skin should be provided, but not both" + ], + "skin": [ + "One of url or skin should be provided, but not both" + ], + "mojangSignature": [ + "The mojangSignature field is required" + ] + } + }`, string(response)) + }) + + t.Run("Perform request without authorization", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + req := httptest.NewRequest("POST", "http://skinsystem.ely.by/api/skins", nil) + req.Header.Add("Authorization", "Bearer invalid.jwt.token") + w := httptest.NewRecorder() + + mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{Reason: "Cannot parse passed JWT token"}) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1)) + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + defer resp.Body.Close() + assert.Equal(403, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "error": "Cannot parse passed JWT token" + }`, string(response)) + }) } -func TestConfig_DeleteSkinByUserId_Success(t *testing.T) { - assert := testify.New(t) +func TestConfig_DeleteSkinByUserId(t *testing.T) { + t.Run("Delete skin by its identity id", func(t *testing.T) { + assert := testify.New(t) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - config, mocks := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/id:1", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/id:1", nil) + w := httptest.NewRecorder() - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil) - mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1)) + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(1).Return(createSkinModel("mock_user", false), nil) + mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1)) - config.CreateHandler().ServeHTTP(w, req) + config.CreateHandler().ServeHTTP(w, req) - resp := w.Result() - defer resp.Body.Close() - assert.Equal(204, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.Empty(response) -} + resp := w.Result() + defer resp.Body.Close() + assert.Equal(204, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(response) + }) -func TestConfig_DeleteSkinByUserId_NotFound(t *testing.T) { - assert := testify.New(t) + t.Run("Try to remove not exists identity id", func(t *testing.T) { + assert := testify.New(t) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - config, mocks := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/id:2", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/id:2", nil) + w := httptest.NewRecorder() - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{"unknown"}) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1)) + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUserId(2).Return(nil, &db.SkinNotFoundError{Who: "unknown"}) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1)) - config.CreateHandler().ServeHTTP(w, req) + config.CreateHandler().ServeHTTP(w, req) - resp := w.Result() - defer resp.Body.Close() - assert.Equal(404, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`[ - "Cannot find record for requested user id" - ]`, string(response)) + resp := w.Result() + defer resp.Body.Close() + assert.Equal(404, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`[ + "Cannot find record for requested user id" + ]`, string(response)) + }) } -func TestConfig_DeleteSkinByUsername_Success(t *testing.T) { - assert := testify.New(t) +func TestConfig_DeleteSkinByUsername(t *testing.T) { + t.Run("Delete skin by its identity username", func(t *testing.T) { + assert := testify.New(t) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - config, mocks := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/mock_user", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/mock_user", nil) + w := httptest.NewRecorder() - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1)) + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) + mocks.Skins.EXPECT().RemoveByUserId(1).Return(nil) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.delete.success", int64(1)) - config.CreateHandler().ServeHTTP(w, req) + config.CreateHandler().ServeHTTP(w, req) - resp := w.Result() - defer resp.Body.Close() - assert.Equal(204, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.Empty(response) -} + resp := w.Result() + defer resp.Body.Close() + assert.Equal(204, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Empty(response) + }) -func TestConfig_DeleteSkinByUsername_NotFound(t *testing.T) { - assert := testify.New(t) + t.Run("Try to remove not exists identity username", func(t *testing.T) { + assert := testify.New(t) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - config, mocks := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/mock_user_2", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("DELETE", "http://skinsystem.ely.by/api/skins/mock_user_2", nil) + w := httptest.NewRecorder() - mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) - mocks.Skins.EXPECT().FindByUsername("mock_user_2").Return(nil, &db.SkinNotFoundError{"mock_user_2"}) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1)) - mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1)) + mocks.Auth.EXPECT().Check(gomock.Any()).Return(nil) + mocks.Skins.EXPECT().FindByUsername("mock_user_2").Return(nil, &db.SkinNotFoundError{Who: "mock_user_2"}) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.success", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.delete.request", int64(1)) + mocks.Log.EXPECT().IncCounter("api.skins.delete.not_found", int64(1)) - config.CreateHandler().ServeHTTP(w, req) + config.CreateHandler().ServeHTTP(w, req) - resp := w.Result() - defer resp.Body.Close() - assert.Equal(404, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`[ + resp := w.Result() + defer resp.Body.Close() + assert.Equal(404, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`[ "Cannot find record for requested username" ]`, string(response)) + }) } -func TestConfig_Authenticate_SignatureKeyNotSet(t *testing.T) { - assert := testify.New(t) +func TestConfig_Authenticate(t *testing.T) { + t.Run("Test behavior when signing key is not set", func(t *testing.T) { + assert := testify.New(t) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - config, mocks := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - req := httptest.NewRequest("POST", "http://localhost", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("POST", "http://localhost", nil) + w := httptest.NewRecorder() - mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{"signing key not available"}) - mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) - mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1)) + mocks.Auth.EXPECT().Check(gomock.Any()).Return(&auth.Unauthorized{Reason: "signing key not available"}) + mocks.Log.EXPECT().IncCounter("authentication.challenge", int64(1)) + mocks.Log.EXPECT().IncCounter("authentication.failed", int64(1)) - res := config.Authenticate(http.HandlerFunc(func (resp http.ResponseWriter, req *http.Request) {})) - res.ServeHTTP(w, req) + res := config.Authenticate(http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {})) + res.ServeHTTP(w, req) - resp := w.Result() - defer resp.Body.Close() - assert.Equal(403, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`{ - "error": "signing key not available" - }`, string(response)) + resp := w.Result() + defer resp.Body.Close() + assert.Equal(403, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "error": "signing key not available" + }`, string(response)) + }) } // base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png diff --git a/http/cape.go b/http/cape.go index 9614ebd..7753fbd 100644 --- a/http/cape.go +++ b/http/cape.go @@ -14,13 +14,26 @@ func (cfg *Config) Cape(response http.ResponseWriter, request *http.Request) { username := parseUsername(mux.Vars(request)["username"]) rec, err := cfg.CapesRepo.FindByUsername(username) - if err != nil { - http.Redirect(response, request, "http://skins.minecraft.net/MinecraftCloaks/" + username + ".png", 301) + if err == nil { + request.Header.Set("Content-Type", "image/png") + _, _ = io.Copy(response, rec.File) return } - request.Header.Set("Content-Type", "image/png") - io.Copy(response, rec.File) + mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username) + if mojangTextures == nil { + response.WriteHeader(http.StatusNotFound) + return + } + + texturesProp := mojangTextures.DecodeTextures() + cape := texturesProp.Textures.Cape + if cape == nil { + response.WriteHeader(http.StatusNotFound) + return + } + + http.Redirect(response, request, cape.Url, 301) } func (cfg *Config) CapeGET(response http.ResponseWriter, request *http.Request) { diff --git a/http/cape_test.go b/http/cape_test.go index fe20a48..5c0de9a 100644 --- a/http/cape_test.go +++ b/http/cape_test.go @@ -5,6 +5,7 @@ import ( "image" "image/png" "io/ioutil" + "net/http" "net/http/httptest" "testing" @@ -15,123 +16,147 @@ import ( "github.com/elyby/chrly/model" ) -func TestConfig_Cape(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - cape := createCape() - - mocks.Capes.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{ - File: bytes.NewReader(cape), - }, nil) - mocks.Log.EXPECT().IncCounter("capes.request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/mocked_username", nil) - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(200, resp.StatusCode) - responseData, _ := ioutil.ReadAll(resp.Body) - assert.Equal(cape, responseData) - assert.Equal("image/png", resp.Header.Get("Content-Type")) -} - -func TestConfig_Cape2(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - mocks.Capes.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"}) - mocks.Log.EXPECT().IncCounter("capes.request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/notch", nil) - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://skins.minecraft.net/MinecraftCloaks/notch.png", resp.Header.Get("Location")) -} - -func TestConfig_CapeGET(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - cape := createCape() - - mocks.Capes.EXPECT().FindByUsername("mocked_username").Return(&model.Cape{ - File: bytes.NewReader(cape), - }, nil) - mocks.Log.EXPECT().IncCounter("capes.request", int64(1)).Times(0) - mocks.Log.EXPECT().IncCounter("capes.get_request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks?name=mocked_username", nil) - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(200, resp.StatusCode) - responseData, _ := ioutil.ReadAll(resp.Body) - assert.Equal(cape, responseData) - assert.Equal("image/png", resp.Header.Get("Content-Type")) +type capesTestCase struct { + Name string + RequestUrl string + ExpectedLogKey string + ExistsInLocalStorage bool + ExistsInMojang bool + HasCapeInMojangResp bool + AssertResponse func(assert *testify.Assertions, resp *http.Response) } -func TestConfig_CapeGET2(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - mocks.Capes.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{"notch"}) - mocks.Log.EXPECT().IncCounter("capes.request", int64(1)).Times(0) - mocks.Log.EXPECT().IncCounter("capes.get_request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks?name=notch", nil) - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://skins.minecraft.net/MinecraftCloaks/notch.png", resp.Header.Get("Location")) +var capesTestCases = []*capesTestCase{ + { + Name: "Obtain cape for known username", + ExistsInLocalStorage: true, + AssertResponse: func(assert *testify.Assertions, resp *http.Response) { + assert.Equal(200, resp.StatusCode) + responseData, _ := ioutil.ReadAll(resp.Body) + assert.Equal(createCape(), responseData) + assert.Equal("image/png", resp.Header.Get("Content-Type")) + }, + }, + { + Name: "Obtain cape for unknown username that exists in Mojang and has a cape", + ExistsInLocalStorage: false, + ExistsInMojang: true, + HasCapeInMojangResp: true, + AssertResponse: func(assert *testify.Assertions, resp *http.Response) { + assert.Equal(301, resp.StatusCode) + assert.Equal("http://mojang/cape.png", resp.Header.Get("Location")) + }, + }, + { + Name: "Obtain cape for unknown username that exists in Mojang, but don't has a cape", + ExistsInLocalStorage: false, + ExistsInMojang: true, + HasCapeInMojangResp: false, + AssertResponse: func(assert *testify.Assertions, resp *http.Response) { + assert.Equal(404, resp.StatusCode) + }, + }, + { + Name: "Obtain cape for unknown username that doesn't exists in Mojang", + ExistsInLocalStorage: false, + ExistsInMojang: false, + AssertResponse: func(assert *testify.Assertions, resp *http.Response) { + assert.Equal(404, resp.StatusCode) + }, + }, } -func TestConfig_CapeGET3(t *testing.T) { - assert := testify.New(t) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/cloaks/?name=notch", nil) - w := httptest.NewRecorder() - - (&Config{}).CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://skinsystem.ely.by/cloaks?name=notch", resp.Header.Get("Location")) +func TestConfig_Cape(t *testing.T) { + performTest := func(t *testing.T, testCase *capesTestCase) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + mocks.Log.EXPECT().IncCounter(testCase.ExpectedLogKey, int64(1)) + if testCase.ExistsInLocalStorage { + mocks.Capes.EXPECT().FindByUsername("mock_username").Return(&model.Cape{ + File: bytes.NewReader(createCape()), + }, nil) + } else { + mocks.Capes.EXPECT().FindByUsername("mock_username").Return(nil, &db.CapeNotFoundError{Who: "mock_username"}) + } + + if testCase.ExistsInMojang { + textures := createTexturesResponse(false, testCase.HasCapeInMojangResp) + mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(textures) + } else { + mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(nil) + } + + req := httptest.NewRequest("GET", testCase.RequestUrl, nil) + w := httptest.NewRecorder() + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + testCase.AssertResponse(assert, resp) + } + + t.Run("Normal API", func(t *testing.T) { + for _, testCase := range capesTestCases { + testCase.RequestUrl = "http://chrly/cloaks/mock_username" + testCase.ExpectedLogKey = "capes.request" + t.Run(testCase.Name, func(t *testing.T) { + performTest(t, testCase) + }) + } + }) + + t.Run("GET fallback API", func(t *testing.T) { + for _, testCase := range capesTestCases { + testCase.RequestUrl = "http://chrly/cloaks?name=mock_username" + testCase.ExpectedLogKey = "capes.get_request" + t.Run(testCase.Name, func(t *testing.T) { + performTest(t, testCase) + }) + } + + t.Run("Should trim trailing slash", func(t *testing.T) { + assert := testify.New(t) + + req := httptest.NewRequest("GET", "http://chrly/cloaks/?name=notch", nil) + w := httptest.NewRecorder() + + (&Config{}).CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(301, resp.StatusCode) + assert.Equal("http://chrly/cloaks?name=notch", resp.Header.Get("Location")) + }) + + t.Run("Return error when name is not provided", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + mocks.Log.EXPECT().IncCounter("capes.get_request", int64(1)) + + req := httptest.NewRequest("GET", "http://chrly/cloaks", nil) + w := httptest.NewRecorder() + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(400, resp.StatusCode) + }) + }) } // Cape md5: 424ff79dce9940af89c28ad80de8aaad func createCape() []byte { img := image.NewAlpha(image.Rect(0, 0, 64, 32)) writer := &bytes.Buffer{} - png.Encode(writer, img) - + _ = png.Encode(writer, img) pngBytes, _ := ioutil.ReadAll(writer) return pngBytes diff --git a/http/http.go b/http/http.go index a539a15..7e53171 100644 --- a/http/http.go +++ b/http/http.go @@ -19,10 +19,11 @@ import ( type Config struct { ListenSpec string - SkinsRepo interfaces.SkinsRepository - CapesRepo interfaces.CapesRepository - Logger wd.Watchdog - Auth interfaces.AuthChecker + SkinsRepo interfaces.SkinsRepository + CapesRepo interfaces.CapesRepository + MojangTexturesQueue interfaces.MojangTexturesQueue + Logger wd.Watchdog + Auth interfaces.AuthChecker } func (cfg *Config) Run() error { diff --git a/http/http_test.go b/http/http_test.go index 884899a..66b3472 100644 --- a/http/http_test.go +++ b/http/http_test.go @@ -2,7 +2,11 @@ package http import ( "testing" + "time" + "github.com/elyby/chrly/api/mojang" + + "github.com/elyby/chrly/tests" "github.com/golang/mock/gomock" testify "github.com/stretchr/testify/assert" @@ -19,6 +23,7 @@ func TestParseUsername(t *testing.T) { type mocks struct { Skins *mock_interfaces.MockSkinsRepository Capes *mock_interfaces.MockCapesRepository + Queue *tests.MojangTexturesQueueMock Auth *mock_interfaces.MockAuthChecker Log *mock_wd.MockWatchdog } @@ -31,16 +36,54 @@ func setupMocks(ctrl *gomock.Controller) ( capesRepo := mock_interfaces.NewMockCapesRepository(ctrl) authChecker := mock_interfaces.NewMockAuthChecker(ctrl) wd := mock_wd.NewMockWatchdog(ctrl) + texturesQueue := &tests.MojangTexturesQueueMock{} return &Config{ - SkinsRepo: skinsRepo, - CapesRepo: capesRepo, - Auth: authChecker, - Logger: wd, + SkinsRepo: skinsRepo, + CapesRepo: capesRepo, + Auth: authChecker, + MojangTexturesQueue: texturesQueue, + Logger: wd, }, &mocks{ Skins: skinsRepo, Capes: capesRepo, Auth: authChecker, + Queue: texturesQueue, Log: wd, } } + +func createTexturesResponse(includeSkin bool, includeCape bool) *mojang.SignedTexturesResponse { + timeZone, _ := time.LoadLocation("Europe/Minsk") + textures := &mojang.TexturesProp{ + Timestamp: time.Date(2019, 4, 27, 23, 56, 12, 0, timeZone).Unix(), + ProfileID: "00000000000000000000000000000000", + ProfileName: "mock_user", + Textures: &mojang.TexturesResponse{}, + } + + if includeSkin { + textures.Textures.Skin = &mojang.SkinTexturesResponse{ + Url: "http://mojang/skin.png", + } + } + + if includeCape { + textures.Textures.Cape = &mojang.CapeTexturesResponse{ + Url: "http://mojang/cape.png", + } + } + + response := &mojang.SignedTexturesResponse{ + Id: "00000000000000000000000000000000", + Name: "mock_user", + Props: []*mojang.Property{ + { + Name: "textures", + Value: mojang.EncodeTextures(textures), + }, + }, + } + + return response +} diff --git a/http/signed_textures.go b/http/signed_textures.go index 158cdaa..34002a5 100644 --- a/http/signed_textures.go +++ b/http/signed_textures.go @@ -6,47 +6,44 @@ import ( "strings" "github.com/gorilla/mux" -) - -type signedTexturesResponse struct { - Id string `json:"id"` - Name string `json:"name"` - Props []property `json:"properties"` -} -type property struct { - Name string `json:"name"` - Signature string `json:"signature,omitempty"` - Value string `json:"value"` -} + "github.com/elyby/chrly/api/mojang" +) func (cfg *Config) SignedTextures(response http.ResponseWriter, request *http.Request) { cfg.Logger.IncCounter("signed_textures.request", 1) username := parseUsername(mux.Vars(request)["username"]) + var responseData *mojang.SignedTexturesResponse + rec, err := cfg.SkinsRepo.FindByUsername(username) - if err != nil || rec.SkinId == 0 || rec.MojangTextures == "" { + if err == nil && rec.SkinId != 0 && rec.MojangTextures != "" { + responseData = &mojang.SignedTexturesResponse{ + Id: strings.Replace(rec.Uuid, "-", "", -1), + Name: rec.Username, + Props: []*mojang.Property{ + { + Name: "textures", + Signature: rec.MojangSignature, + Value: rec.MojangTextures, + }, + }, + } + } else if request.URL.Query().Get("proxy") != "" { + responseData = <-cfg.MojangTexturesQueue.GetTexturesForUsername(username) + } + + if responseData == nil { response.WriteHeader(http.StatusNoContent) return } - responseData:= signedTexturesResponse{ - Id: strings.Replace(rec.Uuid, "-", "", -1), - Name: rec.Username, - Props: []property{ - { - Name: "textures", - Signature: rec.MojangSignature, - Value: rec.MojangTextures, - }, - { - Name: "chrly", - Value: "how do you tame a horse in Minecraft?", - }, - }, - } + responseData.Props = append(responseData.Props, &mojang.Property{ + Name: "chrly", + Value: "how do you tame a horse in Minecraft?", + }) - responseJson,_ := json.Marshal(responseData) + responseJson, _ := json.Marshal(responseData) response.Header().Set("Content-Type", "application/json") response.Write(responseJson) } diff --git a/http/signed_textures_test.go b/http/signed_textures_test.go index 41934f5..c10fe1d 100644 --- a/http/signed_textures_test.go +++ b/http/signed_textures_test.go @@ -12,60 +12,130 @@ import ( ) func TestConfig_SignedTextures(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/signed/mock_user", nil) - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(200, resp.StatusCode) - assert.Equal("application/json", resp.Header.Get("Content-Type")) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`{ - "id": "0f657aa8bfbe415db7005750090d3af3", - "name": "mock_user", - "properties": [ - { - "name": "textures", - "signature": "mocked signature", - "value": "mocked textures base64" - }, - { - "name": "chrly", - "value": "how do you tame a horse in Minecraft?" - } - ] - }`, string(response)) -} - -func TestConfig_SignedTextures2(t *testing.T) { - assert := testify.New(t) + t.Run("Obtain signed textures for exists user", func(t *testing.T) { + assert := testify.New(t) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - config, mocks := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{}) - mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1)) + mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1)) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/signed/mock_user", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user", nil) + w := httptest.NewRecorder() - config.CreateHandler().ServeHTTP(w, req) + config.CreateHandler().ServeHTTP(w, req) - resp := w.Result() - assert.Equal(204, resp.StatusCode) - response, _ := ioutil.ReadAll(resp.Body) - assert.Equal("", string(response)) + resp := w.Result() + assert.Equal(200, resp.StatusCode) + assert.Equal("application/json", resp.Header.Get("Content-Type")) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "id": "0f657aa8bfbe415db7005750090d3af3", + "name": "mock_user", + "properties": [ + { + "name": "textures", + "signature": "mocked signature", + "value": "mocked textures base64" + }, + { + "name": "chrly", + "value": "how do you tame a horse in Minecraft?" + } + ] + }`, string(response)) + }) + + t.Run("Obtain signed textures for not exists user", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1)) + + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{}) + + req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user", nil) + w := httptest.NewRecorder() + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(204, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Equal("", string(response)) + }) + + t.Run("Obtain signed textures for exists user, but without signed textures", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + skinModel := createSkinModel("mock_user", false) + skinModel.MojangTextures = "" + skinModel.MojangSignature = "" + + mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1)) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(skinModel, nil) + + req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user", nil) + w := httptest.NewRecorder() + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(204, resp.StatusCode) + response, _ := ioutil.ReadAll(resp.Body) + assert.Equal("", string(response)) + }) + + t.Run("Obtain signed textures for exists user, but without signed textures", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + skinModel := createSkinModel("mock_user", false) + skinModel.MojangTextures = "" + skinModel.MojangSignature = "" + + mocks.Log.EXPECT().IncCounter("signed_textures.request", int64(1)) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(skinModel, nil) + mocks.Queue.On("GetTexturesForUsername", "mock_user").Once().Return(createTexturesResponse(true, false)) + + req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_user?proxy=true", nil) + w := httptest.NewRecorder() + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(200, resp.StatusCode) + assert.Equal("application/json", resp.Header.Get("Content-Type")) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "id": "00000000000000000000000000000000", + "name": "mock_user", + "properties": [ + { + "name": "textures", + "value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIsInByb2ZpbGVJZCI6IjAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXIiLCJ0ZXh0dXJlcyI6eyJTS0lOIjp7InVybCI6Imh0dHA6Ly9tb2phbmcvc2tpbi5wbmcifX19" + }, + { + "name": "chrly", + "value": "how do you tame a horse in Minecraft?" + } + ] + }`, string(response)) + }) } diff --git a/http/skin.go b/http/skin.go index 49531dc..faa8fae 100644 --- a/http/skin.go +++ b/http/skin.go @@ -13,12 +13,25 @@ func (cfg *Config) Skin(response http.ResponseWriter, request *http.Request) { username := parseUsername(mux.Vars(request)["username"]) rec, err := cfg.SkinsRepo.FindByUsername(username) - if err != nil || rec.SkinId == 0 { - http.Redirect(response, request, "http://skins.minecraft.net/MinecraftSkins/" + username + ".png", 301) + if err == nil && rec.SkinId != 0 { + http.Redirect(response, request, rec.Url, 301) return } - http.Redirect(response, request, rec.Url, 301) + mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username) + if mojangTextures == nil { + response.WriteHeader(http.StatusNotFound) + return + } + + texturesProp := mojangTextures.DecodeTextures() + skin := texturesProp.Textures.Skin + if skin == nil { + response.WriteHeader(http.StatusNotFound) + return + } + + http.Redirect(response, request, skin.Url, 301) } func (cfg *Config) SkinGET(response http.ResponseWriter, request *http.Request) { diff --git a/http/skin_test.go b/http/skin_test.go index 1540171..fbc1da8 100644 --- a/http/skin_test.go +++ b/http/skin_test.go @@ -1,6 +1,7 @@ package http import ( + "net/http" "net/http/httptest" "testing" @@ -11,113 +12,146 @@ import ( "github.com/elyby/chrly/model" ) -func TestConfig_Skin(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - mocks.Log.EXPECT().IncCounter("skins.request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/mock_user", nil) - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://ely.by/minecraft/skins/skin.png", resp.Header.Get("Location")) +type skinsTestCase struct { + Name string + RequestUrl string + ExpectedLogKey string + ExistsInLocalStorage bool + ExistsInMojang bool + HasSkinInMojangResp bool + AssertResponse func(assert *testify.Assertions, resp *http.Response) } -func TestConfig_Skin2(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - mocks.Skins.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"}) - mocks.Log.EXPECT().IncCounter("skins.request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/notch", nil) - w := httptest.NewRecorder() +var skinsTestCases = []*skinsTestCase{ + { + Name: "Obtain skin for known username", + ExistsInLocalStorage: true, + AssertResponse: func(assert *testify.Assertions, resp *http.Response) { + assert.Equal(301, resp.StatusCode) + assert.Equal("http://chrly/skin.png", resp.Header.Get("Location")) + }, + }, + { + Name: "Obtain skin for unknown username that exists in Mojang and has a cape", + ExistsInLocalStorage: false, + ExistsInMojang: true, + HasSkinInMojangResp: true, + AssertResponse: func(assert *testify.Assertions, resp *http.Response) { + assert.Equal(301, resp.StatusCode) + assert.Equal("http://mojang/skin.png", resp.Header.Get("Location")) + }, + }, + { + Name: "Obtain skin for unknown username that exists in Mojang, but don't has a cape", + ExistsInLocalStorage: false, + ExistsInMojang: true, + HasSkinInMojangResp: false, + AssertResponse: func(assert *testify.Assertions, resp *http.Response) { + assert.Equal(404, resp.StatusCode) + }, + }, + { + Name: "Obtain skin for unknown username that doesn't exists in Mojang", + ExistsInLocalStorage: false, + ExistsInMojang: false, + AssertResponse: func(assert *testify.Assertions, resp *http.Response) { + assert.Equal(404, resp.StatusCode) + }, + }, +} - config.CreateHandler().ServeHTTP(w, req) +func TestConfig_Skin(t *testing.T) { + performTest := func(t *testing.T, testCase *skinsTestCase) { + assert := testify.New(t) - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://skins.minecraft.net/MinecraftSkins/notch.png", resp.Header.Get("Location")) -} + ctrl := gomock.NewController(t) + defer ctrl.Finish() -func TestConfig_SkinGET(t *testing.T) { - assert := testify.New(t) + config, mocks := setupMocks(ctrl) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + mocks.Log.EXPECT().IncCounter(testCase.ExpectedLogKey, int64(1)) + if testCase.ExistsInLocalStorage { + mocks.Skins.EXPECT().FindByUsername("mock_username").Return(createSkinModel("mock_username", false), nil) + } else { + mocks.Skins.EXPECT().FindByUsername("mock_username").Return(nil, &db.SkinNotFoundError{Who: "mock_username"}) + } - config, mocks := setupMocks(ctrl) + if testCase.ExistsInMojang { + textures := createTexturesResponse(testCase.HasSkinInMojangResp, true) + mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(textures) + } else { + mocks.Queue.On("GetTexturesForUsername", "mock_username").Return(nil) + } - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - mocks.Log.EXPECT().IncCounter("skins.get_request", int64(1)) - mocks.Log.EXPECT().IncCounter("skins.request", int64(1)).Times(0) + req := httptest.NewRequest("GET", testCase.RequestUrl, nil) + w := httptest.NewRecorder() - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins?name=mock_user", nil) - w := httptest.NewRecorder() + config.CreateHandler().ServeHTTP(w, req) - config.CreateHandler().ServeHTTP(w, req) + resp := w.Result() + testCase.AssertResponse(assert, resp) + } - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://ely.by/minecraft/skins/skin.png", resp.Header.Get("Location")) -} + t.Run("Normal API", func(t *testing.T) { + for _, testCase := range skinsTestCases { + testCase.RequestUrl = "http://chrly/skins/mock_username" + testCase.ExpectedLogKey = "skins.request" + t.Run(testCase.Name, func(t *testing.T) { + performTest(t, testCase) + }) + } + }) -func TestConfig_SkinGET2(t *testing.T) { - assert := testify.New(t) + t.Run("GET fallback API", func(t *testing.T) { + for _, testCase := range skinsTestCases { + testCase.RequestUrl = "http://chrly/skins?name=mock_username" + testCase.ExpectedLogKey = "skins.get_request" + t.Run(testCase.Name, func(t *testing.T) { + performTest(t, testCase) + }) + } - ctrl := gomock.NewController(t) - defer ctrl.Finish() + t.Run("Should trim trailing slash", func(t *testing.T) { + assert := testify.New(t) - config, mocks := setupMocks(ctrl) + req := httptest.NewRequest("GET", "http://chrly/skins/?name=notch", nil) + w := httptest.NewRecorder() - mocks.Skins.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{"notch"}) - mocks.Log.EXPECT().IncCounter("skins.get_request", int64(1)) - mocks.Log.EXPECT().IncCounter("skins.request", int64(1)).Times(0) + (&Config{}).CreateHandler().ServeHTTP(w, req) - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins?name=notch", nil) - w := httptest.NewRecorder() + resp := w.Result() + assert.Equal(301, resp.StatusCode) + assert.Equal("http://chrly/skins?name=notch", resp.Header.Get("Location")) + }) - config.CreateHandler().ServeHTTP(w, req) + t.Run("Return error when name is not provided", func(t *testing.T) { + assert := testify.New(t) - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://skins.minecraft.net/MinecraftSkins/notch.png", resp.Header.Get("Location")) -} + ctrl := gomock.NewController(t) + defer ctrl.Finish() -func TestConfig_SkinGET3(t *testing.T) { - assert := testify.New(t) + config, mocks := setupMocks(ctrl) + mocks.Log.EXPECT().IncCounter("skins.get_request", int64(1)) - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/skins/?name=notch", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://chrly/skins", nil) + w := httptest.NewRecorder() - (&Config{}).CreateHandler().ServeHTTP(w, req) + config.CreateHandler().ServeHTTP(w, req) - resp := w.Result() - assert.Equal(301, resp.StatusCode) - assert.Equal("http://skinsystem.ely.by/skins?name=notch", resp.Header.Get("Location")) + resp := w.Result() + assert.Equal(400, resp.StatusCode) + }) + }) } func createSkinModel(username string, isSlim bool) *model.Skin { return &model.Skin{ UserId: 1, Username: username, - Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3", + Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3", // Use non nil UUID to pass validation in api tests SkinId: 1, - Hash: "55d2a8848764f5ff04012cdb093458bd", - Url: "http://ely.by/minecraft/skins/skin.png", + Hash: "00000000000000000000000000000000", + Url: "http://chrly/skin.png", MojangTextures: "mocked textures base64", MojangSignature: "mocked signature", IsSlim: isSlim, diff --git a/http/textures.go b/http/textures.go index a07e0c8..92cda48 100644 --- a/http/textures.go +++ b/http/textures.go @@ -1,102 +1,61 @@ package http import ( - "crypto/md5" - "encoding/hex" "encoding/json" - "io" "net/http" - "strconv" - "time" "github.com/gorilla/mux" - "github.com/elyby/chrly/model" + "github.com/elyby/chrly/api/mojang" ) -type texturesResponse struct { - Skin *Skin `json:"SKIN"` - Cape *Cape `json:"CAPE,omitempty"` -} - -type Skin struct { - Url string `json:"url"` - Hash string `json:"hash"` - Metadata *skinMetadata `json:"metadata,omitempty"` -} - -type skinMetadata struct { - Model string `json:"model"` -} - -type Cape struct { - Url string `json:"url"` - Hash string `json:"hash"` -} - func (cfg *Config) Textures(response http.ResponseWriter, request *http.Request) { cfg.Logger.IncCounter("textures.request", 1) username := parseUsername(mux.Vars(request)["username"]) - skin, err := cfg.SkinsRepo.FindByUsername(username) - if err != nil || skin.SkinId == 0 { - if skin == nil { - skin = &model.Skin{} - } + var textures *mojang.TexturesResponse + skin, skinErr := cfg.SkinsRepo.FindByUsername(username) + _, capeErr := cfg.CapesRepo.FindByUsername(username) + if (skinErr == nil && skin.SkinId != 0) || capeErr == nil { + textures = &mojang.TexturesResponse{} - skin.Url = "http://skins.minecraft.net/MinecraftSkins/" + username + ".png" - skin.Hash = string(buildNonElyTexturesHash(username)) - } + if skinErr == nil && skin.SkinId != 0 { + skinTextures := &mojang.SkinTexturesResponse{ + Url: skin.Url, + } - textures := texturesResponse{ - Skin: &Skin{ - Url: skin.Url, - Hash: skin.Hash, - }, - } + if skin.IsSlim { + skinTextures.Metadata = &mojang.SkinTexturesMetadata{ + Model: "slim", + } + } - if skin.IsSlim { - textures.Skin.Metadata = &skinMetadata{ - Model: "slim", + textures.Skin = skinTextures } - } - cape, err := cfg.CapesRepo.FindByUsername(username) - if err == nil { - var scheme string = "http://" - if request.TLS != nil { - scheme = "https://" + if capeErr == nil { + textures.Cape = &mojang.CapeTexturesResponse{ + Url: request.URL.Scheme + "://" + request.Host + "/cloaks/" + username, + } + } + } else { + mojangTextures := <-cfg.MojangTexturesQueue.GetTexturesForUsername(username) + if mojangTextures == nil { + response.WriteHeader(http.StatusNoContent) + return } - textures.Cape = &Cape{ - Url: scheme + request.Host + "/cloaks/" + username, - Hash: calculateCapeHash(cape), + texturesProp := mojangTextures.DecodeTextures() + if texturesProp == nil { + response.WriteHeader(http.StatusInternalServerError) + cfg.Logger.Error("Unable to find textures property") + return } + + textures = texturesProp.Textures } responseData, _ := json.Marshal(textures) response.Header().Set("Content-Type", "application/json") response.Write(responseData) } - -func calculateCapeHash(cape *model.Cape) string { - hasher := md5.New() - io.Copy(hasher, cape.File) - - return hex.EncodeToString(hasher.Sum(nil)) -} - -func buildNonElyTexturesHash(username string) string { - hour := getCurrentHour() - hasher := md5.New() - hasher.Write([]byte("non-ely-" + strconv.FormatInt(hour, 10) + "-" + username)) - - return hex.EncodeToString(hasher.Sum(nil)) -} - -var timeNow = time.Now - -func getCurrentHour() int64 { - n := timeNow() - return time.Date(n.Year(), n.Month(), n.Day(), n.Hour(), 0, 0, 0, time.UTC).Unix() -} diff --git a/http/textures_test.go b/http/textures_test.go index c4c879f..d7c57e5 100644 --- a/http/textures_test.go +++ b/http/textures_test.go @@ -5,7 +5,6 @@ import ( "io/ioutil" "net/http/httptest" "testing" - "time" "github.com/golang/mock/gomock" testify "github.com/stretchr/testify/assert" @@ -15,152 +14,181 @@ import ( ) func TestConfig_Textures(t *testing.T) { - assert := testify.New(t) + t.Run("Obtain textures for exists user with only default skin", func(t *testing.T) { + assert := testify.New(t) - ctrl := gomock.NewController(t) - defer ctrl.Finish() + ctrl := gomock.NewController(t) + defer ctrl.Finish() - config, mocks := setupMocks(ctrl) + config, mocks := setupMocks(ctrl) - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{"mock_user"}) - mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) + mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/mock_user", nil) - w := httptest.NewRecorder() + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) + mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{Who: "mock_user"}) - config.CreateHandler().ServeHTTP(w, req) + req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil) + w := httptest.NewRecorder() - resp := w.Result() - assert.Equal(200, resp.StatusCode) - assert.Equal("application/json", resp.Header.Get("Content-Type")) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`{ - "SKIN": { - "url": "http://ely.by/minecraft/skins/skin.png", - "hash": "55d2a8848764f5ff04012cdb093458bd" - } - }`, string(response)) -} + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(200, resp.StatusCode) + assert.Equal("application/json", resp.Header.Get("Content-Type")) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "SKIN": { + "url": "http://chrly/skin.png" + } + }`, string(response)) + }) + + t.Run("Obtain textures for exists user with only slim skin", func(t *testing.T) { + assert := testify.New(t) -func TestConfig_Textures2(t *testing.T) { - assert := testify.New(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() - ctrl := gomock.NewController(t) - defer ctrl.Finish() + config, mocks := setupMocks(ctrl) - config, mocks := setupMocks(ctrl) + mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", true), nil) - mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{"mock_user"}) - mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", true), nil) + mocks.Capes.EXPECT().FindByUsername("mock_user").Return(nil, &db.CapeNotFoundError{Who: "mock_user"}) - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/mock_user", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil) + w := httptest.NewRecorder() - config.CreateHandler().ServeHTTP(w, req) + config.CreateHandler().ServeHTTP(w, req) - resp := w.Result() - assert.Equal(200, resp.StatusCode) - assert.Equal("application/json", resp.Header.Get("Content-Type")) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`{ - "SKIN": { - "url": "http://ely.by/minecraft/skins/skin.png", - "hash": "55d2a8848764f5ff04012cdb093458bd", - "metadata": { - "model": "slim" + resp := w.Result() + assert.Equal(200, resp.StatusCode) + assert.Equal("application/json", resp.Header.Get("Content-Type")) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "SKIN": { + "url": "http://chrly/skin.png", + "metadata": { + "model": "slim" + } } - } - }`, string(response)) -} + }`, string(response)) + }) -func TestConfig_Textures3(t *testing.T) { - assert := testify.New(t) - - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - config, mocks := setupMocks(ctrl) - - mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) - mocks.Capes.EXPECT().FindByUsername("mock_user").Return(&model.Cape{ - File: bytes.NewReader(createCape()), - }, nil) - mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) - - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/mock_user", nil) - w := httptest.NewRecorder() - - config.CreateHandler().ServeHTTP(w, req) - - resp := w.Result() - assert.Equal(200, resp.StatusCode) - assert.Equal("application/json", resp.Header.Get("Content-Type")) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`{ - "SKIN": { - "url": "http://ely.by/minecraft/skins/skin.png", - "hash": "55d2a8848764f5ff04012cdb093458bd" - }, - "CAPE": { - "url": "http://skinsystem.ely.by/cloaks/mock_user", - "hash": "424ff79dce9940af89c28ad80de8aaad" - } - }`, string(response)) -} + t.Run("Obtain textures for exists user with only cape", func(t *testing.T) { + assert := testify.New(t) -func TestConfig_Textures4(t *testing.T) { - assert := testify.New(t) + ctrl := gomock.NewController(t) + defer ctrl.Finish() - ctrl := gomock.NewController(t) - defer ctrl.Finish() + config, mocks := setupMocks(ctrl) - config, mocks := setupMocks(ctrl) + mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) - mocks.Skins.EXPECT().FindByUsername("notch").Return(nil, &db.SkinNotFoundError{}) - mocks.Capes.EXPECT().FindByUsername("notch").Return(nil, &db.CapeNotFoundError{}) - mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) - timeNow = func() time.Time { - return time.Date(2017, time.August, 20, 0, 15, 54, 0, time.UTC) - } + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(nil, &db.SkinNotFoundError{Who: "mock_user"}) + mocks.Capes.EXPECT().FindByUsername("mock_user").Return(&model.Cape{File: bytes.NewReader(createCape())}, nil) - req := httptest.NewRequest("GET", "http://skinsystem.ely.by/textures/notch", nil) - w := httptest.NewRecorder() + req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil) + w := httptest.NewRecorder() - config.CreateHandler().ServeHTTP(w, req) + config.CreateHandler().ServeHTTP(w, req) - resp := w.Result() - assert.Equal(200, resp.StatusCode) - assert.Equal("application/json", resp.Header.Get("Content-Type")) - response, _ := ioutil.ReadAll(resp.Body) - assert.JSONEq(`{ - "SKIN": { - "url": "http://skins.minecraft.net/MinecraftSkins/notch.png", - "hash": "5923cf3f7fa170a279e4d7a9483cfc52" - } - }`, string(response)) -} + resp := w.Result() + assert.Equal(200, resp.StatusCode) + assert.Equal("application/json", resp.Header.Get("Content-Type")) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "CAPE": { + "url": "http://chrly/cloaks/mock_user" + } + }`, string(response)) + }) + + t.Run("Obtain textures for exists user with skin and cape", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) + + mocks.Skins.EXPECT().FindByUsername("mock_user").Return(createSkinModel("mock_user", false), nil) + mocks.Capes.EXPECT().FindByUsername("mock_user").Return(&model.Cape{File: bytes.NewReader(createCape())}, nil) + + req := httptest.NewRequest("GET", "http://chrly/textures/mock_user", nil) + w := httptest.NewRecorder() + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(200, resp.StatusCode) + assert.Equal("application/json", resp.Header.Get("Content-Type")) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "SKIN": { + "url": "http://chrly/skin.png" + }, + "CAPE": { + "url": "http://chrly/cloaks/mock_user" + } + }`, string(response)) + }) + + t.Run("Obtain textures for not exists user that exists in Mojang", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + config, mocks := setupMocks(ctrl) + + mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) + + mocks.Skins.EXPECT().FindByUsername("mock_username").Return(nil, &db.SkinNotFoundError{}) + mocks.Capes.EXPECT().FindByUsername("mock_username").Return(nil, &db.CapeNotFoundError{}) + mocks.Queue.On("GetTexturesForUsername", "mock_username").Once().Return(createTexturesResponse(true, true)) + + req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) + w := httptest.NewRecorder() + + config.CreateHandler().ServeHTTP(w, req) + + resp := w.Result() + assert.Equal(200, resp.StatusCode) + assert.Equal("application/json", resp.Header.Get("Content-Type")) + response, _ := ioutil.ReadAll(resp.Body) + assert.JSONEq(`{ + "SKIN": { + "url": "http://mojang/skin.png" + }, + "CAPE": { + "url": "http://mojang/cape.png" + } + }`, string(response)) + }) + + t.Run("Obtain textures for not exists user that not exists in Mojang too", func(t *testing.T) { + assert := testify.New(t) + + ctrl := gomock.NewController(t) + defer ctrl.Finish() -func TestBuildNonElyTexturesHash(t *testing.T) { - assert := testify.New(t) - timeNow = func() time.Time { - return time.Date(2017, time.November, 30, 16, 15, 34, 0, time.UTC) - } + config, mocks := setupMocks(ctrl) - assert.Equal("686d788a5353cb636e8fdff727634d88", buildNonElyTexturesHash("username"), "Function should return fixed hash by username-time pair") - assert.Equal("fb876f761683a10accdb17d403cef64c", buildNonElyTexturesHash("another-username"), "Function should return fixed hash by username-time pair") + mocks.Log.EXPECT().IncCounter("textures.request", int64(1)) - timeNow = func() time.Time { - return time.Date(2017, time.November, 30, 16, 20, 12, 0, time.UTC) - } + mocks.Skins.EXPECT().FindByUsername("mock_username").Return(nil, &db.SkinNotFoundError{}) + mocks.Capes.EXPECT().FindByUsername("mock_username").Return(nil, &db.CapeNotFoundError{}) + mocks.Queue.On("GetTexturesForUsername", "mock_username").Once().Return(nil) - assert.Equal("686d788a5353cb636e8fdff727634d88", buildNonElyTexturesHash("username"), "Function should do not change it's value if hour the same") - assert.Equal("fb876f761683a10accdb17d403cef64c", buildNonElyTexturesHash("another-username"), "Function should return fixed hash by username-time pair") + req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) + w := httptest.NewRecorder() - timeNow = func() time.Time { - return time.Date(2017, time.November, 30, 17, 1, 3, 0, time.UTC) - } + config.CreateHandler().ServeHTTP(w, req) - assert.Equal("42277892fd24bc0ed86285b3bb8b8fad", buildNonElyTexturesHash("username"), "Function should change it's value if hour changed") + resp := w.Result() + assert.Equal(204, resp.StatusCode) + }) } diff --git a/interfaces/repositories.go b/interfaces/repositories.go index 05d2df5..134f141 100644 --- a/interfaces/repositories.go +++ b/interfaces/repositories.go @@ -1,6 +1,7 @@ package interfaces import ( + "github.com/elyby/chrly/api/mojang" "github.com/elyby/chrly/model" ) @@ -15,3 +16,7 @@ type SkinsRepository interface { type CapesRepository interface { FindByUsername(username string) (*model.Cape, error) } + +type MojangTexturesQueue interface { + GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse +} diff --git a/tests/mojang_textures_queue_mock.go b/tests/mojang_textures_queue_mock.go new file mode 100644 index 0000000..0494243 --- /dev/null +++ b/tests/mojang_textures_queue_mock.go @@ -0,0 +1,33 @@ +package tests + +import ( + "github.com/elyby/chrly/api/mojang" + + "github.com/stretchr/testify/mock" +) + +type MojangTexturesQueueMock struct { + mock.Mock +} + +func (m *MojangTexturesQueueMock) GetTexturesForUsername(username string) chan *mojang.SignedTexturesResponse { + args := m.Called(username) + result := make(chan *mojang.SignedTexturesResponse) + arg := args.Get(0) + switch arg.(type) { + case *mojang.SignedTexturesResponse: + go func() { + result <- arg.(*mojang.SignedTexturesResponse) + }() + case chan *mojang.SignedTexturesResponse: + return arg.(chan *mojang.SignedTexturesResponse) + case nil: + go func() { + result <- nil + }() + default: + panic("unsupported return value") + } + + return result +} diff --git a/tests/wd_mock.go b/tests/wd_mock.go new file mode 100644 index 0000000..1d30a60 --- /dev/null +++ b/tests/wd_mock.go @@ -0,0 +1,61 @@ +package tests + +import ( + "time" + + "github.com/mono83/slf" + "github.com/mono83/slf/wd" + "github.com/stretchr/testify/mock" +) + +type WdMock struct { + mock.Mock +} + +func (m *WdMock) Trace(message string, p ...slf.Param) { + m.Called(message) +} + +func (m *WdMock) Debug(message string, p ...slf.Param) { + m.Called(message) +} + +func (m *WdMock) Info(message string, p ...slf.Param) { + m.Called(message) +} + +func (m *WdMock) Warning(message string, p ...slf.Param) { + m.Called(message) +} + +func (m *WdMock) Error(message string, p ...slf.Param) { + m.Called(message) +} + +func (m *WdMock) Alert(message string, p ...slf.Param) { + m.Called(message) +} + +func (m *WdMock) Emergency(message string, p ...slf.Param) { + m.Called(message) +} + +func (m *WdMock) IncCounter(name string, value int64, p ...slf.Param) { + m.Called(name, value) +} + +func (m *WdMock) UpdateGauge(name string, value int64, p ...slf.Param) { + m.Called(name, value) +} + +func (m *WdMock) RecordTimer(name string, d time.Duration, p ...slf.Param) { + m.Called(name, d) +} + +func (m *WdMock) Timer(name string, p ...slf.Param) slf.Timer { + return slf.NewTimer(name, p, m) +} + +func (m *WdMock) WithParams(p ...slf.Param) wd.Watchdog { + panic("this method shouldn't be used") +}