Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .github/workflows/api-audit.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

### Added
- `Conversation.Properties` and `ConversationProperties.Tabs` expose conversation tab metadata.

## v0.20.0

### Breaking
Expand Down
78 changes: 78 additions & 0 deletions SlackNet.ApiAudit/ApiInventory.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
using System.Reflection;
using SlackNet.WebApi;

namespace SlackNet.ApiAudit;

public record ApiMethod(string Name, IReadOnlySet<string> Parameters, Type ResponseType)
{
public IReadOnlyList<Type> ResponseTypes { get; init; } = [ResponseType];
}

public static class ApiInventory
{
public static async Task<IReadOnlyDictionary<string, ApiMethod>> Read()
{
var client = DispatchProxy.Create<ISlackApiClient, RecordingClient>();
var recorder = (RecordingClient)client;
var methods = new List<ApiMethod>();
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<string, object>)args[1]).Keys.ToHashSet();
foreach (var multipart in args.OfType<MultipartFormDataContent>())
{
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;
}
28 changes: 28 additions & 0 deletions SlackNet.ApiAudit/AuditMarkdown.cs
Original file line number Diff line number Diff line change
@@ -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();
}
}
141 changes: 141 additions & 0 deletions SlackNet.ApiAudit/AuditRunner.cs
Original file line number Diff line number Diff line change
@@ -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<Shape> Shapes);
public record Suppression(string Id, string Reason);
public record AuditReport(DateTime GeneratedAt, IReadOnlyList<SourceCheck> Sources, IReadOnlyList<Observation> Observations, IReadOnlyList<Finding> Findings);

public class AuditRunner(HttpClient publicHttp, string output, bool offline)
{
private readonly List<SourceCheck> _sources = [];
private readonly List<Observation> _observations = [];
private readonly List<Finding> _findings = [];
private static readonly string[] Families = ["users.", "conversations.", "chat."];

public async Task<AuditReport> Run(string token, string channel, string user, string team, IReadOnlyList<Suppression> 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<string>("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<string, ApiMethod> 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<string> 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();
}
89 changes: 89 additions & 0 deletions SlackNet.ApiAudit/LiveProbes.cs
Original file line number Diff line number Diff line change
@@ -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<string, ApiMethod> inventory,
List<Finding> findings, List<Observation> 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 channelResponse = channel == null ? null
: await Probe("conversations.info", new() { ["channel"] = channel, ["include_num_members"] = "true", ["include_locale"] = "true" });
var userId = user ?? auth.Value<string>("user_id");
var teamId = team ?? channelResponse?["channel"]?.Value<string>("context_team_id") ?? auth.Value<string>("team_id");
var usersArguments = new Dictionary<string, string> { ["limit"] = "20", ["include_locale"] = "true" };
var conversationsArguments = new Dictionary<string, string> { ["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.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<JObject> Probe(string method, Dictionary<string, string> 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<bool?>("has_more") == true || !string.IsNullOrEmpty(response["response_metadata"]?.Value<string>("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));
}
return response;
}

private async Task<JObject> Call(string method, Dictionary<string, string> 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<bool?>("ok") != true)
{
// Error names are restricted; never copy response values or content into reports.
var error = json.Value<string>("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;
}
}
}
Loading
Loading