diff --git a/data/capes/.gitignore b/data/capes/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/data/capes/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/db/fs/fs.go b/db/fs/fs.go deleted file mode 100644 index 02babb3..0000000 --- a/db/fs/fs.go +++ /dev/null @@ -1,34 +0,0 @@ -package fs - -import ( - "os" - "path" - "strings" - - "github.com/elyby/chrly/model" -) - -func New(basePath string) (*Filesystem, error) { - return &Filesystem{path: basePath}, nil -} - -// Deprecated -type Filesystem struct { - path string -} - -func (f *Filesystem) FindCapeByUsername(username string) (*model.Cape, error) { - capePath := path.Join(f.path, strings.ToLower(username)+".png") - file, err := os.Open(capePath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - - return nil, err - } - - return &model.Cape{ - File: file, - }, nil -} diff --git a/db/fs/fs_integration_test.go b/db/fs/fs_integration_test.go deleted file mode 100644 index 573567e..0000000 --- a/db/fs/fs_integration_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package fs - -import ( - "fmt" - "io/ioutil" - "os" - "path" - "testing" - - "github.com/stretchr/testify/require" -) - -func TestNew(t *testing.T) { - fs, err := New("base/path") - require.Nil(t, err) - require.Equal(t, "base/path", fs.path) -} - -func TestFilesystem(t *testing.T) { - t.Run("FindCapeByUsername", func(t *testing.T) { - dir, err := ioutil.TempDir("", "capes") - if err != nil { - panic(fmt.Errorf("cannot crete temp directory for tests: %w", err)) - } - defer os.RemoveAll(dir) - - t.Run("exists cape", func(t *testing.T) { - file, err := os.Create(path.Join(dir, "username.png")) - if err != nil { - panic(fmt.Errorf("cannot create temp skin for tests: %w", err)) - } - defer os.Remove(file.Name()) - - fs, _ := New(dir) - cape, err := fs.FindCapeByUsername("username") - require.Nil(t, err) - require.NotNil(t, cape) - capeFile, _ := cape.File.(*os.File) - require.Equal(t, file.Name(), capeFile.Name()) - }) - - t.Run("not exists cape", func(t *testing.T) { - fs, _ := New(dir) - cape, err := fs.FindCapeByUsername("username") - require.Nil(t, err) - require.Nil(t, cape) - }) - - t.Run("empty username", func(t *testing.T) { - fs, _ := New(dir) - cape, err := fs.FindCapeByUsername("") - require.Nil(t, err) - require.Nil(t, cape) - }) - }) -} diff --git a/db/model.go b/db/model.go new file mode 100644 index 0000000..1e03bbe --- /dev/null +++ b/db/model.go @@ -0,0 +1,18 @@ +package db + +type Profile struct { + // Uuid contains user's UUID without dashes in lower case + Uuid string + // Username contains user's username with the original casing + Username string + // SkinUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a skin + SkinUrl string + // SkinModel contains skin's model. It will be empty when the model is default + SkinModel string + // CapeUrl contains a valid URL to user's skin or an empty string in case the user doesn't have a cape + CapeUrl string + // MojangTextures contains the original textures value from Mojang's skinsystem + MojangTextures string + // MojangSignature contains the original textures signature from Mojang's skinsystem + MojangSignature string +} diff --git a/db/redis/redis.go b/db/redis/redis.go index 7decf2b..94e791f 100644 --- a/db/redis/redis.go +++ b/db/redis/redis.go @@ -1,59 +1,52 @@ package redis import ( - "bytes" - "compress/zlib" "context" - "encoding/json" "fmt" - "io" - "strconv" "strings" - "time" "github.com/mediocregopher/radix/v4" - "github.com/elyby/chrly/model" + "github.com/elyby/chrly/db" ) -var now = time.Now +const usernameToProfileKey = "hash:username-to-profile" +const userUuidToUsernameKey = "hash:uuid-to-username" -func New(ctx context.Context, addr string, poolSize int) (*Redis, error) { +type Redis struct { + client radix.Client + context context.Context + serializer db.ProfileSerializer +} + +func New(ctx context.Context, profileSerializer db.ProfileSerializer, addr string, poolSize int) (*Redis, error) { client, err := (radix.PoolConfig{Size: poolSize}).New(ctx, "tcp", addr) if err != nil { return nil, err } return &Redis{ - client: client, - context: ctx, + client: client, + context: ctx, + serializer: profileSerializer, }, nil } -const accountIdToUsernameKey = "hash:username-to-account-id" // TODO: this should be actually "hash:user-id-to-username" -const mojangUsernameToUuidKey = "hash:mojang-username-to-uuid" - -type Redis struct { - client radix.Client - context context.Context -} - -func (db *Redis) FindSkinByUsername(username string) (*model.Skin, error) { - var skin *model.Skin - err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { +func (r *Redis) FindProfileByUsername(username string) (*db.Profile, error) { + var profile *db.Profile + err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { var err error - skin, err = findByUsername(ctx, conn, username) + profile, err = r.findProfileByUsername(ctx, conn, username) return err })) - return skin, err + return profile, err } -func findByUsername(ctx context.Context, conn radix.Conn, username string) (*model.Skin, error) { - redisKey := buildUsernameKey(username) +func (r *Redis) findProfileByUsername(ctx context.Context, conn radix.Conn, username string) (*db.Profile, error) { var encodedResult []byte - err := conn.Do(ctx, radix.Cmd(&encodedResult, "GET", redisKey)) + err := conn.Do(ctx, radix.Cmd(&encodedResult, "HGET", usernameToProfileKey, usernameHashKey(username))) if err != nil { return nil, err } @@ -62,33 +55,14 @@ func findByUsername(ctx context.Context, conn radix.Conn, username string) (*mod return nil, nil } - result, err := zlibDecode(encodedResult) - if err != nil { - return nil, err - } - - var skin *model.Skin - err = json.Unmarshal(result, &skin) - if err != nil { - return nil, err - } - - // Some old data causing issues in the production. - // TODO: remove after investigation will be finished - if skin.Uuid == "" { - return nil, nil - } - - skin.OldUsername = skin.Username - - return skin, nil + return r.serializer.Deserialize(encodedResult) } -func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) { - var skin *model.Skin - err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { +func (r *Redis) FindProfileByUuid(uuid string) (*db.Profile, error) { + var skin *db.Profile + err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { var err error - skin, err = findByUserId(ctx, conn, id) + skin, err = r.findProfileByUuid(ctx, conn, uuid) return err })) @@ -96,9 +70,8 @@ func (db *Redis) FindSkinByUserId(id int) (*model.Skin, error) { return skin, err } -func findByUserId(ctx context.Context, conn radix.Conn, id int) (*model.Skin, error) { - var username string - err := conn.Do(ctx, radix.FlatCmd(&username, "HGET", accountIdToUsernameKey, id)) +func (r *Redis) findProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) (*db.Profile, error) { + username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid) if err != nil { return nil, err } @@ -107,36 +80,51 @@ func findByUserId(ctx context.Context, conn radix.Conn, id int) (*model.Skin, er return nil, nil } - return findByUsername(ctx, conn, username) + return r.findProfileByUsername(ctx, conn, username) } -func (db *Redis) SaveSkin(skin *model.Skin) error { - return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { - return save(ctx, conn, skin) +func (r *Redis) findUsernameHashKeyByUuid(ctx context.Context, conn radix.Conn, uuid string) (string, error) { + var username string + return username, conn.Do(ctx, radix.FlatCmd(&username, "HGET", userUuidToUsernameKey, normalizeUuid(uuid))) +} + +func (r *Redis) SaveProfile(profile *db.Profile) error { + return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { + return r.saveProfile(ctx, conn, profile) })) } -func save(ctx context.Context, conn radix.Conn, skin *model.Skin) error { - err := conn.Do(ctx, radix.Cmd(nil, "MULTI")) +func (r *Redis) saveProfile(ctx context.Context, conn radix.Conn, profile *db.Profile) error { + newUsernameHashKey := usernameHashKey(profile.Username) + existsUsernameHashKey, err := r.findUsernameHashKeyByUuid(ctx, conn, profile.Uuid) + if err != nil { + return err + } + + err = conn.Do(ctx, radix.Cmd(nil, "MULTI")) if err != nil { return err } // If user has changed username, then we must delete his old username record - if skin.OldUsername != "" && skin.OldUsername != skin.Username { - err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(skin.OldUsername))) + if existsUsernameHashKey != "" && existsUsernameHashKey != newUsernameHashKey { + err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, existsUsernameHashKey)) if err != nil { return err } } - // If this is a new record or if the user has changed username, we set the value in the hash table - if skin.OldUsername != "" || skin.OldUsername != skin.Username { - err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", accountIdToUsernameKey, skin.UserId, skin.Username)) + err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", userUuidToUsernameKey, normalizeUuid(profile.Uuid), newUsernameHashKey)) + if err != nil { + return err } - str, _ := json.Marshal(skin) - err = conn.Do(ctx, radix.FlatCmd(nil, "SET", buildUsernameKey(skin.Username), zlibEncode(str))) + serializedProfile, err := r.serializer.Serialize(profile) + if err != nil { + return err + } + + err = conn.Do(ctx, radix.FlatCmd(nil, "HSET", usernameToProfileKey, newUsernameHashKey, serializedProfile)) if err != nil { return err } @@ -146,19 +134,17 @@ func save(ctx context.Context, conn radix.Conn, skin *model.Skin) error { return err } - skin.OldUsername = skin.Username - return nil } -func (db *Redis) RemoveSkinByUserId(id int) error { - return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { - return removeByUserId(ctx, conn, id) +func (r *Redis) RemoveProfileByUuid(uuid string) error { + return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { + return r.removeProfileByUuid(ctx, conn, uuid) })) } -func removeByUserId(ctx context.Context, conn radix.Conn, id int) error { - record, err := findByUserId(ctx, conn, id) +func (r *Redis) removeProfileByUuid(ctx context.Context, conn radix.Conn, uuid string) error { + username, err := r.findUsernameHashKeyByUuid(ctx, conn, uuid) if err != nil { return err } @@ -168,13 +154,13 @@ func removeByUserId(ctx context.Context, conn radix.Conn, id int) error { return err } - err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, id)) + err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", userUuidToUsernameKey, normalizeUuid(uuid))) if err != nil { return err } - if record != nil { - err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username))) + if username != "" { + err = conn.Do(ctx, radix.Cmd(nil, "HDEL", usernameToProfileKey, usernameHashKey(username))) if err != nil { return err } @@ -183,44 +169,10 @@ func removeByUserId(ctx context.Context, conn radix.Conn, id int) error { return conn.Do(ctx, radix.Cmd(nil, "EXEC")) } -func (db *Redis) RemoveSkinByUsername(username string) error { - return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { - return removeByUsername(ctx, conn, username) - })) -} - -func removeByUsername(ctx context.Context, conn radix.Conn, username string) error { - record, err := findByUsername(ctx, conn, username) - if err != nil { - return err - } - - if record == nil { - return nil - } - - err = conn.Do(ctx, radix.Cmd(nil, "MULTI")) - if err != nil { - return err - } - - err = conn.Do(ctx, radix.Cmd(nil, "DEL", buildUsernameKey(record.Username))) - if err != nil { - return err - } - - err = conn.Do(ctx, radix.FlatCmd(nil, "HDEL", accountIdToUsernameKey, record.UserId)) - if err != nil { - return err - } - - return conn.Do(ctx, radix.Cmd(nil, "EXEC")) -} - -func (db *Redis) GetUuidForMojangUsername(username string) (string, string, error) { +func (r *Redis) GetUuidForMojangUsername(username string) (string, string, error) { var uuid string foundUsername := username - err := db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { + err := r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { var err error uuid, foundUsername, err = findMojangUuidByUsername(ctx, conn, username) @@ -231,9 +183,9 @@ func (db *Redis) GetUuidForMojangUsername(username string) (string, string, erro } func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username string) (string, string, error) { - key := strings.ToLower(username) + key := buildMojangUsernameKey(username) var result string - err := conn.Do(ctx, radix.Cmd(&result, "HGET", mojangUsernameToUuidKey, key)) + err := conn.Do(ctx, radix.Cmd(&result, "GET", key)) if err != nil { return "", "", err } @@ -243,51 +195,19 @@ func findMojangUuidByUsername(ctx context.Context, conn radix.Conn, username str } parts := strings.Split(result, ":") - partsCnt := len(parts) - // https://github.com/elyby/chrly/issues/28 - if partsCnt < 2 { - err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key)) - if err != nil { - return "", "", err - } - return "", "", fmt.Errorf("got unexpected response from the mojangUsernameToUuid hash: \"%s\"", result) - } - - var casedUsername, uuid, rawTimestamp string - if partsCnt == 2 { // Legacy, when original username wasn't stored - casedUsername = username - uuid = parts[0] - rawTimestamp = parts[1] - } else { - casedUsername = parts[0] - uuid = parts[1] - rawTimestamp = parts[2] - } - - timestamp, _ := strconv.ParseInt(rawTimestamp, 10, 64) - storedAt := time.Unix(timestamp, 0) - if storedAt.Add(time.Hour * 24 * 30).Before(now()) { - err = conn.Do(ctx, radix.Cmd(nil, "HDEL", mojangUsernameToUuidKey, key)) - if err != nil { - return "", "", err - } - - return "", "", nil - } - - return uuid, casedUsername, nil + return parts[1], parts[0], nil } -func (db *Redis) StoreMojangUuid(username string, uuid string) error { - return db.client.Do(db.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { +func (r *Redis) StoreMojangUuid(username string, uuid string) error { + return r.client.Do(r.context, radix.WithConn("", func(ctx context.Context, conn radix.Conn) error { return storeMojangUuid(ctx, conn, username, uuid) })) } func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid string) error { - value := fmt.Sprintf("%s:%s:%d", username, uuid, now().Unix()) - err := conn.Do(ctx, radix.Cmd(nil, "HSET", mojangUsernameToUuidKey, strings.ToLower(username), value)) + value := fmt.Sprintf("%s:%s", username, uuid) + err := conn.Do(ctx, radix.FlatCmd(nil, "SET", buildMojangUsernameKey(username), value, "EX", 60*60*24*30)) if err != nil { return err } @@ -295,33 +215,18 @@ func storeMojangUuid(ctx context.Context, conn radix.Conn, username string, uuid return nil } -func (db *Redis) Ping() error { - return db.client.Do(db.context, radix.Cmd(nil, "PING")) +func (r *Redis) Ping() error { + return r.client.Do(r.context, radix.Cmd(nil, "PING")) } -func buildUsernameKey(username string) string { - return "username:" + strings.ToLower(username) +func normalizeUuid(uuid string) string { + return strings.ToLower(strings.ReplaceAll(uuid, "-", "")) } -func zlibEncode(str []byte) []byte { - var buff bytes.Buffer - writer := zlib.NewWriter(&buff) - _, _ = writer.Write(str) - _ = writer.Close() - - return buff.Bytes() +func usernameHashKey(username string) string { + return strings.ToLower(username) } -func zlibDecode(bts []byte) ([]byte, error) { - buff := bytes.NewReader(bts) - reader, err := zlib.NewReader(buff) - if err != nil { - return nil, err - } - - resultBuffer := new(bytes.Buffer) - _, _ = io.Copy(resultBuffer, reader) - _ = reader.Close() - - return resultBuffer.Bytes(), nil +func buildMojangUsernameKey(username string) string { + return fmt.Sprintf("mojang:uuid:%s", usernameHashKey(username)) } diff --git a/db/redis/redis_integration_test.go b/db/redis/redis_integration_test.go index caaae59..bc6daa6 100644 --- a/db/redis/redis_integration_test.go +++ b/db/redis/redis_integration_test.go @@ -4,17 +4,18 @@ package redis import ( "context" + "errors" "fmt" "os" "strconv" "testing" - "time" "github.com/mediocregopher/radix/v4" + "github.com/stretchr/testify/mock" assert "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "github.com/elyby/chrly/model" + "github.com/elyby/chrly/db" ) var redisAddr string @@ -33,15 +34,35 @@ func init() { redisAddr = fmt.Sprintf("%s:%d", host, port) } +type MockProfileSerializer struct { + mock.Mock +} + +func (m *MockProfileSerializer) Serialize(profile *db.Profile) ([]byte, error) { + args := m.Called(profile) + + return []byte(args.String(0)), args.Error(1) +} + +func (m *MockProfileSerializer) Deserialize(value []byte) (*db.Profile, error) { + args := m.Called(value) + var result *db.Profile + if casted, ok := args.Get(0).(*db.Profile); ok { + result = casted + } + + return result, args.Error(1) +} + func TestNew(t *testing.T) { t.Run("should connect", func(t *testing.T) { - conn, err := New(context.Background(), redisAddr, 12) + conn, err := New(context.Background(), &MockProfileSerializer{}, redisAddr, 12) assert.Nil(t, err) assert.NotNil(t, conn) }) t.Run("should return error", func(t *testing.T) { - conn, err := New(context.Background(), "localhost:12345", 12) // Use localhost to avoid DNS resolution + conn, err := New(context.Background(), &MockProfileSerializer{}, "localhost:12345", 12) // Use localhost to avoid DNS resolution assert.Error(t, err) assert.Nil(t, conn) }) @@ -50,22 +71,25 @@ func TestNew(t *testing.T) { type redisTestSuite struct { suite.Suite - Redis *Redis + Redis *Redis + Serializer *MockProfileSerializer cmd func(cmd string, args ...interface{}) string } -func (suite *redisTestSuite) SetupSuite() { +func (s *redisTestSuite) SetupSuite() { + s.Serializer = &MockProfileSerializer{} + ctx := context.Background() - conn, err := New(ctx, redisAddr, 10) + conn, err := New(ctx, s.Serializer, redisAddr, 10) if err != nil { panic(fmt.Errorf("cannot establish connection to redis: %w", err)) } - suite.Redis = conn - suite.cmd = func(cmd string, args ...interface{}) string { + s.Redis = conn + s.cmd = func(cmd string, args ...interface{}) string { var result string - err := suite.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...)) + err := s.Redis.client.Do(ctx, radix.FlatCmd(&result, cmd, args...)) if err != nil { panic(err) } @@ -74,365 +98,202 @@ func (suite *redisTestSuite) SetupSuite() { } } -func (suite *redisTestSuite) SetupTest() { +func (s *redisTestSuite) SetupSubTest() { // Cleanup database before each test - suite.cmd("FLUSHALL") + s.cmd("FLUSHALL") } -func (suite *redisTestSuite) TearDownTest() { - // Restore time.Now func - now = time.Now -} - -func (suite *redisTestSuite) RunSubTest(name string, subTest func()) { - suite.SetupTest() - suite.Run(name, subTest) +func (s *redisTestSuite) TearDownSubTest() { + s.Serializer.AssertExpectations(s.T()) + for _, call := range s.Serializer.ExpectedCalls { + call.Unset() + } } func TestRedis(t *testing.T) { suite.Run(t, new(redisTestSuite)) } -/** - * JSON with zlib encoding - * { - * userId: 1, - * uuid: "fd5da1e4d66d4d17aadee2446093896d", - * username: "Mock", - * skinId: 1, - * url: "http://localhost/skin.png", - * is1_8: true, - * isSlim: false, - * mojangTextures: "mock-mojang-textures", - * mojangSignature: "mock-mojang-signature" - * } - */ -var skinRecord = string([]byte{ - 0x78, 0x9c, 0x5c, 0xce, 0x4b, 0x4a, 0x4, 0x41, 0xc, 0xc6, 0xf1, 0xbb, 0x7c, 0xeb, 0x1a, 0xdb, 0xd6, 0xb2, - 0x9c, 0xc9, 0xd, 0x5c, 0x88, 0x8b, 0xd1, 0xb5, 0x84, 0x4e, 0xa6, 0xa7, 0xec, 0x7a, 0xc, 0xf5, 0x0, 0x41, - 0xbc, 0xbb, 0xb4, 0xd2, 0xa, 0x2e, 0xf3, 0xe3, 0x9f, 0x90, 0xf, 0xf4, 0xaa, 0xe5, 0x41, 0x40, 0xa3, 0x41, - 0xef, 0x5e, 0x40, 0x38, 0xc9, 0x9d, 0xf0, 0xa8, 0x56, 0x9c, 0x13, 0x2b, 0xe3, 0x3d, 0xb3, 0xa8, 0xde, 0x58, - 0xeb, 0xae, 0xf, 0xb7, 0xfb, 0x83, 0x13, 0x98, 0xef, 0xa5, 0xc4, 0x51, 0x41, 0x78, 0xcc, 0xd3, 0x2, 0x83, - 0xba, 0xf8, 0xb4, 0x9d, 0x29, 0x1, 0x84, 0x73, 0x6b, 0x17, 0x1a, 0x86, 0x90, 0x27, 0xe, 0xe7, 0x5c, 0xdb, - 0xb0, 0x16, 0x57, 0x97, 0x34, 0xc3, 0xc0, 0xd7, 0xf1, 0x75, 0xf, 0x6a, 0xa5, 0xeb, 0x3a, 0x1c, 0x83, 0x8f, - 0xa0, 0x13, 0x87, 0xaa, 0x6, 0x31, 0xbf, 0x71, 0x9a, 0x9f, 0xf5, 0xbd, 0xf5, 0xa2, 0x15, 0x84, 0x98, 0xa7, - 0x65, 0xf7, 0xa3, 0xbb, 0xb6, 0xf1, 0xd6, 0x1d, 0xfd, 0x9c, 0x78, 0xa5, 0x7f, 0x61, 0xfd, 0x75, 0x83, 0xa7, - 0x20, 0x2f, 0x7f, 0xff, 0xe2, 0xf3, 0x2b, 0x0, 0x0, 0xff, 0xff, 0x6f, 0xdd, 0x51, 0x71, -}) +func (s *redisTestSuite) TestFindProfileByUsername() { + s.Run("exists record", func() { + serializedData := []byte("mock.exists.profile") + expectedProfile := &db.Profile{} + s.cmd("HSET", usernameToProfileKey, "mock", serializedData) + s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil) -func (suite *redisTestSuite) TestFindSkinByUsername() { - suite.RunSubTest("exists record", func() { - suite.cmd("SET", "username:mock", skinRecord) - - skin, err := suite.Redis.FindSkinByUsername("Mock") - suite.Require().Nil(err) - suite.Require().NotNil(skin) - suite.Require().Equal(1, skin.UserId) - suite.Require().Equal("fd5da1e4d66d4d17aadee2446093896d", skin.Uuid) - suite.Require().Equal("Mock", skin.Username) - suite.Require().Equal(1, skin.SkinId) - suite.Require().Equal("http://localhost/skin.png", skin.Url) - suite.Require().True(skin.Is1_8) - suite.Require().False(skin.IsSlim) - suite.Require().Equal("mock-mojang-textures", skin.MojangTextures) - suite.Require().Equal("mock-mojang-signature", skin.MojangSignature) - suite.Require().Equal(skin.Username, skin.OldUsername) + profile, err := s.Redis.FindProfileByUsername("Mock") + s.Require().NoError(err) + s.Require().Same(expectedProfile, profile) }) - suite.RunSubTest("not exists record", func() { - skin, err := suite.Redis.FindSkinByUsername("Mock") - suite.Require().Nil(err) - suite.Require().Nil(skin) + s.Run("not exists record", func() { + profile, err := s.Redis.FindProfileByUsername("Mock") + s.Require().NoError(err) + s.Require().Nil(profile) }) - suite.RunSubTest("invalid zlib encoding", func() { - suite.cmd("SET", "username:mock", "this is really not zlib") - skin, err := suite.Redis.FindSkinByUsername("Mock") - suite.Require().Nil(skin) - suite.Require().EqualError(err, "zlib: invalid header") - }) + s.Run("an error from serializer implementation", func() { + expectedError := errors.New("mock error") + s.cmd("HSET", usernameToProfileKey, "mock", "some-invalid-mock-data") + s.Serializer.On("Deserialize", mock.Anything).Return(nil, expectedError) - suite.RunSubTest("invalid json encoding", func() { - suite.cmd("SET", "username:mock", []byte{ - 0x78, 0x9c, 0xca, 0x48, 0xcd, 0xc9, 0xc9, 0x57, 0x28, 0xcf, 0x2f, 0xca, 0x49, 0x1, 0x4, 0x0, 0x0, 0xff, - 0xff, 0x1a, 0xb, 0x4, 0x5d, - }) - skin, err := suite.Redis.FindSkinByUsername("Mock") - suite.Require().Nil(skin) - suite.Require().EqualError(err, "invalid character 'h' looking for beginning of value") + profile, err := s.Redis.FindProfileByUsername("Mock") + s.Require().Nil(profile) + s.Require().ErrorIs(err, expectedError) }) } -func (suite *redisTestSuite) TestFindSkinByUserId() { - suite.RunSubTest("exists record", func() { - suite.cmd("SET", "username:mock", skinRecord) - suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock") +func (s *redisTestSuite) TestFindProfileByUuid() { + s.Run("exists record", func() { + serializedData := []byte("mock.exists.profile") + expectedProfile := &db.Profile{Username: "Mock"} + s.cmd("HSET", usernameToProfileKey, "mock", serializedData) + s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") + s.Serializer.On("Deserialize", serializedData).Return(expectedProfile, nil) - skin, err := suite.Redis.FindSkinByUserId(1) - suite.Require().Nil(err) - suite.Require().NotNil(skin) - suite.Require().Equal(1, skin.UserId) + profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3") + s.Require().NoError(err) + s.Require().Same(expectedProfile, profile) }) - suite.RunSubTest("not exists record", func() { - skin, err := suite.Redis.FindSkinByUserId(1) - suite.Require().Nil(err) - suite.Require().Nil(skin) + s.Run("not exists record", func() { + profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3") + s.Require().NoError(err) + s.Require().Nil(profile) }) - suite.RunSubTest("exists hash record, but no skin record", func() { - suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock") - skin, err := suite.Redis.FindSkinByUserId(1) - suite.Require().Nil(err) - suite.Require().Nil(skin) + s.Run("exists uuid record, but related profile not exists", func() { + s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") + profile, err := s.Redis.FindProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3") + s.Require().NoError(err) + s.Require().Nil(profile) }) } -func (suite *redisTestSuite) TestSaveSkin() { - suite.RunSubTest("save new entity", func() { - err := suite.Redis.SaveSkin(&model.Skin{ - UserId: 1, - Uuid: "fd5da1e4d66d4d17aadee2446093896d", - Username: "Mock", - SkinId: 1, - Url: "http://localhost/skin.png", - Is1_8: true, - IsSlim: false, - MojangTextures: "mock-mojang-textures", - MojangSignature: "mock-mojang-signature", - }) - suite.Require().Nil(err) - - usernameResp := suite.cmd("GET", "username:mock") - suite.Require().NotEmpty(usernameResp) - suite.Require().Equal(skinRecord, usernameResp) - - idResp := suite.cmd("HGET", "hash:username-to-account-id", 1) - suite.Require().Equal("Mock", idResp) - }) - - suite.RunSubTest("save exists record with changed username", func() { - suite.cmd("SET", "username:mock", skinRecord) - suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock") - - err := suite.Redis.SaveSkin(&model.Skin{ - UserId: 1, - Uuid: "fd5da1e4d66d4d17aadee2446093896d", - Username: "NewMock", - SkinId: 1, - Url: "http://localhost/skin.png", - Is1_8: true, - IsSlim: false, - MojangTextures: "mock-mojang-textures", - MojangSignature: "mock-mojang-signature", - OldUsername: "Mock", - }) - suite.Require().Nil(err) - - usernameResp := suite.cmd("GET", "username:newmock") - suite.Require().NotEmpty(usernameResp) - suite.Require().Equal(string([]byte{ - 0x78, 0x9c, 0x5c, 0x8e, 0xcb, 0x4e, 0xc3, 0x40, 0xc, 0x45, 0xff, 0xe5, 0xae, 0xa7, 0x84, 0x40, 0x18, 0x5a, - 0xff, 0x1, 0xb, 0x60, 0x51, 0x58, 0x23, 0x2b, 0x76, 0xd3, 0x21, 0xf3, 0xa8, 0xe6, 0x21, 0x90, 0x10, 0xff, - 0x8e, 0x52, 0x14, 0x90, 0xba, 0xf4, 0xd1, 0xf1, 0xd5, 0xf9, 0x42, 0x2b, 0x9a, 0x1f, 0x4, 0xd4, 0x1b, 0xb4, - 0xe6, 0x4, 0x84, 0x83, 0xdc, 0x9, 0xf7, 0x3a, 0x88, 0xb5, 0x32, 0x48, 0x7f, 0xcf, 0x2c, 0xaa, 0x37, 0xc3, - 0x60, 0xaf, 0x77, 0xb7, 0xdb, 0x9d, 0x15, 0x98, 0xf3, 0x53, 0xe4, 0xa0, 0x20, 0x3c, 0xe9, 0xc7, 0x63, 0x1a, - 0x67, 0x18, 0x94, 0xd9, 0xc5, 0x75, 0x29, 0x7b, 0x10, 0x8e, 0xb5, 0x9e, 0xa8, 0xeb, 0x7c, 0x1a, 0xd9, 0x1f, - 0x53, 0xa9, 0xdd, 0x62, 0x5c, 0x9d, 0xe2, 0x4, 0x3, 0x57, 0xfa, 0xb7, 0x2d, 0xa8, 0xe6, 0xa6, 0xcb, 0xb1, - 0xf7, 0x2e, 0x80, 0xe, 0xec, 0x8b, 0x1a, 0x84, 0xf4, 0xce, 0x71, 0x7a, 0xd1, 0xcf, 0xda, 0xb2, 0x16, 0x10, - 0x42, 0x1a, 0xe7, 0xcd, 0x2f, 0xdd, 0xd4, 0x15, 0xaf, 0xde, 0xde, 0x4d, 0x91, 0x17, 0x74, 0x21, 0x96, 0x3f, - 0x6e, 0xf0, 0xec, 0xe5, 0xf5, 0x3f, 0xf9, 0xdc, 0xfb, 0xfd, 0x13, 0x0, 0x0, 0xff, 0xff, 0xca, 0xc3, 0x54, - 0x25, - }), usernameResp) - - oldUsernameResp := suite.cmd("GET", "username:mock") - suite.Require().Empty(oldUsernameResp) - - idResp := suite.cmd("HGET", "hash:username-to-account-id", 1) - suite.Require().NotEmpty(usernameResp) - suite.Require().Equal("NewMock", idResp) - }) -} - -func (suite *redisTestSuite) TestRemoveSkinByUserId() { - suite.RunSubTest("exists record", func() { - suite.cmd("SET", "username:mock", skinRecord) - suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock") - - err := suite.Redis.RemoveSkinByUserId(1) - suite.Require().Nil(err) - - usernameResp := suite.cmd("GET", "username:mock") - suite.Require().Empty(usernameResp) - - idResp := suite.cmd("HGET", "hash:username-to-account-id", 1) - suite.Require().Empty(idResp) - }) - - suite.RunSubTest("exists only id", func() { - suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock") - - err := suite.Redis.RemoveSkinByUserId(1) - suite.Require().Nil(err) - - idResp := suite.cmd("HGET", "hash:username-to-account-id", 1) - suite.Require().Empty(idResp) - }) - - suite.RunSubTest("error when querying skin record", func() { - suite.cmd("SET", "username:mock", "invalid zlib") - suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock") - - err := suite.Redis.RemoveSkinByUserId(1) - suite.Require().EqualError(err, "zlib: invalid header") - }) -} - -func (suite *redisTestSuite) TestRemoveSkinByUsername() { - suite.RunSubTest("exists record", func() { - suite.cmd("SET", "username:mock", skinRecord) - suite.cmd("HSET", "hash:username-to-account-id", 1, "Mock") - - err := suite.Redis.RemoveSkinByUsername("Mock") - suite.Require().Nil(err) - - usernameResp := suite.cmd("GET", "username:mock") - suite.Require().Empty(usernameResp) - - idResp := suite.cmd("HGET", "hash:username-to-account-id", 1) - suite.Require().Empty(idResp) - }) - - suite.RunSubTest("exists only username", func() { - suite.cmd("SET", "username:mock", skinRecord) - - err := suite.Redis.RemoveSkinByUsername("Mock") - suite.Require().Nil(err) - - usernameResp := suite.cmd("GET", "username:mock") - suite.Require().Empty(usernameResp) - }) - - suite.RunSubTest("no records", func() { - err := suite.Redis.RemoveSkinByUsername("Mock") - suite.Require().Nil(err) - }) - - suite.RunSubTest("error when querying skin record", func() { - suite.cmd("SET", "username:mock", "invalid zlib") - - err := suite.Redis.RemoveSkinByUsername("Mock") - suite.Require().EqualError(err, "zlib: invalid header") - }) -} - -func (suite *redisTestSuite) TestGetUuid() { - suite.RunSubTest("exists record", func() { - suite.cmd("HSET", - "hash:mojang-username-to-uuid", - "mock", - fmt.Sprintf("%s:%s:%d", "MoCk", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()), - ) - - uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock") - suite.Require().NoError(err) - suite.Require().Equal("MoCk", username) - suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid) - }) - - suite.RunSubTest("exists record (legacy data)", func() { - suite.cmd("HSET", - "hash:mojang-username-to-uuid", - "mock", - fmt.Sprintf("%s:%d", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Unix()), - ) - - uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock") - suite.Require().NoError(err) - suite.Require().Equal("Mock", username) - suite.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid) - }) - - suite.RunSubTest("exists record with empty uuid value", func() { - suite.cmd("HSET", - "hash:mojang-username-to-uuid", - "mock", - fmt.Sprintf("%s::%d", "MoCk", time.Now().Unix()), - ) - - uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock") - suite.Require().NoError(err) - suite.Require().Equal("MoCk", username) - suite.Require().Empty(uuid) - }) - - suite.RunSubTest("not exists record", func() { - uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock") - suite.Require().NoError(err) - suite.Require().Empty(username) - suite.Require().Empty(uuid) - }) - - suite.RunSubTest("exists, but expired record", func() { - suite.cmd("HSET", - "hash:mojang-username-to-uuid", - "mock", - fmt.Sprintf("%s:%s:%d", "MoCk", "d3ca513eb3e14946b58047f2bd3530fd", time.Now().Add(-1*time.Hour*24*31).Unix()), - ) - - uuid, username, err := suite.Redis.GetUuidForMojangUsername("Mock") - suite.Require().NoError(err) - suite.Require().Empty(uuid) - suite.Require().Empty(username) - - resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock") - suite.Require().Empty(resp, "should cleanup expired records") - }) - - suite.RunSubTest("exists, but corrupted record", func() { - suite.cmd("HSET", - "hash:mojang-username-to-uuid", - "mock", - "corrupted value", - ) - - uuid, found, err := suite.Redis.GetUuidForMojangUsername("Mock") - suite.Require().Empty(uuid) - suite.Require().Empty(found) - suite.Require().Error(err, "Got unexpected response from the mojangUsernameToUuid hash: \"corrupted value\"") - - resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock") - suite.Require().Empty(resp, "should cleanup expired records") - }) -} - -func (suite *redisTestSuite) TestStoreUuid() { - suite.RunSubTest("store uuid", func() { - now = func() time.Time { - return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC) +func (s *redisTestSuite) TestSaveProfile() { + s.Run("save new entity", func() { + profile := &db.Profile{ + Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3", + Username: "Mock", } + serializedProfile := "serialized-profile" + s.Serializer.On("Serialize", profile).Return(serializedProfile, nil) - err := suite.Redis.StoreMojangUuid("Mock", "d3ca513eb3e14946b58047f2bd3530fd") - suite.Require().Nil(err) + s.cmd("HSET", usernameToProfileKey, "mock", serializedProfile) + s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") - resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock") - suite.Require().Equal(resp, "Mock:d3ca513eb3e14946b58047f2bd3530fd:1587435016") + err := s.Redis.SaveProfile(profile) + s.Require().NoError(err) + + uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3") + s.Require().Equal("mock", uuidResp) + + profileResp := s.cmd("HGET", usernameToProfileKey, "mock") + s.Require().Equal(serializedProfile, profileResp) }) - suite.RunSubTest("store empty uuid", func() { - now = func() time.Time { - return time.Date(2020, 04, 21, 02, 10, 16, 0, time.UTC) + s.Run("update exists record with changed username", func() { + newProfile := &db.Profile{ + Uuid: "f57f36d5-4f50-4728-948a-42d5d80b18f3", + Username: "NewMock", } + serializedNewProfile := "serialized-new-profile" + s.Serializer.On("Serialize", newProfile).Return(serializedNewProfile, nil) - err := suite.Redis.StoreMojangUuid("Mock", "") - suite.Require().Nil(err) + s.cmd("HSET", usernameToProfileKey, "mock", "serialized-old-profile") + s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") - resp := suite.cmd("HGET", "hash:mojang-username-to-uuid", "mock") - suite.Require().Equal(resp, "Mock::1587435016") + err := s.Redis.SaveProfile(newProfile) + s.Require().NoError(err) + + uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3") + s.Require().Equal("newmock", uuidResp) + + newProfileResp := s.cmd("HGET", usernameToProfileKey, "newmock") + s.Require().Equal(serializedNewProfile, newProfileResp) + + oldProfileResp := s.cmd("HGET", usernameToProfileKey, "mock") + s.Require().Empty(oldProfileResp) }) } -func (suite *redisTestSuite) TestPing() { - err := suite.Redis.Ping() - suite.Require().Nil(err) +func (s *redisTestSuite) TestRemoveProfileByUuid() { + s.Run("exists record", func() { + s.cmd("HSET", usernameToProfileKey, "mock", "serialized-profile") + s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") + + err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3") + s.Require().NoError(err) + + uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3") + s.Require().Empty(uuidResp) + + profileResp := s.cmd("HGET", usernameToProfileKey, "mock") + s.Require().Empty(profileResp) + }) + + s.Run("uuid exists, username is missing", func() { + s.cmd("HSET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3", "mock") + + err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3") + s.Require().NoError(err) + + uuidResp := s.cmd("HGET", userUuidToUsernameKey, "f57f36d54f504728948a42d5d80b18f3") + s.Require().Empty(uuidResp) + }) + + s.Run("uuid not exists", func() { + err := s.Redis.RemoveProfileByUuid("f57f36d5-4f50-4728-948a-42d5d80b18f3") + s.Require().NoError(err) + }) +} + +func (s *redisTestSuite) TestGetUuidForMojangUsername() { + s.Run("exists record", func() { + s.cmd("SET", "mojang:uuid:mock", "MoCk:d3ca513eb3e14946b58047f2bd3530fd") + + uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock") + s.Require().NoError(err) + s.Require().Equal("MoCk", username) + s.Require().Equal("d3ca513eb3e14946b58047f2bd3530fd", uuid) + }) + + s.Run("exists record with empty uuid value", func() { + s.cmd("SET", "mojang:uuid:mock", "MoCk:") + + uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock") + s.Require().NoError(err) + s.Require().Equal("MoCk", username) + s.Require().Empty(uuid) + }) + + s.Run("not exists record", func() { + uuid, username, err := s.Redis.GetUuidForMojangUsername("Mock") + s.Require().NoError(err) + s.Require().Empty(username) + s.Require().Empty(uuid) + }) +} + +func (s *redisTestSuite) TestStoreUuid() { + s.Run("store uuid", func() { + err := s.Redis.StoreMojangUuid("MoCk", "d3ca513eb3e14946b58047f2bd3530fd") + s.Require().NoError(err) + + resp := s.cmd("GET", "mojang:uuid:mock") + s.Require().Equal(resp, "MoCk:d3ca513eb3e14946b58047f2bd3530fd") + }) + + s.Run("store empty uuid", func() { + err := s.Redis.StoreMojangUuid("MoCk", "") + s.Require().NoError(err) + + resp := s.cmd("GET", "mojang:uuid:mock") + s.Require().Equal(resp, "MoCk:") + }) +} + +func (s *redisTestSuite) TestPing() { + err := s.Redis.Ping() + s.Require().Nil(err) } diff --git a/db/serializer.go b/db/serializer.go new file mode 100644 index 0000000..19b2a36 --- /dev/null +++ b/db/serializer.go @@ -0,0 +1,136 @@ +package db + +import ( + "bytes" + "compress/zlib" + "io" + "strings" + + "github.com/valyala/fastjson" +) + +type ProfileSerializer interface { + Serialize(profile *Profile) ([]byte, error) + Deserialize(value []byte) (*Profile, error) +} + +func NewJsonSerializer() *JsonSerializer { + return &JsonSerializer{ + parserPool: &fastjson.ParserPool{}, + } +} + +type JsonSerializer struct { + parserPool *fastjson.ParserPool +} + +// Reasons for manual JSON serialization: +// 1. The Profile must be pure and must not contain tags. +// 2. Without tags it's impossible to apply omitempty during serialization. +// 3. Without omitempty we significantly inflate the storage size, which is critical for large deployments. +// Since the JSON structure in this case is very simple, it's very easy to write a manual serialization, +// achieving all constraints above. +func (s *JsonSerializer) Serialize(profile *Profile) ([]byte, error) { + var builder strings.Builder + // Prepare for the worst case (e.g. long username, long textures links, long Mojang textures and signature) + // to prevent additional memory allocations during serialization + builder.Grow(1536) + builder.WriteString(`{"uuid":"`) + builder.WriteString(profile.Uuid) + builder.WriteString(`","username":"`) + builder.WriteString(profile.Username) + builder.WriteString(`"`) + if profile.SkinUrl != "" { + builder.WriteString(`,"skinUrl":"`) + builder.WriteString(profile.SkinUrl) + builder.WriteString(`"`) + if profile.SkinModel != "" { + builder.WriteString(`,"skinModel":"`) + builder.WriteString(profile.SkinModel) + builder.WriteString(`"`) + } + } + + if profile.CapeUrl != "" { + builder.WriteString(`,"capeUrl":"`) + builder.WriteString(profile.CapeUrl) + builder.WriteString(`"`) + } + + if profile.MojangTextures != "" { + builder.WriteString(`,"mojangTextures":"`) + builder.WriteString(profile.MojangTextures) + builder.WriteString(`","mojangSignature":"`) + builder.WriteString(profile.MojangSignature) + builder.WriteString(`"`) + } + + builder.WriteString("}") + + return []byte(builder.String()), nil +} + +func (s *JsonSerializer) Deserialize(value []byte) (*Profile, error) { + parser := s.parserPool.Get() + defer s.parserPool.Put(parser) + v, err := parser.ParseBytes(value) + if err != nil { + return nil, err + } + + profile := &Profile{ + Uuid: string(v.GetStringBytes("uuid")), + Username: string(v.GetStringBytes("username")), + SkinUrl: string(v.GetStringBytes("skinUrl")), + SkinModel: string(v.GetStringBytes("skinModel")), + CapeUrl: string(v.GetStringBytes("capeUrl")), + MojangTextures: string(v.GetStringBytes("mojangTextures")), + MojangSignature: string(v.GetStringBytes("mojangSignature")), + } + + return profile, nil +} + +func NewZlibEncoder(serializer ProfileSerializer) *ZlibEncoder { + return &ZlibEncoder{serializer} +} + +type ZlibEncoder struct { + serializer ProfileSerializer +} + +func (s *ZlibEncoder) Serialize(profile *Profile) ([]byte, error) { + serialized, err := s.serializer.Serialize(profile) + if err != nil { + return nil, err + } + + var buff bytes.Buffer + writer := zlib.NewWriter(&buff) + _, err = writer.Write(serialized) + if err != nil { + return nil, err + } + + _ = writer.Close() + + return buff.Bytes(), nil +} + +func (s *ZlibEncoder) Deserialize(value []byte) (*Profile, error) { + buff := bytes.NewReader(value) + reader, err := zlib.NewReader(buff) + if err != nil { + return nil, err + } + + resultBuffer := new(bytes.Buffer) + _, err = io.Copy(resultBuffer, reader) + if err != nil { + return nil, err + } + + _ = reader.Close() + + return s.serializer.Deserialize(resultBuffer.Bytes()) +} diff --git a/db/serializer_test.go b/db/serializer_test.go new file mode 100644 index 0000000..bf27e07 --- /dev/null +++ b/db/serializer_test.go @@ -0,0 +1,194 @@ +package db + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestJsonSerializer(t *testing.T) { + var testCases = map[string]*struct { + *Profile + Serialized []byte + Error error + }{ + "full structure": { + Profile: &Profile{ + Uuid: "f57f36d54f504728948a42d5d80b18f3", + Username: "mock-username", + SkinUrl: "https://example.com/skin.png", + SkinModel: "slim", + CapeUrl: "https://example.com/cape.png", + MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=", + MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=", + }, + Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`), + }, + "default skin model": { + Profile: &Profile{ + Uuid: "f57f36d54f504728948a42d5d80b18f3", + Username: "mock-username", + SkinUrl: "https://example.com/skin.png", + }, + Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png"}`), + }, + "cape only": { + Profile: &Profile{ + Uuid: "f57f36d54f504728948a42d5d80b18f3", + Username: "mock-username", + CapeUrl: "https://example.com/cape.png", + }, + Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","capeUrl":"https://example.com/cape.png"}`), + }, + "minimal structure": { + Profile: &Profile{ + Uuid: "f57f36d54f504728948a42d5d80b18f3", + Username: "mock-username", + }, + Serialized: []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username"}`), + }, + "invalid json structure": { + Serialized: []byte(`this is not json`), + Error: errors.New(`cannot parse JSON: unexpected value found: "this is not json"; unparsed tail: "this is not json"`), + }, + } + + serializer := NewJsonSerializer() + t.Run("Serialize", func(t *testing.T) { + for n, c := range testCases { + if c.Profile == nil { + continue + } + + t.Run(n, func(t *testing.T) { + result, err := serializer.Serialize(c.Profile) + require.NoError(t, err) + require.Equal(t, c.Serialized, result) + }) + } + }) + + t.Run("Deserialize", func(t *testing.T) { + for n, c := range testCases { + t.Run(n, func(t *testing.T) { + result, err := serializer.Deserialize(c.Serialized) + require.Equal(t, c.Error, err) + require.Equal(t, c.Profile, result) + }) + } + }) +} + +type ProfileSerializerMock struct { + mock.Mock +} + +func (m *ProfileSerializerMock) Serialize(profile *Profile) ([]byte, error) { + args := m.Called(profile) + var result []byte + if casted, ok := args.Get(0).([]byte); ok { + result = casted + } + + return result, args.Error(1) +} + +func (m *ProfileSerializerMock) Deserialize(value []byte) (*Profile, error) { + args := m.Called(value) + var result *Profile + if casted, ok := args.Get(0).(*Profile); ok { + result = casted + } + + return result, args.Error(1) +} + +func TestZlibEncoder(t *testing.T) { + profile := &Profile{ + Uuid: "f57f36d54f504728948a42d5d80b18f3", + Username: "mock-username", + } + + t.Run("Serialize", func(t *testing.T) { + t.Run("successfully", func(t *testing.T) { + serializer := &ProfileSerializerMock{} + serializer.On("Serialize", profile).Return([]byte("serialized-string"), nil) + encoder := NewZlibEncoder(serializer) + + result, err := encoder.Serialize(profile) + require.NoError(t, err) + require.Equal(t, []byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1}, result) + }) + + t.Run("handle error from serializer", func(t *testing.T) { + expectedError := errors.New("mock error") + serializer := &ProfileSerializerMock{} + serializer.On("Serialize", profile).Return(nil, expectedError) + encoder := NewZlibEncoder(serializer) + + result, err := encoder.Serialize(profile) + require.Same(t, expectedError, err) + require.Nil(t, result) + }) + }) + + t.Run("Deserialize", func(t *testing.T) { + t.Run("successfully", func(t *testing.T) { + serializer := &ProfileSerializerMock{} + serializer.On("Deserialize", []byte("serialized-string")).Return(profile, nil) + encoder := NewZlibEncoder(serializer) + + result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1}) + require.NoError(t, err) + require.Equal(t, profile, result) + }) + + t.Run("handle an error from deserializer", func(t *testing.T) { + expectedError := errors.New("mock error") + + serializer := &ProfileSerializerMock{} + serializer.On("Deserialize", []byte("serialized-string")).Return(nil, expectedError) + encoder := NewZlibEncoder(serializer) + + result, err := encoder.Deserialize([]byte{0x78, 0x9c, 0x2a, 0x4e, 0x2d, 0xca, 0x4c, 0xcc, 0xc9, 0xac, 0x4a, 0x4d, 0xd1, 0x2d, 0x2e, 0x29, 0xca, 0xcc, 0x4b, 0x7, 0x4, 0x0, 0x0, 0xff, 0xff, 0x3e, 0xd8, 0x6, 0xf1}) + require.Same(t, expectedError, err) + require.Nil(t, result) + }) + + t.Run("handle invalid zlib encoding", func(t *testing.T) { + encoder := NewZlibEncoder(&ProfileSerializerMock{}) + + result, err := encoder.Deserialize([]byte{0x6d, 0x6f, 0x63, 0x6b}) + require.ErrorContains(t, err, "invalid") + require.Nil(t, result) + }) + }) +} + +func BenchmarkFastJsonSerializer(b *testing.B) { + profile := &Profile{ + Uuid: "f57f36d54f504728948a42d5d80b18f3", + Username: "mock-username", + SkinUrl: "https://example.com/skin.png", + SkinModel: "slim", + CapeUrl: "https://example.com/cape.png", + MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=", + MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=", + } + serializedProfile := []byte(`{"uuid":"f57f36d54f504728948a42d5d80b18f3","username":"mock-username","skinUrl":"https://example.com/skin.png","skinModel":"slim","capeUrl":"https://example.com/cape.png","mojangTextures":"eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=","mojangSignature":"QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc="}`) + + serializer := NewJsonSerializer() + b.Run("Serialize", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = serializer.Serialize(profile) + } + }) + + b.Run("Deserialize", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _, _ = serializer.Deserialize(serializedProfile) + } + }) +} diff --git a/di/db.go b/di/db.go index 5ac9f31..9757738 100644 --- a/di/db.go +++ b/di/db.go @@ -3,15 +3,14 @@ package di import ( "context" "fmt" - "path" "github.com/defval/di" "github.com/spf13/viper" - "github.com/elyby/chrly/db/fs" + db2 "github.com/elyby/chrly/db" "github.com/elyby/chrly/db/redis" es "github.com/elyby/chrly/eventsubscribers" - "github.com/elyby/chrly/http" + "github.com/elyby/chrly/internal/profiles" "github.com/elyby/chrly/mojang" ) @@ -22,12 +21,10 @@ import ( // all constants in this case point to static specific implementations. var db = di.Options( di.Provide(newRedis, - di.As(new(http.SkinsRepository)), + di.As(new(profiles.ProfilesRepository)), + di.As(new(profiles.ProfilesFinder)), di.As(new(mojang.MojangUuidsStorage)), ), - di.Provide(newFSFactory, - di.As(new(http.CapesRepository)), - ), ) func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error) { @@ -37,6 +34,7 @@ func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error conn, err := redis.New( context.Background(), + db2.NewZlibEncoder(&db2.JsonSerializer{}), fmt.Sprintf("%s:%d", config.GetString("storage.redis.host"), config.GetInt("storage.redis.port")), config.GetInt("storage.redis.poolSize"), ) @@ -55,13 +53,3 @@ func newRedis(container *di.Container, config *viper.Viper) (*redis.Redis, error return conn, nil } - -func newFSFactory(config *viper.Viper) (*fs.Filesystem, error) { - config.SetDefault("storage.filesystem.basePath", "data") - config.SetDefault("storage.filesystem.capesDirName", "capes") - - return fs.New(path.Join( - config.GetString("storage.filesystem.basePath"), - config.GetString("storage.filesystem.capesDirName"), - )) -} diff --git a/di/di.go b/di/di.go index cd2015a..a6c058c 100644 --- a/di/di.go +++ b/di/di.go @@ -10,6 +10,7 @@ func New() (*di.Container, error) { db, mojangTextures, handlers, + profilesDi, server, signer, ) diff --git a/di/handlers.go b/di/handlers.go index 08527b2..2cb5118 100644 --- a/di/handlers.go +++ b/di/handlers.go @@ -88,29 +88,23 @@ func newHandlerFactory( func newSkinsystemHandler( config *viper.Viper, - emitter Emitter, - skinsRepository SkinsRepository, - capesRepository CapesRepository, - mojangTexturesProvider MojangTexturesProvider, + profilesProvider ProfilesProvider, texturesSigner TexturesSigner, ) *mux.Router { config.SetDefault("textures.extra_param_name", "chrly") config.SetDefault("textures.extra_param_value", "how do you tame a horse in Minecraft?") return (&Skinsystem{ - Emitter: emitter, - SkinsRepo: skinsRepository, - CapesRepo: capesRepository, - MojangTexturesProvider: mojangTexturesProvider, + ProfilesProvider: profilesProvider, TexturesSigner: texturesSigner, TexturesExtraParamName: config.GetString("textures.extra_param_name"), TexturesExtraParamValue: config.GetString("textures.extra_param_value"), }).Handler() } -func newApiHandler(skinsRepository SkinsRepository) *mux.Router { +func newApiHandler(profilesManager ProfilesManager) *mux.Router { return (&Api{ - SkinsRepo: skinsRepository, + ProfilesManager: profilesManager, }).Handler() } diff --git a/di/mojang_textures.go b/di/mojang_textures.go index 4567199..1313d19 100644 --- a/di/mojang_textures.go +++ b/di/mojang_textures.go @@ -8,7 +8,7 @@ import ( "github.com/defval/di" "github.com/spf13/viper" - chrlyHttp "github.com/elyby/chrly/http" + "github.com/elyby/chrly/internal/profiles" "github.com/elyby/chrly/mojang" ) @@ -44,7 +44,7 @@ func newMojangApi(config *viper.Viper) (*mojang.MojangApi, error) { func newMojangTexturesProviderFactory( container *di.Container, config *viper.Viper, -) (chrlyHttp.MojangTexturesProvider, error) { +) (profiles.MojangProfilesProvider, error) { config.SetDefault("mojang_textures.enabled", true) if !config.GetBool("mojang_textures.enabled") { return &mojang.NilProvider{}, nil diff --git a/di/profiles.go b/di/profiles.go new file mode 100644 index 0000000..9425a7c --- /dev/null +++ b/di/profiles.go @@ -0,0 +1,27 @@ +package di + +import ( + "github.com/defval/di" + + . "github.com/elyby/chrly/http" + "github.com/elyby/chrly/internal/profiles" +) + +var profilesDi = di.Options( + di.Provide(newProfilesManager, di.As(new(ProfilesManager))), + di.Provide(newProfilesProvider, di.As(new(ProfilesProvider))), +) + +func newProfilesManager(r profiles.ProfilesRepository) *profiles.Manager { + return profiles.NewManager(r) +} + +func newProfilesProvider( + finder profiles.ProfilesFinder, + mojangProfilesProvider profiles.MojangProfilesProvider, +) *profiles.Provider { + return &profiles.Provider{ + ProfilesFinder: finder, + MojangProfilesProvider: mojangProfilesProvider, + } +} diff --git a/go.mod b/go.mod index 972a458..8123a1f 100644 --- a/go.mod +++ b/go.mod @@ -12,13 +12,14 @@ require ( github.com/defval/di v1.12.0 github.com/etherlabsio/healthcheck/v2 v2.0.0 github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea + github.com/go-playground/validator/v10 v10.17.0 github.com/gorilla/mux v1.8.1 github.com/jellydator/ttlcache/v3 v3.1.1 github.com/mediocregopher/radix/v4 v4.1.4 github.com/mono83/slf v0.0.0-20170919161409-79153e9636db github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.1 - github.com/thedevsaddam/govalidator v1.9.10 + github.com/valyala/fastjson v1.6.4 ) // Dev dependencies @@ -27,13 +28,18 @@ require ( github.com/stretchr/testify v1.8.4 ) +// Indirect dependencies require ( github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/leodido/go-urn v1.2.4 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mono83/udpwriter v1.0.2 // indirect @@ -49,7 +55,9 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect github.com/tilinna/clock v1.0.2 // indirect go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.16.0 // indirect golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb // indirect + golang.org/x/net v0.19.0 // indirect golang.org/x/sync v0.5.0 // indirect golang.org/x/sys v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum index c7b4d1d..f03799a 100644 --- a/go.sum +++ b/go.sum @@ -19,12 +19,20 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea h1:t6e33/eet/VyiHHHKs0cBytUISUWQ/hmQwOlqtFoGEo= github.com/getsentry/raven-go v0.2.1-0.20190419175539-919484f041ea/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.17.0 h1:SmVVlfAOtlZncTxRuinDPomC2DkXJ4E5T9gDA0AIH74= +github.com/go-playground/validator/v10 v10.17.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/goradd/maps v0.1.5 h1:Ut7BPJgNy5BYbleI3LswVJJquiM8X5uN0ZuZBHSdRUI= -github.com/goradd/maps v0.1.5/go.mod h1:E5X1CHMgfVm1qFTHgXpgVLVylO5wtlhZdB93dRGjnc0= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= @@ -41,6 +49,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mediocregopher/radix/v4 v4.1.4 h1:Uze6DEbEAvL+VHXUEu/EDBTkUk5CLct5h3nVSGpc6Ts= @@ -65,8 +75,6 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= -github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM= -github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= @@ -87,20 +95,25 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/thedevsaddam/govalidator v1.9.10 h1:m3dLRbSZ5Hts3VUWYe+vxLMG+FdyQuWOjzTeQRiMCvU= -github.com/thedevsaddam/govalidator v1.9.10/go.mod h1:Ilx8u7cg5g3LXbSS943cx5kczyNuUn7LH/cK5MYuE90= github.com/tilinna/clock v1.0.2 h1:6BO2tyAC9JbPExKH/z9zl44FLu1lImh3nDNKA0kgrkI= github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao= -github.com/zyedidia/generic v1.2.1 h1:Zv5KS/N2m0XZZiuLS82qheRG4X1o5gsWreGb0hR7XDc= -github.com/zyedidia/generic v1.2.1/go.mod h1:ly2RBz4mnz1yeuVbQA/VFwGjK3mnHGRj1JuoG336Bis= +github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb h1:c0vyKkb6yr3KR7jEfJaOSv4lG7xPkbN6r52aJz1d8a8= golang.org/x/exp v0.0.0-20231206192017-f3f8817b8deb/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= diff --git a/http/api.go b/http/api.go index 5998e82..af54e16 100644 --- a/http/api.go +++ b/http/api.go @@ -2,196 +2,72 @@ package http import ( "errors" - "fmt" "net/http" - "regexp" - "strconv" - "strings" "github.com/gorilla/mux" - "github.com/thedevsaddam/govalidator" - "github.com/elyby/chrly/model" + "github.com/elyby/chrly/db" + "github.com/elyby/chrly/internal/profiles" ) -var regexUuidAny = regexp.MustCompile("(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$") - -func init() { - // Add ability to validate any possible uuid form - govalidator.AddCustomRule("uuid_any", func(field string, rule string, message string, value interface{}) error { - str := value.(string) - if !regexUuidAny.MatchString(str) { - if message == "" { - message = fmt.Sprintf("The %s field must contain valid UUID", field) - } - - return errors.New(message) - } - - return nil - }) +type ProfilesManager interface { + PersistProfile(profile *db.Profile) error + RemoveProfileByUuid(uuid string) error } type Api struct { - SkinsRepo SkinsRepository + ProfilesManager } func (ctx *Api) Handler() *mux.Router { router := mux.NewRouter().StrictSlash(true) - router.HandleFunc("/skins", ctx.postSkinHandler).Methods(http.MethodPost) - router.HandleFunc("/skins/id:{id:[0-9]+}", ctx.deleteSkinByUserIdHandler).Methods(http.MethodDelete) - router.HandleFunc("/skins/{username}", ctx.deleteSkinByUsernameHandler).Methods(http.MethodDelete) + router.HandleFunc("/profiles", ctx.postProfileHandler).Methods(http.MethodPost) + router.HandleFunc("/profiles/{uuid}", ctx.deleteProfileByUuidHandler).Methods(http.MethodDelete) return router } -func (ctx *Api) postSkinHandler(resp http.ResponseWriter, req *http.Request) { - validationErrors := validatePostSkinRequest(req) - if validationErrors != nil { - apiBadRequest(resp, validationErrors) +func (ctx *Api) postProfileHandler(resp http.ResponseWriter, req *http.Request) { + err := req.ParseForm() + if err != nil { + apiBadRequest(resp, map[string][]string{ + "body": {"The body of the request must be a valid url-encoded string"}, + }) return } - identityId, _ := strconv.Atoi(req.Form.Get("identityId")) - username := req.Form.Get("username") - - record, err := ctx.findIdentityOrCleanup(identityId, username) - if err != nil { - panic(err) + profile := &db.Profile{ + Uuid: req.Form.Get("uuid"), + Username: req.Form.Get("username"), + SkinUrl: req.Form.Get("skinUrl"), + SkinModel: req.Form.Get("skinModel"), + CapeUrl: req.Form.Get("capeUrl"), + MojangTextures: req.Form.Get("mojangTextures"), + MojangSignature: req.Form.Get("mojangSignature"), } - if record == nil { - record = &model.Skin{ - UserId: identityId, - Username: username, + err = ctx.PersistProfile(profile) + if err != nil { + var v *profiles.ValidationError + if errors.As(err, &v) { + apiBadRequest(resp, v.Errors) + return } - } - skinId, _ := strconv.Atoi(req.Form.Get("skinId")) - is18, _ := strconv.ParseBool(req.Form.Get("is1_8")) - isSlim, _ := strconv.ParseBool(req.Form.Get("isSlim")) - - record.Uuid = strings.ToLower(req.Form.Get("uuid")) - record.SkinId = skinId - record.Is1_8 = is18 - record.IsSlim = isSlim - record.Url = req.Form.Get("url") - record.MojangTextures = req.Form.Get("mojangTextures") - record.MojangSignature = req.Form.Get("mojangSignature") - - err = ctx.SkinsRepo.SaveSkin(record) - if err != nil { - panic(err) + apiServerError(resp, "Unable to save profile to db", err) + return } resp.WriteHeader(http.StatusCreated) } -func (ctx *Api) deleteSkinByUserIdHandler(resp http.ResponseWriter, req *http.Request) { - id, _ := strconv.Atoi(mux.Vars(req)["id"]) - skin, err := ctx.SkinsRepo.FindSkinByUserId(id) - ctx.deleteSkin(skin, err, resp) -} - -func (ctx *Api) deleteSkinByUsernameHandler(resp http.ResponseWriter, req *http.Request) { - username := mux.Vars(req)["username"] - skin, err := ctx.SkinsRepo.FindSkinByUsername(username) - ctx.deleteSkin(skin, err, resp) -} - -func (ctx *Api) deleteSkin(skin *model.Skin, err error, resp http.ResponseWriter) { +func (ctx *Api) deleteProfileByUuidHandler(resp http.ResponseWriter, req *http.Request) { + uuid := mux.Vars(req)["uuid"] + err := ctx.ProfilesManager.RemoveProfileByUuid(uuid) if err != nil { - panic(err) - } - - if skin == nil { - apiNotFound(resp, "Cannot find record for the requested identifier") + apiServerError(resp, "Unable to delete profile from db", err) return } - err = ctx.SkinsRepo.RemoveSkinByUserId(skin.UserId) - if err != nil { - panic(err) - } - resp.WriteHeader(http.StatusNoContent) } - -func (ctx *Api) findIdentityOrCleanup(identityId int, username string) (*model.Skin, error) { - record, err := ctx.SkinsRepo.FindSkinByUserId(identityId) - if err != nil { - return nil, err - } - - if record != nil { - // The username may have changed in the external database, - // so we need to remove the old association - if record.Username != username { - _ = ctx.SkinsRepo.RemoveSkinByUserId(identityId) - record.Username = username - } - - return record, nil - } - - // If the requested id was not found, then username was reassigned to another user - // who has not uploaded his data to Chrly yet - record, err = ctx.SkinsRepo.FindSkinByUsername(username) - if err != nil { - return nil, err - } - - // If the target username does exist, clear it as it will be reassigned to the new user - if record != nil { - _ = ctx.SkinsRepo.RemoveSkinByUsername(username) - record.UserId = identityId - - return record, nil - } - - return nil, nil -} - -func validatePostSkinRequest(request *http.Request) map[string][]string { - _ = request.ParseForm() - - validationRules := govalidator.MapData{ - "identityId": {"required", "numeric", "min:1"}, - "username": {"required"}, - "uuid": {"required", "uuid_any"}, - "skinId": {"required", "numeric"}, - "url": {}, - "is1_8": {"bool"}, - "isSlim": {"bool"}, - "mojangTextures": {}, - "mojangSignature": {}, - } - - url := request.Form.Get("url") - if url == "" { - validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:0,0") - } else { - validationRules["url"] = append(validationRules["url"], "url") - validationRules["skinId"] = append(validationRules["skinId"], "numeric_between:1,") - validationRules["is1_8"] = append(validationRules["is1_8"], "required") - validationRules["isSlim"] = append(validationRules["isSlim"], "required") - } - - mojangTextures := request.Form.Get("mojangTextures") - if mojangTextures != "" { - validationRules["mojangSignature"] = append(validationRules["mojangSignature"], "required") - } - - validator := govalidator.New(govalidator.Options{ - Request: request, - Rules: validationRules, - RequiredDefault: false, - }) - validationResults := validator.Validate() - - if len(validationResults) != 0 { - return validationResults - } - - return nil -} diff --git a/http/api_test.go b/http/api_test.go index b07a459..2fb482c 100644 --- a/http/api_test.go +++ b/http/api_test.go @@ -2,417 +2,171 @@ package http import ( "bytes" - "encoding/base64" "errors" "io" - "io/ioutil" "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" - "github.com/elyby/chrly/model" + "github.com/elyby/chrly/db" + "github.com/elyby/chrly/internal/profiles" ) -/*************** - * Setup mocks * - ***************/ +type ProfilesManagerMock struct { + mock.Mock +} -type apiTestSuite struct { +func (m *ProfilesManagerMock) PersistProfile(profile *db.Profile) error { + return m.Called(profile).Error(0) +} + +func (m *ProfilesManagerMock) RemoveProfileByUuid(uuid string) error { + return m.Called(uuid).Error(0) +} + +type ApiTestSuite struct { suite.Suite App *Api - SkinsRepository *skinsRepositoryMock + ProfilesManager *ProfilesManagerMock } -/******************** - * Setup test suite * - ********************/ - -func (suite *apiTestSuite) SetupTest() { - suite.SkinsRepository = &skinsRepositoryMock{} - - suite.App = &Api{ - SkinsRepo: suite.SkinsRepository, +func (t *ApiTestSuite) SetupSubTest() { + t.ProfilesManager = &ProfilesManagerMock{} + t.App = &Api{ + ProfilesManager: t.ProfilesManager, } } -func (suite *apiTestSuite) TearDownTest() { - suite.SkinsRepository.AssertExpectations(suite.T()) +func (t *ApiTestSuite) TearDownSubTest() { + t.ProfilesManager.AssertExpectations(t.T()) } -func (suite *apiTestSuite) RunSubTest(name string, subTest func()) { - suite.SetupTest() - suite.Run(name, subTest) - suite.TearDownTest() -} +func (t *ApiTestSuite) TestPostProfile() { + t.Run("successfully post profile", func() { + t.ProfilesManager.On("PersistProfile", &db.Profile{ + Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3", + Username: "mock_username", + SkinUrl: "https://example.com/skin.png", + SkinModel: "slim", + CapeUrl: "https://example.com/cape.png", + MojangTextures: "bW9jawo=", + MojangSignature: "bW9jawo=", + }).Once().Return(nil) -/************* - * Run tests * - *************/ - -func TestApi(t *testing.T) { - suite.Run(t, new(apiTestSuite)) -} - -/************************* - * Post skin tests cases * - *************************/ - -type postSkinTestCase struct { - Name string - Form io.Reader - BeforeTest func(suite *apiTestSuite) - PanicErr string - AfterTest func(suite *apiTestSuite, response *http.Response) -} - -var postSkinTestsCases = []*postSkinTestCase{ - { - Name: "Upload new identity with textures data", - Form: bytes.NewBufferString(url.Values{ - "identityId": {"1"}, - "username": {"mock_username"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "is1_8": {"0"}, - "isSlim": {"0"}, - "url": {"http://example.com/skin.png"}, - }.Encode()), - BeforeTest: func(suite *apiTestSuite) { - suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil) - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool { - suite.Equal(1, model.UserId) - suite.Equal("mock_username", model.Username) - suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) - suite.Equal(5, model.SkinId) - suite.False(model.Is1_8) - suite.False(model.IsSlim) - suite.Equal("http://example.com/skin.png", model.Url) - - return true - })).Times(1).Return(nil) - }, - AfterTest: func(suite *apiTestSuite, response *http.Response) { - suite.Equal(201, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Empty(body) - }, - }, - { - Name: "Update exists identity by changing only textures data", - Form: bytes.NewBufferString(url.Values{ - "identityId": {"1"}, - "username": {"mock_username"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "is1_8": {"1"}, - "isSlim": {"1"}, - "url": {"http://textures-server.com/skin.png"}, - }.Encode()), - BeforeTest: func(suite *apiTestSuite) { - suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil) - suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool { - suite.Equal(1, model.UserId) - suite.Equal("mock_username", model.Username) - suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) - suite.Equal(5, model.SkinId) - suite.True(model.Is1_8) - suite.True(model.IsSlim) - suite.Equal("http://textures-server.com/skin.png", model.Url) - - return true - })).Times(1).Return(nil) - }, - AfterTest: func(suite *apiTestSuite, response *http.Response) { - suite.Equal(201, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Empty(body) - }, - }, - { - Name: "Update exists identity by changing textures data to empty", - Form: bytes.NewBufferString(url.Values{ - "identityId": {"1"}, - "username": {"mock_username"}, + req := httptest.NewRequest("POST", "http://chrly/profiles", bytes.NewBufferString(url.Values{ "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"0"}, - "is1_8": {"0"}, - "isSlim": {"0"}, - "url": {""}, - "mojangTextures": {""}, - "mojangSignature": {""}, - }.Encode()), - BeforeTest: func(suite *apiTestSuite) { - suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil) - suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool { - suite.Equal(1, model.UserId) - suite.Equal("mock_username", model.Username) - suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) - suite.Equal(0, model.SkinId) - suite.False(model.Is1_8) - suite.False(model.IsSlim) - suite.Equal("", model.Url) - suite.Equal("", model.MojangTextures) - suite.Equal("", model.MojangSignature) - - return true - })).Times(1).Return(nil) - }, - AfterTest: func(suite *apiTestSuite, response *http.Response) { - suite.Equal(201, response.StatusCode) - body, _ := io.ReadAll(response.Body) - suite.Equal("", string(body)) - }, - }, - { - Name: "Update exists identity by changing its identityId", - Form: bytes.NewBufferString(url.Values{ - "identityId": {"2"}, - "username": {"mock_username"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "is1_8": {"0"}, - "isSlim": {"0"}, - "url": {"http://example.com/skin.png"}, - }.Encode()), - BeforeTest: func(suite *apiTestSuite) { - suite.SkinsRepository.On("FindSkinByUserId", 2).Return(nil, nil) - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.SkinsRepository.On("RemoveSkinByUsername", "mock_username").Times(1).Return(nil) - suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool { - suite.Equal(2, model.UserId) - suite.Equal("mock_username", model.Username) - suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) - - return true - })).Times(1).Return(nil) - }, - AfterTest: func(suite *apiTestSuite, response *http.Response) { - suite.Equal(201, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Empty(body) - }, - }, - { - Name: "Update exists identity by changing its username", - Form: bytes.NewBufferString(url.Values{ - "identityId": {"1"}, - "username": {"changed_username"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "is1_8": {"0"}, - "isSlim": {"0"}, - "url": {"http://example.com/skin.png"}, - }.Encode()), - BeforeTest: func(suite *apiTestSuite) { - suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil) - suite.SkinsRepository.On("RemoveSkinByUserId", 1).Times(1).Return(nil) - suite.SkinsRepository.On("SaveSkin", mock.MatchedBy(func(model *model.Skin) bool { - suite.Equal(1, model.UserId) - suite.Equal("changed_username", model.Username) - suite.Equal("0f657aa8-bfbe-415d-b700-5750090d3af3", model.Uuid) - - return true - })).Times(1).Return(nil) - }, - AfterTest: func(suite *apiTestSuite, response *http.Response) { - suite.Equal(201, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Empty(body) - }, - }, - { - Name: "Handle an error when loading the data from the repository", - Form: bytes.NewBufferString(url.Values{ - "identityId": {"1"}, - "username": {"changed_username"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "is1_8": {"0"}, - "isSlim": {"0"}, - "url": {"http://example.com/skin.png"}, - }.Encode()), - BeforeTest: func(suite *apiTestSuite) { - suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, errors.New("can't find skin by user id")) - }, - PanicErr: "can't find skin by user id", - }, - { - Name: "Handle an error when saving the data into the repository", - Form: bytes.NewBufferString(url.Values{ - "identityId": {"1"}, - "username": {"mock_username"}, - "uuid": {"0f657aa8-bfbe-415d-b700-5750090d3af3"}, - "skinId": {"5"}, - "is1_8": {"1"}, - "isSlim": {"1"}, - "url": {"http://textures-server.com/skin.png"}, - }.Encode()), - BeforeTest: func(suite *apiTestSuite) { - suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil) - suite.SkinsRepository.On("SaveSkin", mock.Anything).Return(errors.New("can't save textures")) - }, - PanicErr: "can't save textures", - }, -} - -func (suite *apiTestSuite) TestPostSkin() { - for _, testCase := range postSkinTestsCases { - suite.RunSubTest(testCase.Name, func() { - testCase.BeforeTest(suite) - - req := httptest.NewRequest("POST", "http://chrly/skins", testCase.Form) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - - if testCase.PanicErr != "" { - suite.PanicsWithError(testCase.PanicErr, func() { - suite.App.Handler().ServeHTTP(w, req) - }) - } else { - suite.App.Handler().ServeHTTP(w, req) - testCase.AfterTest(suite, w.Result()) - } - }) - } - - suite.RunSubTest("Get errors about required fields", func() { - req := httptest.NewRequest("POST", "http://chrly/skins", bytes.NewBufferString(url.Values{ - "mojangTextures": {"someBase64EncodedString"}, + "username": {"mock_username"}, + "skinUrl": {"https://example.com/skin.png"}, + "skinModel": {"slim"}, + "capeUrl": {"https://example.com/cape.png"}, + "mojangTextures": {"bW9jawo="}, + "mojangSignature": {"bW9jawo="}, }.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) + t.App.Handler().ServeHTTP(w, req) + result := w.Result() - resp := w.Result() - defer resp.Body.Close() - suite.Equal(400, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.JSONEq(`{ + t.Equal(http.StatusCreated, result.StatusCode) + body, _ := io.ReadAll(result.Body) + t.Empty(body) + }) + + t.Run("handle malformed body", func() { + req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader("invalid;=url?encoded_string")) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + result := w.Result() + + t.Equal(http.StatusBadRequest, result.StatusCode) + body, _ := io.ReadAll(result.Body) + t.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 numeric value between 0 and 0" - ], - "username": [ - "The username field is required" - ], - "uuid": [ - "The uuid field is required", - "The uuid field must contain valid UUID" - ], - "mojangSignature": [ - "The mojangSignature field is required" + "body": [ + "The body of the request must be a valid url-encoded string" ] } }`, string(body)) }) -} -/************************************** - * Delete skin by user id tests cases * - **************************************/ + t.Run("receive validation errors", func() { + t.ProfilesManager.On("PersistProfile", mock.Anything).Once().Return(&profiles.ValidationError{ + Errors: map[string][]string{ + "mock": {"error1", "error2"}, + }, + }) -func (suite *apiTestSuite) TestDeleteByUserId() { - suite.RunSubTest("Delete skin by its identity id", func() { - suite.SkinsRepository.On("FindSkinByUserId", 1).Return(createSkinModel("mock_username", false), nil) - suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil) - - req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil) + req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader("")) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) + t.App.Handler().ServeHTTP(w, req) + result := w.Result() - resp := w.Result() - defer resp.Body.Close() - suite.Equal(204, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.Empty(body) + t.Equal(http.StatusBadRequest, result.StatusCode) + body, _ := io.ReadAll(result.Body) + t.JSONEq(`{ + "errors": { + "mock": [ + "error1", + "error2" + ] + } + }`, string(body)) }) - suite.RunSubTest("Try to remove not exists identity id", func() { - suite.SkinsRepository.On("FindSkinByUserId", 1).Return(nil, nil) + t.Run("receive other error", func() { + t.ProfilesManager.On("PersistProfile", mock.Anything).Once().Return(errors.New("mock error")) - req := httptest.NewRequest("DELETE", "http://chrly/skins/id:1", nil) + req := httptest.NewRequest("POST", "http://chrly/profiles", strings.NewReader("")) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) + t.App.Handler().ServeHTTP(w, req) + result := w.Result() - resp := w.Result() - defer resp.Body.Close() - suite.Equal(404, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.JSONEq(`[ - "Cannot find record for the requested identifier" - ]`, string(body)) + t.Equal(http.StatusInternalServerError, result.StatusCode) }) } -/*************************************** - * Delete skin by username tests cases * - ***************************************/ +func (t *ApiTestSuite) TestDeleteProfileByUuid() { + t.Run("successfully delete", func() { + t.ProfilesManager.On("RemoveProfileByUuid", "0f657aa8-bfbe-415d-b700-5750090d3af3").Once().Return(nil) -func (suite *apiTestSuite) TestDeleteByUsername() { - suite.RunSubTest("Delete skin by its identity username", func() { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.SkinsRepository.On("RemoveSkinByUserId", 1).Once().Return(nil) - - req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil) + req := httptest.NewRequest("DELETE", "http://chrly/profiles/0f657aa8-bfbe-415d-b700-5750090d3af3", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) + t.App.Handler().ServeHTTP(w, req) resp := w.Result() - defer resp.Body.Close() - suite.Equal(204, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.Empty(body) + t.Equal(http.StatusNoContent, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + t.Empty(body) }) - suite.RunSubTest("Try to remove not exists identity username", func() { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) + t.Run("error from manager", func() { + t.ProfilesManager.On("RemoveProfileByUuid", mock.Anything).Return(errors.New("mock error")) - req := httptest.NewRequest("DELETE", "http://chrly/skins/mock_username", nil) + req := httptest.NewRequest("DELETE", "http://chrly/profiles/0f657aa8-bfbe-415d-b700-5750090d3af3", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) + t.App.Handler().ServeHTTP(w, req) resp := w.Result() - defer resp.Body.Close() - suite.Equal(404, resp.StatusCode) - body, _ := ioutil.ReadAll(resp.Body) - suite.JSONEq(`[ - "Cannot find record for the requested identifier" - ]`, string(body)) + t.Equal(http.StatusInternalServerError, resp.StatusCode) }) } -/************* - * Utilities * - *************/ - -// base64 https://github.com/mathiasbynens/small/blob/0ca3c51/png-transparent.png -var OnePxPng = []byte("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==") - -func loadSkinFile() []byte { - result := make([]byte, 92) - _, err := base64.StdEncoding.Decode(result, OnePxPng) - if err != nil { - panic(err) - } - - return result +func TestApi(t *testing.T) { + suite.Run(t, new(ApiTestSuite)) } diff --git a/http/http.go b/http/http.go index c41c5c5..bdeb548 100644 --- a/http/http.go +++ b/http/http.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "log/slog" "net/http" "os" "os/signal" @@ -119,6 +120,15 @@ func apiBadRequest(resp http.ResponseWriter, errorsPerField map[string][]string) _, _ = resp.Write(result) } +var internalServerError = []byte("Internal server error") + +func apiServerError(resp http.ResponseWriter, msg string, err error) { + resp.WriteHeader(http.StatusInternalServerError) + resp.Header().Set("Content-Type", "application/json") + slog.Error(msg, slog.Any("error", err)) + _, _ = resp.Write(internalServerError) +} + func apiForbidden(resp http.ResponseWriter, reason string) { resp.WriteHeader(http.StatusForbidden) resp.Header().Set("Content-Type", "application/json") diff --git a/http/skinsystem.go b/http/skinsystem.go index a4288ee..b910455 100644 --- a/http/skinsystem.go +++ b/http/skinsystem.go @@ -6,35 +6,21 @@ import ( "encoding/base64" "encoding/json" "encoding/pem" - "errors" - "io" "net/http" "strings" "time" "github.com/gorilla/mux" - "github.com/elyby/chrly/model" + "github.com/elyby/chrly/db" "github.com/elyby/chrly/mojang" "github.com/elyby/chrly/utils" ) var timeNow = time.Now -type SkinsRepository interface { - FindSkinByUsername(username string) (*model.Skin, error) - FindSkinByUserId(id int) (*model.Skin, error) - SaveSkin(skin *model.Skin) error - RemoveSkinByUserId(id int) error - RemoveSkinByUsername(username string) error -} - -type CapesRepository interface { - FindCapeByUsername(username string) (*model.Cape, error) -} - -type MojangTexturesProvider interface { - GetForUsername(username string) (*mojang.SignedTexturesResponse, error) +type ProfilesProvider interface { + FindProfileByUsername(username string, allowProxy bool) (*db.Profile, error) } type TexturesSigner interface { @@ -43,29 +29,18 @@ type TexturesSigner interface { } type Skinsystem struct { - Emitter - SkinsRepo SkinsRepository - CapesRepo CapesRepository - MojangTexturesProvider MojangTexturesProvider - TexturesSigner TexturesSigner + ProfilesProvider + TexturesSigner TexturesExtraParamName string TexturesExtraParamValue string } -type profile struct { - Id string - Username string - Textures *mojang.TexturesResponse - CapeFile io.Reader - MojangTextures string - MojangSignature string -} - func (ctx *Skinsystem) Handler() *mux.Router { router := mux.NewRouter().StrictSlash(true) router.HandleFunc("/skins/{username}", ctx.skinHandler).Methods(http.MethodGet) - router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet).Name("cloaks") + router.HandleFunc("/cloaks/{username}", ctx.capeHandler).Methods(http.MethodGet) + // TODO: alias /capes/{username}? router.HandleFunc("/textures/{username}", ctx.texturesHandler).Methods(http.MethodGet) router.HandleFunc("/textures/signed/{username}", ctx.signedTexturesHandler).Methods(http.MethodGet) router.HandleFunc("/profile/{username}", ctx.profileHandler).Methods(http.MethodGet) @@ -80,17 +55,18 @@ func (ctx *Skinsystem) Handler() *mux.Router { } func (ctx *Skinsystem) skinHandler(response http.ResponseWriter, request *http.Request) { - profile, err := ctx.getProfile(request, true) + profile, err := ctx.ProfilesProvider.FindProfileByUsername(parseUsername(mux.Vars(request)["username"]), true) if err != nil { - panic(err) + apiServerError(response, "Unable to retrieve a skin", err) + return } - if profile == nil || profile.Textures == nil || profile.Textures.Skin == nil { + if profile == nil || profile.SkinUrl == "" { response.WriteHeader(http.StatusNotFound) return } - http.Redirect(response, request, profile.Textures.Skin.Url, 301) + http.Redirect(response, request, profile.SkinUrl, http.StatusMovedPermanently) } func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *http.Request) { @@ -106,22 +82,18 @@ func (ctx *Skinsystem) skinGetHandler(response http.ResponseWriter, request *htt } func (ctx *Skinsystem) capeHandler(response http.ResponseWriter, request *http.Request) { - profile, err := ctx.getProfile(request, true) + profile, err := ctx.ProfilesProvider.FindProfileByUsername(parseUsername(mux.Vars(request)["username"]), true) if err != nil { - panic(err) + apiServerError(response, "Unable to retrieve a cape", err) + return } - if profile == nil || profile.Textures == nil || (profile.CapeFile == nil && profile.Textures.Cape == nil) { + if profile == nil || profile.CapeUrl == "" { response.WriteHeader(http.StatusNotFound) return } - if profile.CapeFile == nil { - http.Redirect(response, request, profile.Textures.Cape.Url, 301) - } else { - request.Header.Set("Content-Type", "image/png") - _, _ = io.Copy(response, profile.CapeFile) - } + http.Redirect(response, request, profile.CapeUrl, http.StatusMovedPermanently) } func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *http.Request) { @@ -137,34 +109,51 @@ func (ctx *Skinsystem) capeGetHandler(response http.ResponseWriter, request *htt } func (ctx *Skinsystem) texturesHandler(response http.ResponseWriter, request *http.Request) { - profile, err := ctx.getProfile(request, true) + profile, err := ctx.ProfilesProvider.FindProfileByUsername(mux.Vars(request)["username"], true) if err != nil { - panic(err) + apiServerError(response, "Unable to retrieve a profile", err) + return } - if profile == nil || profile.Textures == nil || (profile.Textures.Skin == nil && profile.Textures.Cape == nil) { + if profile == nil { + response.WriteHeader(http.StatusNotFound) + return + } + + if profile.SkinUrl == "" && profile.CapeUrl == "" { response.WriteHeader(http.StatusNoContent) return } - responseData, _ := json.Marshal(profile.Textures) + textures := texturesFromProfile(profile) + + responseData, _ := json.Marshal(textures) response.Header().Set("Content-Type", "application/json") _, _ = response.Write(responseData) } func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, request *http.Request) { - profile, err := ctx.getProfile(request, request.URL.Query().Get("proxy") != "") + profile, err := ctx.ProfilesProvider.FindProfileByUsername( + mux.Vars(request)["username"], + getToBool(request.URL.Query().Get("proxy")), + ) if err != nil { - panic(err) + apiServerError(response, "Unable to retrieve a profile", err) + return } - if profile == nil || profile.MojangTextures == "" { + if profile == nil { + response.WriteHeader(http.StatusNotFound) + return + } + + if profile.MojangTextures == "" { response.WriteHeader(http.StatusNoContent) return } - profileResponse := &mojang.SignedTexturesResponse{ - Id: profile.Id, + profileResponse := &mojang.ProfileResponse{ + Id: profile.Uuid, Name: profile.Username, Props: []*mojang.Property{ { @@ -185,21 +174,22 @@ func (ctx *Skinsystem) signedTexturesHandler(response http.ResponseWriter, reque } func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *http.Request) { - profile, err := ctx.getProfile(request, true) + profile, err := ctx.ProfilesProvider.FindProfileByUsername(mux.Vars(request)["username"], true) if err != nil { - panic(err) + apiServerError(response, "Unable to retrieve a profile", err) + return } if profile == nil { - response.WriteHeader(http.StatusNoContent) + response.WriteHeader(http.StatusNotFound) return } texturesPropContent := &mojang.TexturesProp{ Timestamp: utils.UnixMillisecond(timeNow()), - ProfileID: profile.Id, + ProfileID: profile.Uuid, ProfileName: profile.Username, - Textures: profile.Textures, + Textures: texturesFromProfile(profile), } texturesPropValueJson, _ := json.Marshal(texturesPropContent) @@ -210,17 +200,18 @@ func (ctx *Skinsystem) profileHandler(response http.ResponseWriter, request *htt Value: texturesPropEncodedValue, } - if request.URL.Query().Get("unsigned") == "false" { + if request.URL.Query().Has("unsigned") && !getToBool(request.URL.Query().Get("unsigned")) { signature, err := ctx.TexturesSigner.SignTextures(texturesProp.Value) if err != nil { - panic(err) + apiServerError(response, "Unable to sign textures", err) + return } texturesProp.Signature = signature } - profileResponse := &mojang.SignedTexturesResponse{ - Id: profile.Id, + profileResponse := &mojang.ProfileResponse{ + Id: profile.Uuid, Name: profile.Username, Props: []*mojang.Property{ texturesProp, @@ -264,101 +255,36 @@ func (ctx *Skinsystem) signatureVerificationKeyHandler(response http.ResponseWri } } -// TODO: in v5 should be extracted into some ProfileProvider interface, -// -// which will encapsulate all logics, declared in this method -func (ctx *Skinsystem) getProfile(request *http.Request, proxy bool) (*profile, error) { - username := parseUsername(mux.Vars(request)["username"]) - - skin, err := ctx.SkinsRepo.FindSkinByUsername(username) - if err != nil { - return nil, err - } - - profile := &profile{ - Textures: &mojang.TexturesResponse{}, // Field must be initialized to avoid "null" after json encoding - } - - if skin != nil { - profile.Id = strings.Replace(skin.Uuid, "-", "", -1) - profile.Username = skin.Username - } - - if skin != nil && skin.Url != "" { - profile.Textures.Skin = &mojang.SkinTexturesResponse{ - Url: skin.Url, - } - - if skin.IsSlim { - profile.Textures.Skin.Metadata = &mojang.SkinTexturesMetadata{ - Model: "slim", - } - } - - cape, _ := ctx.CapesRepo.FindCapeByUsername(username) - if cape != nil { - profile.CapeFile = cape.File - profile.Textures.Cape = &mojang.CapeTexturesResponse{ - // Use statically http since the application doesn't support TLS - Url: "http://" + request.Host + "/cloaks/" + username, - } - } - - profile.MojangTextures = skin.MojangTextures - profile.MojangSignature = skin.MojangSignature - } else if proxy { - mojangProfile, err := ctx.MojangTexturesProvider.GetForUsername(username) - // If we at least know something about the user, - // then we can ignore an error and return profile without textures - if err != nil && profile.Id != "" { - return profile, nil - } - - if err != nil || mojangProfile == nil { - if errors.Is(err, mojang.InvalidUsername) { - return nil, nil - } - - return nil, err - } - - decodedTextures, err := mojangProfile.DecodeTextures() - if err != nil { - return nil, err - } - - // There might be no textures property - if decodedTextures != nil { - profile.Textures = decodedTextures.Textures - } - - var texturesProp *mojang.Property - for _, prop := range mojangProfile.Props { - if prop.Name == "textures" { - texturesProp = prop - break - } - } - - if texturesProp != nil { - profile.MojangTextures = texturesProp.Value - profile.MojangSignature = texturesProp.Signature - } - - // If user id is unknown at this point, then use values from Mojang profile - if profile.Id == "" { - profile.Id = mojangProfile.Id - profile.Username = mojangProfile.Name - } - } else if profile.Id != "" { - return profile, nil - } else { - return nil, nil - } - - return profile, nil -} - func parseUsername(username string) string { return strings.TrimSuffix(username, ".png") } + +func getToBool(v string) bool { + return v == "true" || v == "1" || v == "yes" +} + +func texturesFromProfile(profile *db.Profile) *mojang.TexturesResponse { + var skin *mojang.SkinTexturesResponse + if profile.SkinUrl != "" { + skin = &mojang.SkinTexturesResponse{ + Url: profile.SkinUrl, + } + if profile.SkinModel != "" { + skin.Metadata = &mojang.SkinTexturesMetadata{ + Model: profile.SkinModel, + } + } + } + + var cape *mojang.CapeTexturesResponse + if profile.CapeUrl != "" { + cape = &mojang.CapeTexturesResponse{ + Url: profile.CapeUrl, + } + } + + return &mojang.TexturesResponse{ + Skin: skin, + Cape: cape, + } +} diff --git a/http/skinsystem_test.go b/http/skinsystem_test.go index 5216d40..b09f2ef 100644 --- a/http/skinsystem_test.go +++ b/http/skinsystem_test.go @@ -1,109 +1,48 @@ package http import ( - "bytes" "crypto/rsa" "crypto/x509" "encoding/pem" "errors" - "image" - "image/png" - "io/ioutil" + "io" "net/http" "net/http/httptest" "strings" "testing" "time" - testify "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + testify "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - "github.com/elyby/chrly/model" - "github.com/elyby/chrly/mojang" + "github.com/elyby/chrly/db" ) -/*************** - * Setup mocks * - ***************/ - -type skinsRepositoryMock struct { +type ProfilesProviderMock struct { mock.Mock } -func (m *skinsRepositoryMock) FindSkinByUsername(username string) (*model.Skin, error) { - args := m.Called(username) - var result *model.Skin - if casted, ok := args.Get(0).(*model.Skin); ok { +func (m *ProfilesProviderMock) FindProfileByUsername(username string, allowProxy bool) (*db.Profile, error) { + args := m.Called(username, allowProxy) + var result *db.Profile + if casted, ok := args.Get(0).(*db.Profile); ok { result = casted } return result, args.Error(1) } -func (m *skinsRepositoryMock) FindSkinByUserId(id int) (*model.Skin, error) { - args := m.Called(id) - var result *model.Skin - if casted, ok := args.Get(0).(*model.Skin); ok { - result = casted - } - - return result, args.Error(1) -} - -func (m *skinsRepositoryMock) SaveSkin(skin *model.Skin) error { - args := m.Called(skin) - return args.Error(0) -} - -func (m *skinsRepositoryMock) RemoveSkinByUserId(id int) error { - args := m.Called(id) - return args.Error(0) -} - -func (m *skinsRepositoryMock) RemoveSkinByUsername(username string) error { - args := m.Called(username) - return args.Error(0) -} - -type capesRepositoryMock struct { +type TexturesSignerMock struct { mock.Mock } -func (m *capesRepositoryMock) FindCapeByUsername(username string) (*model.Cape, error) { - args := m.Called(username) - var result *model.Cape - if casted, ok := args.Get(0).(*model.Cape); ok { - result = casted - } - - return result, args.Error(1) -} - -type mojangTexturesProviderMock struct { - mock.Mock -} - -func (m *mojangTexturesProviderMock) GetForUsername(username string) (*mojang.SignedTexturesResponse, error) { - args := m.Called(username) - var result *mojang.SignedTexturesResponse - if casted, ok := args.Get(0).(*mojang.SignedTexturesResponse); ok { - result = casted - } - - return result, args.Error(1) -} - -type texturesSignerMock struct { - mock.Mock -} - -func (m *texturesSignerMock) SignTextures(textures string) (string, error) { +func (m *TexturesSignerMock) SignTextures(textures string) (string, error) { args := m.Called(textures) return args.String(0), args.Error(1) } -func (m *texturesSignerMock) GetPublicKey() (*rsa.PublicKey, error) { +func (m *TexturesSignerMock) GetPublicKey() (*rsa.PublicKey, error) { args := m.Called() var publicKey *rsa.PublicKey if casted, ok := args.Get(0).(*rsa.PublicKey); ok { @@ -113,1125 +52,544 @@ func (m *texturesSignerMock) GetPublicKey() (*rsa.PublicKey, error) { return publicKey, args.Error(1) } -type skinsystemTestSuite struct { +type SkinsystemTestSuite struct { suite.Suite App *Skinsystem - SkinsRepository *skinsRepositoryMock - CapesRepository *capesRepositoryMock - MojangTexturesProvider *mojangTexturesProviderMock - TexturesSigner *texturesSignerMock - Emitter *emitterMock + ProfilesProvider *ProfilesProviderMock + TexturesSigner *TexturesSignerMock } /******************** * Setup test suite * ********************/ -func (suite *skinsystemTestSuite) SetupTest() { +func (t *SkinsystemTestSuite) SetupSubTest() { timeNow = func() time.Time { CET, _ := time.LoadLocation("CET") return time.Date(2021, 02, 25, 01, 50, 23, 0, CET) } - suite.SkinsRepository = &skinsRepositoryMock{} - suite.CapesRepository = &capesRepositoryMock{} - suite.MojangTexturesProvider = &mojangTexturesProviderMock{} - suite.TexturesSigner = &texturesSignerMock{} - suite.Emitter = &emitterMock{} + t.ProfilesProvider = &ProfilesProviderMock{} + t.TexturesSigner = &TexturesSignerMock{} - suite.App = &Skinsystem{ - SkinsRepo: suite.SkinsRepository, - CapesRepo: suite.CapesRepository, - MojangTexturesProvider: suite.MojangTexturesProvider, - TexturesSigner: suite.TexturesSigner, - Emitter: suite.Emitter, + t.App = &Skinsystem{ + ProfilesProvider: t.ProfilesProvider, + TexturesSigner: t.TexturesSigner, TexturesExtraParamName: "texturesParamName", TexturesExtraParamValue: "texturesParamValue", } } -func (suite *skinsystemTestSuite) TearDownTest() { - suite.SkinsRepository.AssertExpectations(suite.T()) - suite.CapesRepository.AssertExpectations(suite.T()) - suite.MojangTexturesProvider.AssertExpectations(suite.T()) - suite.TexturesSigner.AssertExpectations(suite.T()) - suite.Emitter.AssertExpectations(suite.T()) +func (t *SkinsystemTestSuite) TearDownSubTest() { + t.ProfilesProvider.AssertExpectations(t.T()) + t.TexturesSigner.AssertExpectations(t.T()) } -func (suite *skinsystemTestSuite) RunSubTest(name string, subTest func()) { - suite.SetupTest() - suite.Run(name, subTest) - suite.TearDownTest() -} - -/************* - * Run tests * - *************/ - -func TestSkinsystem(t *testing.T) { - suite.Run(t, new(skinsystemTestSuite)) -} - -type skinsystemTestCase struct { - Name string - BeforeTest func(suite *skinsystemTestSuite) - PanicErr string - AfterTest func(suite *skinsystemTestSuite, response *http.Response) -} - -/************************ - * Get skin tests cases * - ************************/ - -var skinsTestsCases = []*skinsystemTestCase{ - { - Name: "Username exists in the local storage", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(301, response.StatusCode) - suite.Equal("http://chrly/skin.png", response.Header.Get("Location")) - }, - }, - { - Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, false), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(301, response.StatusCode) - suite.Equal("http://mojang/skin.png", response.Header.Get("Location")) - }, - }, - { - Name: "Username doesn't exists on the local storage, but exists on Mojang and has no skin texture", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(false, false), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(404, response.StatusCode) - }, - }, - { - Name: "Username doesn't exists on the local storage, but exists on Mojang and has an empty properties", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createEmptyMojangResponse(), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(404, response.StatusCode) - }, - }, - { - Name: "Username doesn't exists on the local storage and doesn't exists on Mojang", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(404, response.StatusCode) - }, - }, - { - Name: "Receive an error from the SkinsRepository", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error")) - }, - PanicErr: "skins repository error", - }, -} - -func (suite *skinsystemTestSuite) TestSkin() { - for _, testCase := range skinsTestsCases { - suite.RunSubTest(testCase.Name, func() { - testCase.BeforeTest(suite) - - req := httptest.NewRequest("GET", "http://chrly/skins/mock_username", nil) +func (t *SkinsystemTestSuite) TestSkinHandler() { + for _, url := range []string{"http://chrly/skins/mock_username", "http://chrly/skins?name=mock_username"} { + t.Run("known username with a skin", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{ + SkinUrl: "https://example.com/skin.png", + }, nil) + req := httptest.NewRequest("GET", url, nil) w := httptest.NewRecorder() - if testCase.PanicErr != "" { - suite.PanicsWithError(testCase.PanicErr, func() { - suite.App.Handler().ServeHTTP(w, req) - }) - } else { - suite.App.Handler().ServeHTTP(w, req) - testCase.AfterTest(suite, w.Result()) - } + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusMovedPermanently, result.StatusCode) + t.Equal("https://example.com/skin.png", result.Header.Get("Location")) + }) + + t.Run("known username without a skin", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{}, nil) + req := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusNotFound, result.StatusCode) + }) + + t.Run("err from profiles provider", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(nil, errors.New("mock error")) + req := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusInternalServerError, result.StatusCode) }) } - suite.RunSubTest("Pass username with png extension", func() { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) - + t.Run("username with png extension", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{ + SkinUrl: "https://example.com/skin.png", + }, nil) req := httptest.NewRequest("GET", "http://chrly/skins/mock_username.png", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) + t.App.Handler().ServeHTTP(w, req) - resp := w.Result() - suite.Equal(301, resp.StatusCode) - suite.Equal("http://chrly/skin.png", resp.Header.Get("Location")) + result := w.Result() + t.Equal(http.StatusMovedPermanently, result.StatusCode) + t.Equal("https://example.com/skin.png", result.Header.Get("Location")) }) -} -func (suite *skinsystemTestSuite) TestSkinGET() { - for _, testCase := range skinsTestsCases { - suite.RunSubTest(testCase.Name, func() { - testCase.BeforeTest(suite) - - req := httptest.NewRequest("GET", "http://chrly/skins?name=mock_username", nil) - w := httptest.NewRecorder() - - if testCase.PanicErr != "" { - suite.PanicsWithError(testCase.PanicErr, func() { - suite.App.Handler().ServeHTTP(w, req) - }) - } else { - suite.App.Handler().ServeHTTP(w, req) - testCase.AfterTest(suite, w.Result()) - } - }) - } - - suite.RunSubTest("Do not pass name param", func() { + t.Run("no name param", func() { req := httptest.NewRequest("GET", "http://chrly/skins", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) + t.App.Handler().ServeHTTP(w, req) resp := w.Result() - suite.Equal(400, resp.StatusCode) + t.Equal(http.StatusBadRequest, resp.StatusCode) }) } -/************************ - * Get cape tests cases * - ************************/ - -var capesTestsCases = []*skinsystemTestCase{ - { - Name: "Username exists in the local storage", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - responseData, _ := ioutil.ReadAll(response.Body) - suite.Equal(createCape(), responseData) - suite.Equal("image/png", response.Header.Get("Content-Type")) - }, - }, - { - Name: "Username doesn't exists on the local storage, but exists on Mojang and has textures", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, true), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(301, response.StatusCode) - suite.Equal("http://mojang/cape.png", response.Header.Get("Location")) - }, - }, - { - Name: "Username doesn't exists on the local storage, but exists on Mojang and has no cape texture", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(false, false), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(404, response.StatusCode) - }, - }, - { - Name: "Username doesn't exists on the local storage, but exists on Mojang and has an empty properties", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createEmptyMojangResponse(), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(404, response.StatusCode) - }, - }, - { - Name: "Username doesn't exists on the local storage and doesn't exists on Mojang", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(404, response.StatusCode) - }, - }, - { - Name: "Receive an error from the SkinsRepository", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error")) - }, - PanicErr: "skins repository error", - }, -} - -func (suite *skinsystemTestSuite) TestCape() { - for _, testCase := range capesTestsCases { - suite.RunSubTest(testCase.Name, func() { - testCase.BeforeTest(suite) - - req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username", nil) +func (t *SkinsystemTestSuite) TestCapeHandler() { + for _, url := range []string{"http://chrly/cloaks/mock_username", "http://chrly/cloaks?name=mock_username"} { + t.Run("known username with a skin", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{ + CapeUrl: "https://example.com/cape.png", + }, nil) + req := httptest.NewRequest("GET", url, nil) w := httptest.NewRecorder() - if testCase.PanicErr != "" { - suite.PanicsWithError(testCase.PanicErr, func() { - suite.App.Handler().ServeHTTP(w, req) - }) - } else { - suite.App.Handler().ServeHTTP(w, req) - testCase.AfterTest(suite, w.Result()) - } + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusMovedPermanently, result.StatusCode) + t.Equal("https://example.com/cape.png", result.Header.Get("Location")) + }) + + t.Run("known username without a skin", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{}, nil) + req := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusNotFound, result.StatusCode) + }) + + t.Run("err from profiles provider", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(nil, errors.New("mock error")) + req := httptest.NewRequest("GET", url, nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusInternalServerError, result.StatusCode) }) } - suite.RunSubTest("Pass username with png extension", func() { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil) - + t.Run("username with png extension", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{ + CapeUrl: "https://example.com/cape.png", + }, nil) req := httptest.NewRequest("GET", "http://chrly/cloaks/mock_username.png", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) + t.App.Handler().ServeHTTP(w, req) - resp := w.Result() - suite.Equal(200, resp.StatusCode) - responseData, _ := ioutil.ReadAll(resp.Body) - suite.Equal(createCape(), responseData) - suite.Equal("image/png", resp.Header.Get("Content-Type")) + result := w.Result() + t.Equal(http.StatusMovedPermanently, result.StatusCode) + t.Equal("https://example.com/cape.png", result.Header.Get("Location")) }) -} -func (suite *skinsystemTestSuite) TestCapeGET() { - for _, testCase := range capesTestsCases { - suite.RunSubTest(testCase.Name, func() { - testCase.BeforeTest(suite) - - req := httptest.NewRequest("GET", "http://chrly/cloaks?name=mock_username", nil) - w := httptest.NewRecorder() - - if testCase.PanicErr != "" { - suite.PanicsWithError(testCase.PanicErr, func() { - suite.App.Handler().ServeHTTP(w, req) - }) - } else { - suite.App.Handler().ServeHTTP(w, req) - testCase.AfterTest(suite, w.Result()) - } - }) - } - - suite.RunSubTest("Do not pass name param", func() { + t.Run("no name param", func() { req := httptest.NewRequest("GET", "http://chrly/cloaks", nil) w := httptest.NewRecorder() - suite.App.Handler().ServeHTTP(w, req) + t.App.Handler().ServeHTTP(w, req) resp := w.Result() - suite.Equal(400, resp.StatusCode) + t.Equal(http.StatusBadRequest, resp.StatusCode) }) } -/**************************** - * Get textures tests cases * - ****************************/ +func (t *SkinsystemTestSuite) TestTexturesHandler() { + t.Run("known username with both textures", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{ + SkinUrl: "https://example.com/skin.png", + CapeUrl: "https://example.com/cape.png", + }, nil) + req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) + w := httptest.NewRecorder() -var texturesTestsCases = []*skinsystemTestCase{ - { - Name: "Username exists and has skin, no cape", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "SKIN": { - "url": "http://chrly/skin.png" - } - }`, string(body)) - }, - }, - { - Name: "Username exists and has slim skin, no cape", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "SKIN": { - "url": "http://chrly/skin.png", - "metadata": { - "model": "slim" - } - } - }`, string(body)) - }, - }, - // There is no case when the user has cape, but has no skin. - // In v5 we will rework textures repositories to be more generic about source of textures, - // but right now it's not possible to return profile entity with a cape only. - { - Name: "Username exists and has both skin and cape", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "SKIN": { - "url": "http://chrly/skin.png" - }, - "CAPE": { - "url": "http://chrly/cloaks/mock_username" - } - }`, string(body)) - }, - }, - { - Name: "Username not exists, but Mojang profile available", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "SKIN": { - "url": "http://mojang/skin.png" - }, - "CAPE": { - "url": "http://mojang/cape.png" - } - }`, string(body)) - }, - }, - { - Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(204, response.StatusCode) - }, - }, - { - Name: "Username not exists, but Mojang profile available, but there is an empty properties", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(204, response.StatusCode) - }, - }, - { - Name: "Username not exists and Mojang profile unavailable", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(204, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Equal("", string(body)) - }, - }, - { - Name: "Receive an error from the SkinsRepository", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error")) - }, - PanicErr: "skins repository error", - }, -} + t.App.Handler().ServeHTTP(w, req) -func (suite *skinsystemTestSuite) TestTextures() { - for _, testCase := range texturesTestsCases { - suite.RunSubTest(testCase.Name, func() { - testCase.BeforeTest(suite) - - req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) - w := httptest.NewRecorder() - - if testCase.PanicErr != "" { - suite.PanicsWithError(testCase.PanicErr, func() { - suite.App.Handler().ServeHTTP(w, req) - }) - } else { - suite.App.Handler().ServeHTTP(w, req) - testCase.AfterTest(suite, w.Result()) + result := w.Result() + t.Equal(http.StatusOK, result.StatusCode) + t.Equal("application/json", result.Header.Get("Content-Type")) + body, _ := io.ReadAll(result.Body) + t.JSONEq(`{ + "SKIN": { + "url": "https://example.com/skin.png" + }, + "CAPE": { + "url": "https://example.com/cape.png" } - }) - } -} + }`, string(body)) + }) -/*********************************** - * Get signed textures tests cases * - ***********************************/ + t.Run("known username with only slim skin", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{ + SkinUrl: "https://example.com/skin.png", + SkinModel: "slim", + }, nil) + req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + body, _ := io.ReadAll(result.Body) + t.JSONEq(`{ + "SKIN": { + "url": "https://example.com/skin.png", + "metadata": { + "model": "slim" + } + } + }`, string(body)) + }) + + t.Run("known username with only cape", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{ + CapeUrl: "https://example.com/cape.png", + }, nil) + req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + body, _ := io.ReadAll(result.Body) + t.JSONEq(`{ + "CAPE": { + "url": "https://example.com/cape.png" + } + }`, string(body)) + }) + + t.Run("known username without any textures", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{}, nil) + req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusNoContent, result.StatusCode) + body, _ := io.ReadAll(result.Body) + t.Empty(body) + }) + + t.Run("unknown username", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(nil, nil) + req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusNotFound, result.StatusCode) + body, _ := io.ReadAll(result.Body) + t.Empty(body) + }) + + t.Run("err from profiles provider", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(nil, errors.New("mock error")) + req := httptest.NewRequest("GET", "http://chrly/textures/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusInternalServerError, result.StatusCode) + }) +} type signedTexturesTestCase struct { Name string AllowProxy bool - BeforeTest func(suite *skinsystemTestSuite) + BeforeTest func(suite *SkinsystemTestSuite) PanicErr string - AfterTest func(suite *skinsystemTestSuite, response *http.Response) + AfterTest func(suite *SkinsystemTestSuite, response *http.Response) } -var signedTexturesTestsCases = []*signedTexturesTestCase{ - { - Name: "Username exists", - AllowProxy: false, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "0f657aa8bfbe415db7005750090d3af3", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "signature": "mocked signature", - "value": "mocked textures base64" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username not exists", - AllowProxy: false, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(204, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Equal("", string(body)) - }, - }, - { - Name: "Username exists, but has no signed textures", - AllowProxy: false, - BeforeTest: func(suite *skinsystemTestSuite) { - skinModel := createSkinModel("mock_username", true) - skinModel.MojangTextures = "" - skinModel.MojangSignature = "" - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skinModel, nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(204, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Equal("", string(body)) - }, - }, - { - Name: "Username not exists, but Mojang profile is available and proxying is enabled", - AllowProxy: true, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(createMojangResponseWithTextures(true, false), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "292a1db7353d476ca99cab8f57mojang", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "value": "eyJ0aW1lc3RhbXAiOjE1NTYzOTg1NzIwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn19fQ==", - "signature": "mojang signature" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username not exists, Mojang profile is unavailable too and proxying is enabled", - AllowProxy: true, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(204, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Equal("", string(body)) - }, - }, - { - Name: "Receive an error from the SkinsRepository", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error")) - }, - PanicErr: "skins repository error", - }, +func (t *SkinsystemTestSuite) TestSignedTextures() { + t.Run("exists profile with mojang textures", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", false).Return(&db.Profile{ + Uuid: "mock-uuid", + Username: "mock", + MojangTextures: "mock-mojang-textures", + MojangSignature: "mock-mojang-signature", + }, nil) + req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusOK, result.StatusCode) + t.Equal("application/json", result.Header.Get("Content-Type")) + body, _ := io.ReadAll(result.Body) + t.JSONEq(`{ + "id": "mock-uuid", + "name": "mock", + "properties": [ + { + "name": "textures", + "signature": "mock-mojang-signature", + "value": "mock-mojang-textures" + }, + { + "name": "texturesParamName", + "value": "texturesParamValue" + } + ] + }`, string(body)) + }) + + t.Run("exists profile without mojang textures", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", false).Return(&db.Profile{}, nil) + req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusNoContent, result.StatusCode) + body, _ := io.ReadAll(result.Body) + t.Empty(body) + }) + + t.Run("not exists profile", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", false).Return(nil, nil) + req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusNotFound, result.StatusCode) + body, _ := io.ReadAll(result.Body) + t.Empty(body) + }) + + t.Run("err from profiles provider", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", false).Return(nil, errors.New("mock error")) + req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusInternalServerError, result.StatusCode) + }) + + t.Run("should allow proxying when specified get param", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(nil, nil) + req := httptest.NewRequest("GET", "http://chrly/textures/signed/mock_username?proxy=true", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + }) } -func (suite *skinsystemTestSuite) TestSignedTextures() { - for _, testCase := range signedTexturesTestsCases { - suite.RunSubTest(testCase.Name, func() { - testCase.BeforeTest(suite) +func (t *SkinsystemTestSuite) TestProfile() { + t.Run("exists profile with skin and cape", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{ + Uuid: "mock-uuid", + Username: "mock_username", + SkinUrl: "https://example.com/skin.png", + SkinModel: "slim", + CapeUrl: "https://example.com/cape.png", + }, nil) + req := httptest.NewRequest("GET", "http://chrly/profile/mock_username", nil) + w := httptest.NewRecorder() - var target string - if testCase.AllowProxy { - target = "http://chrly/textures/signed/mock_username?proxy=true" - } else { - target = "http://chrly/textures/signed/mock_username" - } + t.App.Handler().ServeHTTP(w, req) - req := httptest.NewRequest("GET", target, nil) - w := httptest.NewRecorder() + result := w.Result() + t.Equal(http.StatusOK, result.StatusCode) + t.Equal("application/json", result.Header.Get("Content-Type")) + body, _ := io.ReadAll(result.Body) + t.JSONEq(`{ + "id": "mock-uuid", + "name": "mock_username", + "properties": [ + { + "name": "textures", + "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6Im1vY2stdXVpZCIsInByb2ZpbGVOYW1lIjoibW9ja191c2VybmFtZSIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9za2luLnBuZyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fSwiQ0FQRSI6eyJ1cmwiOiJodHRwczovL2V4YW1wbGUuY29tL2NhcGUucG5nIn19fQ==" + }, + { + "name": "texturesParamName", + "value": "texturesParamValue" + } + ] + }`, string(body)) + }) - if testCase.PanicErr != "" { - suite.PanicsWithError(testCase.PanicErr, func() { - suite.App.Handler().ServeHTTP(w, req) - }) - } else { - suite.App.Handler().ServeHTTP(w, req) - testCase.AfterTest(suite, w.Result()) - } - }) - } + t.Run("exists signed profile with skin", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{ + Uuid: "mock-uuid", + Username: "mock_username", + SkinUrl: "https://example.com/skin.png", + SkinModel: "slim", + }, nil) + t.TexturesSigner.On("SignTextures", "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6Im1vY2stdXVpZCIsInByb2ZpbGVOYW1lIjoibW9ja191c2VybmFtZSIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9za2luLnBuZyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19").Return("mock signature", nil) + req := httptest.NewRequest("GET", "http://chrly/profile/mock_username?unsigned=false", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusOK, result.StatusCode) + t.Equal("application/json", result.Header.Get("Content-Type")) + body, _ := io.ReadAll(result.Body) + t.JSONEq(`{ + "id": "mock-uuid", + "name": "mock_username", + "properties": [ + { + "name": "textures", + "signature": "mock signature", + "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6Im1vY2stdXVpZCIsInByb2ZpbGVOYW1lIjoibW9ja191c2VybmFtZSIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9za2luLnBuZyIsIm1ldGFkYXRhIjp7Im1vZGVsIjoic2xpbSJ9fX19" + }, + { + "name": "texturesParamName", + "value": "texturesParamValue" + } + ] + }`, string(body)) + }) + + t.Run("not exists profile", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(nil, nil) + req := httptest.NewRequest("GET", "http://chrly/profile/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusNotFound, result.StatusCode) + body, _ := io.ReadAll(result.Body) + t.Empty(body) + }) + + t.Run("err from profiles provider", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(nil, errors.New("mock error")) + req := httptest.NewRequest("GET", "http://chrly/profile/mock_username", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusInternalServerError, result.StatusCode) + }) + + t.Run("err from textures signer", func() { + t.ProfilesProvider.On("FindProfileByUsername", "mock_username", true).Return(&db.Profile{}, nil) + t.TexturesSigner.On("SignTextures", mock.Anything).Return("", errors.New("mock error")) + req := httptest.NewRequest("GET", "http://chrly/profile/mock_username?unsigned=false", nil) + w := httptest.NewRecorder() + + t.App.Handler().ServeHTTP(w, req) + + result := w.Result() + t.Equal(http.StatusInternalServerError, result.StatusCode) + }) } -/*************************** - * Get profile tests cases * - ***************************/ - -type profileTestCase struct { - Name string - Signed bool - BeforeTest func(suite *skinsystemTestSuite) - PanicErr string - AfterTest func(suite *skinsystemTestSuite, response *http.Response) -} - -var profileTestsCases = []*profileTestCase{ - { - Name: "Username exists and has both skin and cape, don't sign", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "0f657aa8bfbe415db7005750090d3af3", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username exists and has both skin and cape", - Signed: true, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(createCapeModel(), nil) - suite.TexturesSigner.On("SignTextures", "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19").Return("textures signature", nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "0f657aa8bfbe415db7005750090d3af3", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "signature": "textures signature", - "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifSwiQ0FQRSI6eyJ1cmwiOiJodHRwOi8vY2hybHkvY2xvYWtzL21vY2tfdXNlcm5hbWUifX19" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username exists and has skin, no cape", - Signed: true, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) - suite.TexturesSigner.On("SignTextures", mock.Anything).Return("textures signature", nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "0f657aa8bfbe415db7005750090d3af3", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "signature": "textures signature", - "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmcifX19" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username exists and has slim skin, no cape", - Signed: true, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", true), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) - suite.TexturesSigner.On("SignTextures", mock.Anything).Return("textures signature", nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "0f657aa8bfbe415db7005750090d3af3", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "signature": "textures signature", - "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vY2hybHkvc2tpbi5wbmciLCJtZXRhZGF0YSI6eyJtb2RlbCI6InNsaW0ifX19fQ==" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username exists, but has no skin and Mojang profile with textures available", - Signed: true, - BeforeTest: func(suite *skinsystemTestSuite) { - skin := createSkinModel("mock_username", false) - skin.SkinId = 0 - skin.Url = "" - - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skin, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil) - suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "0f657aa8bfbe415db7005750090d3af3", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "signature": "chrly signature", - "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn0sIkNBUEUiOnsidXJsIjoiaHR0cDovL21vamFuZy9jYXBlLnBuZyJ9fX0=" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username exists, but has no skin and Mojang textures proxy returned an error", - Signed: true, - BeforeTest: func(suite *skinsystemTestSuite) { - skin := createSkinModel("mock_username", false) - skin.SkinId = 0 - skin.Url = "" - - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(skin, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, errors.New("shit happened")) - suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "0f657aa8bfbe415db7005750090d3af3", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "signature": "chrly signature", - "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjBmNjU3YWE4YmZiZTQxNWRiNzAwNTc1MDA5MGQzYWYzIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ==" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username not exists, but Mojang profile with textures available", - Signed: true, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(true, true), nil) - suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "292a1db7353d476ca99cab8f57mojang", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "signature": "chrly signature", - "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnsiU0tJTiI6eyJ1cmwiOiJodHRwOi8vbW9qYW5nL3NraW4ucG5nIn0sIkNBUEUiOnsidXJsIjoiaHR0cDovL21vamFuZy9jYXBlLnBuZyJ9fX0=" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username not exists, but Mojang profile available, but there is an empty skin and cape textures", - Signed: true, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createMojangResponseWithTextures(false, false), nil) - suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "292a1db7353d476ca99cab8f57mojang", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "signature": "chrly signature", - "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ==" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username not exists, but Mojang profile available, but there is an empty properties", - Signed: true, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(createEmptyMojangResponse(), nil) - suite.TexturesSigner.On("SignTextures", mock.Anything).Return("chrly signature", nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(200, response.StatusCode) - suite.Equal("application/json", response.Header.Get("Content-Type")) - body, _ := ioutil.ReadAll(response.Body) - suite.JSONEq(`{ - "id": "292a1db7353d476ca99cab8f57mojang", - "name": "mock_username", - "properties": [ - { - "name": "textures", - "signature": "chrly signature", - "value": "eyJ0aW1lc3RhbXAiOjE2MTQyMTQyMjMwMDAsInByb2ZpbGVJZCI6IjI5MmExZGI3MzUzZDQ3NmNhOTljYWI4ZjU3bW9qYW5nIiwicHJvZmlsZU5hbWUiOiJtb2NrX3VzZXJuYW1lIiwidGV4dHVyZXMiOnt9fQ==" - }, - { - "name": "texturesParamName", - "value": "texturesParamValue" - } - ] - }`, string(body)) - }, - }, - { - Name: "Username not exists and Mojang profile unavailable", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, nil) - }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { - suite.Equal(204, response.StatusCode) - body, _ := ioutil.ReadAll(response.Body) - suite.Equal("", string(body)) - }, - }, - { - Name: "Username not exists and Mojang textures proxy returned an error", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, nil) - suite.MojangTexturesProvider.On("GetForUsername", "mock_username").Once().Return(nil, errors.New("mojang textures provider error")) - }, - PanicErr: "mojang textures provider error", - }, - { - Name: "Receive an error from the SkinsRepository", - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(nil, errors.New("skins repository error")) - }, - PanicErr: "skins repository error", - }, - { - Name: "Receive an error from the TexturesSigner", - Signed: true, - BeforeTest: func(suite *skinsystemTestSuite) { - suite.SkinsRepository.On("FindSkinByUsername", "mock_username").Return(createSkinModel("mock_username", false), nil) - suite.CapesRepository.On("FindCapeByUsername", "mock_username").Return(nil, nil) - suite.TexturesSigner.On("SignTextures", mock.Anything).Return("", errors.New("textures signer error")) - }, - PanicErr: "textures signer error", - }, -} - -func (suite *skinsystemTestSuite) TestProfile() { - for _, testCase := range profileTestsCases { - suite.RunSubTest(testCase.Name, func() { - testCase.BeforeTest(suite) - - url := "http://chrly/profile/mock_username" - if testCase.Signed { - url += "?unsigned=false" - } - - req := httptest.NewRequest("GET", url, nil) - w := httptest.NewRecorder() - - if testCase.PanicErr != "" { - suite.PanicsWithError(testCase.PanicErr, func() { - suite.App.Handler().ServeHTTP(w, req) - }) - } else { - suite.App.Handler().ServeHTTP(w, req) - testCase.AfterTest(suite, w.Result()) - } - }) - } -} - -/*************************** - * Get profile tests cases * - ***************************/ - type signingKeyTestCase struct { Name string KeyFormat string - BeforeTest func(suite *skinsystemTestSuite) + BeforeTest func(suite *SkinsystemTestSuite) PanicErr string - AfterTest func(suite *skinsystemTestSuite, response *http.Response) + AfterTest func(suite *SkinsystemTestSuite, response *http.Response) } var signingKeyTestsCases = []*signingKeyTestCase{ { Name: "Get public key in DER format", KeyFormat: "DER", - BeforeTest: func(suite *skinsystemTestSuite) { + BeforeTest: func(suite *SkinsystemTestSuite) { pubPem, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----")) publicKey, _ := x509.ParsePKIXPublicKey(pubPem.Bytes) suite.TexturesSigner.On("GetPublicKey").Return(publicKey, nil) }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { + AfterTest: func(suite *SkinsystemTestSuite, response *http.Response) { suite.Equal(200, response.StatusCode) suite.Equal("application/octet-stream", response.Header.Get("Content-Type")) suite.Equal("attachment; filename=\"yggdrasil_session_pubkey.der\"", response.Header.Get("Content-Disposition")) - body, _ := ioutil.ReadAll(response.Body) + body, _ := io.ReadAll(response.Body) suite.Equal([]byte{48, 92, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0, 3, 75, 0, 48, 72, 2, 65, 0, 214, 212, 165, 80, 153, 144, 194, 169, 126, 246, 25, 211, 197, 183, 150, 233, 157, 1, 166, 49, 44, 25, 230, 80, 57, 115, 28, 20, 7, 220, 58, 88, 121, 254, 86, 8, 237, 246, 76, 53, 58, 125, 226, 9, 231, 192, 52, 148, 12, 176, 130, 214, 120, 195, 8, 182, 116, 97, 206, 207, 253, 97, 2, 247, 2, 3, 1, 0, 1}, body) }, }, { Name: "Get public key in PEM format", KeyFormat: "PEM", - BeforeTest: func(suite *skinsystemTestSuite) { + BeforeTest: func(suite *SkinsystemTestSuite) { pubPem, _ := pem.Decode([]byte("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----")) publicKey, _ := x509.ParsePKIXPublicKey(pubPem.Bytes) suite.TexturesSigner.On("GetPublicKey").Return(publicKey, nil) }, - AfterTest: func(suite *skinsystemTestSuite, response *http.Response) { + AfterTest: func(suite *SkinsystemTestSuite, response *http.Response) { suite.Equal(200, response.StatusCode) suite.Equal("text/plain; charset=utf-8", response.Header.Get("Content-Type")) suite.Equal("attachment; filename=\"yggdrasil_session_pubkey.pem\"", response.Header.Get("Content-Disposition")) - body, _ := ioutil.ReadAll(response.Body) + body, _ := io.ReadAll(response.Body) suite.Equal("-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBANbUpVCZkMKpfvYZ08W3lumdAaYxLBnm\nUDlzHBQH3DpYef5WCO32TDU6feIJ58A0lAywgtZ4wwi2dGHOz/1hAvcCAwEAAQ==\n-----END PUBLIC KEY-----\n", string(body)) }, }, { Name: "Error while obtaining public key", KeyFormat: "DER", - BeforeTest: func(suite *skinsystemTestSuite) { + BeforeTest: func(suite *SkinsystemTestSuite) { suite.TexturesSigner.On("GetPublicKey").Return(nil, errors.New("textures signer error")) }, PanicErr: "textures signer error", }, } -func (suite *skinsystemTestSuite) TestSignatureVerificationKey() { +func (t *SkinsystemTestSuite) TestSignatureVerificationKey() { for _, testCase := range signingKeyTestsCases { - suite.RunSubTest(testCase.Name, func() { - testCase.BeforeTest(suite) + t.Run(testCase.Name, func() { + testCase.BeforeTest(t) req := httptest.NewRequest("GET", "http://chrly/signature-verification-key."+strings.ToLower(testCase.KeyFormat), nil) w := httptest.NewRecorder() if testCase.PanicErr != "" { - suite.PanicsWithError(testCase.PanicErr, func() { - suite.App.Handler().ServeHTTP(w, req) + t.PanicsWithError(testCase.PanicErr, func() { + t.App.Handler().ServeHTTP(w, req) }) } else { - suite.App.Handler().ServeHTTP(w, req) - testCase.AfterTest(suite, w.Result()) + t.App.Handler().ServeHTTP(w, req) + testCase.AfterTest(t, w.Result()) } }) } } -/**************** - * Custom tests * - ****************/ +func TestSkinsystem(t *testing.T) { + suite.Run(t, new(SkinsystemTestSuite)) +} func TestParseUsername(t *testing.T) { assert := testify.New(t) assert.Equal("test", parseUsername("test.png"), "Function should trim .png at end") assert.Equal("test", parseUsername("test"), "Function should return string itself, if it not contains .png at end") } - -/************* - * Utilities * - *************/ - -func createSkinModel(username string, isSlim bool) *model.Skin { - return &model.Skin{ - UserId: 1, - Username: username, - Uuid: "0f657aa8-bfbe-415d-b700-5750090d3af3", // Use non nil UUID to pass validation in api tests - SkinId: 1, - Url: "http://chrly/skin.png", - MojangTextures: "mocked textures base64", - MojangSignature: "mocked signature", - IsSlim: isSlim, - } -} - -func createCape() []byte { - img := image.NewAlpha(image.Rect(0, 0, 64, 32)) - writer := &bytes.Buffer{} - _ = png.Encode(writer, img) - pngBytes, _ := ioutil.ReadAll(writer) - - return pngBytes -} - -func createCapeModel() *model.Cape { - return &model.Cape{File: bytes.NewReader(createCape())} -} - -func createEmptyMojangResponse() *mojang.SignedTexturesResponse { - return &mojang.SignedTexturesResponse{ - Id: "292a1db7353d476ca99cab8f57mojang", - Name: "mock_username", - Props: []*mojang.Property{}, - } -} - -func createMojangResponseWithTextures(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).UnixNano() / int64(time.Millisecond), - ProfileID: "292a1db7353d476ca99cab8f57mojang", - ProfileName: "mock_username", - 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 := createEmptyMojangResponse() - response.Props = append(response.Props, &mojang.Property{ - Name: "textures", - Value: mojang.EncodeTextures(textures), - Signature: "mojang signature", - }) - - return response -} diff --git a/internal/profiles/manager.go b/internal/profiles/manager.go new file mode 100644 index 0000000..c493e2d --- /dev/null +++ b/internal/profiles/manager.go @@ -0,0 +1,122 @@ +package profiles + +import ( + "fmt" + "regexp" + "strings" + + "github.com/go-playground/validator/v10" + + "github.com/elyby/chrly/db" +) + +type ProfilesRepository interface { + FindProfileByUuid(uuid string) (*db.Profile, error) + SaveProfile(profile *db.Profile) error + RemoveProfileByUuid(uuid string) error +} + +func NewManager(pr ProfilesRepository) *Manager { + return &Manager{ + ProfilesRepository: pr, + profileValidator: createProfileValidator(), + } +} + +type Manager struct { + ProfilesRepository + profileValidator *validator.Validate +} + +func (m *Manager) PersistProfile(profile *db.Profile) error { + validationErrors := m.profileValidator.Struct(profile) + if validationErrors != nil { + return mapValidationErrorsToCommonError(validationErrors.(validator.ValidationErrors)) + } + + profile.Uuid = cleanupUuid(profile.Uuid) + if profile.SkinUrl == "" || isClassicModel(profile.SkinModel) { + profile.SkinModel = "" + } + + return m.ProfilesRepository.SaveProfile(profile) +} + +func (m *Manager) RemoveProfileByUuid(uuid string) error { + return m.ProfilesRepository.RemoveProfileByUuid(cleanupUuid(uuid)) +} + +type ValidationError struct { + Errors map[string][]string +} + +func (e *ValidationError) Error() string { + return "The profile is invalid and cannot be persisted" +} + +func cleanupUuid(uuid string) string { + return strings.ReplaceAll(strings.ToLower(uuid), "-", "") +} + +func createProfileValidator() *validator.Validate { + validate := validator.New() + + regexUuidAny := regexp.MustCompile("(?i)^[a-f0-9]{8}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{4}-?[a-f0-9]{12}$") + _ = validate.RegisterValidation("uuid_any", func(fl validator.FieldLevel) bool { + return regexUuidAny.MatchString(fl.Field().String()) + }) + + regexUsername := regexp.MustCompile(`^[-\w.!$%^&*()\[\]:;]+$`) + _ = validate.RegisterValidation("username", func(fl validator.FieldLevel) bool { + return regexUsername.MatchString(fl.Field().String()) + }) + + validate.RegisterStructValidationMapRules(map[string]string{ + "Username": "required,username,max=21", + "Uuid": "required,uuid_any", + "SkinUrl": "omitempty,url", + "SkinModel": "omitempty,max=20", + "CapeUrl": "omitempty,url", + "MojangTextures": "omitempty,base64", + "MojangSignature": "required_with=MojangTextures,omitempty,base64", + }, db.Profile{}) + + return validate +} + +func mapValidationErrorsToCommonError(err validator.ValidationErrors) *ValidationError { + resultErr := &ValidationError{make(map[string][]string)} + for _, e := range err { + // Manager can return multiple errors per field, but the current validation implementation + // returns only one error per field + resultErr.Errors[e.Field()] = []string{formatValidationErr(e)} + } + + return resultErr +} + +// The go-playground/validator lib already contains tools for translated errors output. +// However, the implementation is very heavy and becomes even more so when you need to add messages for custom validators. +// So for simplicity, I've extracted validation error formatting into this simple implementation +func formatValidationErr(err validator.FieldError) string { + switch err.Tag() { + case "required", "required_with": + return fmt.Sprintf("%s is a required field", err.Field()) + case "username": + return fmt.Sprintf("%s must be a valid username", err.Field()) + case "max": + return fmt.Sprintf("%s must be a maximum of %s in length", err.Field(), err.Param()) + case "uuid_any": + return fmt.Sprintf("%s must be a valid UUID", err.Field()) + case "url": + return fmt.Sprintf("%s must be a valid URL", err.Field()) + case "base64": + return fmt.Sprintf("%s must be a valid Base64 string", err.Field()) + default: + return fmt.Sprintf(`Field validation for "%s" failed on the "%s" tag`, err.Field(), err.Tag()) + } +} + +func isClassicModel(model string) bool { + return model == "" || model == "classic" || model == "default" || model == "steve" +} diff --git a/internal/profiles/manager_test.go b/internal/profiles/manager_test.go new file mode 100644 index 0000000..fdd4fc5 --- /dev/null +++ b/internal/profiles/manager_test.go @@ -0,0 +1,138 @@ +package profiles + +import ( + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/elyby/chrly/db" +) + +type ProfilesRepositoryMock struct { + mock.Mock +} + +func (m *ProfilesRepositoryMock) FindProfileByUuid(uuid string) (*db.Profile, error) { + args := m.Called(uuid) + var result *db.Profile + if casted, ok := args.Get(0).(*db.Profile); ok { + result = casted + } + + return result, args.Error(1) +} + +func (m *ProfilesRepositoryMock) SaveProfile(profile *db.Profile) error { + return m.Called(profile).Error(0) +} + +func (m *ProfilesRepositoryMock) RemoveProfileByUuid(uuid string) error { + return m.Called(uuid).Error(0) +} + +type ManagerTestSuite struct { + suite.Suite + + Manager *Manager + + ProfilesRepository *ProfilesRepositoryMock +} + +func (t *ManagerTestSuite) SetupSubTest() { + t.ProfilesRepository = &ProfilesRepositoryMock{} + t.Manager = NewManager(t.ProfilesRepository) +} + +func (t *ManagerTestSuite) TearDownSubTest() { + t.ProfilesRepository.AssertExpectations(t.T()) +} + +func (t *ManagerTestSuite) TestPersistProfile() { + t.Run("valid profile (full)", func() { + profile := &db.Profile{ + Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51", + Username: "mock-username", + SkinUrl: "https://example.com/skin.png", + SkinModel: "slim", + CapeUrl: "https://example.com/cape.png", + MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=", + MojangSignature: "QH+1rlQJYk8tW+8WlSJnzxZZUL5RIkeOO33dq84cgNoxwCkzL95Zy5pbPMFhoiMXXablqXeqyNRZDQa+OewgDBSZxm0BmkNmwdTLzCPHgnlNYhwbO4sirg3hKjCZ82ORZ2q7VP2NQIwNvc3befiCakhDlMWUuhjxe7p/HKNtmKA7a/JjzmzwW7BWMv8b88ZaQaMaAc7puFQcu2E54G2Zk2kyv3T1Bm7bV4m7ymbL8McOmQc6Ph7C95/EyqIK1a5gRBUHPEFIEj0I06YKTHsCRFU1U/hJpk98xXHzHuULJobpajqYXuVJ8QEVgF8k8dn9VkS8BMbXcjzfbb6JJ36v7YIV6Rlt75wwTk2wr3C3P0ij55y0iXth1HjwcEKsg54n83d9w8yQbkUCiTpMbOqxTEOOS7G2O0ZDBJDXAKQ4n5qCiCXKZ4febv4+dWVQtgfZHnpGJUD3KdduDKslMePnECOXMjGSAOQou//yze2EkL2rBpJtAAiOtvBlm/aWnDZpij5cQk+pWmeHWZIf0LSSlsYRUWRDk/VKBvUTEAO9fqOxWqmSgQRUY2Ea56u0ZsBb4vEa1UY6mlJj3+PNZaWu5aP2E9Unh0DIawV96eW8eFQgenlNXHMmXd4aOra4sz2eeOnY53JnJP+eVE4cB1hlq8RA2mnwTtcy3lahzZonOWc=", + } + t.ProfilesRepository.On("SaveProfile", profile).Once().Return(nil) + + err := t.Manager.PersistProfile(profile) + t.NoError(err) + }) + + t.Run("valid profile (minimal)", func() { + profile := &db.Profile{ + Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51", + Username: "mock-username", + } + t.ProfilesRepository.On("SaveProfile", profile).Once().Return(nil) + + err := t.Manager.PersistProfile(profile) + t.NoError(err) + }) + + t.Run("normalize uuid and skin model", func() { + profile := &db.Profile{ + Uuid: "BA866A9C-C839-4268-A30F-7B26AE604C51", + Username: "mock-username", + SkinUrl: "https://example.com/skin.png", + SkinModel: "default", + } + expectedProfile := *profile + expectedProfile.Uuid = "ba866a9cc8394268a30f7b26ae604c51" + expectedProfile.SkinModel = "" + t.ProfilesRepository.On("SaveProfile", &expectedProfile).Once().Return(nil) + + err := t.Manager.PersistProfile(profile) + t.NoError(err) + }) + + t.Run("require mojangSignature when mojangTexturesProvided", func() { + profile := &db.Profile{ + Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51", + Username: "mock-username", + MojangTextures: "eyJ0aW1lc3RhbXAiOjE0ODYzMzcyNTQ4NzIsInByb2ZpbGVJZCI6ImM0ZjFlNTZmNjFkMTQwYTc4YzMyOGQ5MTY2ZWVmOWU3IiwicHJvZmlsZU5hbWUiOiJXaHlZb3VSZWFkVGhpcyIsInRleHR1cmVzIjp7IlNLSU4iOnsidXJsIjoiaHR0cDovL3RleHR1cmVzLm1pbmVjcmFmdC5uZXQvdGV4dHVyZS83Mzk1NmE4ZTY0ZWU2ZDhlYzY1NmFkYmI0NDA0ZjhlYmZmMzQxMWIwY2I5MGIzMWNiNDc2ZWNiOTk2ZDNiOCJ9fX0=", + } + + err := t.Manager.PersistProfile(profile) + t.Error(err) + t.IsType(&ValidationError{}, err) + castedErr := err.(*ValidationError) + mojangSignatureErr, mojangSignatureErrExists := castedErr.Errors["MojangSignature"] + t.True(mojangSignatureErrExists) + t.Contains(mojangSignatureErr[0], "required") + }) + + t.Run("validate username", func() { + profile := &db.Profile{ + Uuid: "ba866a9c-c839-4268-a30f-7b26ae604c51", + Username: "invalid\"username", + } + + err := t.Manager.PersistProfile(profile) + t.Error(err) + t.IsType(&ValidationError{}, err) + castedErr := err.(*ValidationError) + usernameErrs, usernameErrExists := castedErr.Errors["Username"] + t.True(usernameErrExists) + t.Contains(usernameErrs[0], "valid") + }) + + t.Run("empty profile", func() { + profile := &db.Profile{} + + err := t.Manager.PersistProfile(profile) + t.Error(err) + t.IsType(&ValidationError{}, err) + // TODO: validate errors + }) +} + +func TestManager(t *testing.T) { + suite.Run(t, new(ManagerTestSuite)) +} diff --git a/internal/profiles/provider.go b/internal/profiles/provider.go new file mode 100644 index 0000000..3fbd8b8 --- /dev/null +++ b/internal/profiles/provider.go @@ -0,0 +1,88 @@ +package profiles + +import ( + "errors" + + "github.com/elyby/chrly/db" + "github.com/elyby/chrly/mojang" +) + +type ProfilesFinder interface { + FindProfileByUsername(username string) (*db.Profile, error) +} + +type MojangProfilesProvider interface { + GetForUsername(username string) (*mojang.ProfileResponse, error) +} + +type Provider struct { + ProfilesFinder + MojangProfilesProvider +} + +func (p *Provider) FindProfileByUsername(username string, allowProxy bool) (*db.Profile, error) { + profile, err := p.ProfilesFinder.FindProfileByUsername(username) + if err != nil { + return nil, err + } + + if profile != nil && (profile.SkinUrl != "" || profile.CapeUrl != "") { + return profile, nil + } + + if allowProxy { + mojangProfile, err := p.MojangProfilesProvider.GetForUsername(username) + // If we at least know something about the user, + // then we can ignore an error and return profile without textures + if err != nil && profile != nil { + return profile, nil + } + + if err != nil || mojangProfile == nil { + if errors.Is(err, mojang.InvalidUsername) { + return nil, nil + } + + return nil, err + } + + decodedTextures, err := mojangProfile.DecodeTextures() + if err != nil { + return nil, err + } + + profile = &db.Profile{ + Uuid: mojangProfile.Id, + Username: mojangProfile.Name, + } + + // There might be no textures property + if decodedTextures != nil { + if decodedTextures.Textures.Skin != nil { + profile.SkinUrl = decodedTextures.Textures.Skin.Url + if decodedTextures.Textures.Skin.Metadata != nil { + profile.SkinModel = decodedTextures.Textures.Skin.Metadata.Model + } + } + + if decodedTextures.Textures.Cape != nil { + profile.CapeUrl = decodedTextures.Textures.Cape.Url + } + } + + var texturesProp *mojang.Property + for _, prop := range mojangProfile.Props { + if prop.Name == "textures" { + texturesProp = prop + break + } + } + + if texturesProp != nil { + profile.MojangTextures = texturesProp.Value + profile.MojangSignature = texturesProp.Signature + } + } + + return profile, nil +} diff --git a/internal/profiles/provider_test.go b/internal/profiles/provider_test.go new file mode 100644 index 0000000..65d4c33 --- /dev/null +++ b/internal/profiles/provider_test.go @@ -0,0 +1,272 @@ +package profiles + +import ( + "errors" + "testing" + "time" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" + + "github.com/elyby/chrly/db" + "github.com/elyby/chrly/mojang" + "github.com/elyby/chrly/utils" +) + +type ProfilesFinderMock struct { + mock.Mock +} + +func (m *ProfilesFinderMock) FindProfileByUsername(username string) (*db.Profile, error) { + args := m.Called(username) + var result *db.Profile + if casted, ok := args.Get(0).(*db.Profile); ok { + result = casted + } + + return result, args.Error(1) +} + +type MojangProfilesProviderMock struct { + mock.Mock +} + +func (m *MojangProfilesProviderMock) GetForUsername(username string) (*mojang.ProfileResponse, error) { + args := m.Called(username) + var result *mojang.ProfileResponse + if casted, ok := args.Get(0).(*mojang.ProfileResponse); ok { + result = casted + } + + return result, args.Error(1) +} + +type CombinedProfilesProviderSuite struct { + suite.Suite + + Provider *Provider + + ProfilesRepository *ProfilesFinderMock + MojangProfilesProvider *MojangProfilesProviderMock +} + +func (t *CombinedProfilesProviderSuite) SetupSubTest() { + t.ProfilesRepository = &ProfilesFinderMock{} + t.MojangProfilesProvider = &MojangProfilesProviderMock{} + t.Provider = &Provider{ + ProfilesFinder: t.ProfilesRepository, + MojangProfilesProvider: t.MojangProfilesProvider, + } +} + +func (t *CombinedProfilesProviderSuite) TearDownSubTest() { + t.ProfilesRepository.AssertExpectations(t.T()) + t.MojangProfilesProvider.AssertExpectations(t.T()) +} + +func (t *CombinedProfilesProviderSuite) TestFindByUsername() { + t.Run("exists profile with a skin", func() { + profile := &db.Profile{ + Uuid: "mock-uuid", + Username: "Mock", + SkinUrl: "https://example.com/skin.png", + } + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) + t.NoError(err) + t.Same(profile, foundProfile) + }) + + t.Run("exists profile with a cape", func() { + profile := &db.Profile{ + Uuid: "mock-uuid", + Username: "Mock", + CapeUrl: "https://example.com/cape.png", + } + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) + t.NoError(err) + t.Same(profile, foundProfile) + }) + + t.Run("exists profile without textures (no proxy)", func() { + profile := &db.Profile{ + Uuid: "mock-uuid", + Username: "Mock", + } + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", false) + t.NoError(err) + t.Same(profile, foundProfile) + }) + + t.Run("not exists profile (no proxy)", func() { + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", false) + t.NoError(err) + t.Nil(foundProfile) + }) + + t.Run("handle error from profiles repository", func() { + expectedError := errors.New("mock error") + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, expectedError) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", false) + t.Same(expectedError, err) + t.Nil(foundProfile) + }) + + t.Run("exists profile without textures (with proxy)", func() { + profile := &db.Profile{ + Uuid: "mock-uuid", + Username: "Mock", + } + mojangProfile := createMojangProfile(true, true) + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) + t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) + t.NoError(err) + t.Equal(&db.Profile{ + Uuid: "mock-mojang-uuid", + Username: "mOcK", + SkinUrl: "https://mojang/skin.png", + SkinModel: "slim", + CapeUrl: "https://mojang/cape.png", + MojangTextures: mojangProfile.Props[0].Value, + MojangSignature: mojangProfile.Props[0].Signature, + }, foundProfile) + }) + + t.Run("not exists profile (with proxy)", func() { + mojangProfile := createMojangProfile(true, true) + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) + t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) + t.NoError(err) + t.Equal(&db.Profile{ + Uuid: "mock-mojang-uuid", + Username: "mOcK", + SkinUrl: "https://mojang/skin.png", + SkinModel: "slim", + CapeUrl: "https://mojang/cape.png", + MojangTextures: mojangProfile.Props[0].Value, + MojangSignature: mojangProfile.Props[0].Signature, + }, foundProfile) + }) + + t.Run("should return known profile without textures when received an error from the mojang", func() { + profile := &db.Profile{ + Uuid: "mock-uuid", + Username: "Mock", + } + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(profile, nil) + t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(nil, errors.New("mock error")) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) + t.NoError(err) + t.Same(profile, foundProfile) + }) + + t.Run("should not return an error when passed the invalid username", func() { + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) + t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(nil, mojang.InvalidUsername) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) + t.NoError(err) + t.Nil(foundProfile) + }) + + t.Run("should return an error from mojang provider", func() { + expectedError := errors.New("mock error") + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) + t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(nil, expectedError) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) + t.Same(expectedError, err) + t.Nil(foundProfile) + }) + + t.Run("should correctly handle invalid textures from mojang", func() { + mojangProfile := &mojang.ProfileResponse{ + Props: []*mojang.Property{ + { + Name: "textures", + Value: "this is invalid base64", + Signature: "mojang signature", + }, + }, + } + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) + t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) + t.ErrorContains(err, "illegal base64 data") + t.Nil(foundProfile) + }) + + t.Run("should correctly handle missing textures property from Mojang", func() { + mojangProfile := &mojang.ProfileResponse{ + Id: "mock-mojang-uuid", + Name: "mOcK", + Props: []*mojang.Property{}, + } + t.ProfilesRepository.On("FindProfileByUsername", "Mock").Return(nil, nil) + t.MojangProfilesProvider.On("GetForUsername", "Mock").Return(mojangProfile, nil) + + foundProfile, err := t.Provider.FindProfileByUsername("Mock", true) + t.NoError(err) + t.Equal(&db.Profile{ + Uuid: "mock-mojang-uuid", + Username: "mOcK", + }, foundProfile) + }) +} + +func TestProvider(t *testing.T) { + suite.Run(t, new(CombinedProfilesProviderSuite)) +} + +func createMojangProfile(withSkin bool, withCape bool) *mojang.ProfileResponse { + timeZone, _ := time.LoadLocation("Europe/Warsaw") + textures := &mojang.TexturesProp{ + Timestamp: utils.UnixMillisecond(time.Date(2024, 1, 29, 13, 34, 12, 0, timeZone)), + ProfileID: "mock-mojang-uuid", + ProfileName: "mOcK", + Textures: &mojang.TexturesResponse{}, + } + + if withSkin { + textures.Textures.Skin = &mojang.SkinTexturesResponse{ + Url: "https://mojang/skin.png", + Metadata: &mojang.SkinTexturesMetadata{ + Model: "slim", + }, + } + } + + if withCape { + textures.Textures.Cape = &mojang.CapeTexturesResponse{ + Url: "https://mojang/cape.png", + } + } + + response := &mojang.ProfileResponse{ + Id: textures.ProfileID, + Name: textures.ProfileName, + Props: []*mojang.Property{ + { + Name: "textures", + Value: mojang.EncodeTextures(textures), + Signature: "mojang signature", + }, + }, + } + + return response +} diff --git a/model/cape.go b/model/cape.go deleted file mode 100644 index 26485f9..0000000 --- a/model/cape.go +++ /dev/null @@ -1,9 +0,0 @@ -package model - -import ( - "io" -) - -type Cape struct { - File io.Reader -} diff --git a/model/skin.go b/model/skin.go deleted file mode 100644 index c97e3b9..0000000 --- a/model/skin.go +++ /dev/null @@ -1,14 +0,0 @@ -package model - -type Skin struct { - UserId int `json:"userId"` - Uuid string `json:"uuid"` - Username string `json:"username"` - SkinId int `json:"skinId"` // deprecated - Url string `json:"url"` - Is1_8 bool `json:"is1_8"` - IsSlim bool `json:"isSlim"` - MojangTextures string `json:"mojangTextures"` - MojangSignature string `json:"mojangSignature"` - OldUsername string -} diff --git a/mojang/client.go b/mojang/client.go index 1124fc2..274e05b 100644 --- a/mojang/client.go +++ b/mojang/client.go @@ -75,7 +75,7 @@ func (c *MojangApi) UsernamesToUuids(usernames []string) ([]*ProfileInfo, error) // Obtains textures information for provided uuid // See https://wiki.vg/Mojang_API#UUID_-.3E_Profile_.2B_Skin.2FCape -func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) { +func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*ProfileResponse, error) { normalizedUuid := strings.ReplaceAll(uuid, "-", "") url := c.profileUrl + normalizedUuid if signed { @@ -101,7 +101,7 @@ func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*SignedTexturesRes return nil, errorFromResponse(response) } - var result *SignedTexturesResponse + var result *ProfileResponse body, _ := io.ReadAll(response.Body) err = json.Unmarshal(body, &result) @@ -112,7 +112,7 @@ func (c *MojangApi) UuidToTextures(uuid string, signed bool) (*SignedTexturesRes return result, nil } -type SignedTexturesResponse struct { +type ProfileResponse struct { Id string `json:"id"` Name string `json:"name"` Props []*Property `json:"properties"` @@ -147,7 +147,7 @@ type CapeTexturesResponse struct { Url string `json:"url"` } -func (t *SignedTexturesResponse) DecodeTextures() (*TexturesProp, error) { +func (t *ProfileResponse) DecodeTextures() (*TexturesProp, error) { t.once.Do(func() { var texturesProp string for _, prop := range t.Props { diff --git a/mojang/client_test.go b/mojang/client_test.go index 236d6db..a772017 100644 --- a/mojang/client_test.go +++ b/mojang/client_test.go @@ -184,7 +184,7 @@ func TestMojangApi(t *testing.T) { func TestSignedTexturesResponse(t *testing.T) { t.Run("DecodeTextures", func(t *testing.T) { - obj := &SignedTexturesResponse{ + obj := &ProfileResponse{ Id: "00000000000000000000000000000000", Name: "mock", Props: []*Property{ @@ -200,7 +200,7 @@ func TestSignedTexturesResponse(t *testing.T) { }) t.Run("DecodedTextures without textures prop", func(t *testing.T) { - obj := &SignedTexturesResponse{ + obj := &ProfileResponse{ Id: "00000000000000000000000000000000", Name: "mock", Props: []*Property{}, diff --git a/mojang/provider.go b/mojang/provider.go index 04c901e..76669df 100644 --- a/mojang/provider.go +++ b/mojang/provider.go @@ -18,24 +18,24 @@ type UuidsProvider interface { } type TexturesProvider interface { - GetTextures(uuid string) (*SignedTexturesResponse, error) + GetTextures(uuid string) (*ProfileResponse, error) } type MojangTexturesProvider struct { UuidsProvider TexturesProvider - group singleflight.Group[string, *SignedTexturesResponse] + group singleflight.Group[string, *ProfileResponse] } -func (p *MojangTexturesProvider) GetForUsername(username string) (*SignedTexturesResponse, error) { +func (p *MojangTexturesProvider) GetForUsername(username string) (*ProfileResponse, error) { if !allowedUsernamesRegex.MatchString(username) { return nil, InvalidUsername } username = strings.ToLower(username) - result, err, _ := p.group.Do(username, func() (*SignedTexturesResponse, error) { + result, err, _ := p.group.Do(username, func() (*ProfileResponse, error) { profile, err := p.UuidsProvider.GetUuid(username) if err != nil { return nil, err @@ -54,6 +54,6 @@ func (p *MojangTexturesProvider) GetForUsername(username string) (*SignedTexture type NilProvider struct { } -func (*NilProvider) GetForUsername(username string) (*SignedTexturesResponse, error) { +func (*NilProvider) GetForUsername(username string) (*ProfileResponse, error) { return nil, nil } diff --git a/mojang/provider_test.go b/mojang/provider_test.go index 2756b93..90763b4 100644 --- a/mojang/provider_test.go +++ b/mojang/provider_test.go @@ -29,10 +29,10 @@ type TexturesProviderMock struct { mock.Mock } -func (m *TexturesProviderMock) GetTextures(uuid string) (*SignedTexturesResponse, error) { +func (m *TexturesProviderMock) GetTextures(uuid string) (*ProfileResponse, error) { args := m.Called(uuid) - var result *SignedTexturesResponse - if casted, ok := args.Get(0).(*SignedTexturesResponse); ok { + var result *ProfileResponse + if casted, ok := args.Get(0).(*ProfileResponse); ok { result = casted } @@ -63,7 +63,7 @@ func (suite *providerTestSuite) TearDownTest() { func (suite *providerTestSuite) TestGetForValidUsernameSuccessfully() { expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"} - expectedResult := &SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"} + expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"} suite.UuidsProvider.On("GetUuid", "username").Once().Return(expectedProfile, nil) suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil) @@ -76,7 +76,6 @@ func (suite *providerTestSuite) TestGetForValidUsernameSuccessfully() { func (suite *providerTestSuite) TestGetForUsernameWhichHasNoMojangAccount() { suite.UuidsProvider.On("GetUuid", "username").Once().Return(nil, nil) - // TODO: check that textures provider wasn't called result, err := suite.Provider.GetForUsername("username") @@ -98,7 +97,7 @@ func (suite *providerTestSuite) TestGetForUsernameWhichHasMojangAccountButHasNoM func (suite *providerTestSuite) TestGetForTheSameUsername() { expectedProfile := &ProfileInfo{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"} - expectedResult := &SignedTexturesResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"} + expectedResult := &ProfileResponse{Id: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Name: "username"} awaitChan := make(chan time.Time) @@ -106,7 +105,7 @@ func (suite *providerTestSuite) TestGetForTheSameUsername() { suite.UuidsProvider.On("GetUuid", "username").Once().WaitUntil(awaitChan).Return(expectedProfile, nil) suite.TexturesProvider.On("GetTextures", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Once().Return(expectedResult, nil) - results := make([]*SignedTexturesResponse, 2) + results := make([]*ProfileResponse, 2) var wgStarted sync.WaitGroup var wgDone sync.WaitGroup for i := 0; i < 2; i++ { diff --git a/mojang/textures_provider.go b/mojang/textures_provider.go index 29bdb51..7102ce3 100644 --- a/mojang/textures_provider.go +++ b/mojang/textures_provider.go @@ -8,10 +8,10 @@ import ( ) type MojangApiTexturesProvider struct { - MojangApiTexturesEndpoint func(uuid string, signed bool) (*SignedTexturesResponse, error) + MojangApiTexturesEndpoint func(uuid string, signed bool) (*ProfileResponse, error) } -func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*SignedTexturesResponse, error) { +func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*ProfileResponse, error) { return ctx.MojangApiTexturesEndpoint(uuid, true) } @@ -20,14 +20,14 @@ func (ctx *MojangApiTexturesProvider) GetTextures(uuid string) (*SignedTexturesR type TexturesProviderWithInMemoryCache struct { provider TexturesProvider once sync.Once - cache *ttlcache.Cache[string, *SignedTexturesResponse] + cache *ttlcache.Cache[string, *ProfileResponse] } func NewTexturesProviderWithInMemoryCache(provider TexturesProvider) *TexturesProviderWithInMemoryCache { storage := &TexturesProviderWithInMemoryCache{ provider: provider, - cache: ttlcache.New[string, *SignedTexturesResponse]( - ttlcache.WithDisableTouchOnHit[string, *SignedTexturesResponse](), + cache: ttlcache.New[string, *ProfileResponse]( + ttlcache.WithDisableTouchOnHit[string, *ProfileResponse](), // I'm aware of ttlcache.WithLoader(), but it doesn't allow to return an error ), } @@ -35,7 +35,7 @@ func NewTexturesProviderWithInMemoryCache(provider TexturesProvider) *TexturesPr return storage } -func (s *TexturesProviderWithInMemoryCache) GetTextures(uuid string) (*SignedTexturesResponse, error) { +func (s *TexturesProviderWithInMemoryCache) GetTextures(uuid string) (*ProfileResponse, error) { item := s.cache.Get(uuid) // Don't check item.IsExpired() since Get function is already did this check if item != nil { diff --git a/mojang/textures_provider_test.go b/mojang/textures_provider_test.go index d1eaaeb..8bb9bd7 100644 --- a/mojang/textures_provider_test.go +++ b/mojang/textures_provider_test.go @@ -9,7 +9,7 @@ import ( "github.com/stretchr/testify/suite" ) -var signedTexturesResponse = &SignedTexturesResponse{ +var signedTexturesResponse = &ProfileResponse{ Id: "dead24f9a4fa4877b7b04c8c6c72bb46", Name: "mock", Props: []*Property{ @@ -33,10 +33,10 @@ type MojangUuidToTexturesRequestMock struct { mock.Mock } -func (m *MojangUuidToTexturesRequestMock) UuidToTextures(uuid string, signed bool) (*SignedTexturesResponse, error) { +func (m *MojangUuidToTexturesRequestMock) UuidToTextures(uuid string, signed bool) (*ProfileResponse, error) { args := m.Called(uuid, signed) - var result *SignedTexturesResponse - if casted, ok := args.Get(0).(*SignedTexturesResponse); ok { + var result *ProfileResponse + if casted, ok := args.Get(0).(*ProfileResponse); ok { result = casted }