Files
owncast/activitypub/apmodels/actor_test.go
T
Gabe KangasandGitHub a82efb0e9a Add a layer of safety around required ActivityPub Actor fields (#4703)
* fix(ap): add safe constructors, getters, and validators to ActivityPub actors to address #4701

* chore: replace nil check with the new Validate() method

* chore(test): added test to verify the values extracted from actors

* fix: return the specific error type + test for it

* fix(ap): add recovery for each AP inbox worker for a worst case scenario

* fix(ap): add additional safe accessor methods to other AP entities other than actors

* chore(tests): add tests for the new safe accessors

* fix(ap): handle empty public keys in AP actors
2026-01-17 14:25:06 -08:00

618 lines
21 KiB
Go

package apmodels
import (
"errors"
"io/ioutil"
"net/url"
"os"
"testing"
"github.com/go-fed/activity/streams"
"github.com/go-fed/activity/streams/vocab"
"github.com/owncast/owncast/core/data"
"github.com/owncast/owncast/persistence/configrepository"
)
func makeFakeService() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
name := "Mr Foo"
username := "foodawg"
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
userAvatarURL, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/avatar.png")
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
nameProperty := streams.NewActivityStreamsNameProperty()
nameProperty.AppendXMLSchemaString(name)
service.SetActivityStreamsName(nameProperty)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
image := streams.NewActivityStreamsImage()
imgProp := streams.NewActivityStreamsUrlProperty()
imgProp.AppendIRI(userAvatarURL)
image.SetActivityStreamsUrl(imgProp)
icon := streams.NewActivityStreamsIconProperty()
icon.AppendActivityStreamsImage(image)
service.SetActivityStreamsIcon(icon)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
publicKeyType := streams.NewW3IDSecurityV1PublicKey()
publicKeyProperty.AppendW3IDSecurityV1PublicKey(publicKeyType)
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
}
func TestMain(m *testing.M) {
dbFile, err := ioutil.TempFile(os.TempDir(), "owncast-test-db.db")
if err != nil {
panic(err)
}
data.SetupPersistence(dbFile.Name())
configRepository := configrepository.Get()
configRepository.SetServerURL("https://my.cool.site.biz")
m.Run()
}
func TestMakeActorPropertyWithID(t *testing.T) {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
actor := MakeActorPropertyWithID(iri)
if actor.Begin().GetIRI() != iri {
t.Errorf("actor.IRI = %v, want %v", actor.Begin().GetIRI(), iri)
}
}
func TestGetFullUsernameFromPerson(t *testing.T) {
expected := "foodawg@fake.fediverse.server"
person := makeFakeService()
username := GetFullUsernameFromExternalEntity(person)
if username != expected {
t.Errorf("actor.Username = %v, want %v", username, expected)
}
}
func TestAddMetadataLinkToProfile(t *testing.T) {
person := makeFakeService()
addMetadataLinkToProfile(person, "my site", "https://my.cool.site.biz")
attchment := person.GetActivityStreamsAttachment().At(0)
nameValue := attchment.GetActivityStreamsObject().GetActivityStreamsName().At(0).GetXMLSchemaString()
expected := "my site"
if nameValue != expected {
t.Errorf("attachment name = %v, want %v", nameValue, expected)
}
propertyValue := attchment.GetActivityStreamsObject().GetUnknownProperties()["value"]
expected = `<a href="https://my.cool.site.biz" rel="me nofollow noopener noreferrer" target="_blank">https://my.cool.site.biz</a>`
if propertyValue != expected {
t.Errorf("attachment value = %v, want %v", propertyValue, expected)
}
}
func TestMakeServiceForAccount(t *testing.T) {
person := MakeServiceForAccount("accountname")
expectedIRI := "https://my.cool.site.biz/federation/user/accountname"
if person.GetJSONLDId().Get().String() != expectedIRI {
t.Errorf("actor.IRI = %v, want %v", person.GetJSONLDId().Get().String(), expectedIRI)
}
if person.GetActivityStreamsPreferredUsername().GetXMLSchemaString() != "accountname" {
t.Errorf("actor.PreferredUsername = %v, want %v", person.GetActivityStreamsPreferredUsername().GetXMLSchemaString(), expectedIRI)
}
expectedInbox := "https://my.cool.site.biz/federation/user/accountname/inbox"
if person.GetActivityStreamsInbox().GetIRI().String() != expectedInbox {
t.Errorf("actor.Inbox = %v, want %v", person.GetActivityStreamsInbox().GetIRI().String(), expectedInbox)
}
expectedOutbox := "https://my.cool.site.biz/federation/user/accountname/outbox"
if person.GetActivityStreamsOutbox().GetIRI().String() != expectedOutbox {
t.Errorf("actor.Outbox = %v, want %v", person.GetActivityStreamsOutbox().GetIRI().String(), expectedOutbox)
}
expectedFollowers := "https://my.cool.site.biz/federation/user/accountname/followers"
if person.GetActivityStreamsFollowers().GetIRI().String() != expectedFollowers {
t.Errorf("actor.Followers = %v, want %v", person.GetActivityStreamsFollowers().GetIRI().String(), expectedFollowers)
}
expectedName := "New Owncast Server"
if person.GetActivityStreamsName().Begin().GetXMLSchemaString() != expectedName {
t.Errorf("actor.Name = %v, want %v", person.GetActivityStreamsName().Begin().GetXMLSchemaString(), expectedName)
}
expectedAvatar := "https://my.cool.site.biz/logo/external"
u, err := url.Parse(person.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI().String())
if err != nil {
t.Error(err)
}
u.RawQuery = ""
if u.String() != expectedAvatar {
t.Errorf("actor.Avatar = %v, want %v", person.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI().String(), expectedAvatar)
}
expectedSummary := "This is a new live video streaming server powered by Owncast."
if person.GetActivityStreamsSummary().At(0).GetXMLSchemaString() != expectedSummary {
t.Errorf("actor.Summary = %v, want %v", person.GetActivityStreamsSummary().At(0).GetXMLSchemaString(), expectedSummary)
}
if person.GetActivityStreamsUrl().At(0).GetIRI().String() != expectedIRI {
t.Errorf("actor.URL = %v, want %v", person.GetActivityStreamsUrl().At(0).GetIRI().String(), expectedIRI)
}
}
// Tests for nil-safe accessor methods
func TestActorIriStringWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.ActorIriString()
if result != "" {
t.Errorf("ActorIriString() with nil ActorIri = %v, want empty string", result)
}
}
func TestActorIriStringWithValue(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
actor := ActivityPubActor{ActorIri: iri}
result := actor.ActorIriString()
if result != "https://example.com/user/test" {
t.Errorf("ActorIriString() = %v, want %v", result, "https://example.com/user/test")
}
}
func TestInboxStringWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.InboxString()
if result != "" {
t.Errorf("InboxString() with nil Inbox = %v, want empty string", result)
}
}
func TestInboxStringWithValue(t *testing.T) {
inbox, _ := url.Parse("https://example.com/user/test/inbox")
actor := ActivityPubActor{Inbox: inbox}
result := actor.InboxString()
if result != "https://example.com/user/test/inbox" {
t.Errorf("InboxString() = %v, want %v", result, "https://example.com/user/test/inbox")
}
}
func TestImageStringWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.ImageString()
if result != "" {
t.Errorf("ImageString() with nil Image = %v, want empty string", result)
}
}
func TestImageStringWithValue(t *testing.T) {
image, _ := url.Parse("https://example.com/avatar.png")
actor := ActivityPubActor{Image: image}
result := actor.ImageString()
if result != "https://example.com/avatar.png" {
t.Errorf("ImageString() = %v, want %v", result, "https://example.com/avatar.png")
}
}
func TestFollowRequestIriStringWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.FollowRequestIriString()
if result != "" {
t.Errorf("FollowRequestIriString() with nil FollowRequestIri = %v, want empty string", result)
}
}
func TestFollowRequestIriStringWithValue(t *testing.T) {
followIri, _ := url.Parse("https://example.com/follow/123")
actor := ActivityPubActor{FollowRequestIri: followIri}
result := actor.FollowRequestIriString()
if result != "https://example.com/follow/123" {
t.Errorf("FollowRequestIriString() = %v, want %v", result, "https://example.com/follow/123")
}
}
func TestActorIriHostnameWithNilValue(t *testing.T) {
actor := ActivityPubActor{}
result := actor.ActorIriHostname()
if result != "" {
t.Errorf("ActorIriHostname() with nil ActorIri = %v, want empty string", result)
}
}
func TestActorIriHostnameWithValue(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
actor := ActivityPubActor{ActorIri: iri}
result := actor.ActorIriHostname()
if result != "example.com" {
t.Errorf("ActorIriHostname() = %v, want %v", result, "example.com")
}
}
// Tests for Validate() and IsValid() methods
func TestValidateWithAllNilFields(t *testing.T) {
actor := ActivityPubActor{}
err := actor.Validate()
if err == nil {
t.Error("Validate() with all nil fields should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("Validate() error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestValidateWithNilActorIri(t *testing.T) {
inbox, _ := url.Parse("https://example.com/inbox")
actor := ActivityPubActor{Inbox: inbox}
err := actor.Validate()
if err == nil {
t.Error("Validate() with nil ActorIri should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("Validate() error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestValidateWithNilInbox(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
actor := ActivityPubActor{ActorIri: iri}
err := actor.Validate()
if err == nil {
t.Error("Validate() with nil Inbox should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("Validate() error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestValidateWithRequiredFields(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
inbox, _ := url.Parse("https://example.com/inbox")
actor := ActivityPubActor{ActorIri: iri, Inbox: inbox}
err := actor.Validate()
if err != nil {
t.Errorf("Validate() with required fields should not return error, got %v", err)
}
}
func TestIsValidWithInvalidActor(t *testing.T) {
actor := ActivityPubActor{}
if actor.IsValid() {
t.Error("IsValid() with invalid actor should return false")
}
}
func TestIsValidWithValidActor(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
inbox, _ := url.Parse("https://example.com/inbox")
actor := ActivityPubActor{ActorIri: iri, Inbox: inbox}
if !actor.IsValid() {
t.Error("IsValid() with valid actor should return true")
}
}
// Tests for NewActivityPubActor constructor
func TestNewActivityPubActorWithNilActorIri(t *testing.T) {
inbox, _ := url.Parse("https://example.com/inbox")
_, err := NewActivityPubActor(nil, inbox)
if err == nil {
t.Error("NewActivityPubActor with nil actorIri should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActor error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorWithNilInbox(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
_, err := NewActivityPubActor(iri, nil)
if err == nil {
t.Error("NewActivityPubActor with nil inbox should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActor error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorWithBothNil(t *testing.T) {
_, err := NewActivityPubActor(nil, nil)
if err == nil {
t.Error("NewActivityPubActor with both nil should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActor error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorWithValidArgs(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
inbox, _ := url.Parse("https://example.com/inbox")
actor, err := NewActivityPubActor(iri, inbox)
if err != nil {
t.Errorf("NewActivityPubActor with valid args should not return error, got %v", err)
}
if actor == nil {
t.Error("NewActivityPubActor with valid args should return non-nil actor")
}
if actor.ActorIri != iri {
t.Errorf("actor.ActorIri = %v, want %v", actor.ActorIri, iri)
}
if actor.Inbox != inbox {
t.Errorf("actor.Inbox = %v, want %v", actor.Inbox, inbox)
}
}
// Tests for NewActivityPubActorFromEntity with invalid entities
func makeFakeServiceWithoutUsername() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
}
func makeFakeServiceWithoutPublicKey() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
username := "foodawg"
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
return service
}
func makeFakeServiceWithEmptyPublicKey() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
username := "foodawg"
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
// Set an empty public key property (Len() == 0)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
}
func makeFakeServiceWithoutId() vocab.ActivityStreamsService {
inbox, _ := url.Parse("https://fake.fediverse.server/user/mrfoo/inbox")
username := "foodawg"
service := streams.NewActivityStreamsService()
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
inboxProp := streams.NewActivityStreamsInboxProperty()
inboxProp.SetIRI(inbox)
service.SetActivityStreamsInbox(inboxProp)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
}
func makeFakeServiceWithoutInbox() vocab.ActivityStreamsService {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
username := "foodawg"
service := streams.NewActivityStreamsService()
id := streams.NewJSONLDIdProperty()
id.Set(iri)
service.SetJSONLDId(id)
preferredUsernameProperty := streams.NewActivityStreamsPreferredUsernameProperty()
preferredUsernameProperty.SetXMLSchemaString(username)
service.SetActivityStreamsPreferredUsername(preferredUsernameProperty)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
}
func TestNewActivityPubActorFromEntityWithoutUsername(t *testing.T) {
service := makeFakeServiceWithoutUsername()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity without username should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithoutPublicKey(t *testing.T) {
service := makeFakeServiceWithoutPublicKey()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity without public key should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithEmptyPublicKey(t *testing.T) {
service := makeFakeServiceWithEmptyPublicKey()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity with empty public key should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithoutId(t *testing.T) {
service := makeFakeServiceWithoutId()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity without ID should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithoutInbox(t *testing.T) {
service := makeFakeServiceWithoutInbox()
_, err := NewActivityPubActorFromEntity(service)
if err == nil {
t.Error("NewActivityPubActorFromEntity without inbox should return error")
}
if !errors.Is(err, ErrActorMissingRequiredField) {
t.Errorf("NewActivityPubActorFromEntity error = %v, want ErrActorMissingRequiredField", err)
}
}
func TestNewActivityPubActorFromEntityWithValidEntity(t *testing.T) {
service := makeFakeService()
actor, err := NewActivityPubActorFromEntity(service)
if err != nil {
t.Errorf("NewActivityPubActorFromEntity with valid entity should not return error, got %v", err)
}
if actor == nil {
t.Fatal("NewActivityPubActorFromEntity with valid entity should return non-nil actor")
}
// Verify required fields are non-nil
if actor.ActorIri == nil {
t.Error("actor.ActorIri should not be nil")
}
if actor.Inbox == nil {
t.Error("actor.Inbox should not be nil")
}
// Verify extracted values match the fake service data
expectedIri := "https://fake.fediverse.server/user/mrfoo"
if actor.ActorIriString() != expectedIri {
t.Errorf("actor.ActorIri = %v, want %v", actor.ActorIriString(), expectedIri)
}
expectedInbox := "https://fake.fediverse.server/user/mrfoo/inbox"
if actor.InboxString() != expectedInbox {
t.Errorf("actor.Inbox = %v, want %v", actor.InboxString(), expectedInbox)
}
expectedName := "Mr Foo"
if actor.Name != expectedName {
t.Errorf("actor.Name = %v, want %v", actor.Name, expectedName)
}
expectedUsername := "foodawg"
if actor.Username != expectedUsername {
t.Errorf("actor.Username = %v, want %v", actor.Username, expectedUsername)
}
expectedImage := "https://fake.fediverse.server/user/mrfoo/avatar.png"
if actor.ImageString() != expectedImage {
t.Errorf("actor.Image = %v, want %v", actor.ImageString(), expectedImage)
}
}
// Test that safe accessors don't panic on zero-value struct
func TestSafeAccessorsOnZeroValueStruct(t *testing.T) {
var actor ActivityPubActor
// These should not panic
_ = actor.ActorIriString()
_ = actor.InboxString()
_ = actor.ImageString()
_ = actor.FollowRequestIriString()
_ = actor.ActorIriHostname()
_ = actor.Validate()
_ = actor.IsValid()
}
// Test that safe accessors work correctly with optional nil fields on otherwise valid actor
func TestSafeAccessorsWithOptionalNilFields(t *testing.T) {
iri, _ := url.Parse("https://example.com/user/test")
inbox, _ := url.Parse("https://example.com/inbox")
actor := ActivityPubActor{
ActorIri: iri,
Inbox: inbox,
// Image and FollowRequestIri are intentionally nil
}
// Required fields should return values
if actor.ActorIriString() != "https://example.com/user/test" {
t.Errorf("ActorIriString() = %v, want non-empty", actor.ActorIriString())
}
if actor.InboxString() != "https://example.com/inbox" {
t.Errorf("InboxString() = %v, want non-empty", actor.InboxString())
}
// Optional nil fields should return empty strings without panicking
if actor.ImageString() != "" {
t.Errorf("ImageString() = %v, want empty string", actor.ImageString())
}
if actor.FollowRequestIriString() != "" {
t.Errorf("FollowRequestIriString() = %v, want empty string", actor.FollowRequestIriString())
}
// Actor should still be valid (only ActorIri and Inbox are required)
if !actor.IsValid() {
t.Error("Actor with ActorIri and Inbox should be valid even with nil optional fields")
}
}