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!;
|
||
|
|
}
|
||
|
|
}
|