1/// Unit tests for the identity resolution layer. 2/// 3/// Note: These are basic validation tests. Real integration tests would 4/// require network calls to live services. 5 6import 'package:flutter_test/flutter_test.dart'; 7import 'package:atproto_oauth_flutter/src/identity/identity.dart'; 8 9void main() { 10 group('DID Validation', () { 11 test('isDidPlc validates did:plc correctly', () { 12 // did:plc must be exactly 32 chars total (8 prefix + 24 base32 [a-z2-7]) 13 expect(isDidPlc('did:plc:z72i7hdynmk6r22z27h6abc2'), isTrue); 14 expect(isDidPlc('did:plc:2222222222222222222222ab'), isTrue); 15 expect(isDidPlc('did:plc:abcdefgabcdefgabcdefgabc'), isTrue); 16 17 // Wrong length 18 expect(isDidPlc('did:plc:short'), isFalse); 19 expect(isDidPlc('did:plc:toolonggggggggggggggggggggg'), isFalse); 20 21 // Wrong prefix 22 expect(isDidPlc('did:web:example.com'), isFalse); 23 24 // Invalid characters (not base32) 25 expect(isDidPlc('did:plc:0000000000000000000000'), isFalse); // has 0 26 expect(isDidPlc('did:plc:1111111111111111111111'), isFalse); // has 1 27 }); 28 29 test('isDidWeb validates did:web correctly', () { 30 expect(isDidWeb('did:web:example.com'), isTrue); 31 expect(isDidWeb('did:web:example.com:user:alice'), isTrue); 32 expect(isDidWeb('did:web:localhost%3A3000'), isTrue); 33 34 // Wrong prefix 35 expect(isDidWeb('did:plc:abc123xyz789abc123xyz789'), isFalse); 36 37 // Can't start with colon after prefix 38 expect(isDidWeb('did:web::example.com'), isFalse); 39 }); 40 41 test('isDid validates general DIDs', () { 42 expect(isDid('did:plc:abc123xyz789abc123xyz789'), isTrue); 43 expect(isDid('did:web:example.com'), isTrue); 44 expect( 45 isDid('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'), 46 isTrue, 47 ); 48 49 // Invalid 50 expect(isDid('not-a-did'), isFalse); 51 expect(isDid('did:'), isFalse); 52 expect(isDid('did:method'), isFalse); 53 expect(isDid(''), isFalse); 54 }); 55 56 test('extractDidMethod extracts method name', () { 57 expect(extractDidMethod('did:plc:abc123'), equals('plc')); 58 expect(extractDidMethod('did:web:example.com'), equals('web')); 59 expect(extractDidMethod('did:key:z6Mk...'), equals('key')); 60 }); 61 62 test('didWebToUrl converts did:web to URL', () { 63 final url1 = didWebToUrl('did:web:example.com'); 64 expect(url1.toString(), equals('https://example.com')); 65 66 final url2 = didWebToUrl('did:web:example.com:user:alice'); 67 expect(url2.toString(), equals('https://example.com/user/alice')); 68 69 final url3 = didWebToUrl('did:web:localhost%3A3000'); 70 expect(url3.toString(), equals('http://localhost:3000')); 71 }); 72 73 test('urlToDidWeb converts URL to did:web', () { 74 final did1 = urlToDidWeb(Uri.parse('https://example.com')); 75 expect(did1, equals('did:web:example.com')); 76 77 final did2 = urlToDidWeb(Uri.parse('https://example.com/user/alice')); 78 expect(did2, equals('did:web:example.com:user:alice')); 79 }); 80 }); 81 82 group('Handle Validation', () { 83 test('isValidHandle validates handles', () { 84 expect(isValidHandle('alice.example.com'), isTrue); 85 expect(isValidHandle('user.bsky.social'), isTrue); 86 expect(isValidHandle('sub.domain.example.com'), isTrue); 87 expect(isValidHandle('a.b'), isTrue); 88 89 // Invalid 90 expect(isValidHandle(''), isFalse); 91 expect(isValidHandle('no-tld'), isFalse); 92 expect(isValidHandle('.starts-with-dot.com'), isFalse); 93 expect(isValidHandle('ends-with-dot.com.'), isFalse); 94 expect(isValidHandle('has..double-dot.com'), isFalse); 95 expect(isValidHandle('has spaces.com'), isFalse); 96 97 // Too long (254+ chars) 98 final longHandle = '${'a' * 250}.com'; 99 expect(isValidHandle(longHandle), isFalse); 100 }); 101 102 test('normalizeHandle converts to lowercase', () { 103 expect(normalizeHandle('Alice.Example.Com'), equals('alice.example.com')); 104 expect(normalizeHandle('USER.BSKY.SOCIAL'), equals('user.bsky.social')); 105 }); 106 107 test('asNormalizedHandle validates and normalizes', () { 108 expect( 109 asNormalizedHandle('Alice.Example.Com'), 110 equals('alice.example.com'), 111 ); 112 expect(asNormalizedHandle('invalid'), isNull); 113 expect(asNormalizedHandle(''), isNull); 114 }); 115 }); 116 117 group('DID Document', () { 118 test('DidDocument parses from JSON', () { 119 final json = { 120 'id': 'did:plc:abc123xyz789abc123xyz789', 121 'alsoKnownAs': ['at://alice.bsky.social'], 122 'service': [ 123 { 124 'id': '#atproto_pds', 125 'type': 'AtprotoPersonalDataServer', 126 'serviceEndpoint': 'https://pds.example.com', 127 }, 128 ], 129 }; 130 131 final doc = DidDocument.fromJson(json); 132 133 expect(doc.id, equals('did:plc:abc123xyz789abc123xyz789')); 134 expect(doc.alsoKnownAs, contains('at://alice.bsky.social')); 135 expect(doc.service?.length, equals(1)); 136 expect(doc.service?[0].type, equals('AtprotoPersonalDataServer')); 137 }); 138 139 test('DidDocument extracts PDS URL', () { 140 final doc = DidDocument( 141 id: 'did:plc:test', 142 service: [ 143 DidService( 144 id: '#atproto_pds', 145 type: 'AtprotoPersonalDataServer', 146 serviceEndpoint: 'https://pds.example.com', 147 ), 148 ], 149 ); 150 151 expect(doc.extractPdsUrl(), equals('https://pds.example.com')); 152 }); 153 154 test('DidDocument extracts handle', () { 155 final doc = DidDocument( 156 id: 'did:plc:test', 157 alsoKnownAs: ['at://alice.bsky.social', 'https://example.com'], 158 ); 159 160 expect(doc.extractAtprotoHandle(), equals('alice.bsky.social')); 161 expect(doc.extractNormalizedHandle(), equals('alice.bsky.social')); 162 }); 163 164 test('DidDocument returns null for missing PDS', () { 165 final doc = DidDocument(id: 'did:plc:test'); 166 expect(doc.extractPdsUrl(), isNull); 167 }); 168 169 test('DidDocument returns null for missing handle', () { 170 final doc = DidDocument(id: 'did:plc:test'); 171 expect(doc.extractAtprotoHandle(), isNull); 172 expect(doc.extractNormalizedHandle(), isNull); 173 }); 174 }); 175 176 group('Cache', () { 177 test('InMemoryDidCache stores and retrieves', () async { 178 final cache = InMemoryDidCache(ttl: Duration(seconds: 1)); 179 final doc = DidDocument(id: 'did:plc:test'); 180 181 await cache.set('did:plc:test', doc); 182 final retrieved = await cache.get('did:plc:test'); 183 184 expect(retrieved?.id, equals('did:plc:test')); 185 }); 186 187 test('InMemoryDidCache expires entries', () async { 188 final cache = InMemoryDidCache(ttl: Duration(milliseconds: 100)); 189 final doc = DidDocument(id: 'did:plc:test'); 190 191 await cache.set('did:plc:test', doc); 192 193 // Should exist immediately 194 expect(await cache.get('did:plc:test'), isNotNull); 195 196 // Wait for expiration 197 await Future.delayed(Duration(milliseconds: 150)); 198 199 // Should be expired 200 expect(await cache.get('did:plc:test'), isNull); 201 }); 202 203 test('InMemoryHandleCache stores and retrieves', () async { 204 final cache = InMemoryHandleCache(ttl: Duration(seconds: 1)); 205 206 await cache.set('alice.bsky.social', 'did:plc:test'); 207 final retrieved = await cache.get('alice.bsky.social'); 208 209 expect(retrieved, equals('did:plc:test')); 210 }); 211 212 test('Cache clears all entries', () async { 213 final cache = InMemoryDidCache(); 214 final doc = DidDocument(id: 'did:plc:test'); 215 216 await cache.set('did:plc:test', doc); 217 expect(await cache.get('did:plc:test'), isNotNull); 218 219 await cache.clear(); 220 expect(await cache.get('did:plc:test'), isNull); 221 }); 222 }); 223 224 group('Error Types', () { 225 test('IdentityResolverError has message', () { 226 final error = IdentityResolverError('Test error'); 227 expect(error.message, equals('Test error')); 228 expect(error.toString(), contains('Test error')); 229 }); 230 231 test('InvalidDidError includes DID', () { 232 final error = InvalidDidError('not:valid', 'Invalid format'); 233 expect(error.did, equals('not:valid')); 234 expect(error.toString(), contains('not:valid')); 235 expect(error.toString(), contains('Invalid format')); 236 }); 237 238 test('InvalidHandleError includes handle', () { 239 final error = InvalidHandleError('invalid', 'Invalid format'); 240 expect(error.handle, equals('invalid')); 241 expect(error.toString(), contains('invalid')); 242 expect(error.toString(), contains('Invalid format')); 243 }); 244 }); 245}