fad476f115
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>
79 lines
3.2 KiB
C#
79 lines
3.2 KiB
C#
using System.Net;
|
|
using System.Net.Http.Headers;
|
|
using System.Net.Http.Json;
|
|
using Xunit;
|
|
|
|
namespace TeamUp.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// M2 acceptance: syncing from the Git source (the repo's skills/ dir via the filesystem provider)
|
|
/// indexes the four V1 atoms, published and queryable by their role.
|
|
/// </summary>
|
|
public sealed class SkillSyncTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
|
{
|
|
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
|
|
|
private sealed record SyncResult(int Indexed);
|
|
|
|
private sealed record ActionDto(string Name, string Risk);
|
|
|
|
private sealed record SkillSummary(
|
|
string SkillKey, string Name, string Version, string? Summary, List<string> Roles,
|
|
string Visibility, string MinTier, string Status, List<ActionDto> Actions);
|
|
|
|
[Fact]
|
|
public async Task Sync_indexes_the_four_atoms_queryable_by_role()
|
|
{
|
|
var settings = new Dictionary<string, string?>
|
|
{
|
|
["GitSource:Provider"] = "filesystem",
|
|
["GitSource:Root"] = LocateSkillsDirectory(),
|
|
};
|
|
|
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings);
|
|
using var anon = factory.CreateClient();
|
|
|
|
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);
|
|
|
|
var syncResponse = await client.PostAsync("/api/skills/sync", content: null);
|
|
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
|
|
var result = await syncResponse.Content.ReadFromJsonAsync<SyncResult>();
|
|
Assert.True(result!.Indexed >= 8, $"expected all atoms indexed, got {result.Indexed}");
|
|
|
|
var productOwner = await client.GetFromJsonAsync<List<SkillSummary>>("/api/skills/?role=product-owner");
|
|
Assert.Contains(productOwner!, s => s.SkillKey == "spec-writing");
|
|
Assert.Contains(productOwner!, s => s.SkillKey == "story-breakdown");
|
|
Assert.All(productOwner!, s => Assert.Equal("Published", s.Status));
|
|
|
|
var qa = await client.GetFromJsonAsync<List<SkillSummary>>("/api/skills/?role=qa");
|
|
Assert.Contains(qa!, s => s.SkillKey == "test-plan-generation");
|
|
Assert.Contains(qa!, s => s.SkillKey == "diff-review");
|
|
}
|
|
|
|
private static string LocateSkillsDirectory()
|
|
{
|
|
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
|
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "TeamUp.slnx")))
|
|
{
|
|
dir = dir.Parent;
|
|
}
|
|
|
|
Assert.NotNull(dir);
|
|
var skills = Path.Combine(dir!.FullName, "skills");
|
|
Assert.True(Directory.Exists(skills), $"skills directory not found at {skills}");
|
|
return skills;
|
|
}
|
|
}
|