diff --git a/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs b/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs new file mode 100644 index 000000000..713957804 --- /dev/null +++ b/src/Yavsc.Server.Tests/BlogSpotServiceDetailsTests.cs @@ -0,0 +1,222 @@ +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using Yavsc.Models.Blog; +using Yavsc.Server.Exceptions; +using Yavsc.Server.Tests.Fixtures; + +namespace Yavsc.Server.Tests; + +/// +/// Service-level tests for . +/// +/// loads a +/// (with Author, Tags, Comments, ACL and +/// Publication navigations), runs a resource-based +/// ReadPermission authorization against it, scrubs the ACL for +/// non-owners, derives from the +/// loaded Publication navigation, and eagerly loads each +/// comment's Author. These tests pin each of those behaviours +/// at the service layer against the real +/// (the public/owner/ +/// sponsor/Administrator disjunction) and a real SQLite store — no +/// mocking. +/// +/// Seeding and the service call resolve through separate DI +/// scopes so the DbContext that writes the seed rows is disposed +/// before the one reads through, +/// mirroring the per-request DbContext lifetime in production. +/// +[Collection("Yavsc Server")] +public sealed class BlogSpotServiceDetailsTests : IClassFixture +{ + private readonly ServerServicesFixture _fixture; + + public BlogSpotServiceDetailsTests(ServerServicesFixture fixture) + { + _fixture = fixture; + } + + /// An authenticated principal whose sub claim is + /// . + /// reads sub first, which is what the JwtBearer middleware + /// in production preserves (MapInboundClaims = false), and what + /// compares against + /// . + private static ClaimsPrincipal Authenticated(string userId, + params Claim[] extra) + { + var claims = new List { new("sub", userId) }; + claims.AddRange(extra); + return new ClaimsPrincipal( + new ClaimsIdentity(claims, authenticationType: "Bearer")); + } + + /// An unauthenticated principal: a + /// with no authentication type has + /// == false. The + /// service still runs authorization — a published post's + /// ReadPermission succeeds purely on IsPublic, with no + /// identity required. + private static ClaimsPrincipal Anonymous() + => new(new ClaimsIdentity()); + + /// The Microsoft-format role claim + /// + /// reads when checking the "Administrator" bypass in + /// . + private static Claim AdminRole() + => new("http://schemas.microsoft.com/ws/2008/06/identity/claims/role", + "Administrator"); + + [Fact] + public async Task Details_returns_the_post_for_its_owner_and_keeps_the_acl() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + var circleId = _fixture.SeedCircle("alice", "alice-private"); + var postId = _fixture.SeedBlogPost("alice", "Owner reads own draft"); + _fixture.SeedAcl(postId, circleId); + + var blog = await _fixture.WithServiceAsync(svc => + svc.Details(Authenticated("alice"), postId)); + + Assert.Equal(postId, blog.Id); + Assert.Equal("Owner reads own draft", blog.Title); + // The owner is the one viewer ScrubAclForViewer keeps the ACL + // for — a non-owner would see an empty list. + Assert.Single(blog.ACL!); + // No BlogSpotPublication row was seeded, so IsPublished is + // derived from the (null) Publication navigation as false. + Assert.False(blog.IsPublished); + } + + [Fact] + public async Task Details_serves_IsPublished_true_when_a_publication_row_exists() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + var postId = _fixture.SeedBlogPost("alice", "Published post"); + _fixture.Publish(postId); + + var blog = await _fixture.WithServiceAsync(svc => + svc.Details(Authenticated("alice"), postId)); + + // IsPublished is a wire-only value the service derives from + // the loaded Publication navigation (Details Includes it). + Assert.True(blog.IsPublished); + } + + [Fact] + public async Task Details_lets_an_anonymous_viewer_read_a_published_post_and_scrubs_its_acl() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + var circleId = _fixture.SeedCircle("alice", "alice-private"); + var postId = _fixture.SeedBlogPost("alice", "Public post"); + // The ACL is real, but a non-owner viewer must not see it. + _fixture.SeedAcl(postId, circleId); + _fixture.Publish(postId); + + var blog = await _fixture.WithServiceAsync(svc => + svc.Details(Anonymous(), postId)); + + // ReadPermission succeeds purely on IsPublic (the publication + // row) — no identity needed. + Assert.Equal(postId, blog.Id); + Assert.True(blog.IsPublished); + // ScrubAclForViewer: the anonymous viewer is not the owner, so + // the ACL is replaced with an empty list rather than leaked. + Assert.Empty(blog.ACL!); + } + + [Fact] + public async Task Details_denies_a_draft_to_a_non_owner_who_is_not_a_sponsor() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + _fixture.SeedUser("bob"); + var postId = _fixture.SeedBlogPost("alice", "Alice's draft"); + + // No publication row (not public), bob is not the owner, bob + // is not a member of any circle alice owns, bob is not an + // Administrator — every branch of ReadPermission fails. + await Assert.ThrowsAsync(() => + _fixture.WithServiceAsync(svc => + svc.Details(Authenticated("bob"), postId))); + } + + [Fact] + public async Task Details_lets_a_sponsor_read_a_draft_and_scrubs_its_acl() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + _fixture.SeedUser("bob"); + // Sponsorship = bob is a member of a circle alice owns. + var circleId = _fixture.SeedCircle("alice", "alice-friends"); + _fixture.SeedCircleMember(circleId, "bob"); + var postId = _fixture.SeedBlogPost("alice", "Draft for friends"); + _fixture.SeedAcl(postId, circleId); + + var blog = await _fixture.WithServiceAsync(svc => + svc.Details(Authenticated("bob"), postId)); + + // ReadPermission succeeds on the IsSponsor branch. + Assert.Equal(postId, blog.Id); + Assert.False(blog.IsPublished); + // bob is not the owner, so the ACL is scrubbed even though he + // was granted read access via the circle. + Assert.Empty(blog.ACL!); + } + + [Fact] + public async Task Details_lets_an_administrator_read_a_draft() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + _fixture.SeedUser("root"); + var postId = _fixture.SeedBlogPost("alice", "Draft, admin view"); + + var blog = await _fixture.WithServiceAsync(svc => + svc.Details(Authenticated("root", AdminRole()), postId)); + + // The Administrator bypass in PermissionHandler short-circuits + // the public/owner/sponsor checks. + Assert.Equal(postId, blog.Id); + Assert.False(blog.IsPublished); + } + + [Fact] + public async Task Details_loads_each_comment_author() + { + _fixture.ResetDatabase(); + _fixture.SeedUser("alice"); + _fixture.SeedUser("bob"); + var postId = _fixture.SeedBlogPost("alice", "Post with comments"); + _fixture.SeedComment(postId, "bob", "Nice post!"); + + var blog = await _fixture.WithServiceAsync(svc => + svc.Details(Authenticated("alice"), postId)); + + var comment = Assert.Single(blog.Comments); + // Details explicitly loads c.Author from _context.Users for + // every comment; the navigation must not be left null. + Assert.NotNull(comment.Author); + Assert.Equal("bob", comment.Author.UserName); + } + + [Fact] + public async Task Details_throws_for_a_missing_post() + { + _fixture.ResetDatabase(); + + // The query uses SingleAsync, which throws + // InvalidOperationException when no row matches — the + // `if (blog == null) return null` guard below it is unreachable. + // Pin that current behaviour so a future switch to + // SingleOrDefaultAsync (which would make the guard live) is a + // deliberate, reviewed change rather than a silent one. + await Assert.ThrowsAsync(() => + _fixture.WithServiceAsync(svc => + svc.Details(Authenticated("alice"), 424242))); + } +} \ No newline at end of file diff --git a/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs b/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs new file mode 100644 index 000000000..f57f65839 --- /dev/null +++ b/src/Yavsc.Server.Tests/Fixtures/ServerServicesFixture.cs @@ -0,0 +1,452 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Npgsql; +using Yavsc.Models; +using Yavsc.Models.Access; +using Yavsc.Models.Blog; +using Yavsc.Models.Relationship; +using Yavsc.Services; + +namespace Yavsc.Server.Tests.Fixtures; + +/// +/// Service-level test host for and the +/// other Yavsc.Server services that take an +/// + . +/// +/// Unlike BlogsWebServerFixture (in Yavsc.Blogs.Tests), +/// this fixture does not spin up Kestrel, JWT bearer, or MVC +/// controllers. takes the +/// as a parameter, +/// so the caller controls identity directly — there is no HTTP/auth +/// middleware in the path under test. Building only the DI graph the +/// service actually resolves keeps these tests fast and isolates the +/// service layer from the blog API's transport concerns, which +/// Yavsc.Blogs.Tests already covers end-to-end. +/// +/// Database provider. Mirrors +/// ApiWebServerFixture: SQLite :memory: by default, or +/// real PostgreSQL when YAVSC_SERVER_TEST_DB_PROVIDER=npgsql is +/// set. Production runs on Npgsql, so the Npgsql path — which applies +/// the real Yavsc.Org migrations against a dedicated test +/// database — is the one that catches provider-specific bugs (FK +/// enforcement, type mappings, migration drift). SQLite stays the +/// default so the suite runs out-of-the-box with no external server. +/// +/// +/// SQLite: a named :memory: database +/// (Cache=Shared) backed by a single connection held open for the +/// fixture's lifetime, schema via EnsureCreated. Reset is +/// EnsureDeleted + EnsureCreated. +/// Npgsql: a dedicated database +/// yavscServerTestDb (created from the admin connection if it +/// does not exist), schema via Migrate using the +/// Yavsc.Org migrations assembly. Reset purges the blog-graph +/// tables in FK order — children before parents — so no DELETE +/// violates a constraint, instead of dropping the database (which +/// would force a full re-migration per test). +/// The real and the +/// real : Details calls +/// IAuthorizationService.AuthorizeAsync(user, blog, new +/// ReadPermission()), and answers +/// that requirement with the public/owner/sponsor/Administrator +/// disjunction that the tests below pin. No mocking. +/// A trivial +/// stub: Details never touches the file system, but the DI +/// container needs an instance to construct the service. +/// +/// +/// Marked so a single +/// fixture instance (one store, one DI container) is shared across +/// every [Collection("Yavsc Server")] test class. Each test +/// resets the store via . +/// +[CollectionDefinition("Yavsc Server")] +public sealed class ServerServicesFixture : IDisposable +{ + private const string DbProviderEnvVar = "YAVSC_SERVER_TEST_DB_PROVIDER"; + private const string NpgsqlAdminConnectionEnvVar = "YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION"; + private const string DedicatedNpgsqlDatabaseName = "yavscServerTestDb"; + + private static readonly object _npgsqlLock = new(); + private static string? _sharedNpgsqlConnectionString; + + private readonly SqliteConnection? _sqliteConnection; + private readonly ServiceProvider _services; + + public IServiceProvider Services => _services; + + public ServerServicesFixture() + { + var services = new ServiceCollection(); + + if (UseNpgsqlProvider()) + { + var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated(); + services.AddDbContext(opt => + opt.UseNpgsql(npgsqlConnectionString, + x => x.MigrationsAssembly("Yavsc.Org"))); + } + else + { + // Mode=Memory + Cache=Shared: a named in-memory database + // every connection string referencing "YavscServerTests" + // resolves to the same backing store, as long as at least + // one connection stays open against it. _sqliteConnection + // is held for the fixture's lifetime (disposed in Dispose), + // so the store survives across scoped DbContexts per test. + _sqliteConnection = new SqliteConnection( + "Data Source=YavscServerTests;Mode=Memory;Cache=Shared"); + _sqliteConnection.Open(); + + services.AddDbContext(opt => + // UseSqlite(DbConnection) keeps _sqliteConnection alive + // for the DbContext's lifetime instead of letting EF + // open and close its own — without this each DbContext + // would get a fresh connection pointing at an empty + // store. + opt.UseSqlite(_sqliteConnection)); + } + + // BlogSpotService.AttachFiles writes under siteSettings.Blog. + // Details itself doesn't read it, but the service constructor + // throws if SiteSettings.Blog is null, so configure a temp + // value to keep construction alive. + services.Configure(s => s.Blog = "server-tests-files-root"); + + // Trivial file-system auth: Details never calls into it, but + // the DI container needs an instance to construct the service. + services.AddSingleton( + new NoopFileSystemAuthManager()); + + // The real BlogSpotService — same shape the production host + // builds (ApplicationDbContext, IAuthorizationService, + // IFileSystemAuthManager, IOptions, + // ILoggerFactory). + services.AddScoped(); + + // The real PermissionHandler: Details authorizes with + // ReadPermission, and PermissionHandler resolves it via the + // IsPublic / IsOwner / IsSponsor / Administrator disjunction. + services.AddScoped(); + + // Default authorization services register + // IAuthorizationService (DefaultAuthorizationService), which + // collects the IAuthorizationHandler above and runs it against + // the requirement passed to AuthorizeAsync. + services.AddAuthorization(); + services.AddLogging(); + + _services = services.BuildServiceProvider(); + + // Create the schema once. Npgsql uses Migrate (the Yavsc.Org + // migrations) so the test schema matches production; SQLite uses + // EnsureCreated (idempotent, model-derived). ResetDatabase + // re-creates / purges per test. + using (var scope = _services.CreateScope()) + { + var db = scope.ServiceProvider + .GetRequiredService(); + if (UseNpgsqlProvider()) + db.Database.Migrate(); + else + db.Database.EnsureCreated(); + } + } + + /// True when YAVSC_SERVER_TEST_DB_PROVIDER=npgsql + /// is set — the fixture then runs against a real PostgreSQL server + /// instead of the SQLite in-memory default. + public static bool UseNpgsqlProvider() + => string.Equals( + Environment.GetEnvironmentVariable(DbProviderEnvVar), + "npgsql", + StringComparison.OrdinalIgnoreCase); + + /// Reset the store to a known empty state for the next + /// test. SQLite: EnsureDeleted + EnsureCreated (the + /// named in-memory store survives because _sqliteConnection is held + /// open). Npgsql: purge the blog-graph tables in FK order — + /// children before parents — so no DELETE trips a foreign key, + /// avoiding a full re-migration per test. + public void ResetDatabase() + { + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider + .GetRequiredService(); + + if (UseNpgsqlProvider()) + { + // Children before parents. BlogAttachedFile, Comment and + // BlogSpotPublication reference BlogSpot; CircleAuthorization + // ToBlogPost references BlogSpot + Circle; BlogTag references + // BlogSpot + Tag; BlogSpot references ApplicationUser; + // CircleMember references Circle + ApplicationUser. + db.BlogAttachedFiles.RemoveRange(db.BlogAttachedFiles); + db.Comment.RemoveRange(db.Comment); + db.blogSpotPublications.RemoveRange(db.blogSpotPublications); + db.CircleAuthorizationToBlogPost + .RemoveRange(db.CircleAuthorizationToBlogPost); + db.BlogTag.RemoveRange(db.BlogTag); + db.UploadedFiles.RemoveRange(db.UploadedFiles); + db.BlogSpot.RemoveRange(db.BlogSpot); + db.CircleMembers.RemoveRange(db.CircleMembers); + db.Circle.RemoveRange(db.Circle); + db.Users.RemoveRange(db.Users); + db.SaveChanges(); + return; + } + + db.Database.EnsureDeleted(); + db.Database.EnsureCreated(); + } + + /// Resolve a fresh service scope and the + /// from it, run + /// , and dispose the scope (and its + /// DbContext). Use this for seeding writes that should not share + /// a DbContext with the service call under test. + public T WithDb(Func body) + { + using var scope = _services.CreateScope(); + var db = scope.ServiceProvider + .GetRequiredService(); + return body(db); + } + + public void SeedUser(string userName, + Action? configure = null) + { + WithDb(db => + { + if (db.Users.Any(u => u.Id == userName)) return false; + var user = new ApplicationUser + { + Id = userName, + UserName = userName, + Email = $"{userName}@example.test", + }; + configure?.Invoke(user); + db.Users.Add(user); + db.SaveChanges(); + return true; + }); + } + + public long SeedBlogPost(string authorId, string title, + Action? configure = null) + { + return WithDb(db => + { + var post = new BlogPost + { + AuthorId = authorId, + Title = title, + Article = "Test article body.", + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + }; + configure?.Invoke(post); + db.BlogSpot.Add(post); + db.SaveChanges(); + return post.Id; + }); + } + + /// Insert a row for + /// — the existence of that row is what + /// makes a post "public" (readable by anyone) in + /// . This is the same write + /// SetPublishAsync performs in production. + public void Publish(long postId) + { + WithDb(db => + { + db.blogSpotPublications.Add( + new BlogSpotPublication { PostId = postId }); + db.SaveChanges(); + return true; + }); + } + + /// Seed a circle owned by and + /// return its server-assigned id. Sponsorship in + /// is "the viewer is a member of a + /// circle the post's author owns", so a circle + a + /// entry is what grants a non-owner + /// read access to a draft. + public long SeedCircle(string ownerId, string name) + { + return WithDb(db => + { + var circle = new Circle + { + OwnerId = ownerId, + Name = name, + Public = false, + }; + db.Circle.Add(circle); + db.SaveChanges(); + return circle.Id; + }); + } + + public void SeedCircleMember(long circleId, string memberId) + { + WithDb(db => + { + db.CircleMembers.Add( + new CircleMember { CircleId = circleId, MemberId = memberId }); + db.SaveChanges(); + return true; + }); + } + + /// Attach a ACL + /// entry to for . + /// The owner always sees the ACL; non-owners have it scrubbed by + /// 's ScrubAclForViewer. + public void SeedAcl(long postId, long circleId) + { + WithDb(db => + { + db.CircleAuthorizationToBlogPost.Add( + new CircleAuthorizationToBlogPost + { + BlogPostId = postId, + CircleId = circleId, + }); + db.SaveChanges(); + return true; + }); + } + + /// Seed a comment on authored by + /// . + /// loads each comment's Author navigation from + /// _context.Users; seeding the user first keeps the FK + /// valid (SQLite and PostgreSQL both enforce it). + public void SeedComment(long postId, string authorId, string article) + { + WithDb(db => + { + db.Comment.Add(new Comment + { + ReceiverId = postId, + AuthorId = authorId, + Article = article, + Visible = true, + UserModified = authorId, + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + }); + db.SaveChanges(); + return true; + }); + } + + /// Resolve the real from the + /// fixture's DI container, run against it, + /// and dispose the request scope (and its scoped DbContext). The + /// service is scoped, so it must be resolved from a scope — and the + /// scope is what we dispose to avoid leaking a DbContext against + /// the shared connection across tests. + public async Task WithServiceAsync( + Func> body) + { + using var scope = _services.CreateScope(); + var service = scope.ServiceProvider + .GetRequiredService(); + return await body(service); + } + + private static string EnsureNpgsqlDatabaseCreated() + { + lock (_npgsqlLock) + { + if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString)) + return _sharedNpgsqlConnectionString; + + var adminConnectionString = BuildAdminConnectionString(); + var databaseName = DedicatedNpgsqlDatabaseName; + + using (var adminConnection = new NpgsqlConnection(adminConnectionString)) + { + adminConnection.Open(); + using var existsCommand = adminConnection.CreateCommand(); + existsCommand.CommandText = + "SELECT 1 FROM pg_database WHERE datname = @databaseName"; + existsCommand.Parameters.AddWithValue("databaseName", databaseName); + + if (existsCommand.ExecuteScalar() is null) + { + using var createCommand = adminConnection.CreateCommand(); + createCommand.CommandText = + $"CREATE DATABASE \"{databaseName}\""; + createCommand.ExecuteNonQuery(); + } + } + + var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString) + { + Database = databaseName, + Pooling = false, + IncludeErrorDetail = true + }; + + _sharedNpgsqlConnectionString = testConnectionBuilder.ToString(); + return _sharedNpgsqlConnectionString; + } + } + + private static string BuildAdminConnectionString() + { + // The admin connection string (host + credentials) is supplied + // via environment, never embedded in source — committing a + // real dev password would leak it into git history. The Npgsql + // path is opt-in, so requiring the env var is fine: a missing + // value produces a clear failure rather than a silent fallback + // onto someone's local dev database. + var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar); + if (string.IsNullOrWhiteSpace(configured)) + throw new InvalidOperationException( + $"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test " + + "provider requires an admin PostgreSQL connection string " + + "in that environment variable (e.g. " + + "'Server=localhost;Port=5432;Database=postgres;" + + "Username=yavscdev;Password=...;Include Error Detail=true')."); + + var builder = new NpgsqlConnectionStringBuilder(configured) + { + Pooling = false, + IncludeErrorDetail = true + }; + + // The admin connection targets the maintenance database + // (postgres) so we can CREATE/DROP the dedicated test database; + // whatever database the env var names is overridden here. + builder.Database = "postgres"; + + return builder.ToString(); + } + + public void Dispose() + { + _services.Dispose(); + _sqliteConnection?.Dispose(); + } + + private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager + { + public FileAccessRight GetFilePathAccess( + System.Security.Claims.ClaimsPrincipal user, + string fileRelativePath) + => FileAccessRight.None; + + public void SetAccess(long circleId, string normalizedFullPath, + FileAccessRight access) { } + } +} \ No newline at end of file diff --git a/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj b/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj new file mode 100644 index 000000000..1ccb802cf --- /dev/null +++ b/src/Yavsc.Server.Tests/Yavsc.Server.Tests.csproj @@ -0,0 +1,32 @@ + + + net10.0 + enable + enable + false + Yavsc.Server.Tests + true + 1.1.0.0 + 1.1.0.0 + 1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d + 1.1.0-estimate.1 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/yavsc.sln b/yavsc.sln index 6085a85fd..d7cffd18f 100644 --- a/yavsc.sln +++ b/yavsc.sln @@ -41,6 +41,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt\ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Server.Tests", "src\Yavsc.Server.Tests\Yavsc.Server.Tests.csproj", "{899B7240-729A-48BC-A993-300A6CCFCBEA}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -243,6 +245,18 @@ Global {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.ActiveCfg = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.Build.0 = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.ActiveCfg = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.Build.0 = Debug|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.Build.0 = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.ActiveCfg = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.Build.0 = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.ActiveCfg = Release|Any CPU + {899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -265,5 +279,6 @@ Global {59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE} {49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F} + {899B7240-729A-48BC-A993-300A6CCFCBEA} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} EndGlobalSection EndGlobal