OF-DL/OF DL.Tests/Services/ApiServiceTests.cs

559 lines
21 KiB
C#

using System.Reflection;
using System.Security.Cryptography;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OF_DL.Enumerations;
using OF_DL.Models;
using OF_DL.Models.Config;
using OF_DL.Models.Entities.Common;
using OF_DL.Models.OfdlApi;
using OF_DL.Services;
using UserEntities = OF_DL.Models.Entities.Users;
namespace OF_DL.Tests.Services;
public class ApiServiceTests
{
[Fact]
public void GetDynamicHeaders_ReturnsSignedHeaders()
{
FakeAuthService authService = new()
{
CurrentAuth = new Auth
{
UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_cookie=abc;"
}
};
ApiService service = CreateService(authService);
DynamicRules rules = new()
{
AppToken = "app-token",
StaticParam = "static",
Prefix = "prefix",
Suffix = "suffix",
ChecksumConstant = 7,
ChecksumIndexes = [0, 5, 10, 15]
};
using DynamicRulesCacheScope _ = new(rules);
Dictionary<string, string> headers = service.GetDynamicHeaders("/api2/v2/users", "?limit=1");
Assert.Equal("application/json, text/plain", headers["accept"]);
Assert.Equal("app-token", headers["app-token"]);
Assert.Equal("auth_cookie=abc;", headers["cookie"]);
Assert.Equal("unit-test-agent", headers["user-agent"]);
Assert.Equal("xbc-token", headers["x-bc"]);
Assert.Equal("123", headers["user-id"]);
Assert.True(long.TryParse(headers["time"], out long timestamp));
string expectedSign = BuildSign(rules, timestamp, "/api2/v2/users?limit=1", "123");
Assert.Equal(expectedSign, headers["sign"]);
}
[Fact]
public void GetDynamicHeaders_ThrowsWhenRulesInvalid()
{
FakeAuthService authService = new()
{
CurrentAuth = new Auth
{
UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_cookie=abc;"
}
};
ApiService service = CreateService(authService);
DynamicRules rules = new()
{
AppToken = null,
StaticParam = "static",
Prefix = null,
Suffix = "suffix",
ChecksumConstant = null,
ChecksumIndexes = []
};
using DynamicRulesCacheScope _ = new(rules);
Exception ex = Assert.Throws<Exception>(() => service.GetDynamicHeaders("/api2/v2/users", "?limit=1"));
Assert.Contains("Invalid dynamic rules", ex.Message);
}
[Fact]
public void GetDynamicHeaders_ThrowsWhenAuthMissingFields()
{
FakeAuthService authService = new()
{
CurrentAuth = new Auth
{
UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = null
}
};
ApiService service = CreateService(authService);
DynamicRules rules = new()
{
AppToken = "app-token",
StaticParam = "static",
Prefix = "prefix",
Suffix = "suffix",
ChecksumConstant = 1,
ChecksumIndexes = [0]
};
using DynamicRulesCacheScope _ = new(rules);
Exception ex = Assert.Throws<Exception>(() => service.GetDynamicHeaders("/api2/v2/users", "?limit=1"));
Assert.Contains("Auth service is missing required fields", ex.Message);
}
[Fact]
public async Task BuildHttpRequestMessage_BuildsUrlAndAddsHeaders()
{
FakeAuthService authService = new()
{
CurrentAuth = new Auth
{
UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_cookie=abc;"
}
};
ApiService service = CreateService(authService);
DynamicRules rules = new()
{
AppToken = "app-token",
StaticParam = "static",
Prefix = "prefix",
Suffix = "suffix",
ChecksumConstant = 7,
ChecksumIndexes = [0]
};
using DynamicRulesCacheScope _ = new(rules);
Dictionary<string, string> getParams = new() { { "limit", "10" }, { "offset", "5" } };
HttpRequestMessage request = await InvokeBuildHttpRequestMessage(service, getParams, "/users");
Assert.Equal("https://onlyfans.com/api2/v2/users?limit=10&offset=5", request.RequestUri?.ToString());
Assert.True(request.Headers.Contains("app-token"));
Assert.True(request.Headers.Contains("sign"));
Assert.True(request.Headers.Contains("user-id"));
}
[Fact]
public void DeserializeJson_ReturnsDefaultForWhitespace()
{
object? result = InvokeDeserializeJson(typeof(Dictionary<string, int>), " ", null);
Assert.Null(result);
}
[Fact]
public void DeserializeJson_ParsesValidJson()
{
object? result = InvokeDeserializeJson(typeof(Dictionary<string, int>), "{\"a\":1}", null);
Assert.NotNull(result);
Dictionary<string, int> dict = Assert.IsType<Dictionary<string, int>>(result);
Assert.Equal(1, dict["a"]);
}
[Fact]
public void UpdateGetParamsForDateSelection_BeforeAddsBeforePublishTime()
{
Dictionary<string, string> getParams = new();
InvokeUpdateGetParamsForDateSelection(DownloadDateSelection.before, getParams, "123.000000");
Assert.Equal("123.000000", getParams["beforePublishTime"]);
}
[Fact]
public void UpdateGetParamsForDateSelection_AfterAddsAfterPublishTimeAndOrder()
{
Dictionary<string, string> getParams = new();
InvokeUpdateGetParamsForDateSelection(DownloadDateSelection.after, getParams, "456.000000");
Assert.Equal("publish_date_asc", getParams["order"]);
Assert.Equal("456.000000", getParams["afterPublishTime"]);
}
[Theory]
[InlineData("photo", "Images")]
[InlineData("video", "Videos")]
[InlineData("gif", "Videos")]
[InlineData("audio", "Audios")]
[InlineData("unknown", null)]
public void ResolveMediaType_ReturnsExpectedValue(string input, string? expected)
{
string? result = InvokeResolveMediaType(input);
Assert.Equal(expected, result);
}
[Fact]
public void IsMediaTypeDownloadEnabled_RespectsConfigFlags()
{
Config config = new() { DownloadImages = false, DownloadVideos = false, DownloadAudios = false };
ApiService service = new(new FakeAuthService(), new FakeConfigService(config), new MediaTrackingDbService());
Assert.False(InvokeIsMediaTypeDownloadEnabled(service, "photo"));
Assert.False(InvokeIsMediaTypeDownloadEnabled(service, "video"));
Assert.False(InvokeIsMediaTypeDownloadEnabled(service, "audio"));
Assert.True(InvokeIsMediaTypeDownloadEnabled(service, "other"));
}
[Fact]
public void TryGetDrmInfo_ReturnsTrueWhenComplete()
{
Files files = new()
{
Drm = new Drm
{
Manifest = new Manifest { Dash = "dash" },
Signature = new Signature
{
Dash = new Dash
{
CloudFrontPolicy = "policy",
CloudFrontSignature = "signature",
CloudFrontKeyPairId = "kvp"
}
}
}
};
bool result = InvokeTryGetDrmInfo(files, out string manifestDash, out string policy,
out string signature, out string kvp);
Assert.True(result);
Assert.Equal("dash", manifestDash);
Assert.Equal("policy", policy);
Assert.Equal("signature", signature);
Assert.Equal("kvp", kvp);
}
[Fact]
public void TryGetDrmInfo_ReturnsFalseWhenMissingFields()
{
Files files = new()
{
Drm = new Drm
{
Manifest = new Manifest { Dash = null },
Signature = new Signature { Dash = new Dash { CloudFrontPolicy = "policy" } }
}
};
bool result = InvokeTryGetDrmInfo(files, out _, out _, out _, out _);
Assert.False(result);
}
[Fact]
public void GetCurrentUserIdOrDefault_ReturnsMinValueWhenMissingOrInvalid()
{
ApiService serviceMissing =
new(new FakeAuthService(), new FakeConfigService(new Config()), new MediaTrackingDbService());
Assert.Equal(int.MinValue, InvokeGetCurrentUserIdOrDefault(serviceMissing));
FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "not-a-number" } };
ApiService serviceInvalid = new(authService, new FakeConfigService(new Config()), new MediaTrackingDbService());
Assert.Equal(int.MinValue, InvokeGetCurrentUserIdOrDefault(serviceInvalid));
}
[Fact]
public void GetCurrentUserIdOrDefault_ReturnsParsedUserId()
{
FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "42" } };
ApiService service = new(authService, new FakeConfigService(new Config()), new MediaTrackingDbService());
Assert.Equal(42, InvokeGetCurrentUserIdOrDefault(service));
}
[Fact]
public void ConvertToUnixTimestampWithMicrosecondPrecision_ReturnsExpectedSeconds()
{
double epoch = InvokeConvertToUnixTimestampWithMicrosecondPrecision(
new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc));
double oneSecond = InvokeConvertToUnixTimestampWithMicrosecondPrecision(
new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc));
Assert.Equal(0, epoch, 6);
Assert.Equal(1, oneSecond, 6);
}
[Fact]
public async Task GetDrmMpdInfo_ReturnsSecondPssh()
{
string mpd = """
<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns:cenc="urn:mpeg:cenc:2013">
<Period>
<ContentProtection>
<cenc:pssh>FIRST</cenc:pssh>
<cenc:pssh>SECOND</cenc:pssh>
</ContentProtection>
</Period>
</MPD>
""";
using SimpleHttpServer server = new(mpd);
FakeAuthService authService = new()
{
CurrentAuth = new Auth
{
UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_id=1; sess=2;"
}
};
ApiService service = CreateService(authService);
(string pssh, _, _) = await service.GetDrmMpdInfo(server.Url.ToString(), "policy", "signature", "kvp");
await server.Completion;
Assert.Equal("SECOND", pssh);
}
[Fact]
public async Task GetDrmMpdInfo_ReturnsPsshLastModifiedAndDuration()
{
string mpd = """
<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns:cenc="urn:mpeg:cenc:2013" mediaPresentationDuration="PT1M2.5S">
<Period>
<ContentProtection>
<cenc:pssh>FIRST</cenc:pssh>
<cenc:pssh>SECOND</cenc:pssh>
</ContentProtection>
</Period>
</MPD>
""";
DateTime lastModifiedUtc = new(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc);
using SimpleHttpServer server = new(mpd, lastModifiedUtc);
FakeAuthService authService = new()
{
CurrentAuth = new Auth
{
UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_id=1; sess=2;"
}
};
ApiService service = CreateService(authService);
(string pssh, DateTime lastModified, double? durationSeconds) =
await service.GetDrmMpdInfo(server.Url.ToString(), "policy", "signature", "kvp");
await server.Completion;
Assert.Equal("SECOND", pssh);
Assert.True(durationSeconds.HasValue);
Assert.Equal(62.5, durationSeconds.Value, 3);
Assert.True((lastModified - lastModifiedUtc.ToLocalTime()).Duration() < TimeSpan.FromSeconds(1));
}
[Fact]
public async Task GetDrmMpdInfo_ReturnsLastModifiedHeader()
{
DateTime lastModifiedUtc = new(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc);
using SimpleHttpServer server = new("<MPD />", lastModifiedUtc);
FakeAuthService authService = new()
{
CurrentAuth = new Auth
{
UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_id=1; sess=2;"
}
};
ApiService service = CreateService(authService);
(_, DateTime lastModified, _) =
await service.GetDrmMpdInfo(server.Url.ToString(), "policy", "signature", "kvp");
await server.Completion;
DateTime expectedLocal = lastModifiedUtc.ToLocalTime();
Assert.True((lastModified - expectedLocal).Duration() < TimeSpan.FromSeconds(1));
}
[Fact]
public async Task GetUserInfo_ReturnsNullWhenAuthMissing()
{
ApiService service = CreateService(new FakeAuthService());
using DynamicRulesCacheScope _ = new(BuildTestRules());
UserEntities.User? user = await service.GetUserInfo("/users/me");
Assert.Null(user);
}
[Fact]
public async Task GetUserInfoById_ReturnsNullWhenAuthMissing()
{
ApiService service = CreateService(new FakeAuthService());
using DynamicRulesCacheScope _ = new(BuildTestRules());
JObject? user = await service.GetUserInfoById("/users/list?x[]=1");
Assert.Null(user);
}
[Fact]
public async Task GetActiveSubscriptions_ReturnsNullWhenAuthMissing()
{
ApiService service = CreateService(new FakeAuthService());
using DynamicRulesCacheScope _ = new(BuildTestRules());
Dictionary<string, long>? result = await service.GetActiveSubscriptions("/subscriptions", false);
Assert.Null(result);
}
[Fact]
public async Task GetExpiredSubscriptions_ReturnsNullWhenAuthMissing()
{
ApiService service = CreateService(new FakeAuthService());
using DynamicRulesCacheScope _ = new(BuildTestRules());
Dictionary<string, long>? result = await service.GetExpiredSubscriptions("/subscriptions", false);
Assert.Null(result);
}
[Fact]
public async Task GetLists_ReturnsNullWhenAuthMissing()
{
ApiService service = CreateService(new FakeAuthService());
using DynamicRulesCacheScope _ = new(BuildTestRules());
Dictionary<string, long>? result = await service.GetLists("/lists");
Assert.Null(result);
}
[Fact]
public async Task GetListUsers_ReturnsNullWhenAuthMissing()
{
ApiService service = CreateService(new FakeAuthService());
using DynamicRulesCacheScope _ = new(BuildTestRules());
List<string>? result = await service.GetListUsers("/lists/1/users");
Assert.Null(result);
}
[Fact]
public async Task GetMedia_ReturnsNullWhenAuthMissing()
{
ApiService service = CreateService(new FakeAuthService());
using DynamicRulesCacheScope _ = new(BuildTestRules());
Dictionary<long, string>? result = await service.GetMedia(MediaType.Stories, "/users/1/stories", null, "/tmp");
Assert.Null(result);
}
private static ApiService CreateService(FakeAuthService authService) =>
new(authService, new FakeConfigService(new Config()), new MediaTrackingDbService());
private static DynamicRules BuildTestRules() =>
new()
{
AppToken = "app-token",
StaticParam = "static",
Prefix = "prefix",
Suffix = "suffix",
ChecksumConstant = 7,
ChecksumIndexes = [0]
};
private static async Task<HttpRequestMessage> InvokeBuildHttpRequestMessage(ApiService service,
Dictionary<string, string> getParams, string endpoint)
{
MethodInfo method = typeof(ApiService).GetMethod("BuildHttpRequestMessage",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("BuildHttpRequestMessage not found.");
Task<HttpRequestMessage> task = (Task<HttpRequestMessage>)method.Invoke(service,
[getParams, endpoint])!;
return await task;
}
private static object? InvokeDeserializeJson(Type type, string? body, JsonSerializerSettings? settings)
{
MethodInfo method = typeof(ApiService).GetMethod("DeserializeJson",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("DeserializeJson not found.");
MethodInfo generic = method.MakeGenericMethod(type);
return generic.Invoke(null, [body, settings]);
}
private static void InvokeUpdateGetParamsForDateSelection(DownloadDateSelection selection,
Dictionary<string, string> getParams, string? timestamp)
{
MethodInfo method = typeof(ApiService).GetMethod("UpdateGetParamsForDateSelection",
BindingFlags.NonPublic | BindingFlags.Static, null,
[
typeof(DownloadDateSelection), typeof(Dictionary<string, string>).MakeByRefType(),
typeof(string)
],
null)
?? throw new InvalidOperationException("UpdateGetParamsForDateSelection not found.");
object?[] args = { selection, getParams, timestamp };
method.Invoke(null, args);
}
private static string? InvokeResolveMediaType(string? type)
{
MethodInfo method = typeof(ApiService).GetMethod("ResolveMediaType",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("ResolveMediaType not found.");
return (string?)method.Invoke(null, new object?[] { type });
}
private static bool InvokeIsMediaTypeDownloadEnabled(ApiService service, string? type)
{
MethodInfo method = typeof(ApiService).GetMethod("IsMediaTypeDownloadEnabled",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("IsMediaTypeDownloadEnabled not found.");
return (bool)method.Invoke(service, [type])!;
}
private static bool InvokeTryGetDrmInfo(Files files, out string manifestDash, out string cloudFrontPolicy,
out string cloudFrontSignature, out string cloudFrontKeyPairId)
{
MethodInfo method = typeof(ApiService).GetMethod("TryGetDrmInfo",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException("TryGetDrmInfo not found.");
object?[] args = { files, null, null, null, null };
bool result = (bool)method.Invoke(null, args)!;
manifestDash = (string)args[1]!;
cloudFrontPolicy = (string)args[2]!;
cloudFrontSignature = (string)args[3]!;
cloudFrontKeyPairId = (string)args[4]!;
return result;
}
private static int InvokeGetCurrentUserIdOrDefault(ApiService service)
{
MethodInfo method = typeof(ApiService).GetMethod("GetCurrentUserIdOrDefault",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("GetCurrentUserIdOrDefault not found.");
return (int)method.Invoke(service, null)!;
}
private static double InvokeConvertToUnixTimestampWithMicrosecondPrecision(DateTime dateTime)
{
MethodInfo method = typeof(ApiService).GetMethod("ConvertToUnixTimestampWithMicrosecondPrecision",
BindingFlags.NonPublic | BindingFlags.Static)
?? throw new InvalidOperationException(
"ConvertToUnixTimestampWithMicrosecondPrecision not found.");
return (double)method.Invoke(null, [dateTime])!;
}
private static string BuildSign(DynamicRules rules, long timestamp, string pathWithQuery, string userId)
{
string input = $"{rules.StaticParam}\n{timestamp}\n{pathWithQuery}\n{userId}";
byte[] hashBytes = SHA1.HashData(Encoding.UTF8.GetBytes(input));
string hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();
Assert.NotNull(rules.ChecksumConstant);
int checksum = rules.ChecksumIndexes.Aggregate(0, (current, index) => current + hashString[index]) +
rules.ChecksumConstant.Value;
string checksumHex = checksum.ToString("X").ToLowerInvariant();
return $"{rules.Prefix}:{hashString}:{checksumHex}:{rules.Suffix}";
}
}