New Sign in

fierj

Public

Tiny personal git forge

← fierj / ap_test.go
package main

import (
	"bytes"
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"encoding/pem"
	"html/template"
	"net/http"
	"net/http/httptest"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"testing"
)

// newTestServerWithHost creates a test server with Host configured from the
// actual listener address (needed for ActivityPub URL construction).
func newTestServerWithHost(t *testing.T) *testServer {
	t.Helper()

	dir := t.TempDir()
	usersPath := filepath.Join(dir, "users.json")
	tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html"))

	// Build with placeholder host first, then rebuild after we know the port.
	cfg := Config{
		Addr:         "127.0.0.1:0",
		Host:         "placeholder",
		Dir:          dir,
		UsersPath:    usersPath,
		CookieSecret: "test-secret",
	}

	users, err := LoadUsers(usersPath)
	if err != nil {
		t.Fatalf("LoadUsers: %v", err)
	}

	secret := []byte(cfg.CookieSecret)
	placeholderHandler := newHandler(cfg, users, secret, tmpl)

	ts := httptest.NewUnstartedServer(placeholderHandler)
	cfg.Host = ts.Listener.Addr().String()
	handler := newHandler(cfg, users, secret, tmpl)
	ts.Config.Handler = handler
	ts.Start()

	return &testServer{
		Server: ts,
		cfg:    cfg,
		dir:    dir,
		users:  users,
		secret: secret,
		tmpl:   tmpl,
	}
}

// ---------- WebFinger ----------

func TestWebFingerRepoDiscovery(t *testing.T) {
	ts := newTestServerWithHost(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "ap-repo", "An AP-enabled repo")

	resp := ts.get(t, c, "/.well-known/webfinger?resource=acct:ap-repo@"+ts.cfg.Host)
	assertStatus(t, resp, http.StatusOK)
	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "jrd+json") {
		t.Errorf("content-type should be jrd+json, got %q", ct)
	}

	var result jrd
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		t.Fatalf("parse WebFinger response: %v", err)
	}

	if result.Subject != "acct:ap-repo@"+ts.cfg.Host {
		t.Errorf("subject = %q, want %q", result.Subject, "acct:ap-repo@"+ts.cfg.Host)
	}
	if len(result.Links) != 1 {
		t.Fatalf("expected 1 link, got %d", len(result.Links))
	}
	link := result.Links[0]
	if link.Rel != "self" {
		t.Errorf("link rel = %q, want self", link.Rel)
	}
	if link.Type != "application/activity+json" {
		t.Errorf("link type = %q", link.Type)
	}
	if link.Href != actorURL(ts.cfg.Host, "ap-repo") {
		t.Errorf("link href = %q, want %q", link.Href, actorURL(ts.cfg.Host, "ap-repo"))
	}
}

func TestWebFingerUnknownRepo(t *testing.T) {
	ts := newTestServerWithHost(t)
	c := ts.client()
	ts.setupUser(t, c, "admin", "admin123")

	resp := ts.get(t, c, "/.well-known/webfinger?resource=acct:nonexistent@"+ts.cfg.Host)
	assertStatus(t, resp, http.StatusNotFound)
}

func TestWebFingerBadResource(t *testing.T) {
	ts := newTestServerWithHost(t)
	c := ts.client()
	ts.setupUser(t, c, "admin", "admin123")

	resp := ts.get(t, c, "/.well-known/webfinger?resource=bad-format")
	assertStatus(t, resp, http.StatusNotFound)
}

// ---------- Actor ----------

