214 lines
9.1 KiB
C#
214 lines
9.1 KiB
C#
|
|
using System.Net;
|
||
|
|
using System.Net.Http.Headers;
|
||
|
|
using System.Net.Http.Json;
|
||
|
|
using Xunit;
|
||
|
|
|
||
|
|
namespace TeamUp.IntegrationTests;
|
||
|
|
|
||
|
|
/// <summary>
|
||
|
|
/// 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.
|
||
|
|
/// </summary>
|
||
|
|
public sealed class SkillLibraryTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||
|
|
{
|
||
|
|
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<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 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<BootstrapResponse>(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<SkillDetail>(client, "/api/skills/index", new { content = BuiltinSkill });
|
||
|
|
|
||
|
|
// The org authors its own skill. Roles + a golden test → Published.
|
||
|
|
var authored = await PostOk<SkillDetail>(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<string>(),
|
||
|
|
context = Array.Empty<string>(),
|
||
|
|
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<SkillDetail>(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<object>(),
|
||
|
|
tools = Array.Empty<string>(),
|
||
|
|
context = Array.Empty<string>(),
|
||
|
|
visibility = "private",
|
||
|
|
minTier = "free",
|
||
|
|
body = "Refined.",
|
||
|
|
goldenTests = new[] { new { input = "x", expected = "y" } },
|
||
|
|
});
|
||
|
|
var versions = await client.GetFromJsonAsync<List<SkillDetail>>(
|
||
|
|
$"/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<SkillDetail>(client, "/api/skills/authored", new
|
||
|
|
{
|
||
|
|
organizationId = owner.OrganizationId,
|
||
|
|
skillKey = "rough-idea",
|
||
|
|
name = "Rough Idea",
|
||
|
|
version = "0.1.0",
|
||
|
|
summary = (string?)null,
|
||
|
|
roles = Array.Empty<string>(),
|
||
|
|
inputs = (string?)null,
|
||
|
|
outputs = (string?)null,
|
||
|
|
actions = Array.Empty<object>(),
|
||
|
|
tools = Array.Empty<string>(),
|
||
|
|
context = Array.Empty<string>(),
|
||
|
|
visibility = "private",
|
||
|
|
minTier = "free",
|
||
|
|
body = "WIP.",
|
||
|
|
goldenTests = Array.Empty<object>(),
|
||
|
|
});
|
||
|
|
Assert.Equal("Draft", draft.Skill.Status);
|
||
|
|
|
||
|
|
// The library lists builtins + own skills; another org sees only builtins.
|
||
|
|
var lib = await client.GetFromJsonAsync<List<SkillSummary>>($"/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<SkillDetail>(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<List<SkillDetail>>(
|
||
|
|
$"/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<InviteResponse>(client, "/api/identity/invitations", new
|
||
|
|
{
|
||
|
|
email = "member@alia.test",
|
||
|
|
scopeType = "Organization",
|
||
|
|
scopeId = owner.OrganizationId,
|
||
|
|
role = "Member",
|
||
|
|
organizationId = owner.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/authored", new
|
||
|
|
{
|
||
|
|
organizationId = owner.OrganizationId,
|
||
|
|
skillKey = "sneaky",
|
||
|
|
name = "Sneaky",
|
||
|
|
version = "1.0.0",
|
||
|
|
roles = Array.Empty<string>(),
|
||
|
|
actions = Array.Empty<object>(),
|
||
|
|
tools = Array.Empty<string>(),
|
||
|
|
context = Array.Empty<string>(),
|
||
|
|
visibility = "private",
|
||
|
|
minTier = "free",
|
||
|
|
body = "no",
|
||
|
|
goldenTests = Array.Empty<object>(),
|
||
|
|
});
|
||
|
|
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<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!;
|
||
|
|
}
|
||
|
|
}
|