using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Microsoft.Extensions.DependencyInjection; using TeamUp.Modules.Skills.Domain; using TeamUp.Modules.Skills.Indexing; using Xunit; namespace TeamUp.IntegrationTests; /// /// The skill marketplace, end to end: the publish gate (only golden-tested may list), own-org /// exclusion, another org's published skill listed and installed as a private copy, the duplicate /// conflict, and ManageSkills authorization. One flow per class — bootstrap is single-use. /// public sealed class SkillMarketplaceTests(PostgresFixture postgres) : IClassFixture { private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); private sealed record AuthResponse(string Token, Guid MemberId); private sealed record InviteResponse(Guid InvitationId, string Token); private sealed record ActionDto(string Name, string Risk, string? Description); private sealed record GoldenTestDto(string Input, string Expected); private sealed record SkillSummary( Guid Id, string SkillKey, string Name, string Version, string? Summary, List Roles, string Visibility, string MinTier, string Status, string Origin, Guid? OrganizationId, int GoldenTestCount, List Actions); private sealed record SkillDetail( SkillSummary Skill, string? Inputs, string? Outputs, List Tools, List Context, List GoldenTests, string Body); private sealed record MarketplaceEntry(SkillSummary Skill, bool AlreadyInLibrary); [Fact] public async Task Publishes_lists_installs_with_gate_own_exclusion_and_authorization() { await using var factory = new TeamUpWebFactory(postgres.ConnectionString); using var anon = factory.CreateClient(); var orgA = await PostOk(anon, "/api/identity/bootstrap", new { organizationName = "OrgA", ownerEmail = "owner@alia.test", ownerDisplayName = "Owner", ownerPassword = "Passw0rd!", }); using var client = Authed(factory, orgA.Token); // Gate: a Draft (no golden test) cannot be listed on the marketplace. await PostOk(client, "/api/skills/authored", Authored(orgA.OrganizationId, "draft-skill", goldenTested: false)); var cantPublish = await client.PostAsJsonAsync("/api/skills/draft-skill/publish", new { organizationId = orgA.OrganizationId, version = "1.0.0" }); Assert.Equal(HttpStatusCode.BadRequest, cantPublish.StatusCode); // A published skill lists fine; visibility flips to Public, but it never shows in its own org's marketplace. await PostOk(client, "/api/skills/authored", Authored(orgA.OrganizationId, "prod-skill", goldenTested: true)); var published = await PostOk(client, "/api/skills/prod-skill/publish", new { organizationId = orgA.OrganizationId, version = "1.0.0" }); Assert.Equal("Public", published.Skill.Visibility); var ownMarket = await client.GetFromJsonAsync>( $"/api/skills/marketplace?organizationId={orgA.OrganizationId}"); Assert.DoesNotContain(ownMarket!, e => e.Skill.SkillKey == "prod-skill"); // You can't install your own skill. var prod = await client.GetFromJsonAsync>( $"/api/skills/prod-skill?organizationId={orgA.OrganizationId}"); var installOwn = await client.PostAsJsonAsync("/api/skills/install", new { organizationId = orgA.OrganizationId, sourceSkillId = prod!.Single().Skill.Id }); Assert.Equal(HttpStatusCode.BadRequest, installOwn.StatusCode); // Another org's published skill (no second-org onboarding yet — seed it via the indexer). var orgB = Guid.NewGuid(); var sourceId = await SeedPublishedSkillAsync(factory, orgB, "research-brief", "Research Brief"); // Org A sees org B's skill on the marketplace, not yet in its library. var market = await client.GetFromJsonAsync>( $"/api/skills/marketplace?organizationId={orgA.OrganizationId}"); var entry = Assert.Single(market!, e => e.Skill.SkillKey == "research-brief"); Assert.False(entry.AlreadyInLibrary); Assert.Equal(sourceId, entry.Skill.Id); // Installing lands a private, Installed copy in org A's namespace. var installed = await PostOk(client, "/api/skills/install", new { organizationId = orgA.OrganizationId, sourceSkillId = sourceId }); Assert.Equal("Installed", installed.Skill.Origin); Assert.Equal(orgA.OrganizationId, installed.Skill.OrganizationId); Assert.Equal("PrivateToOrg", installed.Skill.Visibility); Assert.Equal("research-brief", installed.Skill.SkillKey); // Now it's in the library (flagged), and re-installing the same version is a conflict. var market2 = await client.GetFromJsonAsync>( $"/api/skills/marketplace?organizationId={orgA.OrganizationId}"); Assert.True(Assert.Single(market2!, e => e.Skill.SkillKey == "research-brief").AlreadyInLibrary); var dup = await client.PostAsJsonAsync("/api/skills/install", new { organizationId = orgA.OrganizationId, sourceSkillId = sourceId }); Assert.Equal(HttpStatusCode.Conflict, dup.StatusCode); // A newer version of an already-installed key stays installable — the flag is per (key, version). var sourceV2 = await SeedPublishedSkillAsync(factory, orgB, "research-brief", "Research Brief", "2.0.0"); var market3 = await client.GetFromJsonAsync>( $"/api/skills/marketplace?organizationId={orgA.OrganizationId}"); Assert.False(Assert.Single(market3!, e => e.Skill.Id == sourceV2).AlreadyInLibrary); Assert.True(Assert.Single(market3!, e => e.Skill.SkillKey == "research-brief" && e.Skill.Version == "1.0.0").AlreadyInLibrary); // Invariant: re-authoring a listed version without golden tests cannot leave it Public. await PostOk(client, "/api/skills/authored", Authored(orgA.OrganizationId, "regate-skill", goldenTested: true)); await PostOk(client, "/api/skills/regate-skill/publish", new { organizationId = orgA.OrganizationId, version = "1.0.0" }); var degated = await PostOk(client, "/api/skills/authored", Authored(orgA.OrganizationId, "regate-skill", goldenTested: false, visibility: "public")); Assert.Equal("Draft", degated.Skill.Status); Assert.Equal("PrivateToOrg", degated.Skill.Visibility); // A plain Member cannot publish/unpublish (ManageSkills). var invite = await PostOk(client, "/api/identity/invitations", new { email = "member@alia.test", scopeType = "Organization", scopeId = orgA.OrganizationId, role = "Member", organizationId = orgA.OrganizationId, }); var member = await PostOk(anon, "/api/identity/invitations/accept", new { token = invite.Token, displayName = "Member", password = "Passw0rd!" }); using var memberClient = Authed(factory, member.Token); var forbidden = await memberClient.PostAsJsonAsync("/api/skills/prod-skill/unpublish", new { organizationId = orgA.OrganizationId, version = "1.0.0" }); Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); } private static object Authored(Guid organizationId, string key, bool goldenTested, string visibility = "private") => new { organizationId, skillKey = key, name = key, version = "1.0.0", summary = "x", roles = new[] { "engineer" }, inputs = (string?)null, outputs = (string?)null, actions = Array.Empty(), tools = Array.Empty(), context = Array.Empty(), visibility, minTier = "free", body = "Do the thing.", goldenTests = goldenTested ? new[] { new GoldenTestDto("in", "out") } : Array.Empty(), }; // No public second-org onboarding yet, so seed the "other org's" published skill directly through // the same indexer the authoring endpoint uses (InternalsVisibleTo grants the test access). private static async Task SeedPublishedSkillAsync( TeamUpWebFactory factory, Guid orgId, string key, string name, string version = "1.0.0") { using var scope = factory.Services.CreateScope(); var indexer = scope.ServiceProvider.GetRequiredService(); var manifest = new SkillManifest { Id = key, Name = name, Version = version, Summary = "A shared, published skill.", Roles = ["analyst"], Visibility = "public", GoldenTests = [new GoldenExample { Input = "a topic", Expected = "a brief" }], }; var skill = await indexer.IndexAsync(manifest, "Write a research brief.", SkillOwnership.Authored(orgId, Guid.NewGuid())); return skill.Id; } private static HttpClient Authed(TeamUpWebFactory factory, string token) { var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); return client; } private static async Task PostOk(HttpClient client, string url, object body) { var response = await client.PostAsJsonAsync(url, body); Assert.Equal(HttpStatusCode.OK, response.StatusCode); var value = await response.Content.ReadFromJsonAsync(); Assert.NotNull(value); return value!; } }