using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Xunit; namespace TeamUp.IntegrationTests; /// /// The dynamic per-company skill library: an org authors a skill, versions it, and forks a builtin — /// all org-scoped (own + shared builtins visible, gated by ManageSkills), with the publish gate intact. /// public sealed class SkillLibraryTests(PostgresFixture postgres) : IClassFixture { private const string BuiltinSkill = """ --- id: spec-writing name: Spec Writing version: 1.0.0 summary: Turn a request into a spec. roles: [product-owner] actions: - name: write-spec risk: draft golden_tests: - input: "Add a logout button" expected: "A logout button in the header that ends the session." --- # Spec Writing Write a clear, testable spec. """; 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( 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); [Fact] public async Task Org_authors_versions_and_forks_skills_scoped_to_itself() { await using var factory = new TeamUpWebFactory(postgres.ConnectionString); using var anon = factory.CreateClient(); var owner = await PostOk(anon, "/api/identity/bootstrap", new { organizationName = "AliaSaaS", ownerEmail = "owner@alia.test", ownerDisplayName = "Owner", ownerPassword = "Passw0rd!", }); using var client = Authed(factory, owner.Token); client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey); // A builtin exists in the shared (null-org) namespace. await PostOk(client, "/api/skills/index", new { content = BuiltinSkill }); // The org authors its own skill. Roles + a golden test → Published. var authored = await PostOk(client, "/api/skills/authored", new { organizationId = owner.OrganizationId, skillKey = "api-design", name = "API Design", version = "1.0.0", summary = "Design an endpoint.", roles = new[] { "engineer" }, inputs = "A story.", outputs = "Route + shapes.", actions = new[] { new { name = "write-design", risk = "draft", description = "Emit a design." } }, tools = Array.Empty(), context = Array.Empty(), visibility = "private", minTier = "free", body = "You are the engineer. Design the endpoint.", goldenTests = new[] { new { input = "Delete own comment", expected = "DELETE /comments/{id} 204/403/404" } }, }); Assert.Equal("Published", authored.Skill.Status); Assert.Equal("Authored", authored.Skill.Origin); Assert.Equal(owner.OrganizationId, authored.Skill.OrganizationId); // Bump the version → a new row; both coexist. await PostOk(client, "/api/skills/authored", new { organizationId = owner.OrganizationId, skillKey = "api-design", name = "API Design", version = "1.1.0", summary = "Design an endpoint (v2).", roles = new[] { "engineer" }, inputs = (string?)null, outputs = (string?)null, actions = Array.Empty(), tools = Array.Empty(), context = Array.Empty(), visibility = "private", minTier = "free", body = "Refined.", goldenTests = new[] { new { input = "x", expected = "y" } }, }); var versions = await client.GetFromJsonAsync>( $"/api/skills/api-design?organizationId={owner.OrganizationId}"); Assert.Equal(2, versions!.Count); // Without roles or golden tests → Draft (publish gate holds). var draft = await PostOk(client, "/api/skills/authored", new { organizationId = owner.OrganizationId, skillKey = "rough-idea", name = "Rough Idea", version = "0.1.0", summary = (string?)null, roles = Array.Empty(), inputs = (string?)null, outputs = (string?)null, actions = Array.Empty(), tools = Array.Empty(), context = Array.Empty(), visibility = "private", minTier = "free", body = "WIP.", goldenTests = Array.Empty(), }); Assert.Equal("Draft", draft.Skill.Status); // The library lists builtins + own skills; another org sees only builtins. var lib = await client.GetFromJsonAsync>($"/api/skills?organizationId={owner.OrganizationId}"); Assert.Contains(lib!, s => s.SkillKey == "spec-writing" && s.Origin == "Builtin"); Assert.Contains(lib!, s => s.SkillKey == "api-design" && s.OrganizationId == owner.OrganizationId); // Fork the builtin into the org → an editable Authored copy under the org namespace. var forked = await PostOk(client, "/api/skills/spec-writing/fork", new { organizationId = owner.OrganizationId, version = "1.0.0", name = (string?)null, }); Assert.Equal("Authored", forked.Skill.Origin); Assert.Equal(owner.OrganizationId, forked.Skill.OrganizationId); Assert.Equal("spec-writing", forked.Skill.SkillKey); // GET for the key now returns the org's fork AND the builtin. var specVersions = await client.GetFromJsonAsync>( $"/api/skills/spec-writing?organizationId={owner.OrganizationId}"); Assert.Contains(specVersions!, s => s.Skill.OrganizationId == owner.OrganizationId); Assert.Contains(specVersions!, s => s.Skill.OrganizationId == null); // A plain Member cannot author skills (ManageSkills is owner/team-owner). var invite = await PostOk(client, "/api/identity/invitations", new { email = "member@alia.test", scopeType = "Organization", scopeId = owner.OrganizationId, role = "Member", organizationId = owner.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/authored", new { organizationId = owner.OrganizationId, skillKey = "sneaky", name = "Sneaky", version = "1.0.0", roles = Array.Empty(), actions = Array.Empty(), tools = Array.Empty(), context = Array.Empty(), visibility = "private", minTier = "free", body = "no", goldenTests = Array.Empty(), }); Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode); // Builtin management (shared null-org skills) needs the platform admin key — an authenticated // tenant user without it cannot inject or re-sync builtins, even as Owner. using var noKey = Authed(factory, owner.Token); Assert.Equal(HttpStatusCode.Forbidden, (await noKey.PostAsJsonAsync("/api/skills/index", new { content = BuiltinSkill })).StatusCode); Assert.Equal(HttpStatusCode.Forbidden, (await noKey.PostAsync("/api/skills/sync", content: null)).StatusCode); } 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!; } }