func TestAPActor(t *testing.T) {
	ts := newTestServerWithHost(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "actor-test", "Actor test repo")

	resp := ts.get(t, c, "/actor-test/ap/actor")
	assertStatus(t, resp, http.StatusOK)
	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "activity+json") {
		t.Errorf("content-type should be activity+json, got %q", ct)
	}

	var actor APActor
	if err := json.NewDecoder(resp.Body).Decode(&actor); err != nil {
		t.Fatalf("parse actor: %v", err)
	}

	if actor.Context != apContext {
		t.Errorf("@context = %q", actor.Context)
	}
	if actor.Type != "Service" {
		t.Errorf("type = %q, want Service", actor.Type)
	}
	if actor.ID != actorURL(ts.cfg.Host, "actor-test") {
		t.Errorf("id = %q", actor.ID)
	}
	if actor.Name != "actor-test" {
		t.Errorf("name = %q", actor.Name)
	}
	if actor.PreferredUsername != "actor-test" {
		t.Errorf("preferredUsername = %q", actor.PreferredUsername)
	}
	if actor.URL != "https://"+ts.cfg.Host+"/actor-test" {
		t.Errorf("url = %q", actor.URL)
	}
	if actor.Summary != "Actor test repo" {
		t.Errorf("summary = %q", actor.Summary)
	}
	if actor.Inbox != inboxURL(ts.cfg.Host, "actor-test") {
		t.Errorf("inbox = %q", actor.Inbox)
	}
	if actor.Outbox != outboxURL(ts.cfg.Host, "actor-test") {
		t.Errorf("outbox = %q", actor.Outbox)
	}
	if actor.Followers != followersURL(ts.cfg.Host, "actor-test") {
		t.Errorf("followers = %q", actor.Followers)
	}
	if actor.PublicKey.ID != actorURL(ts.cfg.Host, "actor-test")+"#main-key" {
		t.Errorf("publicKey.id = %q", actor.PublicKey.ID)
	}
	if actor.PublicKey.Owner != actorURL(ts.cfg.Host, "actor-test") {
		t.Errorf("publicKey.owner = %q", actor.PublicKey.Owner)
	}
	if !strings.Contains(actor.PublicKey.PublicKeyPem, "PUBLIC KEY") {
		t.Error("publicKey.publicKeyPem should contain PEM block")
	}
}

// ---------- Inbox ----------

func TestAPInboxCreateNoteCreatesThread(t *testing.T) {
	// Set up two servers: server A follows server B's repo.
	// Server A = the receiver (has the inbox)
	tsA := newTestServerWithHost(t)
	cA := tsA.client()
	tsA.setupUser(t, cA, "admin", "admin123")
	tsA.createRepo(t, cA, "target", "Target repo")

	// Get A's actor info for the key.
	respActor := tsA.get(t, cA, "/target/ap/actor")
	assertStatus(t, respActor, http.StatusOK)
	var actorA APActor
	json.NewDecoder(respActor.Body).Decode(&actorA)

	// Build a Follow activity from "server B".
	followActivity := map[string]any{
		"@context": apContext,
		"type":     "Follow",
		"actor":    "https://remote.example/users/alice",
		"object":   actorA.ID,
	}

	jsonBody, _ := json.Marshal(followActivity)
	req, _ := http.NewRequest("POST", tsA.URL+"/target/ap/inbox", bytes.NewReader(jsonBody))
	req.Header.Set("Content-Type", "application/activity+json")

	// Create a fake signature (inbox currently requires a signature header).
	// Since we can't sign externally, we'll test that a missing signature returns 401.
	req.Header.Set("Signature", `keyId="https://remote.example/users/alice#main-key",algorithm="ed25519-sha256",headers="(request-target) host date digest",signature="deadbeef"`)

	resp, err := tsA.client().Do(req)
	if err != nil {
		t.Fatalf("inbox request: %v", err)
	}
	// Strict verification: fake signature must be rejected.
	assertStatus(t, resp, http.StatusUnauthorized)

	// Now test that the inbox endpoint responds at all with a valid setup.
	// (Full signature verification test would require signing with own key.)
}

