mirror of
https://github.com/ppy/osu.git
synced 2024-11-17 15:52:55 +08:00
776fabd77c
Both online and offline using the cache.
The rationale behind this change is that in the current state of
affairs, `TestPartiallyMaliciousSet()` fails in a way that cannot be
reconciled without this sort of change.
The test exercises a scenario where the beatmap being imported has an
online ID in the `.osu` file, but its hash does not match the online
hash of the beatmap. This turns out to be a more frequent scenario than
envisioned because of users doing stupid things with manual file editing
rather than reporting issues properly.
The scenario is realistic only because the behaviour of the endpoint
responsible for looking up beatmaps is such that if multiple parameters
are given (e.g. all three of beatmap MD5, online ID, and filename), it
will try the three in succession:
f6b341813b/app/Http/Controllers/BeatmapsController.php (L260-L266)
and the local metadata cache implementation reflected this
implementation.
Because online ID and filename are inherently unreliable in this
scenario due to being directly manipulable by clueless or malicious
users, neither should not be used as a fallback.
315 lines
13 KiB
C#
315 lines
13 KiB
C#
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
|
|
// See the LICENCE file in the repository root for full licence text.
|
|
|
|
#nullable disable
|
|
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using Newtonsoft.Json;
|
|
using osu.Game.Beatmaps;
|
|
using osu.Game.Database;
|
|
using osu.Game.Online.API;
|
|
using osu.Game.Online.API.Requests;
|
|
using osu.Game.Online.API.Requests.Responses;
|
|
using osu.Game.Online.Rooms;
|
|
using osu.Game.Rulesets;
|
|
using osu.Game.Rulesets.Scoring;
|
|
using osu.Game.Scoring;
|
|
using osu.Game.Screens.OnlinePlay.Components;
|
|
using osu.Game.Tests.Beatmaps;
|
|
using osu.Game.Utils;
|
|
|
|
namespace osu.Game.Tests.Visual.OnlinePlay
|
|
{
|
|
/// <summary>
|
|
/// Represents a handler which pretends to be a server, handling room retrieval and manipulation requests
|
|
/// and returning a roughly expected state, without the need for a server to be running.
|
|
/// </summary>
|
|
public class TestRoomRequestsHandler
|
|
{
|
|
public IReadOnlyList<Room> ServerSideRooms => serverSideRooms;
|
|
|
|
private readonly List<Room> serverSideRooms = new List<Room>();
|
|
|
|
private int currentRoomId = 1;
|
|
private int currentPlaylistItemId = 1;
|
|
private int currentScoreId = 1;
|
|
|
|
/// <summary>
|
|
/// Handles an API request, while also updating the local state to match
|
|
/// how the server would eventually respond and update an <see cref="RoomManager"/>.
|
|
/// </summary>
|
|
/// <param name="request">The API request to handle.</param>
|
|
/// <param name="localUser">The local user to store in responses where required.</param>
|
|
/// <param name="beatmapManager">The beatmap manager to attempt to retrieve beatmaps from, prior to returning dummy beatmaps.</param>
|
|
/// <returns>Whether the request was successfully handled.</returns>
|
|
public bool HandleRequest(APIRequest request, APIUser localUser, BeatmapManager beatmapManager)
|
|
{
|
|
switch (request)
|
|
{
|
|
case CreateRoomRequest createRoomRequest:
|
|
var apiRoom = cloneRoom(createRoomRequest.Room);
|
|
|
|
// Passwords are explicitly not copied between rooms.
|
|
apiRoom.HasPassword.Value = !string.IsNullOrEmpty(createRoomRequest.Room.Password.Value);
|
|
apiRoom.Password.Value = createRoomRequest.Room.Password.Value;
|
|
|
|
AddServerSideRoom(apiRoom, localUser);
|
|
|
|
var responseRoom = new APICreatedRoom();
|
|
responseRoom.CopyFrom(createResponseRoom(apiRoom, false));
|
|
|
|
createRoomRequest.TriggerSuccess(responseRoom);
|
|
return true;
|
|
|
|
case JoinRoomRequest joinRoomRequest:
|
|
{
|
|
var room = ServerSideRooms.Single(r => r.RoomID.Value == joinRoomRequest.Room.RoomID.Value);
|
|
|
|
if (joinRoomRequest.Password != room.Password.Value)
|
|
{
|
|
joinRoomRequest.TriggerFailure(new InvalidOperationException("Invalid password."));
|
|
return true;
|
|
}
|
|
|
|
joinRoomRequest.TriggerSuccess();
|
|
return true;
|
|
}
|
|
|
|
case GetRoomLeaderboardRequest roomLeaderboardRequest:
|
|
roomLeaderboardRequest.TriggerSuccess(new APILeaderboard
|
|
{
|
|
Leaderboard = new List<APIUserScoreAggregate>
|
|
{
|
|
new APIUserScoreAggregate
|
|
{
|
|
TotalScore = 1000000,
|
|
TotalAttempts = 5,
|
|
CompletedBeatmaps = 2,
|
|
User = new APIUser { Username = "best user" }
|
|
},
|
|
new APIUserScoreAggregate
|
|
{
|
|
TotalScore = 50,
|
|
TotalAttempts = 1,
|
|
CompletedBeatmaps = 1,
|
|
User = new APIUser { Username = "worst user" }
|
|
}
|
|
}
|
|
});
|
|
return true;
|
|
|
|
case IndexPlaylistScoresRequest roomLeaderboardRequest:
|
|
roomLeaderboardRequest.TriggerSuccess(new IndexedMultiplayerScores
|
|
{
|
|
Scores =
|
|
{
|
|
new MultiplayerScore
|
|
{
|
|
ID = currentScoreId++,
|
|
Accuracy = 1,
|
|
Position = 1,
|
|
EndedAt = DateTimeOffset.Now,
|
|
Passed = true,
|
|
Rank = ScoreRank.S,
|
|
MaxCombo = 1000,
|
|
TotalScore = 1000000,
|
|
User = new APIUser { Username = "best user" },
|
|
Mods = [new APIMod { Acronym = @"DT" }],
|
|
Statistics = new Dictionary<HitResult, int>()
|
|
},
|
|
new MultiplayerScore
|
|
{
|
|
ID = currentScoreId++,
|
|
Accuracy = 0.7,
|
|
Position = 2,
|
|
EndedAt = DateTimeOffset.Now,
|
|
Passed = true,
|
|
Rank = ScoreRank.B,
|
|
MaxCombo = 100,
|
|
TotalScore = 200000,
|
|
User = new APIUser { Username = "worst user" },
|
|
Statistics = new Dictionary<HitResult, int>()
|
|
},
|
|
},
|
|
UserScore = new MultiplayerScore
|
|
{
|
|
ID = currentScoreId++,
|
|
Accuracy = 0.91,
|
|
Position = 4,
|
|
EndedAt = DateTimeOffset.Now,
|
|
Passed = true,
|
|
Rank = ScoreRank.A,
|
|
MaxCombo = 100,
|
|
TotalScore = 800000,
|
|
User = localUser,
|
|
Statistics = new Dictionary<HitResult, int>()
|
|
},
|
|
});
|
|
return true;
|
|
|
|
case PartRoomRequest partRoomRequest:
|
|
partRoomRequest.TriggerSuccess();
|
|
return true;
|
|
|
|
case GetRoomsRequest getRoomsRequest:
|
|
var roomsWithoutParticipants = new List<Room>();
|
|
|
|
foreach (var r in ServerSideRooms)
|
|
roomsWithoutParticipants.Add(createResponseRoom(r, false));
|
|
|
|
getRoomsRequest.TriggerSuccess(roomsWithoutParticipants);
|
|
return true;
|
|
|
|
case GetRoomRequest getRoomRequest:
|
|
getRoomRequest.TriggerSuccess(createResponseRoom(ServerSideRooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId), true));
|
|
return true;
|
|
|
|
case CreateRoomScoreRequest createRoomScoreRequest:
|
|
createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 });
|
|
return true;
|
|
|
|
case SubmitRoomScoreRequest submitRoomScoreRequest:
|
|
submitRoomScoreRequest.TriggerSuccess(new MultiplayerScore
|
|
{
|
|
ID = currentScoreId++,
|
|
Accuracy = 1,
|
|
EndedAt = DateTimeOffset.Now,
|
|
Passed = true,
|
|
Rank = ScoreRank.S,
|
|
MaxCombo = 1000,
|
|
TotalScore = 1000000,
|
|
User = localUser,
|
|
Statistics = new Dictionary<HitResult, int>()
|
|
});
|
|
return true;
|
|
|
|
case GetBeatmapRequest getBeatmapRequest:
|
|
{
|
|
getBeatmapRequest.TriggerSuccess(createResponseBeatmaps(getBeatmapRequest.OnlineID).Single());
|
|
return true;
|
|
}
|
|
|
|
case GetBeatmapsRequest getBeatmapsRequest:
|
|
{
|
|
getBeatmapsRequest.TriggerSuccess(new GetBeatmapsResponse { Beatmaps = createResponseBeatmaps(getBeatmapsRequest.BeatmapIds.ToArray()) });
|
|
return true;
|
|
}
|
|
|
|
case GetBeatmapSetRequest getBeatmapSetRequest:
|
|
{
|
|
var baseBeatmap = getBeatmapSetRequest.Type == BeatmapSetLookupType.BeatmapId
|
|
? beatmapManager.QueryBeatmap(b => b.OnlineID == getBeatmapSetRequest.ID)
|
|
: beatmapManager.QueryBeatmapSet(s => s.OnlineID == getBeatmapSetRequest.ID)?.PerformRead(s => s.Beatmaps.First().Detach());
|
|
|
|
if (baseBeatmap == null)
|
|
{
|
|
baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo;
|
|
baseBeatmap.OnlineID = getBeatmapSetRequest.ID;
|
|
baseBeatmap.BeatmapSet!.OnlineID = getBeatmapSetRequest.ID;
|
|
}
|
|
|
|
getBeatmapSetRequest.TriggerSuccess(OsuTestScene.CreateAPIBeatmapSet(baseBeatmap));
|
|
return true;
|
|
}
|
|
|
|
case GetUsersRequest getUsersRequest:
|
|
{
|
|
getUsersRequest.TriggerSuccess(new GetUsersResponse
|
|
{
|
|
Users = getUsersRequest.UserIds.Select(id => id == TestUserLookupCache.UNRESOLVED_USER_ID
|
|
? null
|
|
: new APIUser
|
|
{
|
|
Id = id,
|
|
Username = $"User {id}"
|
|
})
|
|
.Where(u => u != null).ToList(),
|
|
});
|
|
return true;
|
|
}
|
|
}
|
|
|
|
List<APIBeatmap> createResponseBeatmaps(params int[] beatmapIds)
|
|
{
|
|
var result = new List<APIBeatmap>();
|
|
|
|
foreach (int id in beatmapIds)
|
|
{
|
|
var baseBeatmap = beatmapManager.QueryBeatmap(b => b.OnlineID == id);
|
|
|
|
if (baseBeatmap == null)
|
|
{
|
|
baseBeatmap = new TestBeatmap(new RulesetInfo { OnlineID = 0 }).BeatmapInfo;
|
|
baseBeatmap.OnlineID = id;
|
|
baseBeatmap.BeatmapSet!.OnlineID = id;
|
|
}
|
|
|
|
result.Add(OsuTestScene.CreateAPIBeatmap(baseBeatmap));
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a room to a local "server-side" list that's returned when a <see cref="GetRoomsRequest"/> is fired.
|
|
/// </summary>
|
|
/// <param name="room">The room.</param>
|
|
/// <param name="host">The room host.</param>
|
|
public void AddServerSideRoom(Room room, APIUser host)
|
|
{
|
|
room.RoomID.Value ??= currentRoomId++;
|
|
room.Host.Value = host;
|
|
|
|
for (int i = 0; i < room.Playlist.Count; i++)
|
|
{
|
|
room.Playlist[i].ID = currentPlaylistItemId++;
|
|
room.Playlist[i].OwnerID = room.Host.Value.OnlineID;
|
|
}
|
|
|
|
serverSideRooms.Add(room);
|
|
}
|
|
|
|
private Room createResponseRoom(Room room, bool withParticipants)
|
|
{
|
|
var responseRoom = cloneRoom(room);
|
|
|
|
// Password is hidden from the response, and is only propagated via HasPassword.
|
|
bool hadPassword = responseRoom.HasPassword.Value;
|
|
responseRoom.Password.Value = null;
|
|
responseRoom.HasPassword.Value = hadPassword;
|
|
|
|
if (!withParticipants)
|
|
responseRoom.RecentParticipants.Clear();
|
|
|
|
return responseRoom;
|
|
}
|
|
|
|
private Room cloneRoom(Room source)
|
|
{
|
|
var result = JsonConvert.DeserializeObject<Room>(JsonConvert.SerializeObject(source));
|
|
Debug.Assert(result != null);
|
|
|
|
// Playlist item IDs and beatmaps aren't serialised.
|
|
if (source.CurrentPlaylistItem.Value != null)
|
|
{
|
|
result.CurrentPlaylistItem.Value = result.CurrentPlaylistItem.Value.With(new Optional<IBeatmapInfo>(source.CurrentPlaylistItem.Value.Beatmap));
|
|
result.CurrentPlaylistItem.Value.ID = source.CurrentPlaylistItem.Value.ID;
|
|
}
|
|
|
|
for (int i = 0; i < source.Playlist.Count; i++)
|
|
{
|
|
result.Playlist[i] = result.Playlist[i].With(new Optional<IBeatmapInfo>(source.Playlist[i].Beatmap));
|
|
result.Playlist[i].ID = source.Playlist[i].ID;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|
|
}
|