Files
Teamup/tests/TeamUp.IntegrationTests/SkillMarketplaceTests.cs
T
soroush.asadi 62883ed01f Skill marketplace: publish, install, org-aware listing (+ adversarial-review fixes)
Orgs can now share skills across the tenant boundary — the next step after the per-org library.

Endpoints (all ManageSkills-gated + audited):
- POST /{key}/publish — list one of your published versions on the marketplace (Visibility→Public;
  only a Published/golden-tested skill may be listed). POST /{key}/unpublish reverses it.
- POST /install — copy a publicly-listed skill (by row id) into your org as a private Installed
  copy; rejects installing your own skill and duplicate (org+key+version) installs.
- GET /marketplace?organizationId= — other orgs' Authored+Public+Published skills (yours excluded),
  each flagged whether that exact (key, version) is already in your library.
- SkillSummary now carries Id (install targets a specific source row). Authored skills default to
  private — listing is an explicit publish step, never a side effect of authoring.

UI (Skills page): a Marketplace tab with Install / "In your library"; Publish / Unlist on your own
published skills; a "Listed" badge.

Fixes from the adversarial review (4 confirmed findings, all addressed):
- HIGH — Public⟹Published is now a domain invariant (Skill.Index forces PrivateToOrg whenever the
  re-derived status isn't Published), so re-authoring a listed version without golden tests can no
  longer leave it Public+Draft or decouple the marketplace gate from the eval gate.
- MEDIUM — install now uses an insert-only indexer path so the (org,key,version) unique index is the
  source of truth: a race with a concurrent install/author becomes a clean 409, never an in-place
  clobber of an existing row's content/ownership.
- MEDIUM/LOW — AlreadyInLibrary is computed per (key, version) to match the install conflict rule, so
  a newer, not-yet-owned version of a key you already hold still shows as installable.

Verified: ArchitectureTests 8/8, IntegrationTests 47/47 (SkillMarketplaceTests: publish gate, own-org
exclusion, cross-org list→install→private copy, duplicate 409, per-version flag, Public⟹Published
invariant, Member 403), client build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 12:27:22 +03:30

196 lines
9.8 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public sealed class SkillMarketplaceTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
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<string> Roles,
string Visibility, string MinTier, string Status, string Origin, Guid? OrganizationId,
int GoldenTestCount, List<ActionDto> Actions);
private sealed record SkillDetail(
SkillSummary Skill, string? Inputs, string? Outputs, List<string> Tools,
List<string> Context, List<GoldenTestDto> 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<BootstrapResponse>(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<SkillDetail>(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<SkillDetail>(client, "/api/skills/authored", Authored(orgA.OrganizationId, "prod-skill", goldenTested: true));
var published = await PostOk<SkillDetail>(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<List<MarketplaceEntry>>(
$"/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<List<SkillDetail>>(
$"/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<List<MarketplaceEntry>>(
$"/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<SkillDetail>(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<List<MarketplaceEntry>>(
$"/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<List<MarketplaceEntry>>(
$"/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<SkillDetail>(client, "/api/skills/authored", Authored(orgA.OrganizationId, "regate-skill", goldenTested: true));
await PostOk<SkillDetail>(client, "/api/skills/regate-skill/publish",
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
var degated = await PostOk<SkillDetail>(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<InviteResponse>(client, "/api/identity/invitations", new
{
email = "member@alia.test",
scopeType = "Organization",
scopeId = orgA.OrganizationId,
role = "Member",
organizationId = orgA.OrganizationId,
});
var member = await PostOk<AuthResponse>(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<ActionDto>(),
tools = Array.Empty<string>(),
context = Array.Empty<string>(),
visibility,
minTier = "free",
body = "Do the thing.",
goldenTests = goldenTested
? new[] { new GoldenTestDto("in", "out") }
: Array.Empty<GoldenTestDto>(),
};
// 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<Guid> 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<SkillIndexer>();
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<T> PostOk<T>(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<T>();
Assert.NotNull(value);
return value!;
}
}