|
| 1 | +import assert from 'node:assert/strict'; |
| 2 | +import { describe, test } from 'node:test'; |
| 3 | +import { |
| 4 | + SiteActionCatalogError, |
| 5 | + normalizeSiteActionCatalog, |
| 6 | + validateOperationInput, |
| 7 | +} from '../src/server/identity/site-action-catalog'; |
| 8 | +import { |
| 9 | + SITE_ACTION_SCOPE, |
| 10 | + signSiteActionToken, |
| 11 | +} from '../src/server/identity/site-action-token'; |
| 12 | +import { importSiteSigningKey } from '../src/server/identity/site-id-token'; |
| 13 | + |
| 14 | +/* Bağlı site eylemlerinin sözleşmesi. |
| 15 | + * |
| 16 | + * Buradaki iddiaların çoğu REDDETME yolları. Sebebi şu: katalog dosyası |
| 17 | + * Orbit'e ait değil, siteye ait. Orbit onu okuyup hem ajana sunuyor hem de |
| 18 | + * kendi girdi doğrulamasında çalıştırıyor. Yani o dosyaya güvenmek, başkasının |
| 19 | + * yazdığı şemayı kendi kodumuz olarak koşturmak demek. |
| 20 | + */ |
| 21 | + |
| 22 | +const gecerliKatalog = { |
| 23 | + version: 1, |
| 24 | + operations: [ |
| 25 | + { |
| 26 | + operationId: 'rota.listeyeEkle', |
| 27 | + summary: 'Listeye anime ekler.', |
| 28 | + idempotent: true, |
| 29 | + input: { |
| 30 | + type: 'object', |
| 31 | + required: ['animeId', 'durum'], |
| 32 | + additionalProperties: false, |
| 33 | + properties: { |
| 34 | + animeId: { type: 'string', maxLength: 300 }, |
| 35 | + durum: { enum: ['IZLIYOR', 'BITTI', 'PLANLI', 'BIRAKTI'] }, |
| 36 | + puan: { type: 'integer', minimum: 1, maximum: 10 }, |
| 37 | + }, |
| 38 | + }, |
| 39 | + }, |
| 40 | + ], |
| 41 | +}; |
| 42 | + |
| 43 | +function reddedilmeli(katalog: unknown, neden: string) { |
| 44 | + assert.throws( |
| 45 | + () => normalizeSiteActionCatalog(katalog), |
| 46 | + SiteActionCatalogError, |
| 47 | + neden, |
| 48 | + ); |
| 49 | +} |
| 50 | + |
| 51 | +describe('bağlı site eylem kataloğu', () => { |
| 52 | + test('geçerli katalog kabul ediliyor', () => { |
| 53 | + const parsed = normalizeSiteActionCatalog(gecerliKatalog); |
| 54 | + assert.equal(parsed.operations.length, 1); |
| 55 | + assert.equal(parsed.operations[0].operationId, 'rota.listeyeEkle'); |
| 56 | + assert.equal(parsed.operations[0].idempotent, true); |
| 57 | + }); |
| 58 | + |
| 59 | + test('katalogdaki actionsEndpoint yok sayılıyor, hata vermiyor', () => { |
| 60 | + /* Adres kayıttan okunuyor. Kontratın erken sürümüne göre dosya hazırlamış |
| 61 | + bir site yüzünden kataloğun tamamı düşmemeli — ama o alanın gövdedeki |
| 62 | + değeri de hiçbir şeyi etkilememeli. */ |
| 63 | + const parsed = normalizeSiteActionCatalog({ |
| 64 | + ...gecerliKatalog, |
| 65 | + actionsEndpoint: 'https://saldirgan.example/al', |
| 66 | + }); |
| 67 | + assert.equal(parsed.operations.length, 1); |
| 68 | + assert.ok(!('actionsEndpoint' in parsed)); |
| 69 | + }); |
| 70 | + |
| 71 | + test('şema dilinin dışına çıkan anahtarlar reddediliyor', () => { |
| 72 | + /* Bu üçü tesadüfen seçilmedi: `$ref` uzak adres çeker, `pattern` düzenli |
| 73 | + ifade çalıştırır (ReDoS), `allOf` iç içe geçip doğrulayıcıyı patlatır. |
| 74 | + Ajanın gördüğü şema aynı zamanda BİZİM koşturduğumuz şema. */ |
| 75 | + for (const kotu of [{ $ref: 'https://x/y' }, { pattern: '(a+)+$' }, { allOf: [] }]) { |
| 76 | + reddedilmeli( |
| 77 | + { version: 1, operations: [{ ...gecerliKatalog.operations[0], input: kotu }] }, |
| 78 | + `${Object.keys(kotu)[0]} kabul edilmemeli`, |
| 79 | + ); |
| 80 | + } |
| 81 | + }); |
| 82 | + |
| 83 | + test('aşırı derin şema reddediliyor', () => { |
| 84 | + let derin: Record<string, unknown> = { type: 'string' }; |
| 85 | + for (let i = 0; i < 12; i += 1) { |
| 86 | + derin = { type: 'object', properties: { alt: derin } }; |
| 87 | + } |
| 88 | + reddedilmeli( |
| 89 | + { version: 1, operations: [{ ...gecerliKatalog.operations[0], input: derin }] }, |
| 90 | + 'özyinelemeli doğrulayıcı yığını taşırabilir', |
| 91 | + ); |
| 92 | + }); |
| 93 | + |
| 94 | + test('yinelenen operationId reddediliyor', () => { |
| 95 | + reddedilmeli( |
| 96 | + { version: 1, operations: [gecerliKatalog.operations[0], gecerliKatalog.operations[0]] }, |
| 97 | + 'hangi işlemin çalışacağı belirsiz kalır', |
| 98 | + ); |
| 99 | + }); |
| 100 | + |
| 101 | + test('biçimsiz operationId reddediliyor', () => { |
| 102 | + for (const kotu of ['listeyeEkle', 'rota..ekle', 'rota.', '.ekle', 'ro ta.ekle']) { |
| 103 | + reddedilmeli( |
| 104 | + { version: 1, operations: [{ ...gecerliKatalog.operations[0], operationId: kotu }] }, |
| 105 | + `${kotu} reddedilmeli`, |
| 106 | + ); |
| 107 | + } |
| 108 | + }); |
| 109 | + |
| 110 | + test('boş katalog ve yanlış sürüm reddediliyor', () => { |
| 111 | + reddedilmeli({ version: 1, operations: [] }, 'boş katalog'); |
| 112 | + reddedilmeli({ version: 2, operations: gecerliKatalog.operations }, 'bilinmeyen sürüm'); |
| 113 | + reddedilmeli(null, 'nesne olmayan katalog'); |
| 114 | + }); |
| 115 | +}); |
| 116 | + |
| 117 | +describe('girdi doğrulaması', () => { |
| 118 | + const schema = gecerliKatalog.operations[0].input; |
| 119 | + |
| 120 | + test('geçerli girdi geçiyor', () => { |
| 121 | + validateOperationInput({ animeId: 'kitsu:1376', durum: 'IZLIYOR' }, schema); |
| 122 | + validateOperationInput({ animeId: 'kitsu:1376', durum: 'BITTI', puan: 9 }, schema); |
| 123 | + }); |
| 124 | + |
| 125 | + test('eksik zorunlu alan reddediliyor', () => { |
| 126 | + assert.throws(() => validateOperationInput({ animeId: 'x' }, schema), SiteActionCatalogError); |
| 127 | + }); |
| 128 | + |
| 129 | + test('tanımsız alan reddediliyor', () => { |
| 130 | + /* Ajanın uydurduğu bir alanı sessizce siteye taşımak, sitenin beklemediği |
| 131 | + bir şeyi yazmasına yol açabilir. */ |
| 132 | + assert.throws( |
| 133 | + () => validateOperationInput({ animeId: 'x', durum: 'IZLIYOR', gizli: true }, schema), |
| 134 | + SiteActionCatalogError, |
| 135 | + ); |
| 136 | + }); |
| 137 | + |
| 138 | + test('additionalProperties belirtilmemişse de kapalı', () => { |
| 139 | + const acikUnutulmus = { type: 'object', properties: { a: { type: 'string' } } }; |
| 140 | + assert.throws( |
| 141 | + () => validateOperationInput({ a: 'x', b: 'y' }, acikUnutulmus), |
| 142 | + SiteActionCatalogError, |
| 143 | + 'varsayılan kapalı olmalı; şemayı yazan siteye güvenip açık bırakmıyoruz', |
| 144 | + ); |
| 145 | + }); |
| 146 | + |
| 147 | + test('enum dışı değer reddediliyor', () => { |
| 148 | + assert.throws( |
| 149 | + () => validateOperationInput({ animeId: 'x', durum: 'HERHANGI' }, schema), |
| 150 | + SiteActionCatalogError, |
| 151 | + ); |
| 152 | + }); |
| 153 | + |
| 154 | + test('sayı sınırları uygulanıyor', () => { |
| 155 | + for (const puan of [0, 11, 5.5]) { |
| 156 | + assert.throws( |
| 157 | + () => validateOperationInput({ animeId: 'x', durum: 'BITTI', puan }, schema), |
| 158 | + SiteActionCatalogError, |
| 159 | + `puan ${puan} reddedilmeli`, |
| 160 | + ); |
| 161 | + } |
| 162 | + }); |
| 163 | + |
| 164 | + test('metin uzunluğu uygulanıyor', () => { |
| 165 | + assert.throws( |
| 166 | + () => validateOperationInput({ animeId: 'x'.repeat(301), durum: 'BITTI' }, schema), |
| 167 | + SiteActionCatalogError, |
| 168 | + ); |
| 169 | + }); |
| 170 | +}); |
| 171 | + |
| 172 | +describe('devretme belgesi', () => { |
| 173 | + test('belge işlemi taşıyor ve aktörü kaybetmiyor', async () => { |
| 174 | + const pair = await crypto.subtle.generateKey( |
| 175 | + { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], |
| 176 | + ); |
| 177 | + const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey); |
| 178 | + const key = await importSiteSigningKey(JSON.stringify({ ...jwk, kid: 'k1' })); |
| 179 | + |
| 180 | + const now = 1_760_000_000_000; |
| 181 | + const token = await signSiteActionToken(key, { |
| 182 | + issuer: 'https://orbit.sametbasbug.dev', |
| 183 | + audience: 'orbit-equinox-rota', |
| 184 | + subject: 'pairwise-abc', |
| 185 | + actorAgentId: 'agent-1', |
| 186 | + actorHandle: 'selene', |
| 187 | + operationId: 'rota.listeyeEkle', |
| 188 | + tokenId: 'jti-1', |
| 189 | + issuedAt: now, |
| 190 | + expiresAt: now + 60_000, |
| 191 | + }); |
| 192 | + |
| 193 | + const [header, payload, signature] = token.split('.'); |
| 194 | + const claims = JSON.parse(Buffer.from(payload, 'base64url').toString()); |
| 195 | + |
| 196 | + assert.equal(JSON.parse(Buffer.from(header, 'base64url').toString()).alg, 'ES256'); |
| 197 | + assert.equal(claims.aud, 'orbit-equinox-rota', 'belge başka siteye taşınamamalı'); |
| 198 | + assert.equal(claims.sub, 'pairwise-abc', 'site kullanıcıyı girişte gördüğü kimlikle bulmalı'); |
| 199 | + assert.equal(claims.scope, SITE_ACTION_SCOPE); |
| 200 | + /* İşlem belgeye gömülü: bir işlem için alınmış belge, gövdesi |
| 201 | + değiştirilerek başka bir işleme çevrilememeli. */ |
| 202 | + assert.equal(claims.operation, 'rota.listeyeEkle'); |
| 203 | + /* İş insanın adına ama kimin yaptığı kaybolmuyor. */ |
| 204 | + assert.equal(claims.act.sub, 'agent:agent-1'); |
| 205 | + assert.equal(claims.act.handle, 'selene'); |
| 206 | + assert.equal(claims.exp - claims.iat, 60, 'ömür 60 saniye'); |
| 207 | + |
| 208 | + const dogru = await crypto.subtle.verify( |
| 209 | + { name: 'ECDSA', hash: 'SHA-256' }, |
| 210 | + pair.publicKey, |
| 211 | + Buffer.from(signature, 'base64url'), |
| 212 | + new TextEncoder().encode(`${header}.${payload}`), |
| 213 | + ); |
| 214 | + assert.ok(dogru, 'imza gerçekten doğrulanmalı; sahte bir yeşil işe yaramaz'); |
| 215 | + }); |
| 216 | +}); |
0 commit comments