A library for handling DID identifiers used in Bluesky AT Protocol

added some tests

+246 -5
+2
Gemfile
··· 8 8 gem "rake", "~> 13.0" 9 9 gem "rspec", "~> 3.0" 10 10 gem 'irb' 11 + gem 'mocha' 12 + gem 'webmock'
+26
spec/dids/dholms.json
··· 1 + { 2 + "@context": [ 3 + "https://www.w3.org/ns/did/v1", 4 + "https://w3id.org/security/multikey/v1", 5 + "https://w3id.org/security/suites/secp256k1-2019/v1" 6 + ], 7 + "id": "did:plc:yk4dd2qkboz2yv6tpubpc6co", 8 + "alsoKnownAs": [ 9 + "at://dholms.xyz" 10 + ], 11 + "verificationMethod": [ 12 + { 13 + "id": "did:plc:yk4dd2qkboz2yv6tpubpc6co#atproto", 14 + "type": "Multikey", 15 + "controller": "did:plc:yk4dd2qkboz2yv6tpubpc6co", 16 + "publicKeyMultibase": "zQ3shsJcHdhfpKyF3U6rBQziDHsY1ikwCAsqGWhdC1tgaPQxq" 17 + } 18 + ], 19 + "service": [ 20 + { 21 + "id": "#atproto_pds", 22 + "type": "AtprotoPersonalDataServer", 23 + "serviceEndpoint": "https://pds.dholms.xyz" 24 + } 25 + ] 26 + }
+26
spec/dids/witchcraft.json
··· 1 + { 2 + "@context": [ 3 + "https://www.w3.org/ns/did/v1", 4 + "https://w3id.org/security/multikey/v1", 5 + "https://w3id.org/security/suites/secp256k1-2019/v1" 6 + ], 7 + "id": "did:web:witchcraft.systems", 8 + "alsoKnownAs": [ 9 + "at://witchcraft.systems" 10 + ], 11 + "verificationMethod": [ 12 + { 13 + "id": "did:web:witchcraft.systems#atproto", 14 + "type": "Multikey", 15 + "controller": "did:web:witchcraft.systems", 16 + "publicKeyMultibase": "zQ3shqRWPzo6kSi1PDn1VXTVeaRiigsK3bxKLQ1gQ6UHqnVxW" 17 + } 18 + ], 19 + "service": [ 20 + { 21 + "id": "#atproto_pds", 22 + "type": "AtprotoPersonalDataServer", 23 + "serviceEndpoint": "https://pds.witchcraft.systems" 24 + } 25 + ] 26 + }
+179
spec/resolver_spec.rb
··· 1 + describe DIDKit::Resolver do 2 + let(:sample_did) { 'did:plc:qhfo22pezo44fa3243z2h4ny' } 3 + 4 + describe '#resolve_handle' do 5 + context 'when handle resolves via HTTP' do 6 + before do 7 + Resolv::DNS.stubs(:open).returns([]) 8 + end 9 + 10 + let(:handle) { 'barackobama.bsky.social' } 11 + 12 + it 'should return a matching DID' do 13 + stub_request(:get, "https://#{handle}/.well-known/atproto-did") 14 + .to_return(body: sample_did) 15 + 16 + result = subject.resolve_handle(handle) 17 + 18 + result.should_not be_nil 19 + result.should be_a(DID) 20 + result.to_s.should == sample_did 21 + result.resolved_by.should == :http 22 + end 23 + 24 + it 'should check DNS first' do 25 + Resolv::DNS.expects(:open).returns([]) 26 + stub_request(:get, "https://#{handle}/.well-known/atproto-did") 27 + .to_return(body: sample_did) 28 + 29 + result = subject.resolve_handle(handle) 30 + end 31 + 32 + context 'when HTTP returns invalid text' do 33 + it 'should return nil' do 34 + stub_request(:get, "https://#{handle}/.well-known/atproto-did") 35 + .to_return(body: "Welcome to nginx!") 36 + 37 + result = subject.resolve_handle(handle) 38 + result.should be_nil 39 + end 40 + end 41 + 42 + context 'when HTTP returns bad response' do 43 + it 'should return nil' do 44 + stub_request(:get, "https://#{handle}/.well-known/atproto-did") 45 + .to_return(status: 400, body: sample_did) 46 + 47 + result = subject.resolve_handle(handle) 48 + result.should be_nil 49 + end 50 + end 51 + 52 + context 'when HTTP throws an exception' do 53 + it 'should catch it and return nil' do 54 + stub_request(:get, "https://#{handle}/.well-known/atproto-did") 55 + .to_raise(Errno::ETIMEDOUT) 56 + 57 + result = 0 58 + 59 + expect { 60 + result = subject.resolve_handle(handle) 61 + }.to_not raise_error 62 + 63 + result.should be_nil 64 + end 65 + end 66 + 67 + context 'when HTTP response has a trailing newline' do 68 + it 'should accept it' do 69 + stub_request(:get, "https://#{handle}/.well-known/atproto-did") 70 + .to_return(body: sample_did + "\n") 71 + 72 + result = subject.resolve_handle(handle) 73 + 74 + result.should_not be_nil 75 + result.should be_a(DID) 76 + result.to_s.should == sample_did 77 + end 78 + end 79 + end 80 + 81 + context 'when handle has a leading @' do 82 + let(:handle) { '@pfrazee.com' } 83 + 84 + before do 85 + Resolv::DNS.stubs(:open).returns([]) 86 + end 87 + 88 + it 'should also return a matching DID' do 89 + stub_request(:get, "https://pfrazee.com/.well-known/atproto-did") 90 + .to_return(body: sample_did) 91 + 92 + result = subject.resolve_handle(handle) 93 + 94 + result.should_not be_nil 95 + result.should be_a(DID) 96 + result.to_s.should == sample_did 97 + result.resolved_by.should == :http 98 + end 99 + end 100 + 101 + context 'when handle has a reserved TLD' do 102 + let(:handle) { 'example.test' } 103 + 104 + it 'should return nil' do 105 + subject.resolve_handle(handle).should be_nil 106 + end 107 + end 108 + 109 + context 'when a DID string is passed' do 110 + let(:handle) { BSKY_APP_DID } 111 + 112 + it 'should return that DID' do 113 + result = subject.resolve_handle(handle) 114 + 115 + result.should be_a(DID) 116 + result.to_s.should == BSKY_APP_DID 117 + end 118 + end 119 + 120 + context 'when a DID object is passed' do 121 + let(:handle) { DID.new(BSKY_APP_DID) } 122 + 123 + it 'should return a new DID object with that DID' do 124 + result = subject.resolve_handle(handle) 125 + 126 + result.should be_a(DID) 127 + result.to_s.should == BSKY_APP_DID 128 + result.equal?(handle).should == false 129 + end 130 + end 131 + end 132 + 133 + describe '#resolve_did' do 134 + context 'when passed a did:plc string' do 135 + let(:did) { 'did:plc:yk4dd2qkboz2yv6tpubpc6co' } 136 + 137 + it 'should return a parsed DID document object' do 138 + stub_request(:get, "https://plc.directory/#{did}") 139 + .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' }) 140 + 141 + result = subject.resolve_did(did) 142 + result.should be_a(DIDKit::Document) 143 + result.handles.should == ['dholms.xyz'] 144 + result.pds_endpoint.should == 'https://pds.dholms.xyz' 145 + end 146 + 147 + it 'should require a valid content type' do 148 + stub_request(:get, "https://plc.directory/#{did}") 149 + .to_return(body: load_did_file('dholms.json'), headers: { 'Content-Type': 'text/plain' }) 150 + 151 + expect { subject.resolve_did(did) }.to raise_error(DIDKit::APIError) 152 + end 153 + end 154 + 155 + context 'when passed a did:web string' do 156 + let(:did) { 'did:web:witchcraft.systems' } 157 + 158 + it 'should return a parsed DID document object' do 159 + stub_request(:get, "https://witchcraft.systems/.well-known/did.json") 160 + .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'application/did+ld+json; charset=utf-8' }) 161 + 162 + result = subject.resolve_did(did) 163 + result.should be_a(DIDKit::Document) 164 + result.handles.should == ['witchcraft.systems'] 165 + result.pds_endpoint.should == 'https://pds.witchcraft.systems' 166 + end 167 + 168 + it 'should NOT require a valid content type' do 169 + stub_request(:get, "https://witchcraft.systems/.well-known/did.json") 170 + .to_return(body: load_did_file('witchcraft.json'), headers: { 'Content-Type': 'text/plain' }) 171 + 172 + result = subject.resolve_did(did) 173 + result.should be_a(DIDKit::Document) 174 + result.handles.should == ['witchcraft.systems'] 175 + result.pds_endpoint.should == 'https://pds.witchcraft.systems' 176 + end 177 + end 178 + end 179 + end
+13 -5
spec/spec_helper.rb
··· 1 1 # frozen_string_literal: true 2 2 3 - require "didkit" 3 + require 'didkit' 4 + require 'webmock/rspec' 4 5 5 6 RSpec.configure do |config| 6 7 # Enable flags like --only-failures and --next-failure 7 8 config.example_status_persistence_file_path = ".rspec_status" 8 9 9 - # Disable RSpec exposing methods globally on `Module` and `main` 10 - config.disable_monkey_patching! 11 - 12 10 config.expect_with :rspec do |c| 13 - c.syntax = :expect 11 + c.syntax = [:should, :expect] 14 12 end 13 + 14 + config.mock_with :mocha 15 + end 16 + 17 + BSKY_APP_DID = 'did:plc:z72i7hdynmk6r22z27h6tvur' 18 + 19 + WebMock.enable! 20 + 21 + def load_did_file(name) 22 + File.read(File.join(__dir__, 'dids', name)) 15 23 end