// TestAPInboxNoSignature verifies that inbox rejects requests without a signature.
func TestAPInboxNoSignature(t *testing.T) {
	ts := newTestServerWithHost(t)
	c := ts.client()
	ts.setupUser(t, c, "admin", "admin123")
	ts.createRepo(t, c, "sigtest", "Signature test")

	body, _ := json.Marshal(map[string]string{"type": "Create"})
	req, _ := http.NewRequest("POST", ts.URL+"/sigtest/ap/inbox", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/activity+json")

	resp, err := ts.client().Do(req)
	if err != nil {
		t.Fatalf("inbox request: %v", err)
	}
	assertStatus(t, resp, http.StatusUnauthorized)
}

// ---------- Outbox ----------

func TestAPOutbox(t *testing.T) {
	ts := newTestServerWithHost(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "outbox-test", "Outbox test")

	// Create a thread so there's something in the outbox.
	ts.postForm(t, c, "/outbox-test/threads/new", url.Values{
		"title": {"Hello federation"},
		"body":  {"First federated thread."},
	})

	resp := ts.get(t, c, "/outbox-test/ap/outbox")
	assertStatus(t, resp, http.StatusOK)
	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "activity+json") {
		t.Errorf("content-type should be activity+json, got %q", ct)
	}

	var coll APOrderedCollection
	if err := json.NewDecoder(resp.Body).Decode(&coll); err != nil {
		t.Fatalf("parse outbox: %v", err)
	}

	if coll.Type != "OrderedCollection" {
		t.Errorf("type = %q, want OrderedCollection", coll.Type)
	}
	if coll.TotalItems < 1 {
		t.Error("outbox should have at least 1 item")
	}
	// Check that an item contains our thread title.
	found := false
	for _, item := range coll.OrderedItems {
		itemMap, ok := item.(map[string]any)
		if !ok {
			continue
		}
		obj, ok := itemMap["object"].(map[string]any)
		if !ok {
			continue
		}
		content, _ := obj["content"].(string)
		if strings.Contains(content, "Hello federation") {
			found = true
			break
		}
	}
	if !found {
		t.Error("outbox should contain the thread about 'Hello federation'")
	}
}

func TestAPOutboxEmpty(t *testing.T) {
	ts := newTestServerWithHost(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "empty-out", "Empty outbox")

	resp := ts.get(t, c, "/empty-out/ap/outbox")
	assertStatus(t, resp, http.StatusOK)

	var coll APOrderedCollection
	json.NewDecoder(resp.Body).Decode(&coll)
	if coll.TotalItems != 0 {
		t.Errorf("empty outbox should have 0 items, got %d", coll.TotalItems)
	}
}

// ---------- Followers ----------

func TestAPFollowers(t *testing.T) {
	ts := newTestServerWithHost(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "followed", "Followed repo")

	// Followers should start empty.
	resp := ts.get(t, c, "/followed/ap/followers")
	assertStatus(t, resp, http.StatusOK)

	var coll APOrderedCollection
	json.NewDecoder(resp.Body).Decode(&coll)
	if coll.TotalItems != 0 {
		t.Errorf("initial followers should be 0, got %d", coll.TotalItems)
	}
}

// ---------- Key management ----------

func TestAPKeyGeneration(t *testing.T) {
	dir := t.TempDir()
	repoPath := filepath.Join(dir, "keytest.git")
	os.MkdirAll(repoPath, 0755)

	meta, err := loadOrCreateAPMeta(repoPath)
	if err != nil {
		t.Fatalf("loadOrCreateAPMeta: %v", err)
	}

	if meta.PublicKey == "" || meta.PrivateKey == "" {
		t.Fatal("keys should be generated")
	}

	// Parse the keys to ensure they're valid.
	priv, err := parsePrivateKey(meta.PrivateKey)
	if err != nil {
		t.Fatalf("parse private key: %v", err)
	}
	pub, err := parsePublicKey(meta.PublicKey)
	if err != nil {
		t.Fatalf("parse public key: %v", err)
	}

	// Verify the keys match by signing with private and verifying with public.
	msg := []byte("test message")
	hash := sha256.Sum256(msg)
	sig, err := rsa.SignPKCS1v15(rand.Reader, priv.(*rsa.PrivateKey), crypto.SHA256, hash[:])
	if err != nil {
		t.Fatalf("sign: %v", err)
	}
	if err := rsa.VerifyPKCS1v15(pub.(*rsa.PublicKey), crypto.SHA256, hash[:], sig); err != nil {
		t.Error("keypair verification failed")
	}

	// Load again — should get the same keys.
	meta2, err := loadOrCreateAPMeta(repoPath)
	if err != nil {
		t.Fatalf("second load: %v", err)
	}
	if meta2.PublicKey != meta.PublicKey || meta2.PrivateKey != meta.PrivateKey {
		t.Error("keys should persist across loads")
	}
}

