describe DIDKit::Document do subject { described_class } let(:did) { DID.new('did:plc:yk4dd2qkboz2yv6tpubpc6co') } let(:base_json) { load_did_json('dholms.json') } describe '#initialize' do context 'with valid input' do let(:json) { base_json } it 'should return a Document object' do doc = subject.new(did, json) doc.should be_a(DIDKit::Document) doc.did.should == did doc.json.should == json end it 'should parse services from the JSON' do doc = subject.new(did, json) doc.services.should be_an(Array) doc.services.length.should == 1 doc.services[0].should be_a(DIDKit::ServiceRecord) doc.services[0].key.should == 'atproto_pds' doc.services[0].type.should == 'AtprotoPersonalDataServer' doc.services[0].endpoint.should == 'https://pds.dholms.xyz' end it 'should parse handles from the JSON' do doc = subject.new(did, json) doc.handles.should == ['dholms.xyz'] end end context 'when id is missing' do let(:json) { base_json.dup.tap { |h| h.delete('id') }} it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when id is not a string' do let(:json) { base_json.merge('id' => 123) } it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when id does not match the DID' do let(:json) { base_json.merge('id' => 'did:plc:notmatching') } it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when alsoKnownAs is not an array' do let(:json) { base_json.merge('alsoKnownAs' => 'at://dholms.xyz') } it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when alsoKnownAs elements are not strings' do let(:json) { base_json.merge('alsoKnownAs' => [666]) } it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when alsoKnownAs contains multiple handles' do let(:json) { base_json.merge('alsoKnownAs' => [ 'at://dholms.xyz', 'https://example.com', 'at://other.handle' ]) } it 'should pick those starting with at:// and remove the prefixes' do doc = subject.new(did, json) doc.handles.should == ['dholms.xyz', 'other.handle'] end it 'should return all entries in #also_known_as' do op = subject.new(did, json) op.also_known_as.should == ['at://dholms.xyz', 'https://example.com', 'at://other.handle'] end end context 'when service is not an array' do let(:json) { base_json.merge('service' => 'not-an-array') } it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when service entries are not hashes' do let(:json) { base_json.merge('service' => ['invalid']) } it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when service entry id is missing' do let(:json) { base_json.merge('service' => [service]) } let(:service) {{ 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' }} it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when service entry id is not a string' do let(:json) { base_json.merge('service' => [service]) } let(:service) {{ 'id' => 5, 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' }} it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when service entry id does not start with a #' do let(:json) { base_json.merge('service' => [service]) } let(:service) {{ 'id' => 'atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' }} it 'should *not* raise a format error' do expect { subject.new(did, json) }.to_not raise_error end end context 'when service entry type is missing' do let(:json) { base_json.merge('service' => [service]) } let(:service) {{ 'id' => '#atproto_pds', 'serviceEndpoint' => 'https://pds.dholms.xyz' }} it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when service entry type is not a string' do let(:json) { base_json.merge('service' => [service]) } let(:service) {{ 'id' => '#atproto_pds', 'type' => 7, 'serviceEndpoint' => 'https://pds.dholms.xyz' }} it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when service entry endpoint is missing' do let(:json) { base_json.merge('service' => [service]) } let(:service) {{ 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer' }} it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when service entry endpoint is not a string' do let(:json) { base_json.merge('service' => [service]) } let(:service) {{ 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => :somewhere }} it 'should raise a format error' do expect { subject.new(did, json) }.to raise_error(DIDKit::FormatError) end end context 'when service entry endpoint is not an URI' do let(:json) { base_json.merge('service' => [service]) } let(:service) {{ 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => '' }} it 'should *not* raise a format error' do expect { subject.new(did, json) }.to_not raise_error end end context 'when service entries are partially valid' do let(:services) { [ { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' }, { 'id' => 'missing_hash', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' }, { 'id' => '#wrong_endpoint', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'this is not a url' }, { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' } ] } let(:json) { base_json.merge('service' => services) } it 'should only keep the valid records' do doc = subject.new(did, json) doc.services.length.should == 2 doc.services.map(&:key).should == ['atproto_pds', 'lycan'] doc.services.map(&:type).should == ['AtprotoPersonalDataServer', 'LycanService'] doc.services.map(&:endpoint).should == ['https://pds.dholms.xyz', 'https://lycan.feeds.blue'] end end end describe '#get_verified_handle' do let(:json) { base_json.merge('alsoKnownAs' => [ "mozilla.org", "at://dholms.xyz", "at://dholms.bsky.team" ])} it 'should return first syntactically correct handle if it validates' do DIDKit::Resolver.any_instance.stubs(:resolve_handle).with('dholms.xyz').returns(did.to_s) doc = subject.new(did, json) doc.get_verified_handle.should == 'dholms.xyz' end it "should return nil if the first handle doesn't validate" do DIDKit::Resolver.any_instance.stubs(:resolve_handle).with('dholms.xyz').returns('') doc = subject.new(did, json) doc.get_verified_handle.should be_nil end it "should return nil if there are no syntactically valid handles" do json = base_json.merge('alsoKnownAs' => ["mozilla.org"]) doc = subject.new(did, json) doc.get_verified_handle.should be_nil end end describe '#signing_key' do let(:verification_method) { base_json['verificationMethod'].first } let(:other_method) { verification_method.merge('publicKeyMultibase' => "zDnaeeroaSbjDQ9hs1vkwgihZfFpcXtL7b7hdGBD1zYoJMfqJ") } it 'should decode a public key from verificationMethod with id including a DID + #atproto' do key = subject.new(did, base_json).signing_key key.should be_a(DIDKit::PublicKey) key.type.should == :k256 key.multibase.should == verification_method['publicKeyMultibase'] end it 'should accept a relative #atproto id without DID' do json = base_json.merge('verificationMethod' => [verification_method.merge('id' => '#atproto')]) key = subject.new(did, json).signing_key key.should be_a(DIDKit::PublicKey) key.type.should == :k256 key.multibase.should == verification_method['publicKeyMultibase'] end it 'should return nil if verificationMethod is missing' do json = base_json.dup.tap { |data| data.delete('verificationMethod') } subject.new(did, json).signing_key.should be_nil end it 'should return nil if verificationMethod is empty' do json = base_json.merge('verificationMethod' => []) subject.new(did, json).signing_key.should be_nil end it 'should raise a format error if verificationMethod is not an array' do json = base_json.merge('verificationMethod' => verification_method) expect { subject.new(did, json).signing_key }.to raise_error(DIDKit::FormatError) end it 'should raise a format error if an entry is not a hash' do json = base_json.merge('verificationMethod' => ['invalid']) expect { subject.new(did, json).signing_key }.to raise_error(DIDKit::FormatError) end it 'should skip entries whose required fields do not match' do methods = [ other_method.merge('id' => 'did:plc:someoneelse#atproto'), other_method.merge('controller' => 'did:plc:someoneelse'), other_method.merge('id' => '#atproto', 'controller' => 'did:plc:someoneelse'), other_method.merge('id' => 'did:plc:someoneelse#atproto', 'controller' => 'did:plc:someoneelse'), other_method.merge('type' => 'OtherKeyType'), other_method.merge('publicKeyMultibase' => nil), verification_method ] json = base_json.merge('verificationMethod' => methods) key = subject.new(did, json).signing_key key.should_not be_nil key.multibase.should == verification_method['publicKeyMultibase'] end it 'should return nil if no entry has all required fields' do methods = [ other_method.merge('id' => 'did:plc:someoneelse#atproto'), other_method.merge('controller' => 'did:plc:someoneelse'), other_method.merge('id' => '#atproto', 'controller' => 'did:plc:someoneelse'), other_method.merge('id' => 'did:plc:someoneelse#atproto', 'controller' => 'did:plc:someoneelse'), verification_method.merge('type' => 'OtherKeyType'), verification_method.merge('publicKeyMultibase' => nil) ] json = base_json.merge('verificationMethod' => methods) key = subject.new(did, json).signing_key key.should be_nil end it 'should not fall through to next key if one key is invalid' do invalid = verification_method.merge('publicKeyMultibase' => 'z0') json = base_json.merge('verificationMethod' => [invalid, verification_method]) expect { subject.new(did, json).signing_key }.to raise_error(DIDKit::KeyError) end end describe 'service helpers' do let(:service_json) { base_json.merge('service' => [ { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz' }, { 'id' => '#atproto_labeler', 'type' => 'AtprotoLabeler', 'serviceEndpoint' => 'https://labels.dholms.xyz' }, { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' } ]) } describe '#pds_endpoint' do it 'should return the endpoint of #atproto_pds' do doc = subject.new(did, service_json) doc.pds_endpoint.should == 'https://pds.dholms.xyz' end end describe '#pds_host' do it 'should return the host part of #atproto_pds endpoint' do doc = subject.new(did, service_json) doc.pds_host.should == 'pds.dholms.xyz' end end describe '#labeler_endpoint' do it 'should return the endpoint of #atproto_labeler' do doc = subject.new(did, service_json) doc.labeler_endpoint.should == 'https://labels.dholms.xyz' end end describe '#labeler_host' do it 'should return the host part of #atproto_labeler endpoint' do doc = subject.new(did, service_json) doc.labeler_host.should == 'labels.dholms.xyz' end end describe '#get_service' do it 'should fetch a service by key and type' do doc = subject.new(did, service_json) lycan = doc.get_service('lycan', 'LycanService') lycan.should_not be_nil lycan.endpoint.should == 'https://lycan.feeds.blue' end it 'should return nil if none of the services match' do doc = subject.new(did, service_json) result = doc.get_service('lycan', 'AtprotoLabeler') result.should be_nil result = doc.get_service('atproto_pds', 'PDS') result.should be_nil result = doc.get_service('unknown', 'Test') result.should be_nil end end it 'should expose the "labeller" aliases for endpoint and host' do doc = subject.new(did, service_json) doc.labeller_endpoint.should == 'https://labels.dholms.xyz' doc.labeller_host.should == 'labels.dholms.xyz' end context 'when endpoint URLs include custom ports' do let(:service_json) { base_json.merge('service' => [ { 'id' => '#atproto_pds', 'type' => 'AtprotoPersonalDataServer', 'serviceEndpoint' => 'https://pds.dholms.xyz:3000' }, { 'id' => '#atproto_labeler', 'type' => 'AtprotoLabeler', 'serviceEndpoint' => 'https://labels.dholms.xyz:4567' }, { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue:6789' } ]) } it 'should keep ports in endpoint URLs' do doc = subject.new(did, service_json) pds, labeller, custom = doc.services pds.endpoint.should == 'https://pds.dholms.xyz:3000' doc.pds_endpoint.should == 'https://pds.dholms.xyz:3000' labeller.endpoint.should == 'https://labels.dholms.xyz:4567' doc.labeller_endpoint.should == 'https://labels.dholms.xyz:4567' custom.endpoint.should == 'https://lycan.feeds.blue:6789' end it 'should exclude ports from host properties' do doc = subject.new(did, service_json) doc.pds_host.should == 'pds.dholms.xyz' doc.labeller_host.should == 'labels.dholms.xyz' end end describe 'if there is no matching service' do let(:service_json) { base_json.merge('service' => [ { 'id' => '#lycan', 'type' => 'LycanService', 'serviceEndpoint' => 'https://lycan.feeds.blue' } ]) } it 'should return nil from the relevant methods' do doc = subject.new(did, service_json) doc.pds_endpoint.should be_nil doc.pds_host.should be_nil doc.labeller_endpoint.should be_nil doc.labeller_host.should be_nil doc.labeler_endpoint.should be_nil doc.labeler_host.should be_nil end end end end