naco-api/internal/activitypub/outbox_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

224 lines
8 KiB
Go

package activitypub
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// newRemoteServer serves a synthetic ActivityPub instance: a webfinger
// endpoint, an actor with an outbox collection, two outbox pages (with mixed
// activity types and an IRI-referenced object) and a dereferenceable note.
// Returns the server and the actor's canonical URL.
func newRemoteServer() (*httptest.Server, string) {
base := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body string
switch {
case strings.HasSuffix(r.URL.Path, "/.well-known/webfinger"):
body = `{"subject":"acct:alice@example.social","links":[` +
`{"rel":"self","type":"application/activity+json","href":"` + base + `/users/alice"},` +
`{"rel":"http://webfinger.net/rel/profile-page","type":"text/html","href":"` + base + `/@alice"}]}`
case r.URL.Path == "/users/alice":
body = `{"id":"` + base + `/users/alice","type":"Person","preferredUsername":"alice","url":"` + base + `/@alice","outbox":"` + base + `/users/alice/outbox"}`
case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "":
body = `{"type":"OrderedCollection","totalItems":5,"first":"` + base + `/users/alice/outbox?page=1"}`
case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "1":
body = `{"type":"OrderedCollectionPage","partOf":"` + base + `/users/alice/outbox","orderedItems":[` +
`{"type":"Create","published":"2024-03-01T00:00:00Z","object":{"type":"Note","id":"` + base + `/notes/1","published":"2024-03-01T00:00:00Z","content":"created note 1","url":"` + base + `/@alice/statuses/1"}},` +
`{"type":"Announce","object":"` + base + `/notes/9"},` +
`{"type":"Like","object":"` + base + `/notes/8"},` +
`{"type":"Create","object":"` + base + `/notes/2"},` +
`{"type":"Note","id":"` + base + `/notes/3","published":"2024-03-03T00:00:00Z","content":"bare note 3","url":["` + base + `/@alice/statuses/3"]}` +
`],"next":"` + base + `/users/alice/outbox?page=2"}`
case r.URL.Path == "/users/alice/outbox" && r.URL.Query().Get("page") == "2":
body = `{"type":"OrderedCollectionPage","orderedItems":[` +
`{"type":"Create","object":{"type":"Note","id":"` + base + `/notes/4","published":"2024-01-15T00:00:00Z","content":"note 4"}}` +
`]}`
case r.URL.Path == "/notes/2":
body = `{"type":"Note","id":"` + base + `/notes/2","published":"2024-02-01T00:00:00Z","content":"deref note 2","url":"` + base + `/@alice/statuses/2"}`
default:
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/activity+json")
_, _ = fmt.Fprint(w, body)
}))
base = server.URL
return server, base + "/users/alice"
}
func TestOutboxGetLatest(t *testing.T) {
server, actorURL := newRemoteServer()
defer server.Close()
oc := newOutboxClient()
posts, err := oc.getLatest(context.Background(), actorURL, "alice@example.social", 10)
if err != nil {
t.Fatalf("getLatest error: %v", err)
}
if len(posts) != 4 {
t.Fatalf("expected 4 Create/Note posts (skipping Announce/Like), got %d: %+v", len(posts), posts)
}
want := []string{"created note 1", "deref note 2", "bare note 3", "note 4"}
for i, w := range want {
if posts[i].Content != w {
t.Fatalf("posts[%d].Content = %q, want %q", i, posts[i].Content, w)
}
}
if posts[0].Author != "alice@example.social" {
t.Fatalf("Author = %q, want handle", posts[0].Author)
}
}
func TestOutboxGetLatestLimit(t *testing.T) {
server, actorURL := newRemoteServer()
defer server.Close()
oc := newOutboxClient()
posts, err := oc.getLatest(context.Background(), actorURL, "h", 2)
if err != nil {
t.Fatalf("getLatest error: %v", err)
}
if len(posts) != 2 {
t.Fatalf("expected 2 posts, got %d", len(posts))
}
want := []string{"created note 1", "deref note 2"}
for i, w := range want {
if posts[i].Content != w {
t.Fatalf("posts[%d].Content = %q, want %q", i, posts[i].Content, w)
}
}
}
func TestOutboxGetInTimeframe(t *testing.T) {
server, actorURL := newRemoteServer()
defer server.Close()
oc := newOutboxClient()
start := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2024, 3, 2, 0, 0, 0, 0, time.UTC)
posts, err := oc.getInTimeframe(context.Background(), actorURL, "h", start, end, 10)
if err != nil {
t.Fatalf("getInTimeframe error: %v", err)
}
if len(posts) != 2 {
t.Fatalf("expected 2 in-window posts (note 3 and 4 are outside), got %d", len(posts))
}
if posts[0].Content != "created note 1" || posts[1].Content != "deref note 2" {
t.Fatalf("unexpected in-window posts: %+v", posts)
}
}
func TestOutboxResolveViaWebfinger(t *testing.T) {
server, _ := newRemoteServer()
defer server.Close()
scheme := webfingerScheme
webfingerScheme = "http"
defer func() { webfingerScheme = scheme }()
handle := "@alice@" + strings.TrimPrefix(server.URL, "http://")
oc := newOutboxClient()
posts, err := oc.getLatest(context.Background(), "", handle, 10)
if err != nil {
t.Fatalf("getLatest via webfinger error: %v", err)
}
if len(posts) != 4 {
t.Fatalf("expected 4 posts via webfinger, got %d", len(posts))
}
}
func TestOutboxResolveNote(t *testing.T) {
server, _ := newRemoteServer()
defer server.Close()
oc := newOutboxClient()
note, err := oc.resolveNote(context.Background(), server.URL+"/notes/2")
if err != nil {
t.Fatalf("resolveNote error: %v", err)
}
if note.Content != "deref note 2" {
t.Fatalf("unexpected note: %+v", note)
}
}
func TestNoteToInternalPostSetsAuthor(t *testing.T) {
note := &apNote{
ID: "https://example.social/notes/1",
Content: "hello",
Published: "2024-01-01T00:00:00Z",
}
p := noteToInternalPost(note, "alice@example.social")
if p.Account.Acct != "alice@example.social" {
t.Fatalf("expected Account.Acct = 'alice@example.social', got %q", p.Account.Acct)
}
if p.toPost().Author != "alice@example.social" {
t.Fatalf("expected toPost().Author = 'alice@example.social', got %q", p.toPost().Author)
}
}
func TestNoteToInternalPostNoAuthor(t *testing.T) {
note := &apNote{
ID: "https://example.social/notes/1",
Content: "hello",
Published: "2024-01-01T00:00:00Z",
}
p := noteToInternalPost(note, "")
if p.Account.Acct != "" {
t.Fatalf("expected empty Account.Acct when no author, got %q", p.Account.Acct)
}
if p.toPost().Author != "" {
t.Fatalf("expected empty author when none provided, got %q", p.toPost().Author)
}
}
func TestMergePostsBySourceDedup(t *testing.T) {
created := time.Date(2024, 3, 1, 0, 0, 0, 0, time.UTC)
cached := []*internalPost{
{ID: "local-1", URI: "https://example.social/notes/1", Content: "cached", CreatedAt: created},
}
outbox := []*Post{
{ID: "https://example.social/notes/1", Content: "cached dup", CreatedAt: created},
{ID: "https://example.social/notes/2", Content: "outbox only", CreatedAt: created.Add(-time.Hour)},
}
merged := mergePostsBySource(cached, outbox)
if len(merged) != 2 {
t.Fatalf("expected dedup to drop the duplicate, got %d", len(merged))
}
if merged[0].Content != "cached" {
t.Fatalf("newest first: expected cached first, got %q", merged[0].Content)
}
if merged[1].Content != "outbox only" {
t.Fatalf("expected outbox only second, got %q", merged[1].Content)
}
}
func TestParseActivityTime(t *testing.T) {
for _, s := range []string{"2024-01-01T00:00:00Z", "2024-01-01T00:00:00.000Z", "2024-01-01T00:00:00+02:00"} {
if _, ok := parseActivityTime(s); !ok {
t.Fatalf("parseActivityTime(%q) failed", s)
}
}
if _, ok := parseActivityTime("not a date"); ok {
t.Fatal("expected invalid timestamp to fail")
}
}
func TestAPStringOrArray(t *testing.T) {
var sa apStringOrArray
if err := json.Unmarshal([]byte(`"https://a"`), &sa); err != nil || len(sa) != 1 || sa[0] != "https://a" {
t.Fatalf("string form failed: %v %v", sa, err)
}
if err := json.Unmarshal([]byte(`["https://a","https://b"]`), &sa); err != nil || len(sa) != 2 {
t.Fatalf("array form failed: %v %v", sa, err)
}
if err := json.Unmarshal([]byte(`null`), &sa); err != nil || len(sa) != 0 {
t.Fatalf("null form failed: %v %v", sa, err)
}
}