server-manual.html (16941B)
1 <!doctype html> 2 <html> 3 <head> 4 <title>Annotation Protocol Must Tests</title> 5 <script src="/resources/testharness.js"></script> 6 <script src="/resources/testharnessreport.js"></script> 7 <script src="/common/utils.js"></script> 8 <script type="application/javascript"> 9 /* globals header, assert_equals, promise_test, assert_true, uuid, assert_regexp_match */ 10 11 /* jshint unused: false, strict: false */ 12 13 setup( { explicit_timeout: true, explicit_done: true } ); 14 15 // just ld+json here as the full profile'd media type is a SHOULD 16 var MEDIA_TYPE = 'application/ld+json'; 17 var MEDIA_TYPE_REGEX = /application\/ld\+json/; 18 // a request timeout if there is not one specified in the parent window 19 20 var myTimeout = 5000; 21 22 function request(method, url, headers, content) { 23 if (method === undefined) { 24 method = "GET"; 25 } 26 27 return new Promise(function (resolve, reject) { 28 var xhr = new XMLHttpRequest(); 29 30 // this gets returned when the request completes 31 var resp = { 32 xhr: xhr, 33 headers: null, 34 status: 0, 35 body: null, 36 text: "" 37 }; 38 39 xhr.open(method, url); 40 41 // headers? 42 if (headers !== undefined) { 43 headers.forEach(function(ref, idx) { 44 xhr.setRequestHeader(ref[0], ref[1]); 45 }); 46 } 47 48 // xhr.timeout = myTimeout; 49 50 xhr.ontimeout = function() { 51 resp.timeout = myTimeout; 52 resolve(resp); 53 }; 54 55 xhr.onerror = function() { 56 resolve(resp); 57 }; 58 59 xhr.onload = function () { 60 resp.status = this.status; 61 if (this.status >= 200 && this.status < 300) { 62 var d = xhr.response; 63 resp.text = d; 64 // we have it; what is it? 65 var type = xhr.getResponseHeader('Content-Type'); 66 if (type) { 67 resp.type = type.split(';')[0]; 68 if (resp.type === MEDIA_TYPE) { 69 try { 70 d = JSON.parse(d); 71 resp.body = d; 72 } 73 catch(err) { 74 resp.body = null; 75 } 76 } 77 } else { 78 resp.type = null; 79 resp.body = null; 80 } 81 82 } 83 resolve(resp); 84 }; 85 86 if (content !== undefined) { 87 if ("object" === typeof(content)) { 88 xhr.send(JSON.stringify(content)); 89 } else if ("function" === typeof(content)) { 90 xhr.send(content()); 91 } else if ("string" === typeof(content)) { 92 xhr.send(content); 93 } 94 } else { 95 xhr.send(); 96 } 97 }); 98 } 99 100 function checkBody(res, pat, isRE) { 101 if (isRE === undefined) { 102 isRE = true; 103 } 104 if (!res.body) { 105 if (isRE) { 106 assert_regexp_match("", pat, header + " not found in body"); 107 } else { 108 assert_equals("", pat, header + " not found in body") ; 109 } 110 } else { 111 if (isRE) { 112 assert_regexp_match(res.body, pat, pat + " not found in body "); 113 } else { 114 assert_equals(res.body, pat, pat + " not found in body"); 115 } 116 } 117 } 118 119 function checkHeader(res, header, pat, isRE) { 120 if (isRE === undefined) { 121 isRE = true; 122 } 123 if (!res.xhr.getResponseHeader(header)) { 124 if (isRE) { 125 assert_regexp_match("", pat, header + " not found in response"); 126 } else { 127 assert_equals("", pat, header + " not found in response") ; 128 } 129 } else { 130 var val = res.xhr.getResponseHeader(header) ; 131 if (isRE) { 132 assert_regexp_match(val, pat, pat + " not found in " + header); 133 } else { 134 assert_equals(val, pat, pat + " not found in " + header); 135 } 136 } 137 } 138 139 /* makePromiseTests 140 * 141 * thennable - Promise that when resolved will send data into the test 142 * criteria - Array of assertions 143 */ 144 145 function makePromiseTests( thennable, criteria ) { 146 // loop over the array of criteria 147 // 148 // create a promise_test for each one 149 criteria.forEach(function(ref) { 150 promise_test(function() { 151 return thennable.then(function(res) { 152 if (ref.header !== undefined) { 153 // it is a header check 154 if (ref.pat !== undefined) { 155 checkHeader(res, ref.header, ref.pat, true); 156 } else if (ref.string !== undefined) { 157 checkHeader(res, ref.header, ref.string, false); 158 } else if (ref.test !== undefined) { 159 assert_true(ref.test(res)); 160 } 161 } else { 162 if (ref.pat !== undefined) { 163 checkBody(res, ref.pat, true); 164 } else if (ref.string !== undefined) { 165 checkBody(res, ref.string, false); 166 } else if (ref.test !== undefined) { 167 assert_true(ref.test(res)); 168 } 169 } 170 }); 171 }, ref.assertion); 172 }); 173 } 174 175 function runTests( container_url, annotation_url ) { 176 // trim whitespace from incoming variables 177 container_url = container_url.trim(); 178 annotation_url = annotation_url.trim(); 179 180 // Section 4 has a requirement that the URL end in a slash, so... 181 // ensure the url has a length 182 test(function() { 183 assert_regexp_match(container_url, /\/$/, 'Container URL did not end in a "/" character'); 184 }, 'Container MUST end in a "/" character'); 185 186 // Container tests 187 var theContainer = request("GET", container_url); 188 189 makePromiseTests( theContainer, [ 190 { header: 'Allow', pat: /GET/, assertion: "Containers MUST support GET (check Allow on GET)" }, 191 { header: 'Allow', pat: /HEAD/, assertion: "Containers MUST support HEAD (check Allow on GET)" }, 192 { header: 'Allow', pat: /OPTIONS/, assertion: "Containers MUST support OPTIONS (check Allow on GET)" }, 193 { header: 'Content-Type', pat: MEDIA_TYPE_REGEX, assertion: 'Containers MUST have a Content-Type header with the application/ld+json media type'}, 194 { header: 'Content-Type', pat: MEDIA_TYPE_REGEX, assertion: 'Containers MUST response with the JSON-LD representation (by default)'}, 195 { test: function(res) { return ( 'type' in res.body && res.body.type.indexOf('BasicContainer') > -1 ); }, assertion: 'Containers MUST return a description of the container with BasicContainer' }, 196 { test: function(res) { return ( 'type' in res.body && res.body.type.indexOf('AnnotationCollection') > -1 ); }, assertion: 'Containers MUST return a description of the container with AnnotationCollection' }, 197 { header: 'Link', pat: /(.*)/, assertion: 'Containers MUST return a Link header (rfc5988) on all responses' }, 198 { header: 'ETag', pat: /(.*)/, assertion: 'Containers MUST have an ETag header'}, 199 { header: 'Vary', pat: /Accept/, assertion: 'Containers MUST have a Vary header with Accept in the value'}, 200 { header: 'Link', pat: /rel\=\"type\"|\/ns\/ldp#|Container/, assertion: 'Containers MUST advertise its type by including a link where the rel parameter value is type and the target IRI is the appropriate Container Type'}, 201 { header: 'Link', pat: /rel\=\"type\"|\/ns\/ldp#|Container/, 202 assertion: 'Containers MUST advertise that it imposes Annotation protocol specific' + 203 ' constraints by including a link where the target IRI is' + 204 ' http://www.w3.org/TR/annotation-protocol/, and the rel parameter' + 205 ' value is the IRI http://www.w3.org/ns/ldp#constrainedBy'}, 206 ] ); 207 208 209 promise_test(function() { 210 return request("HEAD", container_url).then(function(res) { 211 assert_equals(res.status, 200, "HEAD request returned " + res.status); 212 }); 213 }, "Containers MUST support HEAD method"); 214 215 promise_test(function() { 216 return request("OPTIONS", container_url).then(function(res) { 217 assert_equals(res.status, 200, "OPTIONS request returned " + res.status); 218 }); 219 }, "Containers MUST support OPTIONS method"); 220 221 // Container representation tests 222 223 224 makePromiseTests( theContainer, [ 225 { header: 'Content-Location', pat: /(.*)/, assertion: "Containers MUST include a Content-Location header with the IRI as its value" }, 226 { header: 'Content-Location', test: function(res) { if (res.xhr.getResponseHeader('content-location') === res.body.id ) { return true; } else { return false;} }, assertion: "Container's Content-Location and `id` MUST match" } 227 ]); 228 229 promise_test(function() { 230 return theContainer.then(function(res) { 231 var f = res.body.first; 232 if (f !== undefined && f !== "") { 233 request("GET", f).then(function(lres) { 234 assert_true(('partOf' in lres.body) || ('id' in lres.body.partOf), "No partOf in response"); 235 }); 236 } else { 237 assert_true(false, "no 'first' in response from Container"); 238 } 239 }); 240 }, "Annotation Pages must have a link to the container they are part of, using the partOf property"); 241 242 promise_test(function() { 243 return theContainer.then(function(res) { 244 var l = res.body.last; 245 request("GET", l).then(function(lres) { 246 assert_true(('prev' in lres.body), "No link to the previous page in response"); 247 }); 248 }); 249 }, "Annotation Pages MUST have a link to the previous page in the sequence, using the prev property (if not the first page)"); 250 251 promise_test(function() { 252 return theContainer.then(function(res) { 253 var f = res.body.first; 254 request("GET", f).then(function(lres) { 255 assert_true(('next' in lres.body), "No link to the next page in response"); 256 }); 257 }); 258 }, "Annotation Pages MUST have a link to the next page in the sequence, using the next property (if not the last page)"); 259 260 // Annotation Tests 261 var theRequest = request("GET", annotation_url); 262 263 makePromiseTests( theRequest, [ 264 { header: 'Allow', pat: /GET/, assertion: "Annotations MUST support GET (check Allow on GET)" }, 265 { header: 'Allow', pat: /HEAD/, assertion: "Annotations MUST support HEAD (check Allow on GET)" }, 266 { header: 'Allow', pat: /OPTIONS/, assertion: "Annotations MUST support OPTIONS (check Allow on GET)" }, 267 { header: 'Content-Type', pat: MEDIA_TYPE_REGEX, assertion: 'Annotations MUST have a Content-Type header with the application/ld+json media type'}, 268 { header: 'Link', string: '<http://www.w3.org/ns/ldp#Resource>; rel="type"', assertion: 'Annotations MUST have a Link header entry where the target IRI is http://www.w3.org/ns/ldp#Resource and the rel parameter value is type'}, 269 { header: 'ETag', pat: /(.*)/, assertion: 'Annotations MUST have an ETag header'}, 270 { header: 'Vary', pat: /Accept/, assertion: 'Annotations MUST have a Vary header with Accept in the value'}, 271 ] ); 272 273 promise_test(function() { 274 return request("HEAD", annotation_url).then(function(res) { 275 assert_equals(res.status, 200, "HEAD request returned " + res.status); 276 }); 277 }, "Annotations MUST support HEAD method"); 278 279 promise_test(function() { 280 return request("OPTIONS", annotation_url).then(function(res) { 281 assert_equals(res.status, 200, "OPTIONS request returned " + res.status); 282 }); 283 }, "Annotations MUST support OPTIONS method"); 284 285 286 // creation and deletion tests 287 288 var theAnnotation = { 289 "@context": "http://www.w3.org/ns/anno.jsonld", 290 "type": "Annotation", 291 "body": { 292 "type": "TextualBody", 293 "value": "I like this page!" 294 }, 295 "target": "http://www.example.com/index.html", 296 "canonical": 'urn:uuid:' + token() 297 }; 298 299 var theCreation = request("POST", container_url, [ [ 'Content-Type', MEDIA_TYPE ] ], theAnnotation); 300 301 makePromiseTests( theCreation, [ 302 { test: function(res) { return ('id' in res.body); }, assertion: "Created Annotation MUST have an id property" }, 303 { test: function(res) { return (('id' in res.body) && (res.body.id.search(container_url) > -1));}, assertion: "Created Annotation MUST have an id that starts with the Container IRI" }, 304 { test: function(res) { return ( 'canonical' in res.body && res.body.canonical === theAnnotation.canonical ); }, assertion: "Created Annotation MUST preserve any canonical IRI" }, 305 { test: function(res) { return ( res.status === 201 ) ; }, assertion: "Annotation Server MUST respond with a 201 Created code if the creation is successful" }, 306 { header: "Location", test: function(res) { return res.body.id === res.xhr.getResponseHeader('location') ; } , assertion: "Location header SHOULD match the id of the new Annotation" }, 307 ]); 308 309 promise_test(function() { 310 return theCreation.then(function(res) { 311 var newAnnotation = res.body ; 312 newAnnotation.target = "http://other.example/"; 313 return request("PUT", res.body.id, [['Content-Type', MEDIA_TYPE]], newAnnotation) 314 .then(function(lres) { 315 assert_equals(lres.body.target, newAnnotation.target, "Annotation did not update"); 316 }) 317 .catch(function(err) { 318 assert_true(false, "Update of annotation failed"); 319 }); 320 }); 321 }, "Annotation update must be done with the PUT method"); 322 323 promise_test(function() { 324 return theCreation.then(function(res) { 325 request("DELETE", res.body.id) 326 .then(function(lres) { 327 assert_equals(lres.status, 204, "DELETE of " + res.body.id + " did not return a 204 Status" ); 328 }); 329 }); 330 }, "Annotation deletion with DELETE method MUST return a 204 status" ); 331 332 // SHOULD tests 333 334 test(function() { 335 assert_equals(container_url.toLowerCase().substr(0,5), "https", "Server is not using HTTPS"); 336 }, "Annotation server SHOULD use HTTPS rather than HTTP"); 337 338 var thePrefRequest = request("GET", container_url, 339 [['Prefer', 'return=representation;include="http://www.w3.org/ns/ldp#PreferMinimalContainer"']]); 340 341 promise_test(function() { 342 return thePrefRequest 343 .then(function(res) { 344 var f = res.body.first; 345 request("GET", f).then(function(fres) { 346 fres.body.items.forEach(function(item) { 347 assert_true('@context' in item, "Annotation does not contain `@context`"); 348 }); 349 }); 350 }); 351 }, "SHOULD return the full annotation descriptions"); 352 353 354 makePromiseTests( thePrefRequest, [ 355 { test: function(res) { return ('total' in res.body); }, assertion: "SHOULD include the total property with the total number of annotations in the container" }, 356 { test: function(res) { return ('first' in res.body); }, assertion: "SHOULD have a link to the first page of its contents using `first`" }, 357 { test: function(res) { return ('last' in res.body); }, assertion: "SHOULD have a link to the last page of its contents using `last`" }, 358 { test: function(res) { return (!('items' in res.body)); }, assertion: "Response contains annotations via `items` when it SHOULD NOT"}, 359 { test: function(res) { return (!('ldp:contains' in res.body)); }, assertion: "Response contains annotations via `ldp:contains` when it SHOULD NOT" }, 360 { header: 'Vary', pat: /Prefer/, assertion: "SHOULD include Prefer in the Vary header" } 361 ]); 362 363 promise_test(function() { 364 return thePrefRequest 365 .then(function(res) { 366 var h = res.xhr.getResponseHeader('Prefer'); 367 assert_equals(h, null, "Reponse contains the `Prefer` header when it SHOULD NOT"); 368 }); 369 }, 'SHOULD NOT [receive] the Prefer header when requesting the page'); 370 371 } 372 373 // set up an event handler one the document is loaded that will run the tests once we 374 // have a URI to run against 375 on_event(document, "DOMContentLoaded", function() { 376 var serverURI = document.getElementById("uri") ; 377 var annotationURI = document.getElementById("annotation") ; 378 var runButton = document.getElementById("endpoint-submit-button") ; 379 on_event(runButton, "click", function() { 380 // user clicked 381 var URI = serverURI.value; 382 var ANN = annotationURI.value; 383 runButton.disabled = true; 384 385 // okay, they clicked. run the tests with that URI 386 runTests(URI, ANN); 387 done(); 388 }); 389 }); 390 </script> 391 </head> 392 <body> 393 <p>The scripts associated with this test will exercise all of the MUST and SHOULD requirements 394 for an Annotation Protocol server implementation. In order to do so, the server must have 395 its CORS settings configured such that your test machine can access the annotations and containers 396 and such that it can get certain information from the headers. In particular, the container and 397 annotations within the container 398 under test must permit access to the Allow, Content-Location, Content-Type, ETag, Link, Location, Prefer, and Vary headers. 399 Correct CORS access can be achieved with headers like:</p> 400 <pre> 401 Access-Control-Allow-Headers: Content-Type, Prefer 402 Access-Control-Allow-Methods: GET,HEAD,OPTIONS,DELETE,PUT 403 Access-Control-Allow-Origin: * 404 Access-Control-Expose-Headers: ETag, Allow, Vary, Link, Content-Type, Location, Content-Location, Prefer 405 </pre> 406 <p>Provide endpoint and annotation URIs and select "Go" to start testing.</p> 407 <form name="endpoint"> 408 <p><label for="uri">Endpoint URI:</label> <input type="text" size="50" id="uri" name="uri"></p> 409 <p><label for="uri">Annotation URI:</label> <input type="text" size="50" id="annotation" name="annotation"></p> 410 <input type="button" id="endpoint-submit-button" value="Go"> 411 </form> 412 </body> 413 </html>