// ---------- Follow/unfollow persistence ----------

func TestAPFollowPersistence(t *testing.T) {
	dir := t.TempDir()
	repoPath := filepath.Join(dir, "followtest.git")
	os.MkdirAll(repoPath, 0755)

	// Add a follower.
	if err := addFollower(repoPath, "https://example.com/users/alice"); err != nil {
		t.Fatalf("addFollower: %v", err)
	}

	meta, _ := loadOrCreateAPMeta(repoPath)
	if len(meta.Followers) != 1 || meta.Followers[0] != "https://example.com/users/alice" {
		t.Fatalf("followers = %v, want [alice]", meta.Followers)
	}

	// Add another.
	addFollower(repoPath, "https://example.com/users/bob")
	meta, _ = loadOrCreateAPMeta(repoPath)
	if len(meta.Followers) != 2 {
		t.Fatalf("followers count = %d, want 2", len(meta.Followers))
	}

	// Remove alice.
	removeFollower(repoPath, "https://example.com/users/alice")
	meta, _ = loadOrCreateAPMeta(repoPath)
	if len(meta.Followers) != 1 || meta.Followers[0] != "https://example.com/users/bob" {
		t.Fatalf("followers after remove = %v, want [bob]", meta.Followers)
	}

	// Dedup.
	addFollower(repoPath, "https://example.com/users/bob")
	meta, _ = loadOrCreateAPMeta(repoPath)
	if len(meta.Followers) != 1 {
		t.Fatalf("followers should be deduplicated, got %d", len(meta.Followers))
	}
}

// ---------- HTTP Signature parsing ----------

func TestParseSignature(t *testing.T) {
	header := `keyId="https://example.com/actor#main-key",algorithm="ed25519-sha256",headers="(request-target) host date digest",signature="c29tZXNpZ25hdHVyZQ=="`

	sig, err := parseSignature(header)
	if err != nil {
		t.Fatalf("parseSignature: %v", err)
	}
	if sig.KeyID != "https://example.com/actor#main-key" {
		t.Errorf("keyId = %q", sig.KeyID)
	}
	if sig.Algorithm != "ed25519-sha256" {
		t.Errorf("algorithm = %q", sig.Algorithm)
	}
	if len(sig.Headers) != 4 {
		t.Errorf("headers = %v", sig.Headers)
	}
	if len(sig.Signature) == 0 {
		t.Error("signature is empty")
	}
}

func TestParseSignatureIncomplete(t *testing.T) {
	_, err := parseSignature(`keyId="https://example.com"`)
	if err == nil {
		t.Error("should fail for incomplete signature")
	}
}

