524 lines
19 KiB
C#
524 lines
19 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 GetDrmMpdPssh_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.GetDrmMpdPssh(server.Url.ToString(), "policy", "signature", "kvp");
|
|
await server.Completion;
|
|
|
|
Assert.Equal("SECOND", pssh);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task GetDrmMpdLastModified_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 result =
|
|
await service.GetDrmMpdLastModified(server.Url.ToString(), "policy", "signature", "kvp");
|
|
await server.Completion;
|
|
|
|
DateTime expectedLocal = lastModifiedUtc.ToLocalTime();
|
|
Assert.True((result - 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}";
|
|
}
|
|
}
|