naco-api/internal/activitypub/client_test.go
Nacorid c96447e3e0
fix(activitypub): harden client, add outbox fallback and throttled GTS warming
Extract ActivityPub outbox walk into internal/activitypub/outbox.go with
webfinger/actor resolution, Create/Note filtering and IRI dereferencing.
Fix searchStatus returning wrong post, nil-account panic in SearchActors,
@local handle resolution, empty Author via noteToInternalPost, protocol-
relative URL handling, pagination loops, limit clamping (200) and cacheErr
masking. Add per-host token-bucket throttle (1/s, cross-host concurrency)
in throttle.go and background GTS warming via search?resolve for outbox-
fetched posts without following remotes. Align AGENTS.md.

Co-Authored-By: internal-model
2026-09-10 09:59:44 +00:00

597 lines
20 KiB
Go

package activitypub
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
)
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func newTestClient(serverURL string) *APClient {
ap, err := NewAPClient("https://example.com", "tok")
if err != nil {
panic(err)
}
host := strings.TrimPrefix(serverURL, "http://")
ap.client = &http.Client{
Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
u := *r.URL
u.Scheme = "http"
u.Host = host
r2 := r.Clone(r.Context())
r2.URL = &u
return http.DefaultTransport.RoundTrip(r2)
}),
}
return ap
}
func TestNewAPClientEmpty(t *testing.T) {
if _, err := NewAPClient("", "tok"); err == nil {
t.Fatal("expected error for empty host")
}
if _, err := NewAPClient("host", ""); err == nil {
t.Fatal("expected error for empty apikey")
}
}
func TestGetLatestNPosts(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer tok" {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Query().Get("max_id") != "" {
_, _ = fmt.Fprint(w, `[]`)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","content":"hello world","text":"hello world","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 10)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 1 {
t.Fatalf("expected 1 post, got %d", len(posts))
}
if posts[0].Content != "hello world" || posts[0].Author != "alice" {
t.Fatalf("unexpected post: %+v", posts[0])
}
}
func TestGetLatestNPostsPaginates(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Query().Get("max_id") {
case "40":
ids := make([]map[string]any, 0, 10)
for id := 41; id <= 50; id++ {
ids = append(ids, map[string]any{
"id": fmt.Sprintf("%d", id), "content": fmt.Sprintf("post %d", id), "created_at": "2024-01-01T00:00:00Z",
"account": map[string]any{"acct": "alice"},
})
}
_ = json.NewEncoder(w).Encode(ids)
case "":
ids := make([]map[string]any, 0, 40)
for id := 1; id <= 40; id++ {
ids = append(ids, map[string]any{
"id": fmt.Sprintf("%d", id), "content": fmt.Sprintf("post %d", id), "created_at": "2024-01-01T00:00:00Z",
"account": map[string]any{"acct": "alice"},
})
}
_ = json.NewEncoder(w).Encode(ids)
default:
_ = json.NewEncoder(w).Encode([]map[string]any{})
}
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 50)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 50 {
t.Fatalf("expected 50 posts across 2 pages, got %d", len(posts))
}
if posts[49].Content != "post 50" {
t.Fatalf("expected last post 'post 50', got %q", posts[49].Content)
}
}
func TestGetLatestNPostsZeroLimit(t *testing.T) {
ap := newTestClient("http://example.com")
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 0)
if err != nil {
t.Fatalf("GetLatestNPosts with zero limit error: %v", err)
}
if len(posts) != 0 {
t.Fatalf("expected no posts for zero limit, got %d", len(posts))
}
}
func TestGetLatestNPostsLimitClamp(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
limit := r.URL.Query().Get("limit")
l, _ := strconv.Atoi(limit)
if l > maxLimit {
t.Fatalf("expected limit <= %d, got %s", maxLimit, limit)
}
_, _ = fmt.Fprint(w, `[{"id":"1","content":"hello","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
posts, err := ap.GetLatestNPosts(context.Background(), "alice", 500)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) == 0 {
t.Fatal("expected at least one post")
}
}
func TestGetPost(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/statuses/42", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"id":"42","content":"a post","text":"a post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"bob"}}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
post, err := ap.GetPost(context.Background(), "42")
if err != nil {
t.Fatalf("GetPost error: %v", err)
}
if post.Content != "a post" || post.Author != "bob" {
t.Fatalf("unexpected post: %+v", post)
}
}
func TestGetPostURLResolves(t *testing.T) {
url := "https://inst.social/@alice/statuses/42"
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != url {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.URL.Query().Get("type") != "statuses" || r.URL.Query().Get("resolve") != "true" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[{"id":"42","uri":"`+url+`","url":"`+url+`","content":"resolved post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"bob@inst.social"}}]}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
post, err := ap.GetPost(context.Background(), url)
if err != nil {
t.Fatalf("GetPost error: %v", err)
}
if post.Content != "resolved post" || post.Author != "bob@inst.social" {
t.Fatalf("unexpected post: %+v", post)
}
}
func TestGetPostURLNotFound(t *testing.T) {
derefTarget := httptest.NewServer(http.NotFoundHandler())
defer derefTarget.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[]}`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
if _, err := ap.GetPost(context.Background(), derefTarget.URL+"/missed"); err == nil {
t.Fatal("expected error when search and dereference both return nothing")
}
}
func TestGetPostURLSearchReturnsWrongPost(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"accounts":[],"statuses":[{"id":"wrong","uri":"https://other.com/1","content":"wrong post"}]}`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
if _, err := ap.GetPost(context.Background(), "https://inst.social/@alice/statuses/42"); err == nil {
t.Fatal("expected error when search returns no matching post")
}
}
func TestGetPostURLDerefFallback(t *testing.T) {
remote, _ := newRemoteServer()
defer remote.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v2/search", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
post, err := ap.GetPost(context.Background(), remote.URL+"/notes/2")
if err != nil {
t.Fatalf("GetPost error: %v", err)
}
if post.Content != "deref note 2" {
t.Fatalf("expected dereferenced note, got %+v", post)
}
}
func TestGetLatestNPostsRemoteFallsBackToOutbox(t *testing.T) {
remote, remoteBase := newRemoteServer()
defer remote.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("max_id") != "" {
_, _ = fmt.Fprint(w, `[]`)
return
}
_, _ = fmt.Fprint(w, `[{"id":"c1","content":"cached","uri":`+`"`+remoteBase+`/notes/9","created_at":"2024-03-10T00:00:00Z","account":{"acct":"alice@example.social"}}]`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
posts, err := ap.GetLatestNPosts(context.Background(), "@alice@example.social", 5)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 5 {
t.Fatalf("expected 5 merged posts, got %d: %+v", len(posts), posts)
}
if posts[0].Content != "cached" {
t.Fatalf("expected newest cached first, got %q", posts[0].Content)
}
if posts[4].Content != "note 4" {
t.Fatalf("expected oldest outbox post last, got %q", posts[4].Content)
}
}
func TestGetLatestNPostsRemoteEmptyCacheFallsBack(t *testing.T) {
remote, remoteBase := newRemoteServer()
defer remote.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("max_id") != "" {
_, _ = fmt.Fprint(w, `[]`)
return
}
_, _ = fmt.Fprint(w, `[]`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
posts, err := ap.GetLatestNPosts(context.Background(), "@alice@example.social", 4)
if err != nil {
t.Fatalf("GetLatestNPosts error: %v", err)
}
if len(posts) != 4 {
t.Fatalf("expected all 4 posts from outbox, got %d", len(posts))
}
if posts[0].Content != "bare note 3" {
t.Fatalf("expected outbox post newest-first, got %q", posts[0].Content)
}
}
func TestGetPostsInTimeframeRemoteFallsBackToOutbox(t *testing.T) {
remote, remoteBase := newRemoteServer()
defer remote.Close()
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintf(w, `[{"id":"1","acct":"alice@example.social","url":%q}]`, remoteBase)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("max_id") != "" {
_, _ = fmt.Fprint(w, `[]`)
return
}
_, _ = fmt.Fprint(w, `[{"id":"c1","content":"cached in window","uri":`+`"`+remoteBase+`/notes/5","created_at":"2024-02-20T00:00:00Z","account":{"acct":"alice@example.social"}}]`)
})
gts := httptest.NewServer(mux)
defer gts.Close()
ap, err := NewAPClient(gts.URL, "tok")
if err != nil {
t.Fatal(err)
}
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC)
posts, err := ap.GetPostsInTimeframe(context.Background(), "@alice@example.social", start, end, 3)
if err != nil {
t.Fatalf("GetPostsInTimeframe error: %v", err)
}
if len(posts) != 3 {
t.Fatalf("expected 3 in-window posts, got %d: %+v", len(posts), posts)
}
if posts[0].Content != "created note 1" || posts[1].Content != "cached in window" || posts[2].Content != "deref note 2" {
t.Fatalf("unexpected merged order: %+v", posts)
}
}
func TestGetPostsInTimeframe(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
feed := []map[string]any{}
switch r.URL.Query().Get("max_id") {
case "":
feed = []map[string]any{
{"id": "5", "content": "too-new", "text": "too-new", "created_at": "2025-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
{"id": "4", "content": "mid", "text": "mid", "created_at": "2024-06-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
}
case "4":
feed = []map[string]any{
{"id": "3", "content": "early-mid", "text": "early-mid", "created_at": "2024-03-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
{"id": "2", "content": "too-old", "text": "too-old", "created_at": "2023-01-01T00:00:00Z", "account": map[string]any{"acct": "alice"}},
}
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(feed); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC)
posts, err := ap.GetPostsInTimeframe(context.Background(), "alice", start, end, 5)
if err != nil {
t.Fatalf("GetPostsInTimeframe error: %v", err)
}
if len(posts) != 2 {
t.Fatalf("expected 2 in-window posts, got %d", len(posts))
}
if posts[0].Content != "mid" || posts[1].Content != "early-mid" {
t.Fatalf("unexpected posts: %+v", posts)
}
}
func TestGetLatestNPostsHTTPError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/accounts/1/statuses", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
if _, err := ap.GetLatestNPosts(context.Background(), "alice", 10); err == nil {
t.Fatal("expected error on non-200 status from timeline")
}
}
func TestGetActor(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hello there","avatar":"https://img/avatar.png","followers_count":10,"following_count":5,"statuses_count":3,"url":"https://inst.social/@alice"}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
prof, err := ap.GetActor(context.Background(), "alice")
if err != nil {
t.Fatalf("GetActor error: %v", err)
}
if prof.Platform != "ActivityPub" || prof.Handle != "alice@inst.social" || prof.DisplayName != "Alice" {
t.Fatalf("unexpected profile: %+v", prof)
}
if prof.Bio != "hello there" || prof.AvatarURL != "https://img/avatar.png" {
t.Fatalf("unexpected profile fields: %+v", prof)
}
if prof.FollowersCount != 10 || prof.FollowingCount != 5 || prof.PostCount != 3 {
t.Fatalf("unexpected counts: %+v", prof)
}
if prof.URL != "https://inst.social/@alice" {
t.Fatalf("unexpected url: %q", prof.URL)
}
}
func TestGetActorIDFallback(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[]`)
})
mux.HandleFunc("/api/v1/accounts/alice", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"hi","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
prof, err := ap.GetActor(context.Background(), "alice")
if err != nil {
t.Fatalf("GetActor error: %v", err)
}
if prof.Handle != "alice@inst.social" {
t.Fatalf("expected fallback id lookup to succeed, got %+v", prof)
}
}
func TestGetActorAtLocalHandle(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q != "@alice" && q != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice","display_name":"Alice","note":"hi","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
prof, err := ap.GetActor(context.Background(), "@alice")
if err != nil {
t.Fatalf("GetActor error: %v", err)
}
if prof.Handle != "alice" {
t.Fatalf("expected local handle, got %q", prof.Handle)
}
}
func TestSearchActors(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("q") != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.URL.Query().Get("resolve") != "true" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice@inst.social","display_name":"Alice","note":"bio","avatar":"https://img/a.png","followers_count":1,"following_count":2,"statuses_count":3,"url":"https://x"}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
results, err := ap.SearchActors(context.Background(), "alice", 10)
if err != nil {
t.Fatalf("SearchActors error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result, got %d", len(results))
}
if results[0].Platform != "ActivityPub" || results[0].Handle != "alice@inst.social" {
t.Fatalf("unexpected result: %+v", results[0])
}
}
func TestExtractHandleFromURL(t *testing.T) {
tests := []struct {
url string
expected string
}{
{"https://inst.social/@alice/statuses/42", "alice"},
{"http://example.com/@bob/posts/1", "bob"},
{"https://no-at-here.com/notes/1", ""},
{"https://host/@user/note", "user"},
}
for _, tt := range tests {
got := extractHandleFromURL(tt.url)
if got != tt.expected {
t.Fatalf("extractHandleFromURL(%q) = %q, want %q", tt.url, got, tt.expected)
}
}
}
func TestGetPostBareLocalHandle(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if q != "@alice" && q != "alice" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = fmt.Fprint(w, `[{"id":"1","acct":"alice"}]`)
})
mux.HandleFunc("/api/v1/statuses/alice", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `{"id":"1","content":"local post","created_at":"2024-01-01T00:00:00Z","account":{"acct":"alice"}}`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
_, err := ap.GetPost(context.Background(), "alice")
if err != nil {
t.Fatalf("GetPost with local handle error: %v", err)
}
}
func TestSearchActorsNilEntries(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/api/v1/accounts/search", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, `[null,{"id":"1","acct":"alice@inst.social"}]`)
})
server := httptest.NewServer(mux)
defer server.Close()
ap := newTestClient(server.URL)
results, err := ap.SearchActors(context.Background(), "alice", 10)
if err != nil {
t.Fatalf("SearchActors error: %v", err)
}
if len(results) != 1 {
t.Fatalf("expected 1 result after filtering nil, got %d", len(results))
}
if results[0].Handle != "alice@inst.social" {
t.Fatalf("unexpected result: %+v", results[0])
}
}