// TestRSASignatureRoundTrip verifies RSA key parsing and signature verification.
func TestRSASignatureRoundTrip(t *testing.T) {
	// Generate an RSA key (similar to what Mastodon uses).
	rsaKey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		t.Fatalf("generate RSA key: %v", err)
	}

	// Marshal public key to PEM.
	pubBytes, err := x509.MarshalPKIXPublicKey(&rsaKey.PublicKey)
	if err != nil {
		t.Fatal(err)
	}
	pubPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes}))

	// Parse it back using parsePublicKey.
	pub, err := parsePublicKey(pubPEM)
	if err != nil {
		t.Fatalf("parsePublicKey (RSA): %v", err)
	}

	// Build a fake request and sign it.
	req, _ := http.NewRequest("POST", "https://example.com/inbox", bytes.NewReader([]byte(`{"type":"Follow"}`)))
	req.Header.Set("Host", "example.com")
	req.Header.Set("Date", "Sun, 06 Nov 1994 08:49:37 GMT")

	body := []byte(`{"type":"Follow"}`)
	digestHash := sha256.Sum256(body)
	req.Header.Set("Digest", "SHA-256="+base64.StdEncoding.EncodeToString(digestHash[:]))

	// Build signing string.
	target := "post /inbox"
	signingString := "(request-target): " + target + "\nhost: example.com\ndate: Sun, 06 Nov 1994 08:49:37 GMT\ndigest: " + req.Header.Get("Digest")
	hash := sha256.Sum256([]byte(signingString))

	// Sign with RSA.
	sig, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, hash[:])
	if err != nil {
		t.Fatal(err)
	}
	sigB64 := base64.StdEncoding.EncodeToString(sig)

	// Build signature header.
	sigHeader := `keyId="https://example.com/actor#main-key",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="` + sigB64 + `"`

	parsed, err := parseSignature(sigHeader)
	if err != nil {
		t.Fatalf("parseSignature: %v", err)
	}

	// Verify.
	if err := verifySignature(parsed, pub, req, body); err != nil {
		t.Fatalf("RSA verify failed: %v", err)
	}

	// Bad signature should fail.
	parsed.Signature[0] ^= 0xff
	if err := verifySignature(parsed, pub, req, body); err == nil {
		t.Error("tampered signature should fail verification")
	}
}

// TestBuildReplyViews verifies that a reply's "replying to" pointer only
// shows up when it isn't simply continuing the line right above it.
func TestBuildReplyViews(t *testing.T) {
	replies := []Reply{
		{ID: "r1", AuthorName: "Priya", Body: "top-level, replies to the thread root", ParentID: ""},
		{ID: "r2", Body: "continues r1, the message right above it", ParentID: "r1"},
		{ID: "r3", Body: "another top-level comment", ParentID: ""},
		{ID: "r4", Body: "reaches back to r1, not the previous message", ParentID: "r1"},
	}

	views := buildReplyViews(replies)
	if len(views) != 4 {
		t.Fatalf("expected 4 views, got %d", len(views))
	}
	if views[0].ShowParent {
		t.Error("reply to thread root should not show a pointer")
	}
	if views[1].ShowParent {
		t.Error("reply to the immediately preceding message should not show a pointer")
	}
	if views[2].ShowParent {
		t.Error("another top-level reply should not show a pointer")
	}
	if !views[3].ShowParent {
		t.Error("reply that reaches back past the previous message should show a pointer")
	}
	if views[3].ParentAuthor != "Priya" {
		t.Errorf("ParentAuthor = %q, want %q", views[3].ParentAuthor, "Priya")
	}
}

// TestNestedReplyFromInbox verifies a nested reply (inReplyTo with #reply-<id>)
// is stored with the correct ParentID.
func TestNestedReplyFromInbox(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "nested-reply", "Nested reply test")

	// Create a thread and a reply directly.
	rp := repoPath(ts.cfg, "nested-reply")
	thread, err := createThread(rp, "Thread", "Body", "dev", "dev")
	if err != nil {
		t.Fatal(err)
	}
	addReply(rp, thread.ID, "First reply", "dev", "dev")

	// Load the thread to get the reply ID.
	thread, _ = loadThread(rp, thread.ID)
	if len(thread.Replies) != 1 {
		t.Fatal("expected 1 reply")
	}
	replyID := thread.Replies[0].ID

	// Simulate an AP reply with inReplyTo pointing to the reply.
	inReplyTo := "https://" + ts.cfg.Host + "/nested-reply/threads/" + thread.ID + "#reply-" + replyID
	obj := map[string]any{
		"type":      "Note",
		"content":   "Nested reply content",
		"inReplyTo": inReplyTo,
	}
	handleCreateNote(ts.cfg, "nested-reply", "https://remote.example/users/bob", obj)

	// Verify the nested reply has the correct ParentID.
	thread, _ = loadThread(rp, thread.ID)
	if len(thread.Replies) != 2 {
		t.Fatalf("expected 2 replies, got %d", len(thread.Replies))
	}
	last := thread.Replies[1]
	if last.ParentID != replyID {
		t.Errorf("nested reply ParentID = %q, want %q", last.ParentID, replyID)
	}
}

