using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using Microsoft.Extensions.DependencyInjection; using TeamUp.Modules.Assembler.Queue; using TeamUp.Modules.Assembler.Runtime; using TeamUp.Modules.Skills.Domain; using TeamUp.Modules.Skills.Indexing; using Xunit; namespace TeamUp.IntegrationTests; /// /// Closes the library → delivery loop: when an AI seat runs a task, the skill bodies assembled into /// its prompt are resolved within the org's namespace only — the org's own (authored/forked) skill is /// preferred over the shared builtin of the same key, and another org's same-key skill never leaks in. /// public sealed class SkillRunScopingTests(PostgresFixture postgres) : IClassFixture { private const string OrgABody = "ACME_ORG_A_CUSTOM_IMPL_BODY — our house implementation steps."; private const string OrgBPoison = "ORG_B_POISON_BODY_MUST_NEVER_APPEAR_IN_ANOTHER_ORGS_PROMPT."; private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); private sealed record IdResponse(Guid Id); private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name); private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId); private sealed record TaskResponse(Guid Id); private sealed record SyncResult(int Indexed); private sealed record RunResponse( Guid Id, Guid SeatId, Guid WorkItemId, Guid? AgentId, string Status, string? ActionType, string? ActionRisk, string? Prompt, string? Output, string? Error); [Fact] public async Task A_run_assembles_the_orgs_own_skill_over_builtin_and_never_another_orgs() { var settings = new Dictionary { ["GitSource:Provider"] = "filesystem", ["GitSource:Root"] = LocateSkillsDirectory(), }; await using var factory = new TeamUpWebFactory(postgres.ConnectionString, settings); using var anon = factory.CreateClient(); var owner = await PostOk(anon, "/api/identity/bootstrap", new { organizationName = "OrgA", ownerEmail = "owner@alia.test", ownerDisplayName = "Owner", ownerPassword = "Passw0rd!", }); using var client = Authed(factory, owner.Token); await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "OrgA" }); var team = await PostOk(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" }); var config = await PostOk(client, "/api/integrations/api-configs", new { organizationId = owner.OrganizationId, name = "Stub", provider = "stub", model = "gemini-pro", apiKey = "sk-demo-key", }); // The shared builtin "code-implementation" exists (Published) after a sync. var sync = await PostOk(client, "/api/skills/sync", new { }); Assert.True(sync.Indexed >= 8); // Org A authors its OWN "code-implementation" (same key as the builtin) with a distinctive body. await PostOk(client, "/api/skills/authored", new { organizationId = owner.OrganizationId, skillKey = "code-implementation", name = "ACME Code Impl", version = "1.0.0", summary = "Our house implementation skill.", roles = new[] { "engineer" }, inputs = (string?)null, outputs = (string?)null, actions = new[] { new { name = "implement-code", risk = "draft", description = (string?)null } }, tools = Array.Empty(), context = Array.Empty(), visibility = "private", minTier = "free", body = OrgABody, goldenTests = new[] { new { input = "task", expected = "code" } }, }); // A DIFFERENT org publishes its own "code-implementation" — it must never reach org A's prompt. await SeedSkillAsync(factory, Guid.NewGuid(), "code-implementation", OrgBPoison); var seat = await PostOk(client, "/api/orgboard/seats", new { teamId = team.Id, roleName = "Backend Engineer" }); await client.PostAsJsonAsync($"/api/orgboard/seats/{seat.Id}/agent", new { name = "Edison", monogram = "ED", autonomy = "Gated", apiConfigId = config.Id, skillKeys = new[] { "code-implementation" }, docs = Array.Empty(), }); var task = await PostOk(client, "/api/orgboard/tasks", new { teamId = team.Id, title = "Implement the logout endpoint", description = "POST /logout clears the session.", type = "Story", }); var run = await PostOk(client, "/api/assembler/runs", new { seatId = seat.Id, workItemId = task.Id }); await DrainOneJob(factory); var done = await client.GetFromJsonAsync($"/api/assembler/runs/{run.Id}"); Assert.Equal("Completed", done!.Status); Assert.Equal("implement-code", done.ActionType); // The org's own skill body was assembled in — preferred over the builtin of the same key… Assert.Contains(OrgABody, done.Prompt); // …and another org's same-key skill never leaked across the tenant boundary. Assert.DoesNotContain(OrgBPoison, done.Prompt); } private static async Task SeedSkillAsync(TeamUpWebFactory factory, Guid orgId, string key, string body) { using var scope = factory.Services.CreateScope(); var indexer = scope.ServiceProvider.GetRequiredService(); var manifest = new SkillManifest { Id = key, Name = key, Version = "1.0.0", Summary = "another org's skill", Roles = ["engineer"], Visibility = "public", GoldenTests = [new GoldenExample { Input = "task", Expected = "code" }], }; await indexer.IndexAsync(manifest, body, SkillOwnership.Authored(orgId, Guid.NewGuid())); } private static async Task DrainOneJob(TeamUpWebFactory factory) { await using var scope = factory.Services.CreateAsyncScope(); var queue = scope.ServiceProvider.GetRequiredService(); var job = await queue.ClaimNextAsync("test-worker"); Assert.NotNull(job); await scope.ServiceProvider.GetRequiredService().ProcessAsync(job!); } private static HttpClient Authed(TeamUpWebFactory factory, string token) { var client = factory.CreateClient(); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey); 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!; } 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); return Path.Combine(dir!.FullName, "skills"); } }