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
This commit is contained in:
Gabe Kangas
2026-01-17 14:25:06 -08:00
committed by GitHub
parent 0ff73d5e1e
commit a82efb0e9a
19 changed files with 1671 additions and 177 deletions
+124 -40
View File
@@ -38,6 +38,129 @@ type ActivityPubActor struct {
FullUsername string
}
// ErrActorMissingRequiredField is returned when an actor is missing a required field.
var ErrActorMissingRequiredField = errors.New("actor missing required field")
// Validate checks that required fields are present on the actor.
// Returns an error if ActorIri or Inbox are nil.
func (a *ActivityPubActor) Validate() error {
if a.ActorIri == nil {
return fmt.Errorf("%w: ActorIri is required", ErrActorMissingRequiredField)
}
if a.Inbox == nil {
return fmt.Errorf("%w: Inbox is required", ErrActorMissingRequiredField)
}
return nil
}
// IsValid returns true if the actor has all required fields.
func (a *ActivityPubActor) IsValid() bool {
return a.Validate() == nil
}
// ActorIriString returns the string representation of ActorIri, or empty string if nil.
func (a *ActivityPubActor) ActorIriString() string {
if a.ActorIri == nil {
return ""
}
return a.ActorIri.String()
}
// InboxString returns the string representation of Inbox, or empty string if nil.
func (a *ActivityPubActor) InboxString() string {
if a.Inbox == nil {
return ""
}
return a.Inbox.String()
}
// ImageString returns the string representation of Image, or empty string if nil.
func (a *ActivityPubActor) ImageString() string {
if a.Image == nil {
return ""
}
return a.Image.String()
}
// FollowRequestIriString returns the string representation of FollowRequestIri, or empty string if nil.
func (a *ActivityPubActor) FollowRequestIriString() string {
if a.FollowRequestIri == nil {
return ""
}
return a.FollowRequestIri.String()
}
// ActorIriHostname returns the hostname of ActorIri, or empty string if nil.
func (a *ActivityPubActor) ActorIriHostname() string {
if a.ActorIri == nil {
return ""
}
return a.ActorIri.Hostname()
}
// NewActivityPubActor creates a new ActivityPubActor with required fields.
// Returns an error if actorIri or inbox are nil.
func NewActivityPubActor(actorIri, inbox *url.URL) (*ActivityPubActor, error) {
if actorIri == nil {
return nil, fmt.Errorf("%w: actorIri is required", ErrActorMissingRequiredField)
}
if inbox == nil {
return nil, fmt.Errorf("%w: inbox is required", ErrActorMissingRequiredField)
}
return &ActivityPubActor{
ActorIri: actorIri,
Inbox: inbox,
}, nil
}
// NewActivityPubActorFromEntity creates a new ActivityPubActor from an external entity
// with validation of required fields.
func NewActivityPubActorFromEntity(entity ExternalEntity) (*ActivityPubActor, error) {
// ActorIri is required (must validate before GetFullUsernameFromExternalEntity which uses it)
if entity.GetJSONLDId() == nil || entity.GetJSONLDId().Get() == nil {
return nil, fmt.Errorf("%w: entity is missing actor IRI", ErrActorMissingRequiredField)
}
actorIri := entity.GetJSONLDId().Get()
// Inbox is required
if entity.GetActivityStreamsInbox() == nil || entity.GetActivityStreamsInbox().GetIRI() == nil {
return nil, fmt.Errorf("%w: entity is missing inbox", ErrActorMissingRequiredField)
}
inbox := entity.GetActivityStreamsInbox().GetIRI()
// Username is required (but not a part of the official ActivityPub spec)
if entity.GetActivityStreamsPreferredUsername() == nil || entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString() == "" {
return nil, fmt.Errorf("%w: entity is missing preferred username", ErrActorMissingRequiredField)
}
username := GetFullUsernameFromExternalEntity(entity)
// Key is required
if entity.GetW3IDSecurityV1PublicKey() == nil || entity.GetW3IDSecurityV1PublicKey().Len() == 0 {
return nil, fmt.Errorf("%w: entity is missing public key", ErrActorMissingRequiredField)
}
// Name is optional
var name string
if entity.GetActivityStreamsName() != nil && !entity.GetActivityStreamsName().Empty() {
name = entity.GetActivityStreamsName().At(0).GetXMLSchemaString()
}
// Image is optional
image := GetImageFromIcon(entity.GetActivityStreamsIcon())
apActor := &ActivityPubActor{
ActorIri: actorIri,
Inbox: inbox,
Name: name,
Username: entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString(),
FullUsername: username,
W3IDSecurityV1PublicKey: entity.GetW3IDSecurityV1PublicKey(),
Image: image,
}
return apActor, nil
}
// DeleteRequest represents a request for delete.
type DeleteRequest struct {
ActorIri string
@@ -53,45 +176,6 @@ type ExternalEntity interface {
GetW3IDSecurityV1PublicKey() vocab.W3IDSecurityV1PublicKeyProperty
}
// MakeActorFromExernalAPEntity takes a full ActivityPub entity and returns our
// internal representation of an actor.
func MakeActorFromExernalAPEntity(entity ExternalEntity) (*ActivityPubActor, error) {
// Username is required (but not a part of the official ActivityPub spec)
if entity.GetActivityStreamsPreferredUsername() == nil || entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString() == "" {
return nil, errors.New("remote activitypub entity does not have a preferred username set, rejecting")
}
username := GetFullUsernameFromExternalEntity(entity)
// Key is required
if entity.GetW3IDSecurityV1PublicKey() == nil {
return nil, errors.New("remote activitypub entity does not have a public key set, rejecting")
}
// Name is optional
var name string
if entity.GetActivityStreamsName() != nil && !entity.GetActivityStreamsName().Empty() {
name = entity.GetActivityStreamsName().At(0).GetXMLSchemaString()
}
// Image is optional
var image *url.URL
if entity.GetActivityStreamsIcon() != nil && !entity.GetActivityStreamsIcon().Empty() && entity.GetActivityStreamsIcon().At(0).GetActivityStreamsImage() != nil {
image = entity.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().Begin().GetIRI()
}
apActor := ActivityPubActor{
ActorIri: entity.GetJSONLDId().Get(),
Inbox: entity.GetActivityStreamsInbox().GetIRI(),
Name: name,
Username: entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString(),
FullUsername: username,
W3IDSecurityV1PublicKey: entity.GetW3IDSecurityV1PublicKey(),
Image: image,
}
return &apActor, nil
}
// MakeActorPropertyWithID will return an actor property filled with the provided IRI.
func MakeActorPropertyWithID(idIRI *url.URL) vocab.ActivityStreamsActorProperty {
actor := streams.NewActivityStreamsActorProperty()
@@ -241,7 +325,7 @@ func MakeServiceForAccount(accountName string) vocab.ActivityStreamsService {
// GetFullUsernameFromExternalEntity will return the full username from an
// internal representation of an ExternalEntity. Returns user@host.tld.
func GetFullUsernameFromExternalEntity(entity ExternalEntity) string {
hostname := entity.GetJSONLDId().GetIRI().Hostname()
hostname := GetHostnameFromJSONLDId(entity.GetJSONLDId())
username := entity.GetActivityStreamsPreferredUsername().GetXMLSchemaString()
fullUsername := fmt.Sprintf("%s@%s", username, hostname)
+463 -28
View File
@@ -1,6 +1,7 @@
package apmodels
import (
"errors"
"io/ioutil"
"net/url"
"os"
@@ -46,6 +47,8 @@ func makeFakeService() vocab.ActivityStreamsService {
service.SetActivityStreamsIcon(icon)
publicKeyProperty := streams.NewW3IDSecurityV1PublicKeyProperty()
publicKeyType := streams.NewW3IDSecurityV1PublicKey()
publicKeyProperty.AppendW3IDSecurityV1PublicKey(publicKeyType)
service.SetW3IDSecurityV1PublicKey(publicKeyProperty)
return service
@@ -65,34 +68,6 @@ func TestMain(m *testing.M) {
m.Run()
}
func TestMakeActorFromExternalAPEntity(t *testing.T) {
service := makeFakeService()
actor, err := MakeActorFromExernalAPEntity(service)
if err != nil {
t.Error(err)
}
if actor.ActorIri != service.GetJSONLDId().GetIRI() {
t.Errorf("actor.ID = %v, want %v", actor.ActorIri, service.GetJSONLDId().GetIRI())
}
if actor.Name != service.GetActivityStreamsName().At(0).GetXMLSchemaString() {
t.Errorf("actor.Name = %v, want %v", actor.Name, service.GetActivityStreamsName().At(0).GetXMLSchemaString())
}
if actor.Username != service.GetActivityStreamsPreferredUsername().GetXMLSchemaString() {
t.Errorf("actor.Username = %v, want %v", actor.Username, service.GetActivityStreamsPreferredUsername().GetXMLSchemaString())
}
if actor.Inbox != service.GetActivityStreamsInbox().GetIRI() {
t.Errorf("actor.Inbox = %v, want %v", actor.Inbox.String(), service.GetActivityStreamsInbox().GetIRI())
}
if actor.Image != service.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().At(0).GetIRI() {
t.Errorf("actor.Image = %v, want %v", actor.Image, service.GetActivityStreamsIcon().At(0).GetActivityStreamsImage().GetActivityStreamsUrl().At(0).GetIRI())
}
}
func TestMakeActorPropertyWithID(t *testing.T) {
iri, _ := url.Parse("https://fake.fediverse.server/user/mrfoo")
actor := MakeActorPropertyWithID(iri)
@@ -180,3 +155,463 @@ func TestMakeServiceForAccount(t *testing.T) {
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")
}
}
+147
View File
@@ -2,6 +2,7 @@ package apmodels
import (
"encoding/json"
"fmt"
"net/url"
"path"
"path/filepath"
@@ -118,3 +119,149 @@ func GetLogoType() string {
}
return logoType
}
// ErrMissingIRI is returned when an IRI cannot be extracted from an ActivityStreams property.
var ErrMissingIRI = fmt.Errorf("missing IRI")
// GetIRIFromActorProperty safely extracts the IRI from an ActivityStreamsActorProperty.
// Returns the IRI and nil error on success, or nil and an error if the IRI cannot be extracted.
func GetIRIFromActorProperty(actor vocab.ActivityStreamsActorProperty) (*url.URL, error) {
if actor == nil || actor.Empty() || actor.Len() == 0 {
return nil, fmt.Errorf("%w: actor property is empty or nil", ErrMissingIRI)
}
first := actor.At(0)
if first == nil {
return nil, fmt.Errorf("%w: actor property first element is nil", ErrMissingIRI)
}
iri := first.GetIRI()
if iri == nil {
return nil, fmt.Errorf("%w: actor IRI is nil", ErrMissingIRI)
}
return iri, nil
}
// GetIRIStringFromActorProperty safely extracts the IRI string from an ActivityStreamsActorProperty.
// Returns the IRI string and nil error on success, or empty string and an error if extraction fails.
func GetIRIStringFromActorProperty(actor vocab.ActivityStreamsActorProperty) (string, error) {
iri, err := GetIRIFromActorProperty(actor)
if err != nil {
return "", err
}
return iri.String(), nil
}
// GetIRIFromObjectProperty safely extracts the IRI from an ActivityStreamsObjectProperty.
// Returns the IRI and nil error on success, or nil and an error if the IRI cannot be extracted.
func GetIRIFromObjectProperty(object vocab.ActivityStreamsObjectProperty) (*url.URL, error) {
if object == nil || object.Len() == 0 {
return nil, fmt.Errorf("%w: object property is empty or nil", ErrMissingIRI)
}
first := object.At(0)
if first == nil {
return nil, fmt.Errorf("%w: object property first element is nil", ErrMissingIRI)
}
iri := first.GetIRI()
if iri == nil {
return nil, fmt.Errorf("%w: object IRI is nil", ErrMissingIRI)
}
return iri, nil
}
// GetIRIStringFromObjectProperty safely extracts the IRI string from an ActivityStreamsObjectProperty.
// Returns the IRI string and nil error on success, or empty string and an error if extraction fails.
func GetIRIStringFromObjectProperty(object vocab.ActivityStreamsObjectProperty) (string, error) {
iri, err := GetIRIFromObjectProperty(object)
if err != nil {
return "", err
}
return iri.String(), nil
}
// GetIRIFromJSONLDIdProperty safely extracts the IRI from a JSONLDIdProperty.
// Returns the IRI and nil error on success, or nil and an error if the IRI cannot be extracted.
func GetIRIFromJSONLDIdProperty(id vocab.JSONLDIdProperty) (*url.URL, error) {
if id == nil {
return nil, fmt.Errorf("%w: JSONLD id property is nil", ErrMissingIRI)
}
iri := id.GetIRI()
if iri == nil {
return nil, fmt.Errorf("%w: JSONLD id IRI is nil", ErrMissingIRI)
}
return iri, nil
}
// GetIRIStringFromJSONLDIdProperty safely extracts the IRI string from a JSONLDIdProperty.
// Returns the IRI string and nil error on success, or empty string and an error if extraction fails.
func GetIRIStringFromJSONLDIdProperty(id vocab.JSONLDIdProperty) (string, error) {
iri, err := GetIRIFromJSONLDIdProperty(id)
if err != nil {
return "", err
}
return iri.String(), nil
}
// GetPublicKeyPem safely extracts the public key PEM from a W3IDSecurityV1PublicKey.
// Returns the PEM string and nil error on success, or empty string and an error if extraction fails.
func GetPublicKeyPem(publicKey vocab.W3IDSecurityV1PublicKey) (string, error) {
if publicKey == nil {
return "", fmt.Errorf("public key is nil")
}
pemProp := publicKey.GetW3IDSecurityV1PublicKeyPem()
if pemProp == nil {
return "", fmt.Errorf("public key PEM property is nil")
}
return pemProp.Get(), nil
}
// IsFirstObjectActivityStreamsPerson safely checks if the first element of an
// ActivityStreamsObjectProperty is an ActivityStreamsPerson.
// Returns false if the object is nil, empty, or the first element is not a Person.
func IsFirstObjectActivityStreamsPerson(object vocab.ActivityStreamsObjectProperty) bool {
if object == nil || object.Len() == 0 {
return false
}
first := object.At(0)
if first == nil {
return false
}
return first.IsActivityStreamsPerson()
}
// GetImageFromIcon safely extracts the image URL from an ActivityStreamsIconProperty.
// Returns the URL and nil error on success, or nil and nil if the icon is not present or invalid.
// This handles the common pattern of icon -> image -> url -> iri.
func GetImageFromIcon(icon vocab.ActivityStreamsIconProperty) *url.URL {
if icon == nil || icon.Empty() {
return nil
}
first := icon.At(0)
if first == nil {
return nil
}
image := first.GetActivityStreamsImage()
if image == nil {
return nil
}
urlProp := image.GetActivityStreamsUrl()
if urlProp == nil {
return nil
}
begin := urlProp.Begin()
if begin == nil {
return nil
}
return begin.GetIRI()
}
// GetHostnameFromJSONLDId safely extracts the hostname from a JSONLDIdProperty.
// Returns the hostname string, or empty string if extraction fails.
func GetHostnameFromJSONLDId(id vocab.JSONLDIdProperty) string {
if id == nil {
return ""
}
iri := id.GetIRI()
if iri == nil {
return ""
}
return iri.Hostname()
}
+302
View File
@@ -0,0 +1,302 @@
package apmodels
import (
"errors"
"net/url"
"testing"
"github.com/go-fed/activity/streams"
)
func TestGetIRIFromActorPropertyWithNil(t *testing.T) {
_, err := GetIRIFromActorProperty(nil)
if err == nil {
t.Error("GetIRIFromActorProperty(nil) should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromActorProperty(nil) error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromActorPropertyWithEmpty(t *testing.T) {
actor := streams.NewActivityStreamsActorProperty()
_, err := GetIRIFromActorProperty(actor)
if err == nil {
t.Error("GetIRIFromActorProperty with empty actor should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromActorProperty error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromActorPropertyWithValidIRI(t *testing.T) {
actor := streams.NewActivityStreamsActorProperty()
iri, _ := url.Parse("https://example.com/user/test")
actor.AppendIRI(iri)
result, err := GetIRIFromActorProperty(actor)
if err != nil {
t.Errorf("GetIRIFromActorProperty with valid IRI should not return error, got %v", err)
}
if result.String() != "https://example.com/user/test" {
t.Errorf("GetIRIFromActorProperty = %v, want %v", result.String(), "https://example.com/user/test")
}
}
func TestGetIRIStringFromActorPropertyWithValidIRI(t *testing.T) {
actor := streams.NewActivityStreamsActorProperty()
iri, _ := url.Parse("https://example.com/user/test")
actor.AppendIRI(iri)
result, err := GetIRIStringFromActorProperty(actor)
if err != nil {
t.Errorf("GetIRIStringFromActorProperty with valid IRI should not return error, got %v", err)
}
if result != "https://example.com/user/test" {
t.Errorf("GetIRIStringFromActorProperty = %v, want %v", result, "https://example.com/user/test")
}
}
func TestGetIRIFromObjectPropertyWithNil(t *testing.T) {
_, err := GetIRIFromObjectProperty(nil)
if err == nil {
t.Error("GetIRIFromObjectProperty(nil) should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromObjectProperty(nil) error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromObjectPropertyWithEmpty(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
_, err := GetIRIFromObjectProperty(object)
if err == nil {
t.Error("GetIRIFromObjectProperty with empty object should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromObjectProperty error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromObjectPropertyWithValidIRI(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
iri, _ := url.Parse("https://example.com/post/123")
object.AppendIRI(iri)
result, err := GetIRIFromObjectProperty(object)
if err != nil {
t.Errorf("GetIRIFromObjectProperty with valid IRI should not return error, got %v", err)
}
if result.String() != "https://example.com/post/123" {
t.Errorf("GetIRIFromObjectProperty = %v, want %v", result.String(), "https://example.com/post/123")
}
}
func TestGetIRIStringFromObjectPropertyWithValidIRI(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
iri, _ := url.Parse("https://example.com/post/123")
object.AppendIRI(iri)
result, err := GetIRIStringFromObjectProperty(object)
if err != nil {
t.Errorf("GetIRIStringFromObjectProperty with valid IRI should not return error, got %v", err)
}
if result != "https://example.com/post/123" {
t.Errorf("GetIRIStringFromObjectProperty = %v, want %v", result, "https://example.com/post/123")
}
}
func TestGetIRIFromJSONLDIdPropertyWithNil(t *testing.T) {
_, err := GetIRIFromJSONLDIdProperty(nil)
if err == nil {
t.Error("GetIRIFromJSONLDIdProperty(nil) should return error")
}
if !errors.Is(err, ErrMissingIRI) {
t.Errorf("GetIRIFromJSONLDIdProperty(nil) error = %v, want ErrMissingIRI", err)
}
}
func TestGetIRIFromJSONLDIdPropertyWithValidIRI(t *testing.T) {
id := streams.NewJSONLDIdProperty()
iri, _ := url.Parse("https://example.com/activity/456")
id.SetIRI(iri)
result, err := GetIRIFromJSONLDIdProperty(id)
if err != nil {
t.Errorf("GetIRIFromJSONLDIdProperty with valid IRI should not return error, got %v", err)
}
if result.String() != "https://example.com/activity/456" {
t.Errorf("GetIRIFromJSONLDIdProperty = %v, want %v", result.String(), "https://example.com/activity/456")
}
}
func TestGetIRIStringFromJSONLDIdPropertyWithValidIRI(t *testing.T) {
id := streams.NewJSONLDIdProperty()
iri, _ := url.Parse("https://example.com/activity/456")
id.SetIRI(iri)
result, err := GetIRIStringFromJSONLDIdProperty(id)
if err != nil {
t.Errorf("GetIRIStringFromJSONLDIdProperty with valid IRI should not return error, got %v", err)
}
if result != "https://example.com/activity/456" {
t.Errorf("GetIRIStringFromJSONLDIdProperty = %v, want %v", result, "https://example.com/activity/456")
}
}
func TestGetIRIFromJSONLDIdPropertyWithNoIRI(t *testing.T) {
id := streams.NewJSONLDIdProperty()
// Set a non-IRI value (using Set instead of SetIRI)
iri, _ := url.Parse("https://example.com/activity/456")
id.Set(iri)
// When using Set() the IRI should still be retrievable via GetIRI()
result, err := GetIRIFromJSONLDIdProperty(id)
if err != nil {
t.Errorf("GetIRIFromJSONLDIdProperty should work with Set(), got error %v", err)
}
if result == nil {
t.Error("GetIRIFromJSONLDIdProperty result should not be nil")
}
}
func TestGetPublicKeyPemWithNil(t *testing.T) {
_, err := GetPublicKeyPem(nil)
if err == nil {
t.Error("GetPublicKeyPem(nil) should return error")
}
}
func TestGetPublicKeyPemWithValidKey(t *testing.T) {
publicKey := streams.NewW3IDSecurityV1PublicKey()
pemProp := streams.NewW3IDSecurityV1PublicKeyPemProperty()
pemProp.Set("-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----")
publicKey.SetW3IDSecurityV1PublicKeyPem(pemProp)
result, err := GetPublicKeyPem(publicKey)
if err != nil {
t.Errorf("GetPublicKeyPem with valid key should not return error, got %v", err)
}
if result != "-----BEGIN PUBLIC KEY-----\ntest\n-----END PUBLIC KEY-----" {
t.Errorf("GetPublicKeyPem = %v, want PEM string", result)
}
}
func TestGetPublicKeyPemWithNoPem(t *testing.T) {
publicKey := streams.NewW3IDSecurityV1PublicKey()
// Don't set PEM property
_, err := GetPublicKeyPem(publicKey)
if err == nil {
t.Error("GetPublicKeyPem with no PEM should return error")
}
}
// Test that safe accessors don't panic on nil/empty inputs
func TestSafeAccessorsNoPanic(t *testing.T) {
// These should not panic
_, _ = GetIRIFromActorProperty(nil)
_, _ = GetIRIStringFromActorProperty(nil)
_, _ = GetIRIFromObjectProperty(nil)
_, _ = GetIRIStringFromObjectProperty(nil)
_, _ = GetIRIFromJSONLDIdProperty(nil)
_, _ = GetIRIStringFromJSONLDIdProperty(nil)
_, _ = GetPublicKeyPem(nil)
_ = GetImageFromIcon(nil)
_ = GetHostnameFromJSONLDId(nil)
_ = IsFirstObjectActivityStreamsPerson(nil)
// Empty properties should also not panic
_, _ = GetIRIFromActorProperty(streams.NewActivityStreamsActorProperty())
_, _ = GetIRIFromObjectProperty(streams.NewActivityStreamsObjectProperty())
_ = GetImageFromIcon(streams.NewActivityStreamsIconProperty())
_ = IsFirstObjectActivityStreamsPerson(streams.NewActivityStreamsObjectProperty())
}
func TestGetImageFromIconWithNil(t *testing.T) {
result := GetImageFromIcon(nil)
if result != nil {
t.Error("GetImageFromIcon(nil) should return nil")
}
}
func TestGetImageFromIconWithEmpty(t *testing.T) {
icon := streams.NewActivityStreamsIconProperty()
result := GetImageFromIcon(icon)
if result != nil {
t.Error("GetImageFromIcon with empty icon should return nil")
}
}
func TestGetImageFromIconWithValidImage(t *testing.T) {
icon := streams.NewActivityStreamsIconProperty()
image := streams.NewActivityStreamsImage()
urlProp := streams.NewActivityStreamsUrlProperty()
imageURL, _ := url.Parse("https://example.com/avatar.png")
urlProp.AppendIRI(imageURL)
image.SetActivityStreamsUrl(urlProp)
icon.AppendActivityStreamsImage(image)
result := GetImageFromIcon(icon)
if result == nil {
t.Error("GetImageFromIcon with valid image should not return nil")
}
if result.String() != "https://example.com/avatar.png" {
t.Errorf("GetImageFromIcon = %v, want %v", result.String(), "https://example.com/avatar.png")
}
}
func TestGetHostnameFromJSONLDIdWithNil(t *testing.T) {
result := GetHostnameFromJSONLDId(nil)
if result != "" {
t.Errorf("GetHostnameFromJSONLDId(nil) = %v, want empty string", result)
}
}
func TestGetHostnameFromJSONLDIdWithValidId(t *testing.T) {
id := streams.NewJSONLDIdProperty()
iri, _ := url.Parse("https://example.com/user/test")
id.SetIRI(iri)
result := GetHostnameFromJSONLDId(id)
if result != "example.com" {
t.Errorf("GetHostnameFromJSONLDId = %v, want %v", result, "example.com")
}
}
func TestIsFirstObjectActivityStreamsPersonWithNil(t *testing.T) {
result := IsFirstObjectActivityStreamsPerson(nil)
if result {
t.Error("IsFirstObjectActivityStreamsPerson(nil) should return false")
}
}
func TestIsFirstObjectActivityStreamsPersonWithEmpty(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
result := IsFirstObjectActivityStreamsPerson(object)
if result {
t.Error("IsFirstObjectActivityStreamsPerson with empty object should return false")
}
}
func TestIsFirstObjectActivityStreamsPersonWithPerson(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
person := streams.NewActivityStreamsPerson()
object.AppendActivityStreamsPerson(person)
result := IsFirstObjectActivityStreamsPerson(object)
if !result {
t.Error("IsFirstObjectActivityStreamsPerson with person should return true")
}
}
func TestIsFirstObjectActivityStreamsPersonWithNonPerson(t *testing.T) {
object := streams.NewActivityStreamsObjectProperty()
note := streams.NewActivityStreamsNote()
object.AppendActivityStreamsNote(note)
result := IsFirstObjectActivityStreamsPerson(object)
if result {
t.Error("IsFirstObjectActivityStreamsPerson with note should return false")
}
}