Files
Teamup/tests/TeamUp.IntegrationTests/SkillRegistryTests.cs
T
soroush.asadi fad476f115 Dynamic per-org skill library: in-app authoring, versioning, fork (+ marketplace seam)
Skills move from a global Git-only registry to a per-company library that orgs author and
version in-app — Git stays as the shared *starter* library.

Domain & persistence:
- Skill gains OrganizationId (null = shared builtin, visible to every org), Origin
  (Builtin | Authored | Installed), AuthoredByMemberId. Identity is now
  (OrganizationId, SkillKey, Version); the unique index uses NULLS NOT DISTINCT so builtins
  stay unique by key+version while each org gets its own namespace (and can fork a builtin).
  AddSkillOwnership migration backfills existing rows as Builtin.
- Owned GoldenExample rows are cloned in Skill.Index so a fork can't re-parent the source's
  tracked entities.

Authoring (tenant, dynamic):
- POST /api/skills/authored — structured fields → same indexer pipeline (embedding +
  publish gate apply identically), tagged org + author. POST /api/skills/{key}/fork copies a
  builtin/global skill into your org as an editable Authored draft. List/Get are org-scoped
  (your org + shared builtins). New Capability.ManageSkills (Owner + TeamOwner), audited.
- GET /api/skills/marketplace: read-only seam listing public skills across orgs (install is
  the next step).

Security (from adversarial review — two confirmed criticals):
- Managing shared builtins is an operator action, not a tenant one. /index (posts arbitrary
  content as a global builtin) and /sync (re-indexes the shared library) now require a
  platform admin key (X-Skills-Admin-Key, fixed-time compare, fail-closed when unset) via
  SkillAdminOptions — previously any authenticated user of any org could inject/poison global
  skills. New test asserts an authenticated Owner without the key gets 403 on both.

UI: new /skills library page — browse shared + org skills grouped by key with their versions,
create / new-version / fork, golden-test editor + body, Draft/Published badge and the
publish-gate hint (needs roles + ≥1 golden test).

Verified: ArchitectureTests 8/8, IntegrationTests 46/46 (new SkillLibraryTests: org
isolation, version coexistence, fork, publish gate, Member 403, admin-gate 403), client build
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 11:09:02 +03:30

116 lines
4.9 KiB
C#

using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// M2 skill-registry acceptance: index a SKILL.md, then find it queryable by role; a skill with
/// roles + golden tests publishes, a malformed one is rejected, and the catalogue needs auth.
/// </summary>
public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
private const string SpecWritingSkill =
"""
---
id: spec-writing
name: Spec Writing
version: 1.0.0
summary: Turn a feature request into a structured spec and child stories.
roles: [product-owner]
inputs: A feature request or task.
outputs: A spec plus proposed child stories.
actions:
- name: write-spec
risk: draft
- name: create-child-stories
risk: draft
tools: []
context: [house-style]
visibility: public
min_tier: free
golden_tests:
- input: "Add a logout button"
expected: "Spec: a logout button in the header that ends the session."
---
# Spec Writing
Write a clear, testable spec, then propose child stories.
""";
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
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<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);
[Fact]
public async Task Index_publishes_and_makes_skill_queryable_by_role()
{
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
using var anon = factory.CreateClient();
// The catalogue requires auth.
Assert.Equal(HttpStatusCode.Unauthorized, (await anon.GetAsync("/api/skills/")).StatusCode);
var bootstrap = await anon.PostAsJsonAsync("/api/identity/bootstrap", new
{
organizationName = "AliaSaaS",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
var owner = await bootstrap.Content.ReadFromJsonAsync<BootstrapResponse>();
Assert.NotNull(owner);
using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
// Index the SKILL.md.
var indexResponse = await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill });
Assert.Equal(HttpStatusCode.OK, indexResponse.StatusCode);
var indexed = await indexResponse.Content.ReadFromJsonAsync<SkillDetail>();
Assert.NotNull(indexed);
Assert.Equal("spec-writing", indexed!.Skill.SkillKey);
Assert.Equal("Published", indexed.Skill.Status); // has roles + a golden test
Assert.Equal("Builtin", indexed.Skill.Origin);
Assert.Null(indexed.Skill.OrganizationId);
Assert.Single(indexed.GoldenTests);
Assert.Contains(indexed.Skill.Actions, a => a.Name == "write-spec" && a.Risk == "Draft");
// Queryable by its role…
var forPo = await client.GetFromJsonAsync<List<SkillSummary>>("/api/skills/?role=product-owner");
Assert.Contains(forPo!, s => s.SkillKey == "spec-writing");
// …but not under an unrelated role.
var forQa = await client.GetFromJsonAsync<List<SkillSummary>>("/api/skills/?role=qa");
Assert.DoesNotContain(forQa!, s => s.SkillKey == "spec-writing");
// Detail by key.
var detail = await client.GetFromJsonAsync<List<SkillDetail>>("/api/skills/spec-writing");
Assert.Single(detail!);
Assert.Equal("public", detail![0].Skill.Visibility.ToLowerInvariant());
// Re-indexing the same key+version updates in place (no duplicate).
await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill });
var afterReindex = await client.GetFromJsonAsync<List<SkillDetail>>("/api/skills/spec-writing");
Assert.Single(afterReindex!);
// A malformed SKILL.md (no frontmatter) is rejected.
var bad = await client.PostAsJsonAsync("/api/skills/index", new { content = "# no frontmatter here" });
Assert.Equal(HttpStatusCode.BadRequest, bad.StatusCode);
}
}