62883ed01f
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>
196 lines
9.8 KiB
C#
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!;
|
|
}
|
|
}
|