// TestThreadViewLinear verifies the thread page renders replies as a single
// chronological conversation, with a "replying to" pointer only on the one
// that reaches back past the message immediately before it.
func TestThreadViewLinear(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "views", "View test")

	rp := repoPath(ts.cfg, "views")
	thread, _ := createThread(rp, "Root", "Root body", "dev", "dev")
	addReply(rp, thread.ID, "First reply", "dev", "dev")
	thread, _ = loadThread(rp, thread.ID)
	firstID := thread.Replies[0].ID
	addReply(rp, thread.ID, "Second reply, top-level", "dev", "dev")
	addNestedReply(rp, thread.ID, firstID, "Reaches back to the first reply", "dev", "dev")

	resp := ts.get(t, c, "/views/threads/"+thread.ID)
	assertStatus(t, resp, http.StatusOK)
	b := body(t, resp)
	if !strings.Contains(b, "First reply") || !strings.Contains(b, "Second reply, top-level") || !strings.Contains(b, "Reaches back to the first reply") {
		t.Error("thread view should show all replies in one linear list")
	}
	if got := strings.Count(b, `class="reply-pointer"`); got != 1 {
		t.Errorf("expected exactly one reply pointer (for the reply that skips back), got %d", got)
	}
}

// TestReplyToSpecificComment verifies parent_id creates a nested reply via web UI.
func TestReplyToSpecificComment(t *testing.T) {
	ts := newTestServer(t)
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "replies", "Reply test")

	rp := repoPath(ts.cfg, "replies")
	thread, _ := createThread(rp, "Root", "Root body", "dev", "dev")
	addReply(rp, thread.ID, "First", "dev", "dev")
	thread, _ = loadThread(rp, thread.ID)
	firstReplyID := thread.Replies[0].ID

	// Reply to the first reply via the web form.
	resp := ts.postForm(t, c, "/replies/threads/"+thread.ID+"/reply", url.Values{
		"body":      {"Nested from UI"},
		"parent_id": {firstReplyID},
	})
	assertStatus(t, resp, http.StatusOK)

	thread, _ = loadThread(rp, thread.ID)
	if len(thread.Replies) != 2 {
		t.Fatalf("expected 2 replies, got %d", len(thread.Replies))
	}
	if thread.Replies[1].ParentID != firstReplyID {
		t.Errorf("nested reply ParentID = %q, want %q", thread.Replies[1].ParentID, firstReplyID)
	}
}

// ---------- End-to-end federation flow ----------

