Merge pull request #177 from NicolasConstant/topic_federate-migration
Topic federate migration
This commit is contained in:
commit
cc37ed32c2
9 changed files with 394 additions and 40 deletions
|
@ -5,5 +5,7 @@ namespace BirdsiteLive.Common.Regexes
|
|||
public class UrlRegexes
|
||||
{
|
||||
public static readonly Regex Url = new Regex(@"(.?)(((http|ftp|https):\/\/)[\w\-_]+(\.[\w\-_]+)+([\w\-\.,@?^=%&:/~\+#]*[\w\-\@?^=%&/~\+#])?)");
|
||||
|
||||
public static readonly Regex Domain = new Regex(@"^[a-zA-Z0-9\-_]+(\.[a-zA-Z0-9\-_]+)+$");
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using BirdsiteLive.Twitter;
|
||||
using System.Security.Cryptography;
|
||||
|
@ -11,25 +12,34 @@ using BirdsiteLive.ActivityPub.Converters;
|
|||
using BirdsiteLive.Common.Settings;
|
||||
using BirdsiteLive.DAL.Models;
|
||||
using BirdsiteLive.Domain.Enum;
|
||||
using System.Net.Http;
|
||||
using BirdsiteLive.Common.Regexes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace BirdsiteLive.Domain
|
||||
{
|
||||
public class MigrationService
|
||||
{
|
||||
private readonly InstanceSettings _instanceSettings;
|
||||
private readonly ITheFedInfoService _theFedInfoService;
|
||||
private readonly ITwitterTweetsService _twitterTweetsService;
|
||||
private readonly IActivityPubService _activityPubService;
|
||||
private readonly ITwitterUserDal _twitterUserDal;
|
||||
private readonly IFollowersDal _followersDal;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<MigrationService> _logger;
|
||||
|
||||
#region Ctor
|
||||
public MigrationService(ITwitterTweetsService twitterTweetsService, IActivityPubService activityPubService, ITwitterUserDal twitterUserDal, IFollowersDal followersDal, InstanceSettings instanceSettings)
|
||||
public MigrationService(ITwitterTweetsService twitterTweetsService, IActivityPubService activityPubService, ITwitterUserDal twitterUserDal, IFollowersDal followersDal, InstanceSettings instanceSettings, ITheFedInfoService theFedInfoService, IHttpClientFactory httpClientFactory, ILogger<MigrationService> logger)
|
||||
{
|
||||
_twitterTweetsService = twitterTweetsService;
|
||||
_activityPubService = activityPubService;
|
||||
_twitterUserDal = twitterUserDal;
|
||||
_followersDal = followersDal;
|
||||
_instanceSettings = instanceSettings;
|
||||
_theFedInfoService = theFedInfoService;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
#endregion
|
||||
|
||||
|
@ -63,7 +73,7 @@ namespace BirdsiteLive.Domain
|
|||
|
||||
if (tweet.CreatorName.Trim().ToLowerInvariant() != acct.Trim().ToLowerInvariant())
|
||||
throw new Exception($"Tweet not published by @{acct}");
|
||||
|
||||
|
||||
if (!tweet.MessageContent.Contains(code))
|
||||
{
|
||||
var message = "Tweet don't have migration code";
|
||||
|
@ -180,7 +190,7 @@ namespace BirdsiteLive.Domain
|
|||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
_logger.LogError(e, e.Message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -225,7 +235,14 @@ namespace BirdsiteLive.Domain
|
|||
|
||||
var t1 = Task.Run(async () =>
|
||||
{
|
||||
await _activityPubService.DeleteUserAsync(acct, host, sharedInbox);
|
||||
try
|
||||
{
|
||||
await _activityPubService.DeleteUserAsync(acct, host, sharedInbox);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, e.Message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -239,20 +256,74 @@ namespace BirdsiteLive.Domain
|
|||
|
||||
var t1 = Task.Run(async () =>
|
||||
{
|
||||
await _activityPubService.DeleteUserAsync(acct, host, sharedInbox);
|
||||
try
|
||||
{
|
||||
await _activityPubService.DeleteUserAsync(acct, host, sharedInbox);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, e.Message);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async Task TriggerRemoteMigrationAsync(string id, string tweetid, string handle)
|
||||
public async Task TriggerRemoteMigrationAsync(string id, string tweetIdStg, string handle)
|
||||
{
|
||||
//TODO
|
||||
var url = $"https://{{0}}/migration/move/{{1}}/{{2}}/{handle}";
|
||||
await ProcessRemoteMigrationAsync(id, tweetIdStg, url);
|
||||
|
||||
}
|
||||
|
||||
public async Task TriggerRemoteDeleteAsync(string id, string tweetid)
|
||||
public async Task TriggerRemoteDeleteAsync(string id, string tweetIdStg)
|
||||
{
|
||||
//TODO
|
||||
var url = $"https://{{0}}/migration/delete/{{1}}/{{2}}";
|
||||
await ProcessRemoteMigrationAsync(id, tweetIdStg, url);
|
||||
}
|
||||
|
||||
private async Task ProcessRemoteMigrationAsync(string id, string tweetIdStg, string urlPattern)
|
||||
{
|
||||
try
|
||||
{
|
||||
var instances = await RetrieveCompatibleBslInstancesAsync();
|
||||
var tweetId = ExtractedTweetId(tweetIdStg);
|
||||
|
||||
foreach (var instance in instances)
|
||||
{
|
||||
try
|
||||
{
|
||||
var host = instance.Host;
|
||||
if(!UrlRegexes.Domain.IsMatch(host)) continue;
|
||||
|
||||
var url = string.Format(urlPattern, host, id, tweetId);
|
||||
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
var result = await client.PostAsync(url, null);
|
||||
result.EnsureSuccessStatusCode();
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, e.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, e.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<BslInstanceInfo>> RetrieveCompatibleBslInstancesAsync()
|
||||
{
|
||||
var instances = await _theFedInfoService.GetBslInstanceListAsync();
|
||||
var filteredInstances = instances
|
||||
.Where(x => x.Version >= new Version(0, 21, 0))
|
||||
.Where(x => string.Compare(x.Host,
|
||||
_instanceSettings.Domain,
|
||||
StringComparison.InvariantCultureIgnoreCase) != 0)
|
||||
.ToList();
|
||||
return filteredInstances;
|
||||
}
|
||||
|
||||
private byte[] GetHash(string inputString)
|
||||
|
|
162
src/BirdsiteLive.Domain/TheFedInfoService.cs
Normal file
162
src/BirdsiteLive.Domain/TheFedInfoService.cs
Normal file
|
@ -0,0 +1,162 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace BirdsiteLive.Domain
|
||||
{
|
||||
public interface ITheFedInfoService
|
||||
{
|
||||
Task<List<BslInstanceInfo>> GetBslInstanceListAsync();
|
||||
}
|
||||
|
||||
public class TheFedInfoService : ITheFedInfoService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
#region Ctor
|
||||
public TheFedInfoService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
#endregion
|
||||
|
||||
public async Task<List<BslInstanceInfo>> GetBslInstanceListAsync()
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
|
||||
var result = await CallGraphQlAsync<MyResponseData>(
|
||||
new Uri("https://the-federation.info/graphql"),
|
||||
HttpMethod.Get,
|
||||
"query ($platform: String!) { nodes(platform: $platform) { host, version } }",
|
||||
new
|
||||
{
|
||||
platform = "birdsitelive",
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
var convertedResults = ConvertResults(result);
|
||||
return convertedResults;
|
||||
}
|
||||
|
||||
private List<BslInstanceInfo> ConvertResults(GraphQLResponse<MyResponseData> qlData)
|
||||
{
|
||||
var results = new List<BslInstanceInfo>();
|
||||
|
||||
foreach (var instanceInfo in qlData.Data.Nodes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rawVersion = instanceInfo.Version.Split('+').First();
|
||||
if (string.IsNullOrWhiteSpace(rawVersion)) continue;
|
||||
var version = Version.Parse(rawVersion);
|
||||
if(version <= new Version(0,1,0)) continue;
|
||||
|
||||
var instance = new BslInstanceInfo
|
||||
{
|
||||
Host = instanceInfo.Host,
|
||||
Version = version
|
||||
};
|
||||
results.Add(instance);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine(e);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private async Task<GraphQLResponse<TResponse>> CallGraphQlAsync<TResponse>(Uri endpoint, HttpMethod method, string query, object variables, CancellationToken cancellationToken)
|
||||
{
|
||||
var content = new StringContent(SerializeGraphQlCall(query, variables), Encoding.UTF8, "application/json");
|
||||
var httpRequestMessage = new HttpRequestMessage
|
||||
{
|
||||
Method = method,
|
||||
Content = content,
|
||||
RequestUri = endpoint,
|
||||
};
|
||||
//add authorization headers if necessary here
|
||||
httpRequestMessage.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
var httpClient = _httpClientFactory.CreateClient();
|
||||
using (var response = await httpClient.SendAsync(httpRequestMessage, cancellationToken))
|
||||
{
|
||||
//if (response.IsSuccessStatusCode)
|
||||
if (response?.Content.Headers.ContentType?.MediaType == "application/json")
|
||||
{
|
||||
var responseString = await response.Content.ReadAsStringAsync(); //cancellationToken supported for .NET 5/6
|
||||
return DeserializeGraphQlCall<TResponse>(responseString);
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new ApplicationException($"Unable to contact '{endpoint}': {response.StatusCode} - {response.ReasonPhrase}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private string SerializeGraphQlCall(string query, object variables)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var textWriter = new StringWriter(sb);
|
||||
var serializer = new JsonSerializer();
|
||||
serializer.Serialize(textWriter, new
|
||||
{
|
||||
query = query,
|
||||
variables = variables,
|
||||
});
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private GraphQLResponse<TResponse> DeserializeGraphQlCall<TResponse>(string response)
|
||||
{
|
||||
var serializer = new JsonSerializer();
|
||||
var stringReader = new StringReader(response);
|
||||
var jsonReader = new JsonTextReader(stringReader);
|
||||
var result = serializer.Deserialize<GraphQLResponse<TResponse>>(jsonReader);
|
||||
return result;
|
||||
}
|
||||
|
||||
private class GraphQLResponse<TResponse>
|
||||
{
|
||||
public List<GraphQLError> Errors { get; set; }
|
||||
public TResponse Data { get; set; }
|
||||
}
|
||||
|
||||
private class GraphQLError
|
||||
{
|
||||
public string Message { get; set; }
|
||||
public List<GraphQLErrorLocation> Locations { get; set; }
|
||||
public List<object> Path { get; set; } //either int or string
|
||||
}
|
||||
|
||||
private class GraphQLErrorLocation
|
||||
{
|
||||
public int Line { get; set; }
|
||||
public int Column { get; set; }
|
||||
}
|
||||
|
||||
private class MyResponseData
|
||||
{
|
||||
public Node[] Nodes { get; set; }
|
||||
}
|
||||
|
||||
private class Node
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public string Version { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public class BslInstanceInfo
|
||||
{
|
||||
public string Host { get; set; }
|
||||
public Version Version { get; set; }
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@ using Npgsql.TypeHandlers;
|
|||
using BirdsiteLive.Domain;
|
||||
using BirdsiteLive.Domain.Enum;
|
||||
using BirdsiteLive.DAL.Contracts;
|
||||
using BirdsiteLive.Models;
|
||||
using System.Reflection.Metadata;
|
||||
|
||||
namespace BirdsiteLive.Controllers
|
||||
{
|
||||
|
@ -76,8 +78,8 @@ namespace BirdsiteLive.Controllers
|
|||
data.ErrorMessage = "This account has been deleted, it can't be migrated";
|
||||
return View("Index", data);
|
||||
}
|
||||
if (twitterAccount != null &&
|
||||
(!string.IsNullOrWhiteSpace(twitterAccount.MovedTo)
|
||||
if (twitterAccount != null &&
|
||||
(!string.IsNullOrWhiteSpace(twitterAccount.MovedTo)
|
||||
|| !string.IsNullOrWhiteSpace(twitterAccount.MovedToAcct)))
|
||||
{
|
||||
data.ErrorMessage = "This account has been moved already, it can't be migrated again";
|
||||
|
@ -103,7 +105,7 @@ namespace BirdsiteLive.Controllers
|
|||
try
|
||||
{
|
||||
await _migrationService.MigrateAccountAsync(fediverseUserValidation, id);
|
||||
await _migrationService.TriggerRemoteMigrationAsync(id, tweetid, handle);
|
||||
_migrationService.TriggerRemoteMigrationAsync(id, tweetid, handle);
|
||||
data.MigrationSuccess = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
|
@ -131,7 +133,7 @@ namespace BirdsiteLive.Controllers
|
|||
|
||||
TweetId = tweetid
|
||||
};
|
||||
|
||||
|
||||
//Verify can be deleted
|
||||
var twitterAccount = await _twitterUserDal.GetTwitterUserAsync(id);
|
||||
if (twitterAccount != null && twitterAccount.Deleted)
|
||||
|
@ -156,7 +158,7 @@ namespace BirdsiteLive.Controllers
|
|||
try
|
||||
{
|
||||
await _migrationService.DeleteAccountAsync(id);
|
||||
await _migrationService.TriggerRemoteDeleteAsync(id, tweetid);
|
||||
_migrationService.TriggerRemoteDeleteAsync(id, tweetid);
|
||||
data.MigrationSuccess = true;
|
||||
}
|
||||
catch (Exception e)
|
||||
|
@ -173,11 +175,16 @@ namespace BirdsiteLive.Controllers
|
|||
[Route("/migration/move/{id}/{tweetid}/{handle}")]
|
||||
public async Task<IActionResult> RemoteMigrateMove(string id, string tweetid, string handle)
|
||||
{
|
||||
//Check inputs
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(tweetid) ||
|
||||
string.IsNullOrWhiteSpace(handle))
|
||||
return StatusCode(422);
|
||||
|
||||
//Verify can be migrated
|
||||
var twitterAccount = await _twitterUserDal.GetTwitterUserAsync(id);
|
||||
if (twitterAccount.Deleted
|
||||
if (twitterAccount != null && (twitterAccount.Deleted
|
||||
|| !string.IsNullOrWhiteSpace(twitterAccount.MovedTo)
|
||||
|| !string.IsNullOrWhiteSpace(twitterAccount.MovedToAcct))
|
||||
|| !string.IsNullOrWhiteSpace(twitterAccount.MovedToAcct)))
|
||||
return Ok();
|
||||
|
||||
// Start migration
|
||||
|
@ -197,9 +204,13 @@ namespace BirdsiteLive.Controllers
|
|||
[Route("/migration/delete/{id}/{tweetid}")]
|
||||
public async Task<IActionResult> RemoteMigrateDelete(string id, string tweetid)
|
||||
{
|
||||
//Check inputs
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(tweetid))
|
||||
return StatusCode(422);
|
||||
|
||||
//Verify can be deleted
|
||||
var twitterAccount = await _twitterUserDal.GetTwitterUserAsync(id);
|
||||
if (twitterAccount.Deleted) return Ok();
|
||||
if (twitterAccount != null && twitterAccount.Deleted) return Ok();
|
||||
|
||||
// Start deletion
|
||||
var isTweetValid = _migrationService.ValidateTweet(id, tweetid, MigrationTypeEnum.Deletion);
|
||||
|
@ -213,25 +224,4 @@ namespace BirdsiteLive.Controllers
|
|||
return StatusCode(400);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public class MigrationData
|
||||
{
|
||||
public string Acct { get; set; }
|
||||
|
||||
public string FediverseAccount { get; set; }
|
||||
public string TweetId { get; set; }
|
||||
|
||||
public string MigrationCode { get; set; }
|
||||
|
||||
public bool IsTweetProvided { get; set; }
|
||||
public bool IsAcctProvided { get; set; }
|
||||
|
||||
public bool IsTweetValid { get; set; }
|
||||
public bool IsAcctValid { get; set; }
|
||||
|
||||
public string ErrorMessage { get; set; }
|
||||
public bool MigrationSuccess { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
21
src/BirdsiteLive/Models/MigrationData.cs
Normal file
21
src/BirdsiteLive/Models/MigrationData.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
namespace BirdsiteLive.Models
|
||||
{
|
||||
public class MigrationData
|
||||
{
|
||||
public string Acct { get; set; }
|
||||
|
||||
public string FediverseAccount { get; set; }
|
||||
public string TweetId { get; set; }
|
||||
|
||||
public string MigrationCode { get; set; }
|
||||
|
||||
public bool IsTweetProvided { get; set; }
|
||||
public bool IsAcctProvided { get; set; }
|
||||
|
||||
public bool IsTweetValid { get; set; }
|
||||
public bool IsAcctValid { get; set; }
|
||||
|
||||
public string ErrorMessage { get; set; }
|
||||
public bool MigrationSuccess { get; set; }
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
@model BirdsiteLive.Controllers.MigrationData
|
||||
@model MigrationData
|
||||
@{
|
||||
ViewData["Title"] = "Migration";
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
@model BirdsiteLive.Controllers.MigrationData
|
||||
@model MigrationData
|
||||
@{
|
||||
ViewData["Title"] = "Migration";
|
||||
}
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
using BirdsiteLive.Common.Regexes;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace BirdsiteLive.Common.Tests
|
||||
{
|
||||
[TestClass]
|
||||
public class UrlRegexesTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Url_Test()
|
||||
{
|
||||
var input = "https://misskey.tdl/users/8hwf6zy2k1#main-key";
|
||||
Assert.IsTrue(UrlRegexes.Url.IsMatch(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Url_Not_Test()
|
||||
{
|
||||
var input = "misskey.tdl/users/8hwf6zy2k1#main-key";
|
||||
Assert.IsFalse(UrlRegexes.Url.IsMatch(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Domain_Test()
|
||||
{
|
||||
var input = "misskey-data_sq.tdl";
|
||||
Assert.IsTrue(UrlRegexes.Domain.IsMatch(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Domain_Numbers_Test()
|
||||
{
|
||||
var input = "miss45654QAzedqskey-data_sq.tdl";
|
||||
Assert.IsTrue(UrlRegexes.Domain.IsMatch(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Domain_Subdomain_Test()
|
||||
{
|
||||
var input = "s.sub.dqdq-_Dz9sd.tdl";
|
||||
Assert.IsTrue(UrlRegexes.Domain.IsMatch(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Domain_Not_Test()
|
||||
{
|
||||
var input = "mis$s45654QAzedqskey-data_sq.tdl";
|
||||
Assert.IsFalse(UrlRegexes.Domain.IsMatch(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Domain_Slash_Test()
|
||||
{
|
||||
var input = "miss45654QAz/edqskey-data_sq.tdl";
|
||||
Assert.IsFalse(UrlRegexes.Domain.IsMatch(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Domain_NotSub_Test()
|
||||
{
|
||||
var input = ".mis$s45654QAzedqskey-data_sq.tdl";
|
||||
Assert.IsFalse(UrlRegexes.Domain.IsMatch(input));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Domain_NotExt_Test()
|
||||
{
|
||||
var input = ".mis$s45654QAzedqskey-data_sq.tdl";
|
||||
Assert.IsFalse(UrlRegexes.Domain.IsMatch(input));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
using System;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Threading.Tasks;
|
||||
using BirdsiteLive.Common.Regexes;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Moq;
|
||||
|
||||
namespace BirdsiteLive.Domain.Tests
|
||||
{
|
||||
[TestClass]
|
||||
public class TheFedInfoServiceTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task GetBslInstanceListAsyncTest()
|
||||
{
|
||||
var httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
httpClientFactoryMock
|
||||
.Setup(x => x.CreateClient(It.IsAny<string>()))
|
||||
.Returns(new HttpClient());
|
||||
|
||||
var service = new TheFedInfoService(httpClientFactoryMock.Object);
|
||||
|
||||
var bslInstanceList = await service.GetBslInstanceListAsync();
|
||||
|
||||
Assert.IsTrue(bslInstanceList.Count > 0);
|
||||
|
||||
foreach (var instanceInfo in bslInstanceList)
|
||||
{
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(instanceInfo.Host));
|
||||
Assert.IsTrue(UrlRegexes.Domain.IsMatch(instanceInfo.Host));
|
||||
Assert.IsTrue(instanceInfo.Version > new Version(0, 1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in a new issue