fierj
PublicTiny personal git forge
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)
}
}
}