func TestFederationEndToEnd(t *testing.T) {
	// Server A hosts a repo that someone wants to follow and interact with.
	tsA := newTestServerWithHost(t)
	cA := tsA.client()
	tsA.setupUser(t, cA, "admin", "admin123")
	tsA.createRepo(t, cA, "fed-repo", "Federated repository")

	// Get A's actor and public key.
	respActorA := tsA.get(t, cA, "/fed-repo/ap/actor")
	assertStatus(t, respActorA, http.StatusOK)
	var actorA APActor
	json.NewDecoder(respActorA.Body).Decode(&actorA)

	// Load A's private key for signing.
	rpA := repoPath(tsA.cfg, "fed-repo")
	metaA, err := loadOrCreateAPMeta(rpA)
	if err != nil {
		t.Fatalf("load AP meta: %v", err)
	}
	privA, err := parsePrivateKey(metaA.PrivateKey)
	if err != nil {
		t.Fatalf("parse private key: %v", err)
	}

	// Construct URLs using http:// (test server is HTTP).
	actorID := "http://" + tsA.cfg.Host + "/fed-repo/ap/actor"
	inboxA := tsA.URL + "/fed-repo/ap/inbox"
	keyID := actorID + "#main-key"

	// Simulate "server B" — a remote user posting a Follow to A's inbox.
	// We'll sign it with A's own key (since we are A and we have the private key).
	// This lets us test the full pipeline: signature verification + activity dispatch.
	followBody, _ := json.Marshal(map[string]any{
		"@context": apContext,
		"type":     "Follow",
		"actor":    "https://remote.example/users/alice",
		"object":   actorA.ID,
	})

	reqFollow, _ := http.NewRequest("POST", inboxA, bytes.NewReader(followBody))
	reqFollow.Header.Set("Content-Type", "application/activity+json")
	reqFollow.Header.Set("Host", tsA.cfg.Host)
	if err := signRequest(reqFollow, followBody, keyID, privA); err != nil {
		t.Fatalf("sign follow request: %v", err)
	}

	respFollow, err := tsA.client().Do(reqFollow)
	if err != nil {
		t.Fatalf("send follow: %v", err)
	}
	bodyStr := body(t, respFollow)
	t.Logf("follow response status: %d, body: %.200s", respFollow.StatusCode, bodyStr)
	// Should succeed (200 or 202).
	if respFollow.StatusCode != 200 && respFollow.StatusCode != 202 {
		t.Errorf("follow should return 200/202, got %d", respFollow.StatusCode)
	}

	// Verify alice is now a follower.
	metaA, _ = loadOrCreateAPMeta(rpA)
	found := false
	for _, f := range metaA.Followers {
		if f == "https://remote.example/users/alice" {
			found = true
			break
		}
	}
	if !found {
		t.Error("alice should be in followers after Follow activity")
	}

	// Now simulate Alice creating a Note (thread) via A's inbox.
	noteBody, _ := json.Marshal(map[string]any{
		"@context": apContext,
		"type":     "Create",
		"actor":    "https://remote.example/users/alice",
		"object": map[string]any{
			"type":         "Note",
			"attributedTo": "https://remote.example/users/alice",
			"content":      "Hello from Alice\n\nThis is a federated thread from a remote user.",
		},
	})

	reqNote, _ := http.NewRequest("POST", inboxA, bytes.NewReader(noteBody))
	reqNote.Header.Set("Content-Type", "application/activity+json")
	reqNote.Header.Set("Host", tsA.cfg.Host)
	if err := signRequest(reqNote, noteBody, keyID, privA); err != nil {
		t.Fatalf("sign note request: %v", err)
	}

	respNote, err := tsA.client().Do(reqNote)
	if err != nil {
		t.Fatalf("send note: %v", err)
	}
	if respNote.StatusCode != 200 {
		noteBodyStr := body(t, respNote)
		t.Fatalf("note creation should return 200, got %d: %s", respNote.StatusCode, noteBodyStr)
	}

	// Verify the thread was created on server A.
	rp := repoPath(tsA.cfg, "fed-repo")
	threads, _, _ := listThreadsFiltered(rp, "open")
	if len(threads) != 1 {
		t.Fatalf("expected 1 thread from federation, got %d", len(threads))
	}
	if threads[0].Title != "Hello from Alice" {
		t.Errorf("thread title = %q, want 'Hello from Alice'", threads[0].Title)
	}
	if threads[0].Author != "https://remote.example/users/alice" {
		t.Errorf("thread author = %q, want remote user", threads[0].Author)
	}

	// Verify the thread is visible via the web UI.
	respWeb := tsA.get(t, cA, "/fed-repo/threads/"+threads[0].ID)
	assertStatus(t, respWeb, http.StatusOK)
	b := body(t, respWeb)
	if !strings.Contains(b, "Hello from Alice") {
		t.Error("thread page should show federated thread title")
	}
	if !strings.Contains(b, "federated thread") {
		t.Error("thread page should show federated thread body")
	}

	// Now simulate a reply via inbox.
	replyBody, _ := json.Marshal(map[string]any{
		"@context": apContext,
		"type":     "Create",
		"actor":    "https://remote.example/users/alice",
		"object": map[string]any{
			"type":         "Note",
			"attributedTo": "https://remote.example/users/alice",
			"content":      "This is a reply\n\nReplying to the federated thread.",
			"inReplyTo":    "https://" + tsA.cfg.Host + "/fed-repo/threads/" + threads[0].ID,
		},
	})

	reqReply, _ := http.NewRequest("POST", inboxA, bytes.NewReader(replyBody))
	reqReply.Header.Set("Content-Type", "application/activity+json")
	reqReply.Header.Set("Host", tsA.cfg.Host)
	signRequest(reqReply, replyBody, keyID, privA)

	respReply, err := tsA.client().Do(reqReply)
	if err != nil {
		t.Fatalf("send reply: %v", err)
	}
	if respReply.StatusCode != 200 {
		bodyStr = body(t, respReply)
		t.Fatalf("reply should return 200, got %d: %s", respReply.StatusCode, bodyStr)
	}

	// Reload the thread and verify the reply.
	thread, err := loadThread(rp, threads[0].ID)
	if err != nil {
		t.Fatalf("load thread: %v", err)
	}
	if len(thread.Replies) != 1 {
		t.Fatalf("expected 1 reply, got %d", len(thread.Replies))
	}
	if thread.Replies[0].Author != "https://remote.example/users/alice" {
		t.Errorf("reply author = %q", thread.Replies[0].Author)
	}
	if !strings.Contains(thread.Replies[0].Body, "Replying to the federated thread") {
		t.Error("reply body should contain the reply text")
	}

	// Undo the Follow.
	undoBody, _ := json.Marshal(map[string]any{
		"@context": apContext,
		"type":     "Undo",
		"actor":    "https://remote.example/users/alice",
		"object": map[string]any{
			"type":   "Follow",
			"actor":  "https://remote.example/users/alice",
			"object": actorA.ID,
		},
	})

	reqUndo, _ := http.NewRequest("POST", inboxA, bytes.NewReader(undoBody))
	reqUndo.Header.Set("Content-Type", "application/activity+json")
	reqUndo.Header.Set("Host", tsA.cfg.Host)
	signRequest(reqUndo, undoBody, keyID, privA)

	respUndo, err := tsA.client().Do(reqUndo)
	if err != nil {
		t.Fatalf("send undo: %v", err)
	}
	if respUndo.StatusCode != 200 {
		bodyStr = body(t, respUndo)
		t.Fatalf("undo should return 200, got %d: %s", respUndo.StatusCode, bodyStr)
	}

	// Alice should be removed from followers.
	metaA, _ = loadOrCreateAPMeta(rpA)
	for _, f := range metaA.Followers {
		if f == "https://remote.example/users/alice" {
			t.Error("alice should be removed after Undo Follow")
			break
		}
	}
}

// ---------- No host config disables AP ----------

func TestAPDisabledWithoutHost(t *testing.T) {
	ts := newTestServer(t) // no Host configured
	c := ts.client()
	ts.setupUser(t, c, "dev", "secret123")
	ts.createRepo(t, c, "no-ap", "No AP")

	// All AP endpoints should return 404.
	for _, path := range []string{
		"/.well-known/webfinger?resource=acct:no-ap@localhost",
		"/no-ap/ap/actor",
		"/no-ap/ap/outbox",
		"/no-ap/ap/followers",
	} {
		resp := ts.get(t, c, path)
		if resp.StatusCode != 404 {
			t.Errorf("%s should return 404 when Host is not configured, got %d", path, resp.StatusCode)
		}
	}
}