@@ -344,4 +344,143 @@ describe('Feed API', () => {
344344 } ) ;
345345 } ) ;
346346 } ) ;
347+
348+ describe ( 'XML escaping and URL encoding' , ( ) => {
349+ const SERVICE = 'Service B!' ;
350+ const TERMS = 'Privacy Policy' ;
351+ const FETCH_DATE = new Date ( '2024-05-15T10:00:00Z' ) ;
352+
353+ let response ;
354+ let repository ;
355+
356+ before ( async function ( ) {
357+ this . timeout ( 5000 ) ;
358+ repository = RepositoryFactory . create ( storageConfig ) ;
359+ await repository . initialize ( ) ;
360+
361+ await repository . save ( new Version ( {
362+ serviceId : SERVICE ,
363+ termsType : TERMS ,
364+ content : 'content with & and <tags>' ,
365+ fetchDate : FETCH_DATE ,
366+ snapshotIds : [ 's_escape' ] ,
367+ } ) ) ;
368+
369+ response = await request . get ( `${ basePath } /v1/feed/${ encodeURIComponent ( SERVICE ) } /${ encodeURIComponent ( TERMS ) } ` ) ;
370+ } ) ;
371+
372+ after ( ( ) => repository . removeAll ( ) ) ;
373+
374+ it ( 'responds with 200' , ( ) => {
375+ expect ( response . status ) . to . equal ( 200 ) ;
376+ } ) ;
377+
378+ it ( 'URL-encodes spaces and special characters in the self link href' , ( ) => {
379+ const href = response . text . match ( / < l i n k [ ^ > ] * r e l = " s e l f " [ ^ > ] * h r e f = " ( [ ^ " ] + ) " / ) [ 1 ] ;
380+
381+ expect ( href ) . to . include ( 'Service%20B!' ) ;
382+ expect ( href ) . to . include ( 'Privacy%20Policy' ) ;
383+ expect ( href ) . to . not . include ( 'Service B!' ) ;
384+ } ) ;
385+
386+ it ( 'URL-encodes spaces and special characters in entry alternate links' , ( ) => {
387+ const href = response . text . match ( / < l i n k [ ^ > ] * r e l = " a l t e r n a t e " [ ^ > ] * h r e f = " ( [ ^ " ] + ) " / ) [ 1 ] ;
388+
389+ expect ( href ) . to . include ( 'Service%20B!' ) ;
390+ expect ( href ) . to . include ( 'Privacy%20Policy' ) ;
391+ } ) ;
392+ } ) ;
393+
394+ describe ( 'GET /feed/:serviceId/:termsType' , ( ) => {
395+ const SERVICE = 'service_without_history' ;
396+ const TERMS = 'Terms of Service' ;
397+ const UNKNOWN_TERMS = 'Imprint' ;
398+
399+ let repository ;
400+
401+ before ( async function ( ) {
402+ this . timeout ( 5000 ) ;
403+ repository = RepositoryFactory . create ( storageConfig ) ;
404+ await repository . initialize ( ) ;
405+
406+ await repository . save ( new Version ( {
407+ serviceId : SERVICE ,
408+ termsType : TERMS ,
409+ content : 'first' ,
410+ fetchDate : new Date ( '2024-01-01T00:00:00Z' ) ,
411+ snapshotIds : [ 's1' ] ,
412+ } ) ) ;
413+ await repository . save ( new Version ( {
414+ serviceId : SERVICE ,
415+ termsType : TERMS ,
416+ content : 'updated' ,
417+ fetchDate : new Date ( '2024-02-01T00:00:00Z' ) ,
418+ snapshotIds : [ 's2' ] ,
419+ } ) ) ;
420+ } ) ;
421+
422+ after ( ( ) => repository . removeAll ( ) ) ;
423+
424+ context ( 'when the service and terms type match' , ( ) => {
425+ let response ;
426+
427+ before ( async ( ) => {
428+ response = await request . get ( `${ basePath } /v1/feed/${ encodeURIComponent ( SERVICE ) } /${ encodeURIComponent ( TERMS ) } ` ) ;
429+ } ) ;
430+
431+ it ( 'responds with 200' , ( ) => {
432+ expect ( response . status ) . to . equal ( 200 ) ;
433+ } ) ;
434+
435+ it ( 'includes entries for the combination' , ( ) => {
436+ const entries = response . text . match ( / < e n t r y > / g) || [ ] ;
437+
438+ expect ( entries . length ) . to . be . at . least ( 1 ) ;
439+ } ) ;
440+
441+ it ( 'entries only have the expected terms type' , ( ) => {
442+ const termsTypeTerms = [ ...response . text . matchAll ( / < c a t e g o r y [ ^ / ] * s c h e m e = " t a g : o p e n t e r m s a r c h i v e .o r g , 2 0 2 6 : s c h e m e : t e r m s - t y p e " [ ^ / ] * t e r m = " ( [ ^ " ] + ) " / g) ]
443+ . concat ( [ ...response . text . matchAll ( / < c a t e g o r y [ ^ / ] * t e r m = " ( [ ^ " ] + ) " [ ^ / ] * s c h e m e = " t a g : o p e n t e r m s a r c h i v e .o r g , 2 0 2 6 : s c h e m e : t e r m s - t y p e " / g) ] )
444+ . map ( match => match [ 1 ] ) ;
445+
446+ for ( const term of termsTypeTerms ) {
447+ expect ( term ) . to . equal ( TERMS ) ;
448+ }
449+ } ) ;
450+
451+ it ( 'has a feed id that includes both service and terms type' , ( ) => {
452+ expect ( extractTag ( response . text , 'id' ) ) . to . equal ( `tag:opentermsarchive.org,2026:feed:test:${ SERVICE } :${ TERMS } ` ) ;
453+ } ) ;
454+
455+ it ( 'has a self link pointing to the combination endpoint' , ( ) => {
456+ const href = response . text . match ( / < l i n k [ ^ > ] * r e l = " s e l f " [ ^ > ] * h r e f = " ( [ ^ " ] + ) " / ) [ 1 ] ;
457+
458+ expect ( href ) . to . match ( new RegExp ( `/feed/${ SERVICE } /${ encodeURIComponent ( TERMS ) } $` ) ) ;
459+ } ) ;
460+ } ) ;
461+
462+ context ( 'when the service exists but does not declare the terms type' , ( ) => {
463+ let response ;
464+
465+ before ( async ( ) => {
466+ response = await request . get ( `${ basePath } /v1/feed/${ encodeURIComponent ( SERVICE ) } /${ encodeURIComponent ( UNKNOWN_TERMS ) } ` ) ;
467+ } ) ;
468+
469+ it ( 'responds with 404' , ( ) => {
470+ expect ( response . status ) . to . equal ( 404 ) ;
471+ } ) ;
472+ } ) ;
473+
474+ context ( 'when the service does not exist' , ( ) => {
475+ let response ;
476+
477+ before ( async ( ) => {
478+ response = await request . get ( `${ basePath } /v1/feed/DoesNotExist/${ encodeURIComponent ( TERMS ) } ` ) ;
479+ } ) ;
480+
481+ it ( 'responds with 404' , ( ) => {
482+ expect ( response . status ) . to . equal ( 404 ) ;
483+ } ) ;
484+ } ) ;
485+ } ) ;
347486} ) ;
0 commit comments