From ac55221e874fc0d04a7359c3509881c9b2a35185 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 1 Nov 2026 18:00:39 +1000 Subject: [PATCH 1/3] Add a manual Slack API audit pilot --- .github/workflows/api-audit.yml | 49 ++++ SlackNet.ApiAudit/ApiInventory.cs | 78 +++++++ SlackNet.ApiAudit/AuditMarkdown.cs | 28 +++ SlackNet.ApiAudit/AuditRunner.cs | 141 ++++++++++++ SlackNet.ApiAudit/LiveProbes.cs | 89 ++++++++ SlackNet.ApiAudit/Program.cs | 34 +++ SlackNet.ApiAudit/ResponseAudit.cs | 111 +++++++++ SlackNet.ApiAudit/SlackDocumentation.cs | 51 +++++ SlackNet.ApiAudit/SlackNet.ApiAudit.csproj | 11 + SlackNet.Tests/ApiAuditTests.cs | 248 +++++++++++++++++++++ SlackNet.Tests/SlackNet.Tests.csproj | 1 + SlackNet.sln | 6 + docs/api-audit-suppressions.json | 6 + docs/api-audit.md | 39 ++++ readme.md | 2 + 15 files changed, 894 insertions(+) create mode 100644 .github/workflows/api-audit.yml create mode 100644 SlackNet.ApiAudit/ApiInventory.cs create mode 100644 SlackNet.ApiAudit/AuditMarkdown.cs create mode 100644 SlackNet.ApiAudit/AuditRunner.cs create mode 100644 SlackNet.ApiAudit/LiveProbes.cs create mode 100644 SlackNet.ApiAudit/Program.cs create mode 100644 SlackNet.ApiAudit/ResponseAudit.cs create mode 100644 SlackNet.ApiAudit/SlackDocumentation.cs create mode 100644 SlackNet.ApiAudit/SlackNet.ApiAudit.csproj create mode 100644 SlackNet.Tests/ApiAuditTests.cs create mode 100644 docs/api-audit-suppressions.json create mode 100644 docs/api-audit.md diff --git a/.github/workflows/api-audit.yml b/.github/workflows/api-audit.yml new file mode 100644 index 0000000..e56c34b --- /dev/null +++ b/.github/workflows/api-audit.yml @@ -0,0 +1,49 @@ +name: API audit + +on: + workflow_dispatch: + inputs: + live: + description: Run read-only dev-workspace probes + type: boolean + default: false + channel: + description: Dev fixture channel ID (optional) + type: string + user: + description: Dev fixture user ID (optional; defaults to token user) + type: string + team: + description: Dev workspace ID (optional; useful for organization tokens) + type: string + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 8.0.x + 10.0.x + - name: Audit + env: + SLACK_API_AUDIT_TOKEN: ${{ inputs.live && secrets.SLACK_API_AUDIT_TOKEN || '' }} + AUDIT_LIVE: ${{ inputs.live }} + AUDIT_CHANNEL: ${{ inputs.channel }} + AUDIT_USER: ${{ inputs.user }} + AUDIT_TEAM: ${{ inputs.team }} + run: >- + dotnet run --project SlackNet.ApiAudit -- + --live "$AUDIT_LIVE" --channel "$AUDIT_CHANNEL" --user "$AUDIT_USER" --team "$AUDIT_TEAM" + - uses: actions/upload-artifact@v4 + if: always() + with: + name: api-audit + path: artifacts/api-audit + if-no-files-found: error diff --git a/SlackNet.ApiAudit/ApiInventory.cs b/SlackNet.ApiAudit/ApiInventory.cs new file mode 100644 index 0000000..8cb05e9 --- /dev/null +++ b/SlackNet.ApiAudit/ApiInventory.cs @@ -0,0 +1,78 @@ +using System.Reflection; +using SlackNet.WebApi; + +namespace SlackNet.ApiAudit; + +public record ApiMethod(string Name, IReadOnlySet Parameters, Type ResponseType) +{ + public IReadOnlyList ResponseTypes { get; init; } = [ResponseType]; +} + +public static class ApiInventory +{ + public static async Task> Read() + { + var client = DispatchProxy.Create(); + var recorder = (RecordingClient)client; + var methods = new List(); + object[] apis = [new UsersApi(client), new UserProfileApi(client), new ConversationsApi(client), + new ChatApi(client, Default.JsonSettings()), new ScheduledMessagesApi(client)]; + + foreach (var api in apis) + { + foreach (var method in api.GetType().GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + recorder.LastCall = null; + var task = (Task)method.Invoke(api, method.GetParameters().Select(DummyValue).ToArray()); + try + { + await task; + } + catch (RequestRecordedException) + { + // Stop wrappers before they try to unwrap an invented response. + } + methods.Add(recorder.LastCall ?? throw new InvalidOperationException($"No Slack request recorded for {method.Name}.")); + } + } + + return methods.GroupBy(m => m.Name).ToDictionary(g => g.Key, + g => new ApiMethod(g.Key, g.SelectMany(m => m.Parameters).ToHashSet(), g.First().ResponseType) + { ResponseTypes = g.Select(m => m.ResponseType).Distinct().ToArray() }); + } + + private static object DummyValue(ParameterInfo parameter) => DummyValue(parameter.ParameterType, parameter.Name); + + private static object DummyValue(Type type, string name) => + type == typeof(string) ? name == "contentType" ? "image/png" : "audit" + : type.IsArray ? Array.CreateInstance(type.GetElementType()!, 0) + : type == typeof(Stream) ? new MemoryStream() + : type == typeof(DateTime) ? DateTime.UnixEpoch + : type.IsValueType ? Activator.CreateInstance(type) + : type.GetConstructor(Type.EmptyTypes) != null ? Activator.CreateInstance(type) + : null; + + public class RecordingClient : DispatchProxy + { + public ApiMethod LastCall { get; set; } + + protected override object Invoke(MethodInfo targetMethod, object[] args) + { + var responseType = targetMethod.IsGenericMethod ? targetMethod.GetGenericArguments()[0] : typeof(object); + var parameters = ((Dictionary)args[1]).Keys.ToHashSet(); + foreach (var multipart in args.OfType()) + { + parameters.UnionWith(multipart.Select(part => part.Headers.ContentDisposition!.Name!.Trim('"'))); + multipart.Dispose(); + } + LastCall = new ApiMethod((string)args[0], parameters, responseType); + + return targetMethod.IsGenericMethod + ? typeof(Task).GetMethods().Single(m => m.Name == nameof(Task.FromException) && m.IsGenericMethod) + .MakeGenericMethod(responseType).Invoke(null, [new RequestRecordedException()]) + : Task.FromException(new RequestRecordedException()); + } + } + + private class RequestRecordedException : Exception; +} diff --git a/SlackNet.ApiAudit/AuditMarkdown.cs b/SlackNet.ApiAudit/AuditMarkdown.cs new file mode 100644 index 0000000..292a390 --- /dev/null +++ b/SlackNet.ApiAudit/AuditMarkdown.cs @@ -0,0 +1,28 @@ +using System.Text; + +namespace SlackNet.ApiAudit; + +public static class AuditMarkdown +{ + public static string Render(AuditReport report) + { + var text = new StringBuilder("# Slack API audit\n\n"); + text.AppendLine($"Generated {report.GeneratedAt:O}. Scope: users.*, conversations.*, chat.*."); + text.AppendLine("\nFindings are candidates, not confirmed API contracts. Live probes sample a single page; missing fields never imply removal. SDK and changelog snapshots are change signals for investigation, not parsed schemas.\n"); + text.AppendLine("## Findings\n\n| ID | Method | Finding | Path | Model | Evidence | Disposition |\n| --- | --- | --- | --- | --- | --- | --- |"); + foreach (var finding in report.Findings) + text.AppendLine($"| {finding.Id} | {finding.Method} | {finding.Kind} | {finding.Path} | {finding.Model} | {finding.Evidence}: {finding.Source} | {finding.Disposition}{(finding.Reason == null ? "" : $": {finding.Reason}")} |"); + if (report.Findings.Count == 0) + text.AppendLine("\nNo discrepancies found in the covered sources and scenarios."); + + text.AppendLine("\n## Coverage\n\n| Method | Evidence | Status | Detail |\n| --- | --- | --- | --- |"); + foreach (var observation in report.Observations) + text.AppendLine($"| {observation.Method} | {observation.Evidence} | {observation.Status} | {observation.Detail} |"); + text.AppendLine("\n## Sources\n\n| Source | Status | Change | SHA-256 |\n| --- | --- | --- | --- |"); + foreach (var source in report.Sources) + text.AppendLine($"| {source.Url} | {source.Status} | {source.Change} | {source.Sha256} |"); + + text.AppendLine("\n## Candidate PR handoff\n\nChoose one related group of review findings. Inspect cached docs and SDK types; corroborate undocumented behavior with dev-workspace probes. Use existing types; preserve public-interface compatibility. A parameter being accepted does not prove it has an effect. Include concise Unreleased notes and behavior tests where existing convention coverage is insufficient. The PR must list finding IDs, source hashes/links, live scenarios and unverified behavior. Reuse an existing PR with those IDs. Never merge automatically.\n"); + return text.ToString(); + } +} diff --git a/SlackNet.ApiAudit/AuditRunner.cs b/SlackNet.ApiAudit/AuditRunner.cs new file mode 100644 index 0000000..61cb215 --- /dev/null +++ b/SlackNet.ApiAudit/AuditRunner.cs @@ -0,0 +1,141 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SlackNet.ApiAudit; + +public record SourceCheck(string Url, string File, string Status, string Sha256 = null, string Change = null); +public record Observation(string Method, string Evidence, string Status, string Detail, IReadOnlyList Shapes); +public record Suppression(string Id, string Reason); +public record AuditReport(DateTime GeneratedAt, IReadOnlyList Sources, IReadOnlyList Observations, IReadOnlyList Findings); + +public class AuditRunner(HttpClient publicHttp, string output, bool offline) +{ + private readonly List _sources = []; + private readonly List _observations = []; + private readonly List _findings = []; + private static readonly string[] Families = ["users.", "conversations.", "chat."]; + + public async Task Run(string token, string channel, string user, string team, IReadOnlyList suppressions) + { + Directory.CreateDirectory(Path.Combine(output, "sources")); + var inventory = await ApiInventory.Read(); + var index = await Fetch("https://docs.slack.dev/reference/methods.json", "methods.json"); + var names = MethodNames(index); + + foreach (var name in names) + { + Console.WriteLine($"Checking {name}"); + await CheckMethod(name, inventory); + } + + await Fetch("https://docs.slack.dev/changelog/rss.xml", "changelog.xml"); + foreach (var file in new[] { "request/users.ts", "request/conversations.ts", "request/chat.ts", "request/common.ts", + "response/UsersInfoResponse.ts", "response/ConversationsInfoResponse.ts", "response/ConversationsHistoryResponse.ts" }) + await Fetch($"https://raw.githubusercontent.com/slackapi/node-slack-sdk/main/packages/web-api/src/types/{file}", + $"sdk-{file.Replace('/', '-')}"); + + if (token != null) + { + using var liveHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(45) }; + liveHttp.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + await new LiveProbes(liveHttp, inventory, _findings, _observations).Run(channel, user, team); + } + else + _observations.Add(new Observation("Live probes", "Live", "Not run", "No live run requested. Workspace behavior is unverified.", [])); + + var findings = _findings.DistinctBy(f => (f.Id, f.Evidence, f.Source)).OrderBy(f => f.Method).ThenBy(f => f.Path).ToArray(); + foreach (var finding in findings) + { + var suppression = suppressions.SingleOrDefault(s => s.Id == finding.Id); + if (suppression != null) + { + finding.Disposition = "Suppressed"; + finding.Reason = suppression.Reason; + } + } + + return new AuditReport(DateTime.UtcNow, _sources, _observations, findings); + } + + private string[] MethodNames(string index) + { + if (index == null) + return []; + + try + { + var names = JArray.Parse(index).Select(m => m.Value("name")) + .Where(n => n != null && Families.Any(n.StartsWith)).Order().ToArray(); + if (names.Length == 0) + throw new FormatException(); + return names; + } + catch (Exception e) when (e is JsonException or FormatException or InvalidOperationException) + { + _observations.Add(new Observation("Method index", "Documentation", "Failed", "Unrecognized method index; no method coverage established.", [])); + return []; + } + } + + private async Task CheckMethod(string name, IReadOnlyDictionary inventory) + { + var url = $"https://docs.slack.dev/reference/methods/{name}.md"; + var markdown = await Fetch(url, $"{name}.md"); + if (!inventory.TryGetValue(name, out var implemented)) + _findings.Add(Finding.Create("Missing method", name, "$", "", "Documentation", url)); + + if (markdown != null && implemented != null) + { + try + { + var documentation = SlackDocumentation.Parse(markdown); + foreach (var parameter in documentation.Parameters.Except(implemented.Parameters).Order()) + _findings.Add(Finding.Create("Missing parameter", name, parameter, "", "Documentation", url)); + + foreach (var example in documentation.Examples) + { + var audits = implemented.ResponseTypes.Select(type => + { + var audit = new ResponseAudit(); + audit.Inspect(example, type, name, "Documentation example", url); + return audit; + }).ToArray(); + var best = audits.OrderBy(a => a.Findings.Count).First(); + _findings.AddRange(best.Findings); + } + _observations.Add(new Observation(name, "Documentation example", documentation.Examples.Count == 0 ? "Not covered" : "Checked", + $"{documentation.Parameters.Count} documented parameters; {documentation.Examples.Count} success examples; {documentation.UnparseableExamples} unparseable examples.", [])); + } + catch (Exception e) when (e is FormatException or JsonException) + { + _observations.Add(new Observation(name, "Documentation", "Failed", "Unrecognized documentation format; review the cached source.", [])); + } + } + } + + private async Task Fetch(string url, string file) + { + var path = Path.Combine(output, "sources", file); + var previous = System.IO.File.Exists(path) ? await System.IO.File.ReadAllTextAsync(path) : null; + try + { + var content = offline + ? previous ?? throw new FileNotFoundException() + : await publicHttp.GetStringAsync(url); + if (!offline) + await System.IO.File.WriteAllTextAsync(path, content); + _sources.Add(new SourceCheck(url, $"sources/{file}", offline ? "Cached" : "Fetched", Hash(content), + previous == null ? "First seen" : previous == content ? "Unchanged" : "Changed")); + return content; + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException or FileNotFoundException) + { + _sources.Add(new SourceCheck(url, $"sources/{file}", "Failed")); + return null; + } + } + + private static string Hash(string content) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(content))).ToLowerInvariant(); +} diff --git a/SlackNet.ApiAudit/LiveProbes.cs b/SlackNet.ApiAudit/LiveProbes.cs new file mode 100644 index 0000000..ed4e12b --- /dev/null +++ b/SlackNet.ApiAudit/LiveProbes.cs @@ -0,0 +1,89 @@ +using System.Net; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SlackNet.ApiAudit; + +public class LiveProbes(HttpClient http, IReadOnlyDictionary inventory, + List findings, List observations) +{ + public async Task Run(string channel, string user, string team = null) + { + var auth = await Call("auth.test", []); + if (auth == null) + observations.Add(new Observation("Live probes", "Live", "Not covered", "Authentication failed; dependent probes were not run.", [])); + else + { + var userId = user ?? auth.Value("user_id"); + var teamId = team ?? auth.Value("team_id"); + var usersArguments = new Dictionary { ["limit"] = "20", ["include_locale"] = "true" }; + var conversationsArguments = new Dictionary { ["limit"] = "20", ["exclude_archived"] = "true" }; + if (!string.IsNullOrEmpty(teamId)) + { + usersArguments["team_id"] = teamId; + conversationsArguments["team_id"] = teamId; + } + await Probe("users.list", usersArguments); + if (userId != null) + await Probe("users.info", new() { ["user"] = userId, ["include_locale"] = "true" }); + await Probe("conversations.list", conversationsArguments); + + if (channel != null) + { + await Probe("conversations.info", new() { ["channel"] = channel, ["include_num_members"] = "true", ["include_locale"] = "true" }); + await Probe("conversations.history", new() { ["channel"] = channel, ["limit"] = "15", ["include_all_metadata"] = "true" }); + } + else + { + observations.Add(new Observation("conversations.info", "Live", "Not covered", "No fixture channel supplied.", [])); + observations.Add(new Observation("conversations.history", "Live", "Not covered", "No fixture channel supplied.", [])); + } + } + } + + private async Task Probe(string method, Dictionary arguments) + { + var response = await Call(method, arguments); + if (response != null) + { + var audit = new ResponseAudit(); + audit.Inspect(response, inventory[method].ResponseType, method, "Live", "Dev workspace"); + findings.AddRange(audit.Findings); + var count = (response["members"] as JArray ?? response["channels"] as JArray ?? response["messages"] as JArray)?.Count; + var more = response.Value("has_more") == true || !string.IsNullOrEmpty(response["response_metadata"]?.Value("next_cursor")); + observations.Add(new Observation(method, "Live", count == 0 ? "Partial" : "Checked", + $"One response sampled{(count == null ? "" : $"; {count} items")}{(more ? "; more pages available" : "")}. Values discarded; absent fields do not imply removal.", audit.Shapes)); + } + } + + private async Task Call(string method, Dictionary arguments) + { + var query = string.Join("&", arguments.Select(a => $"{Uri.EscapeDataString(a.Key)}={Uri.EscapeDataString(a.Value)}")); + try + { + using var response = await http.GetAsync($"https://slack.com/api/{method}?{query}"); + if (response.StatusCode == HttpStatusCode.TooManyRequests) + { + observations.Add(new Observation(method, "Live", "Not covered", "Rate limited; retry in a later run after Slack's Retry-After interval.", [])); + return null; + } + response.EnsureSuccessStatusCode(); + var json = JObject.Parse(await response.Content.ReadAsStringAsync()); + if (json.Value("ok") != true) + { + // Error names are restricted; never copy response values or content into reports. + var error = json.Value("error"); + var detail = error != null && Regex.IsMatch(error, "^[a-z_]+$") ? error : "Slack rejected the probe"; + observations.Add(new Observation(method, "Live", "Not covered", detail, [])); + return null; + } + return json; + } + catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) + { + observations.Add(new Observation(method, "Live", "Failed", "Transport or JSON failure; response content was discarded.", [])); + return null; + } + } +} diff --git a/SlackNet.ApiAudit/Program.cs b/SlackNet.ApiAudit/Program.cs new file mode 100644 index 0000000..7d6030b --- /dev/null +++ b/SlackNet.ApiAudit/Program.cs @@ -0,0 +1,34 @@ +using Newtonsoft.Json; +using SlackNet.ApiAudit; + +var options = args.Chunk(2).ToDictionary(pair => pair[0], pair => pair.Length == 2 ? pair[1] : throw new ArgumentException("Options require values.")); +var allowed = new[] { "--output", "--offline", "--live", "--token-file", "--channel", "--user", "--team", "--suppressions" }; +if (options.Keys.Except(allowed).Any()) + throw new ArgumentException($"Supported options: {string.Join(", ", allowed)}. Boolean options take true/false."); + +var output = Path.GetFullPath(Option("--output", "artifacts/api-audit")); +var offline = bool.Parse(Option("--offline", "false")); +var live = bool.Parse(Option("--live", "false")); +if (live && offline) + throw new ArgumentException("Offline replay cannot make live requests."); +var token = !live ? null : Option("--token-file") is { } tokenFile + ? (await File.ReadAllTextAsync(tokenFile)).Trim() + : Environment.GetEnvironmentVariable("SLACK_API_AUDIT_TOKEN"); +if (live && string.IsNullOrWhiteSpace(token)) + throw new ArgumentException("Live probes require SLACK_API_AUDIT_TOKEN or --token-file. Never put a token in command-line arguments."); +var suppressionsFile = Option("--suppressions", "docs/api-audit-suppressions.json"); +var suppressions = JsonConvert.DeserializeObject(await File.ReadAllTextAsync(suppressionsFile)) + ?? throw new FormatException("Suppressions must be an array."); +if (suppressions.Any(s => string.IsNullOrWhiteSpace(s.Id) || string.IsNullOrWhiteSpace(s.Reason)) || suppressions.DistinctBy(s => s.Id).Count() != suppressions.Length) + throw new FormatException("Each suppression needs a unique finding ID and a reason."); + +using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(45) }; +http.DefaultRequestHeaders.UserAgent.ParseAdd("SlackNet-ApiAudit/1.0"); +var report = await new AuditRunner(http, output, offline).Run(token, Option("--channel"), Option("--user"), Option("--team"), suppressions); +await File.WriteAllTextAsync(Path.Combine(output, "report.json"), JsonConvert.SerializeObject(report, Formatting.Indented)); +await File.WriteAllTextAsync(Path.Combine(output, "report.md"), AuditMarkdown.Render(report)); +Console.WriteLine($"{report.Findings.Count(f => f.Disposition == "Review")} findings; report: {Path.Combine(output, "report.md")}"); +Console.WriteLine($"Live coverage: {report.Observations.Count(o => o.Evidence == "Live" && o.Status == "Checked")} checked; {report.Observations.Count(o => o.Evidence == "Live" && o.Status != "Checked")} incomplete."); +return report.Sources.Any(s => s.Status == "Failed") || report.Observations.Any(o => o.Status == "Failed") ? 1 : 0; + +string Option(string name, string fallback = null) => options.GetValueOrDefault(name) is { Length: > 0 } value ? value : fallback; diff --git a/SlackNet.ApiAudit/ResponseAudit.cs b/SlackNet.ApiAudit/ResponseAudit.cs new file mode 100644 index 0000000..aa4d7d0 --- /dev/null +++ b/SlackNet.ApiAudit/ResponseAudit.cs @@ -0,0 +1,111 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using Newtonsoft.Json.Serialization; + +namespace SlackNet.ApiAudit; + +public record Finding(string Id, string Kind, string Method, string Path, string Model, string Evidence, string Source) +{ + public string Disposition { get; set; } = "Review"; + public string Reason { get; set; } + + public static Finding Create(string kind, string method, string path, string model, string evidence, string source) => + new(Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{kind}|{method}|{path}|{model}")))[..16].ToLowerInvariant(), + kind, method, path, model, evidence, source); +} + +public record Shape(string Path, string Kind); + +public class ResponseAudit +{ + private readonly JsonSerializer _serializer = JsonSerializer.Create(Default.JsonSettings().SerializerSettings); + private readonly List _findings = []; + private readonly HashSet _shapes = []; + private static readonly HashSet EnvelopeProperties = ["ok", "error", "warning", "warnings", "needed", "provided"]; + + public IReadOnlyList Findings => _findings.DistinctBy(f => f.Id).ToArray(); + public IReadOnlyList Shapes => _shapes.OrderBy(s => s.Path).ThenBy(s => s.Kind).ToArray(); + + public void Inspect(JToken json, Type type, string method, string evidence, string source, string path = "$") + { + _shapes.Add(new Shape(path, json.Type.ToString())); + if (json.Type == JTokenType.Null || type == typeof(object) || typeof(JToken).IsAssignableFrom(type)) + return; + + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + if (underlyingType.IsEnum) + { + var known = Enum.GetValues(underlyingType).Cast().Select(e => JToken.FromObject(e, _serializer)); + if (!known.Any(e => JToken.DeepEquals(e, json))) + AddFinding("Unknown enum value", type); + } + else + { + object value = null; + try + { + value = json.ToObject(type, _serializer); + } + catch (Exception e) when (e is JsonException or ArgumentException or FormatException or InvalidOperationException or OverflowException) + { + AddFinding("Incompatible value", type); + } + + var contract = _serializer.ContractResolver.ResolveContract(value?.GetType() ?? type); + switch (json, contract) + { + case (JObject obj, JsonObjectContract objectContract): + foreach (var property in obj.Properties()) + { + var member = objectContract.Properties.GetClosestMatchProperty(property.Name); + if (path == "$" && EnvelopeProperties.Contains(property.Name)) + { + // SlackApiClient consumes these before returning the typed result. + } + else if (objectContract.UnderlyingType == typeof(Icons) && Regex.IsMatch(property.Name, @"^image_\d+$")) + _shapes.Add(new Shape($"{path}.image_*", property.Value.Type.ToString())); + else if (member == null || member.Ignored) + { + _shapes.Add(new Shape($"{path}.{property.Name}", property.Value.Type.ToString())); + _findings.Add(Finding.Create("Unmodeled property", method, $"{path}.{property.Name}", + objectContract.UnderlyingType.FullName, evidence, source)); + } + else if (member.Converter != null && property.Value is JArray { Count: 0 }) + { + _shapes.Add(new Shape($"{path}.{property.Name}", "Array")); + try + { + using var reader = property.Value.CreateReader(); + reader.Read(); + member.Converter.ReadJson(reader, member.PropertyType, null, _serializer); + } + catch (Exception e) when (e is JsonException or ArgumentException or FormatException or InvalidOperationException) + { + _findings.Add(Finding.Create("Incompatible value", method, $"{path}.{property.Name}", member.PropertyType.FullName, evidence, source)); + } + } + else + Inspect(property.Value, member.PropertyType, method, evidence, source, $"{path}.{property.Name}"); + } + break; + case (JArray array, JsonArrayContract arrayContract): + foreach (var item in array) + Inspect(item, arrayContract.CollectionItemType ?? typeof(object), method, evidence, source, $"{path}[]"); + break; + case (JObject obj, JsonDictionaryContract dictionaryContract): + foreach (var property in obj.Properties()) + Inspect(property.Value, dictionaryContract.DictionaryValueType ?? typeof(object), method, evidence, source, $"{path}.*"); + break; + case (JObject or JArray, _): + AddFinding("Incompatible shape", type); + break; + } + } + + void AddFinding(string kind, Type model) => + _findings.Add(Finding.Create(kind, method, path, model.FullName, evidence, source)); + } +} diff --git a/SlackNet.ApiAudit/SlackDocumentation.cs b/SlackNet.ApiAudit/SlackDocumentation.cs new file mode 100644 index 0000000..d069552 --- /dev/null +++ b/SlackNet.ApiAudit/SlackDocumentation.cs @@ -0,0 +1,51 @@ +using System.Text.RegularExpressions; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace SlackNet.ApiAudit; + +public record MethodDocumentation(IReadOnlySet Parameters, IReadOnlyList Examples, int UnparseableExamples); + +public static class SlackDocumentation +{ + public static MethodDocumentation Parse(string markdown) + { + var arguments = Section(markdown, "Arguments"); + var parameters = Regex.Matches(arguments, @"^\*\*`(?[a-zA-Z0-9_]+)`\*\*", RegexOptions.Multiline) + .Select(m => m.Groups["name"].Value) + .Where(n => n != "token") + .ToHashSet(); + if (parameters.Count == 0 && !arguments.Contains("**`token`**")) + throw new FormatException("No recognizable argument definitions."); + + var response = Section(markdown, "Response", required: false); + var examples = new List(); + var unparseable = 0; + foreach (Match code in Regex.Matches(response, @"^```(?:json)?\s*\r?\n(?[\s\S]*?)^```", RegexOptions.Multiline)) + { + if (code.Groups["json"].Value.TrimStart().StartsWith('{')) + { + try + { + var example = JObject.Parse(code.Groups["json"].Value); + if (example.Value("ok") == true) + examples.Add(example); + } + catch (JsonException) + { + unparseable++; + } + } + } + + return new MethodDocumentation(parameters, examples, unparseable); + } + + private static string Section(string markdown, string heading, bool required = true) + { + var match = Regex.Match(markdown, $@"^## {heading}(?:[ \t][^\r\n]*)?\r?\n(?[\s\S]*?)(?=^## |\z)", RegexOptions.Multiline); + if (required && !match.Success) + throw new FormatException($"Missing {heading} section."); + return match.Groups["body"].Value; + } +} diff --git a/SlackNet.ApiAudit/SlackNet.ApiAudit.csproj b/SlackNet.ApiAudit/SlackNet.ApiAudit.csproj new file mode 100644 index 0000000..7751da3 --- /dev/null +++ b/SlackNet.ApiAudit/SlackNet.ApiAudit.csproj @@ -0,0 +1,11 @@ + + + Exe + net8.0 + enable + false + + + + + diff --git a/SlackNet.Tests/ApiAuditTests.cs b/SlackNet.Tests/ApiAuditTests.cs new file mode 100644 index 0000000..5e6040a --- /dev/null +++ b/SlackNet.Tests/ApiAuditTests.cs @@ -0,0 +1,248 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using EasyAssertions; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using SlackNet.ApiAudit; +using SlackNet.Blocks; +using SlackNet.Events; + +namespace SlackNet.Tests; + +public class ApiAuditTests +{ + [Test] + public async Task ApiInventory_Read_RecordsWireNamesAndCombinesOverloads() + { + var result = await ApiInventory.Read(); + + result["users.info"].Parameters.ShouldMatch(["user", "include_locale"]); + result["chat.unfurl"].Parameters.Contains("source").ShouldBe(true); + result["chat.unfurl"].Parameters.Contains("unfurls").ShouldBe(true); + result["chat.postMessage"].Parameters.Contains("metadata").ShouldBe(true); + result["users.setPhoto"].Parameters.Contains("image").ShouldBe(true); + result.ContainsKey("users.profile.get").ShouldBe(true); + result.ContainsKey("chat.scheduledMessages.list").ShouldBe(true); + result["conversations.open"].ResponseTypes.Count.ShouldBe(2); + result["users.info"].ResponseType.GetProperty("User").ShouldNotBeNull(); + } + + [Test] + public void ResponseAudit_Inspect_NestedAndExtensionDataPropertiesAreReported() + { + var sut = new ResponseAudit(); + + sut.Inspect(JObject.Parse(""" + {"type":"message","future_event_field":true,"user":"U-private","blocks":[ + {"type":"section","text":{"type":"plain_text","text":"Private message"},"future_block_field":42} + ]} + """), typeof(Event), "event", "Live", "Dev workspace"); + var result = sut.Findings; + + result.Select(f => f.Path).ShouldMatch(["$.future_event_field", "$.blocks[].future_block_field"]); + result.Single(f => f.Path == "$.blocks[].future_block_field").Model.ShouldBe(typeof(SectionBlock).FullName); + string.Join(" ", sut.Shapes).ShouldNotContain("Private message"); + string.Join(" ", result).ShouldNotContain("U-private"); + } + + [Test] + public void ResponseAudit_Inspect_UnknownEnumIsReportedWithoutItsValue() + { + var sut = new ResponseAudit(); + + sut.Inspect(JObject.Parse("""{"type":"plain_text_input","action_id":"secret","focus_on_load":true,"dispatch_action_config":{"trigger_actions_on":["new_secret_trigger"]}}"""), + typeof(BlockElement), "blocks", "Live", "Dev workspace"); + + sut.Findings.Any(f => f.Kind == "Unknown enum value").ShouldBe(true); + string.Join(" ", sut.Findings).ShouldNotContain("new_secret_trigger"); + } + + [Test] + public void ResponseAudit_Inspect_DictionaryKeysAndMissingPropertiesAreNotFindings() + { + var sut = new ResponseAudit(); + + sut.Inspect(JObject.Parse("""{"private_field_id":{"value":"private profile content"}}"""), + typeof(Dictionary), "profile", "Live", "Dev workspace"); + + sut.Findings.ShouldBeEmpty(); + sut.Shapes.Any(s => s.Path == "$.*.value").ShouldBe(true); + string.Join(" ", sut.Shapes).ShouldNotContain("private_field_id"); + } + + [Test] + public void ResponseAudit_Inspect_RepeatedArrayFieldsHaveStableFindingIds() + { + var sut = new ResponseAudit(); + + sut.Inspect(JArray.Parse("""[{"new_field":1},{"new_field":2}]"""), typeof(List), "users.list", "Live", "Dev workspace"); + var result = sut.Findings; + + result.Count.ShouldBe(1); + result.Single().Id.ShouldBe(Finding.Create("Unmodeled property", "users.list", "$[].new_field", typeof(User).FullName, "Documentation", "another source").Id); + } + + [Test] + public void ResponseAudit_Inspect_IntentionalDynamicFieldsAndArrayQuirksAreCovered() + { + var sut = new ResponseAudit(); + + sut.Inspect(JObject.Parse("""{"fields":[]}"""), typeof(UserProfile), "profile", "Live", "Dev workspace"); + sut.Inspect(JObject.Parse("""{"image_44":"private URL","image_default":true}"""), typeof(Icons), "team", "Live", "Dev workspace"); + + sut.Findings.ShouldBeEmpty(); + string.Join(" ", sut.Shapes).ShouldNotContain("private URL"); + } + + [Test] + public void SlackDocumentation_Parse_ArgumentsAndSuccessExamplesExcludeOtherSections() + { + var result = SlackDocumentation.Parse(""" + ## Arguments {#arguments} + ### Required arguments + **`token`**`string`Required + **`channel`**`string`Required + ### Optional arguments + **`include_locale`**`boolean`Optional + ## Usage info {#usage-info} + **`not_an_argument`** + ## Response {#response} + ``` + {"ok":true,"channel":{"id":"C123"}} + ``` + ```json + {"ok":false,"error":"missing_scope"} + ``` + ## Errors {#errors} + **`another_non_argument`** + """); + + result.Parameters.ShouldMatch(["channel", "include_locale"]); + result.Examples.Count.ShouldBe(1); + } + + [TestCase("# A redesigned docs page")] + [TestCase("## Arguments\nA redesigned argument table")] + public void SlackDocumentation_Parse_UnrecognizedFormatFailsExplicitly(string markdown) => + Assert.Throws(() => SlackDocumentation.Parse(markdown)); + + [Test] + public void SlackDocumentation_Parse_ElidedExampleDoesNotDiscardArguments() + { + var result = SlackDocumentation.Parse(""" + ## Arguments + **`channel`**`string`Required + ## Response + ``` + {"ok":true,"icon":{...}} + ``` + """); + + result.Parameters.ShouldMatch(["channel"]); + result.UnparseableExamples.ShouldBe(1); + } + + [Test] + public async Task LiveProbes_Run_ScopeFailuresAndMissingFixtureRemainUnverified() + { + using var http = new HttpClient(new ProbeHandler()); + var findings = new List(); + var observations = new List(); + var sut = new LiveProbes(http, await ApiInventory.Read(), findings, observations); + + await sut.Run(null, null); + + findings.ShouldBeEmpty(); + observations.Count(o => o.Status == "Not covered").ShouldBe(5); + observations.Any(o => o.Method == "conversations.history" && o.Detail == "No fixture channel supplied.").ShouldBe(true); + } + + [Test] + public async Task LiveProbes_Run_RateLimitIsReportedWithoutRetryingOrTreatingItAsSchemaDrift() + { + using var http = new HttpClient(new ProbeHandler(HttpStatusCode.TooManyRequests)); + var findings = new List(); + var observations = new List(); + var sut = new LiveProbes(http, await ApiInventory.Read(), findings, observations); + + await sut.Run("C-fixture", null); + + findings.ShouldBeEmpty(); + observations.Single(o => o.Method == "auth.test").Detail.ShouldContain("Rate limited"); + observations.Count.ShouldBe(2); + } + + [Test] + public async Task AuditRunner_Run_FullResponseExampleUsesAvailableFullResponseOverload() + { + using var http = new HttpClient(new SourceHandler("""[{"name":"conversations.open"}]""", """ + ## Arguments + **`channel`**`string`Optional + **`users`**`string`Optional + **`return_im`**`boolean`Optional + ## Response + ``` + {"ok":true,"already_open":true,"no_op":false,"channel":{"id":"C123","is_im":true}} + ``` + """)); + var output = Directory.CreateTempSubdirectory("SlackNet-ApiAuditTests-"); + try + { + var sut = new AuditRunner(http, output.FullName, false); + + var result = await sut.Run(null, null, null, null, []); + + result.Findings.ShouldBeEmpty(); + result.Observations.Single(o => o.Method == "conversations.open").Status.ShouldBe("Checked"); + } + finally + { + output.Delete(true); + } + } + + [Test] + public async Task AuditRunner_Run_BrokenIndexCannotEstablishMethodCoverage() + { + using var http = new HttpClient(new SourceHandler("[]", "")); + var output = Directory.CreateTempSubdirectory("SlackNet-ApiAuditTests-"); + try + { + var sut = new AuditRunner(http, output.FullName, false); + + var result = await sut.Run(null, null, null, null, []); + + result.Observations.Single(o => o.Method == "Method index").Status.ShouldBe("Failed"); + } + finally + { + output.Delete(true); + } + } + + private class SourceHandler(string index, string markdown) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(request.RequestUri.AbsolutePath.EndsWith("methods.json") ? index : markdown) + }); + } + + private class ProbeHandler(HttpStatusCode status = HttpStatusCode.OK) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(status) + { + Content = new StringContent(request.RequestUri.AbsolutePath.EndsWith("auth.test") + ? """{"ok":true,"user_id":"U-private"}""" + : """{"ok":false,"error":"missing_scope","needed":"private scope details"}""") + }); + } +} diff --git a/SlackNet.Tests/SlackNet.Tests.csproj b/SlackNet.Tests/SlackNet.Tests.csproj index 5240348..9bbbe03 100644 --- a/SlackNet.Tests/SlackNet.Tests.csproj +++ b/SlackNet.Tests/SlackNet.Tests.csproj @@ -17,6 +17,7 @@ + diff --git a/SlackNet.sln b/SlackNet.sln index a2d2522..6827ad3 100644 --- a/SlackNet.sln +++ b/SlackNet.sln @@ -51,6 +51,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "scripts", "scripts", "{865F scripts\test.cs = scripts\test.cs EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SlackNet.ApiAudit", "SlackNet.ApiAudit\SlackNet.ApiAudit.csproj", "{A3FEAE40-E036-4AD3-8E0D-E7C9D01D4DD7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -113,6 +115,10 @@ Global {0EDA6AF2-6725-47C8-BA9E-06EB714778AC}.Debug|Any CPU.Build.0 = Debug|Any CPU {0EDA6AF2-6725-47C8-BA9E-06EB714778AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {0EDA6AF2-6725-47C8-BA9E-06EB714778AC}.Release|Any CPU.Build.0 = Release|Any CPU + {A3FEAE40-E036-4AD3-8E0D-E7C9D01D4DD7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A3FEAE40-E036-4AD3-8E0D-E7C9D01D4DD7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A3FEAE40-E036-4AD3-8E0D-E7C9D01D4DD7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A3FEAE40-E036-4AD3-8E0D-E7C9D01D4DD7}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/api-audit-suppressions.json b/docs/api-audit-suppressions.json new file mode 100644 index 0000000..7901c11 --- /dev/null +++ b/docs/api-audit-suppressions.json @@ -0,0 +1,6 @@ +[ + { + "Id": "536284a7762fbd14", + "Reason": "users.setActive is deprecated; intentionally excluded by MissingApis." + } +] diff --git a/docs/api-audit.md b/docs/api-audit.md new file mode 100644 index 0000000..d46116c --- /dev/null +++ b/docs/api-audit.md @@ -0,0 +1,39 @@ +# Slack API audit + +Run from the repo root with the .NET 10 SDK and .NET 8 runtime installed: + +```sh +dotnet run --project SlackNet.ApiAudit +``` + +The pilot compares `users.*`, `conversations.*` and `chat.*` with Slack's method index and Markdown references. It records the real request argument names, including multipart fields, by invoking SlackNet's wrappers against an in-memory client; no Slack methods execute during discovery. Documented response examples are inspected using SlackNet's JSON contracts and polymorphic deserialization. When overloads expose different response shapes, each example uses the response contract with the fewest discrepancies. Elided or invalid examples remain explicitly unverified without discarding parameter coverage. + +Results go to ignored `artifacts/api-audit/report.md` and `report.json`. Public source snapshots include SHA-256 hashes and changes since the previous run in that output directory. The first run reports existing gaps. Changelog and official Node SDK snapshots are investigation signals; the pilot does not parse them into schemas. Compare their changes before proposing a fix. + +To replay downloaded sources without network access: + +```sh +dotnet run --project SlackNet.ApiAudit -- --offline true +``` + +## Live probes + +Provide a dedicated dev-workspace bot or user token through the `SLACK_API_AUDIT_TOKEN` environment variable or `--token-file /private/path`. Never pass the token itself in arguments or commit it. The tool does not load application settings or log HTTP payloads. + +```sh +dotnet run --project SlackNet.ApiAudit -- --live true --channel C_FIXTURE --user U_FIXTURE +``` + +The user is optional and defaults to the token's user. The channel is optional; omitting it leaves channel info and history explicitly unverified. An empty channel is not automatically replaced by another workspace channel. List probes use the authenticated workspace ID, or `--team T_FIXTURE` for organization tokens that need an explicit workspace. + +Read-only probes: `auth.test`, `users.list`, `users.info`, `conversations.list`, `conversations.info`, `conversations.history`. Use `users:read`, `channels:read`, and `channels:history` for public-channel coverage; private channels require the corresponding scopes and membership. Token type, workspace features and permissions can restrict coverage. Lists sample one page of 20 users/channels; history samples up to 15 messages. No messages or fixtures are created or modified. No pagination or rate-limit retries occur in this pilot. Rate limits, scope errors, empty samples and missing fixtures require further investigation, not a clean bill of health. + +Live payloads exist only in memory. Saved evidence contains field paths and JSON kinds, not scalar values, IDs, message text, tokens or dynamic dictionary keys. Unknown fields captured in extension data remain findings. Missing fields never cause property removals. Nulls and empty arrays provide no evidence about their possible values or element types. Undocumented parameter discovery and verification of parameter effects require additional targeted scenarios. + +## Review and PRs + +Findings have IDs derived from their kind, method, path and model. To acknowledge an intentional omission or rejected finding, add `{"Id":"finding-id","Reason":"specific reason or existing PR URL"}` to `docs/api-audit-suppressions.json`. Suppressed findings remain visible. Sources or parser failures make the command exit nonzero; findings alone do not. Coverage gaps remain in the report. + +For each run, review source changes and findings. Group related findings into one candidate PR, reusing an existing PR containing the IDs. Include the finding IDs, source links/hashes, live observations and unverified scenarios. Observed fields do not establish requiredness, all possible values or availability in other workspaces. Request acceptance alone does not prove a new parameter works. Preserve customer-implemented interfaces; flag necessary breaks explicitly. Match Slack XML docs and add concise Unreleased entries. Test new behavior; conventional snake_case properties inherit existing tests. + +The **API audit** GitHub Actions workflow is manually triggered and uploads the report and public sources. Live runs use the repository secret `SLACK_API_AUDIT_TOKEN`. It has read-only repository permissions and does not create PRs or run an agent. Start scheduled detection and automatic draft PR creation after reviewing the pilot's findings and coverage. diff --git a/readme.md b/readme.md index a853cc0..877c805 100644 --- a/readme.md +++ b/readme.md @@ -119,5 +119,7 @@ Please also [raise an issue](https://github.com/soxtoby/SlackNet/issues) for any ## Contributing Contributions are welcome. Currently, changes must be made on a feature branch, otherwise the CI build will fail. +The [API audit](docs/api-audit.md) checks Slack's current method documentation against SlackNet and can probe a dev workspace for response fields the library does not model. + Slack's API is large and changes often, and while their documentation is very good, it's not always 100% complete or accurate, which can easily lead to bugs or missing features in SlackNet. Raising issues or submitting pull requests for these sorts of discrepancies is highly appreciated, as realistically, I have to rely on the documentation unless I happen to be using a particular API myself. From 6325b4718cbac28e21914c8409e7ba8a8cb405d9 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 1 Nov 2026 18:01:04 +1000 Subject: [PATCH 2/3] Expose conversation tab metadata --- CHANGELOG.md | 3 +++ SlackNet/Objects/Conversation.cs | 7 ++++++- SlackNet/Objects/ConversationProperties.cs | 18 ++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 SlackNet/Objects/ConversationProperties.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index c382dea..94b11aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Added +- `Conversation.Properties` and `ConversationProperties.Tabs` expose conversation tab metadata. + ## v0.20.0 ### Breaking diff --git a/SlackNet/Objects/Conversation.cs b/SlackNet/Objects/Conversation.cs index 7b4b253..5da7965 100644 --- a/SlackNet/Objects/Conversation.cs +++ b/SlackNet/Objects/Conversation.cs @@ -170,6 +170,11 @@ public class Conversation public Purpose Purpose { get; set; } + /// + /// Additional conversation properties, including . + /// + public ConversationProperties Properties { get; set; } + public string[] PreviousNames { get; set; } public int Priority { get; set; } @@ -201,4 +206,4 @@ public class Conversation public override string ToString() => IsIm ? Link.User(User).ToString() : Link.Conversation(Id).ToString(); -} \ No newline at end of file +} diff --git a/SlackNet/Objects/ConversationProperties.cs b/SlackNet/Objects/ConversationProperties.cs new file mode 100644 index 0000000..8edb206 --- /dev/null +++ b/SlackNet/Objects/ConversationProperties.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace SlackNet; + +public class ConversationProperties +{ + /// + /// Tabs in the conversation. Slack omits this property when there are no tabs. + /// + public IList Tabs { get; set; } = []; +} + +public class ConversationTab +{ + public string Id { get; set; } + public string Label { get; set; } + public string Type { get; set; } +} From a80998e816769df3ba8783e74ab5822d44d6e717 Mon Sep 17 00:00:00 2001 From: Simon Oxtoby Date: Sun, 1 Nov 2026 21:35:56 +1000 Subject: [PATCH 3/3] Refine live API audit coverage Use the fixture workspace for list probes, preserving explicit overrides. Suppress the legacy chat.delete.as_user parameter. --- SlackNet.ApiAudit/LiveProbes.cs | 10 ++++----- SlackNet.Tests/ApiAuditTests.cs | 35 ++++++++++++++++++++++++++++++++ docs/api-audit-suppressions.json | 4 ++++ docs/api-audit.md | 2 +- 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/SlackNet.ApiAudit/LiveProbes.cs b/SlackNet.ApiAudit/LiveProbes.cs index ed4e12b..a7abb46 100644 --- a/SlackNet.ApiAudit/LiveProbes.cs +++ b/SlackNet.ApiAudit/LiveProbes.cs @@ -15,8 +15,10 @@ public async Task Run(string channel, string user, string team = null) observations.Add(new Observation("Live probes", "Live", "Not covered", "Authentication failed; dependent probes were not run.", [])); else { + var channelResponse = channel == null ? null + : await Probe("conversations.info", new() { ["channel"] = channel, ["include_num_members"] = "true", ["include_locale"] = "true" }); var userId = user ?? auth.Value("user_id"); - var teamId = team ?? auth.Value("team_id"); + var teamId = team ?? channelResponse?["channel"]?.Value("context_team_id") ?? auth.Value("team_id"); var usersArguments = new Dictionary { ["limit"] = "20", ["include_locale"] = "true" }; var conversationsArguments = new Dictionary { ["limit"] = "20", ["exclude_archived"] = "true" }; if (!string.IsNullOrEmpty(teamId)) @@ -30,10 +32,7 @@ public async Task Run(string channel, string user, string team = null) await Probe("conversations.list", conversationsArguments); if (channel != null) - { - await Probe("conversations.info", new() { ["channel"] = channel, ["include_num_members"] = "true", ["include_locale"] = "true" }); await Probe("conversations.history", new() { ["channel"] = channel, ["limit"] = "15", ["include_all_metadata"] = "true" }); - } else { observations.Add(new Observation("conversations.info", "Live", "Not covered", "No fixture channel supplied.", [])); @@ -42,7 +41,7 @@ public async Task Run(string channel, string user, string team = null) } } - private async Task Probe(string method, Dictionary arguments) + private async Task Probe(string method, Dictionary arguments) { var response = await Call(method, arguments); if (response != null) @@ -55,6 +54,7 @@ private async Task Probe(string method, Dictionary arguments) observations.Add(new Observation(method, "Live", count == 0 ? "Partial" : "Checked", $"One response sampled{(count == null ? "" : $"; {count} items")}{(more ? "; more pages available" : "")}. Values discarded; absent fields do not imply removal.", audit.Shapes)); } + return response; } private async Task Call(string method, Dictionary arguments) diff --git a/SlackNet.Tests/ApiAuditTests.cs b/SlackNet.Tests/ApiAuditTests.cs index 5e6040a..569c134 100644 --- a/SlackNet.Tests/ApiAuditTests.cs +++ b/SlackNet.Tests/ApiAuditTests.cs @@ -178,6 +178,20 @@ public async Task LiveProbes_Run_RateLimitIsReportedWithoutRetryingOrTreatingItA observations.Count.ShouldBe(2); } + [TestCase(null, "T-fixture")] + [TestCase("T-override", "T-override")] + public async Task LiveProbes_Run_ListsUseFixtureWorkspaceUnlessExplicitlyOverridden(string team, string allowedTeam) + { + using var http = new HttpClient(new WorkspaceProbeHandler(allowedTeam)); + var observations = new List(); + var sut = new LiveProbes(http, await ApiInventory.Read(), [], observations); + + await sut.Run("C-fixture", null, team); + + observations.Single(o => o.Method == "users.list").Status.ShouldBe("Checked"); + observations.Single(o => o.Method == "conversations.list").Status.ShouldBe("Checked"); + } + [Test] public async Task AuditRunner_Run_FullResponseExampleUsesAvailableFullResponseOverload() { @@ -226,6 +240,27 @@ public async Task AuditRunner_Run_BrokenIndexCannotEstablishMethodCoverage() } } + private class WorkspaceProbeHandler(string allowedTeam) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var method = request.RequestUri.AbsolutePath.Split('/').Last(); + var response = method switch + { + "auth.test" => """{"ok":true,"user_id":"U-private","team_id":"T-default"}""", + "conversations.info" => """{"ok":true,"channel":{"id":"C-fixture","context_team_id":"T-fixture"}}""", + "users.list" or "conversations.list" when !request.RequestUri.Query.Contains($"team_id={allowedTeam}") => + """{"ok":false,"error":"team_access_not_granted"}""", + "users.list" => """{"ok":true,"members":[{"id":"U-private"}]}""", + "conversations.list" => """{"ok":true,"channels":[{"id":"C-fixture"}]}""", + "users.info" => """{"ok":true,"user":{"id":"U-private"}}""", + "conversations.history" => """{"ok":true,"messages":[{"type":"message","text":"private"}]}""", + _ => throw new InvalidOperationException($"Unexpected probe: {method}") + }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response) }); + } + } + private class SourceHandler(string index, string markdown) : HttpMessageHandler { protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => diff --git a/docs/api-audit-suppressions.json b/docs/api-audit-suppressions.json index 7901c11..07a6391 100644 --- a/docs/api-audit-suppressions.json +++ b/docs/api-audit-suppressions.json @@ -2,5 +2,9 @@ { "Id": "536284a7762fbd14", "Reason": "users.setActive is deprecated; intentionally excluded by MissingApis." + }, + { + "Id": "3c02e2c2c6c8e758", + "Reason": "chat.delete.as_user is a legacy parameter; intentionally not implemented." } ] diff --git a/docs/api-audit.md b/docs/api-audit.md index d46116c..5066fbe 100644 --- a/docs/api-audit.md +++ b/docs/api-audit.md @@ -24,7 +24,7 @@ Provide a dedicated dev-workspace bot or user token through the `SLACK_API_AUDIT dotnet run --project SlackNet.ApiAudit -- --live true --channel C_FIXTURE --user U_FIXTURE ``` -The user is optional and defaults to the token's user. The channel is optional; omitting it leaves channel info and history explicitly unverified. An empty channel is not automatically replaced by another workspace channel. List probes use the authenticated workspace ID, or `--team T_FIXTURE` for organization tokens that need an explicit workspace. +The user is optional and defaults to the token's user. The channel is optional; omitting it leaves channel info and history explicitly unverified. An empty channel is not automatically replaced by another workspace channel. List probes use the fixture channel's context workspace when available, falling back to the authenticated workspace. `--team T_FIXTURE` explicitly overrides either choice for organization tokens. Read-only probes: `auth.test`, `users.list`, `users.info`, `conversations.list`, `conversations.info`, `conversations.history`. Use `users:read`, `channels:read`, and `channels:history` for public-channel coverage; private channels require the corresponding scopes and membership. Token type, workspace features and permissions can restrict coverage. Lists sample one page of 20 users/channels; history samples up to 15 messages. No messages or fixtures are created or modified. No pagination or rate-limit retries occur in this pilot. Rate limits, scope errors, empty samples and missing fixtures require further investigation, not a clean bill of health.