diff --git a/artifacts/tests/coverage-gateway/lcov-report/base.css b/artifacts/tests/coverage-gateway/lcov-report/base.css new file mode 100644 index 0000000..f418035 --- /dev/null +++ b/artifacts/tests/coverage-gateway/lcov-report/base.css @@ -0,0 +1,224 @@ +body, html { + margin:0; padding: 0; + height: 100%; +} +body { + font-family: Helvetica Neue, Helvetica, Arial; + font-size: 14px; + color:#333; +} +.small { font-size: 12px; } +*, *:after, *:before { + -webkit-box-sizing:border-box; + -moz-box-sizing:border-box; + box-sizing:border-box; + } +h1 { font-size: 20px; margin: 0;} +h2 { font-size: 14px; } +pre { + font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { color:#0074D9; text-decoration:none; } +a:hover { text-decoration:underline; } +.strong { font-weight: bold; } +.space-top1 { padding: 10px 0 0 0; } +.pad2y { padding: 20px 0; } +.pad1y { padding: 10px 0; } +.pad2x { padding: 0 20px; } +.pad2 { padding: 20px; } +.pad1 { padding: 10px; } +.space-left2 { padding-left:55px; } +.space-right2 { padding-right:20px; } +.center { text-align:center; } +.clearfix { display:block; } +.clearfix:after { + content:''; + display:block; + height:0; + clear:both; + visibility:hidden; + } +.fl { float: left; } +@media only screen and (max-width:640px) { + .col3 { width:100%; max-width:100%; } + .hide-mobile { display:none!important; } +} + +.quiet { + color: #7f7f7f; + color: rgba(0,0,0,0.5); +} +.quiet a { opacity: 0.7; } + +.fraction { + font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #E8E8E8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, div.path a:visited { color: #333; } +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 10px; + min-width:20px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, .skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { border-bottom: 1px solid #bbb; } +.keyline-all { border: 1px solid #ddd; } +.coverage-summary td, .coverage-summary th { padding: 10px; } +.coverage-summary tbody { border: 1px solid #bbb; } +.coverage-summary td { border-right: 1px solid #bbb; } +.coverage-summary td:last-child { border-right: none; } +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { border-right: none !important; } +.coverage-summary th.pct { } +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { text-align: right; } +.coverage-summary td.file { white-space: nowrap; } +.coverage-summary td.pic { min-width: 120px !important; } +.coverage-summary tfoot td { } + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { height: 10px; } +/* yellow */ +.cbranch-no { background: yellow !important; color: #111; } +/* dark red */ +.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 } +.low .chart { border:1px solid #C21F39 } +.highlighted, +.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{ + background: #C21F39 !important; +} +/* medium red */ +.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE } +/* light red */ +.low, .cline-no { background:#FCE1E5 } +/* light green */ +.high, .cline-yes { background:rgb(230,245,208) } +/* medium green */ +.cstat-yes { background:rgb(161,215,106) } +/* dark green */ +.status-line.high, .high .cover-fill { background:rgb(77,146,33) } +.high .chart { border:1px solid rgb(77,146,33) } +/* dark yellow (gold) */ +.status-line.medium, .medium .cover-fill { background: #f9cd0b; } +.medium .chart { border:1px solid #f9cd0b; } +/* light yellow */ +.medium { background: #fff4c2; } + +.cstat-skip { background: #ddd; color: #111; } +.fstat-skip { background: #ddd; color: #111 !important; } +.cbranch-skip { background: #ddd !important; color: #111; } + +span.cline-neutral { background: #eaeaea; } + +.coverage-summary td.empty { + opacity: .5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, .cover-empty { + display:inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { color: #999 !important; } +.ignore-none { color: #999; font-weight: normal; } + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, .push { + height: 48px; +} diff --git a/artifacts/tests/coverage-gateway/lcov-report/block-navigation.js b/artifacts/tests/coverage-gateway/lcov-report/block-navigation.js new file mode 100644 index 0000000..530d1ed --- /dev/null +++ b/artifacts/tests/coverage-gateway/lcov-report/block-navigation.js @@ -0,0 +1,87 @@ +/* eslint-disable */ +var jumpToCode = (function init() { + // Classes of code we would like to highlight in the file view + var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no']; + + // Elements to highlight in the file listing view + var fileListingElements = ['td.pct.low']; + + // We don't want to select elements that are direct descendants of another match + var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > ` + + // Selector that finds elements on the page to which we can jump + var selector = + fileListingElements.join(', ') + + ', ' + + notSelector + + missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b` + + // The NodeList of matching elements + var missingCoverageElements = document.querySelectorAll(selector); + + var currentIndex; + + function toggleClass(index) { + missingCoverageElements + .item(currentIndex) + .classList.remove('highlighted'); + missingCoverageElements.item(index).classList.add('highlighted'); + } + + function makeCurrent(index) { + toggleClass(index); + currentIndex = index; + missingCoverageElements.item(index).scrollIntoView({ + behavior: 'smooth', + block: 'center', + inline: 'center' + }); + } + + function goToPrevious() { + var nextIndex = 0; + if (typeof currentIndex !== 'number' || currentIndex === 0) { + nextIndex = missingCoverageElements.length - 1; + } else if (missingCoverageElements.length > 1) { + nextIndex = currentIndex - 1; + } + + makeCurrent(nextIndex); + } + + function goToNext() { + var nextIndex = 0; + + if ( + typeof currentIndex === 'number' && + currentIndex < missingCoverageElements.length - 1 + ) { + nextIndex = currentIndex + 1; + } + + makeCurrent(nextIndex); + } + + return function jump(event) { + if ( + document.getElementById('fileSearch') === document.activeElement && + document.activeElement != null + ) { + // if we're currently focused on the search input, we don't want to navigate + return; + } + + switch (event.which) { + case 78: // n + case 74: // j + goToNext(); + break; + case 66: // b + case 75: // k + case 80: // p + goToPrevious(); + break; + } + }; +})(); +window.addEventListener('keydown', jumpToCode); diff --git a/artifacts/tests/coverage-gateway/lcov-report/favicon.png b/artifacts/tests/coverage-gateway/lcov-report/favicon.png new file mode 100644 index 0000000..c1525b8 Binary files /dev/null and b/artifacts/tests/coverage-gateway/lcov-report/favicon.png differ diff --git a/artifacts/tests/coverage-gateway/lcov-report/index.html b/artifacts/tests/coverage-gateway/lcov-report/index.html new file mode 100644 index 0000000..e1c9591 --- /dev/null +++ b/artifacts/tests/coverage-gateway/lcov-report/index.html @@ -0,0 +1,116 @@ + + + + + + Code coverage report for All files + + + + + + + + + +
+
+

All files

+
+ +
+ 100% + Statements + 11/11 +
+ + +
+ 100% + Branches + 8/8 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 11/11 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileStatementsBranchesFunctionsLines
reference-http-gateway.ts +
+
100%11/11100%8/8100%5/5100%11/11
+
+
+
+ + + + + + + + \ No newline at end of file diff --git a/artifacts/tests/coverage-gateway/lcov-report/prettify.css b/artifacts/tests/coverage-gateway/lcov-report/prettify.css new file mode 100644 index 0000000..b317a7c --- /dev/null +++ b/artifacts/tests/coverage-gateway/lcov-report/prettify.css @@ -0,0 +1 @@ +.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee} diff --git a/artifacts/tests/coverage-gateway/lcov-report/prettify.js b/artifacts/tests/coverage-gateway/lcov-report/prettify.js new file mode 100644 index 0000000..b322523 --- /dev/null +++ b/artifacts/tests/coverage-gateway/lcov-report/prettify.js @@ -0,0 +1,2 @@ +/* eslint-disable */ +window.PR_SHOULD_USE_CONTINUATION=true;(function(){var h=["break,continue,do,else,for,if,return,while"];var u=[h,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"];var p=[u,"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"];var l=[p,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"];var x=[p,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"];var R=[x,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"];var r="all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes";var w=[p,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"];var s="caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END";var I=[h,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"];var f=[h,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"];var H=[h,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"];var A=[l,R,w,s+I,f,H];var e=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/;var C="str";var z="kwd";var j="com";var O="typ";var G="lit";var L="pun";var F="pln";var m="tag";var E="dec";var J="src";var P="atn";var n="atv";var N="nocode";var M="(?:^^\\.?|[+-]|\\!|\\!=|\\!==|\\#|\\%|\\%=|&|&&|&&=|&=|\\(|\\*|\\*=|\\+=|\\,|\\-=|\\->|\\/|\\/=|:|::|\\;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|\\?|\\@|\\[|\\^|\\^=|\\^\\^|\\^\\^=|\\{|\\||\\|=|\\|\\||\\|\\|=|\\~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\\s*";function k(Z){var ad=0;var S=false;var ac=false;for(var V=0,U=Z.length;V122)){if(!(al<65||ag>90)){af.push([Math.max(65,ag)|32,Math.min(al,90)|32])}if(!(al<97||ag>122)){af.push([Math.max(97,ag)&~32,Math.min(al,122)&~32])}}}}af.sort(function(av,au){return(av[0]-au[0])||(au[1]-av[1])});var ai=[];var ap=[NaN,NaN];for(var ar=0;arat[0]){if(at[1]+1>at[0]){an.push("-")}an.push(T(at[1]))}}an.push("]");return an.join("")}function W(al){var aj=al.source.match(new RegExp("(?:\\[(?:[^\\x5C\\x5D]|\\\\[\\s\\S])*\\]|\\\\u[A-Fa-f0-9]{4}|\\\\x[A-Fa-f0-9]{2}|\\\\[0-9]+|\\\\[^ux0-9]|\\(\\?[:!=]|[\\(\\)\\^]|[^\\x5B\\x5C\\(\\)\\^]+)","g"));var ah=aj.length;var an=[];for(var ak=0,am=0;ak=2&&ai==="["){aj[ak]=X(ag)}else{if(ai!=="\\"){aj[ak]=ag.replace(/[a-zA-Z]/g,function(ao){var ap=ao.charCodeAt(0);return"["+String.fromCharCode(ap&~32,ap|32)+"]"})}}}}return aj.join("")}var aa=[];for(var V=0,U=Z.length;V=0;){S[ac.charAt(ae)]=Y}}var af=Y[1];var aa=""+af;if(!ag.hasOwnProperty(aa)){ah.push(af);ag[aa]=null}}ah.push(/[\0-\uffff]/);V=k(ah)})();var X=T.length;var W=function(ah){var Z=ah.sourceCode,Y=ah.basePos;var ad=[Y,F];var af=0;var an=Z.match(V)||[];var aj={};for(var ae=0,aq=an.length;ae=5&&"lang-"===ap.substring(0,5);if(am&&!(ai&&typeof ai[1]==="string")){am=false;ap=J}if(!am){aj[ag]=ap}}var ab=af;af+=ag.length;if(!am){ad.push(Y+ab,ap)}else{var al=ai[1];var ak=ag.indexOf(al);var ac=ak+al.length;if(ai[2]){ac=ag.length-ai[2].length;ak=ac-al.length}var ar=ap.substring(5);B(Y+ab,ag.substring(0,ak),W,ad);B(Y+ab+ak,al,q(ar,al),ad);B(Y+ab+ac,ag.substring(ac),W,ad)}}ah.decorations=ad};return W}function i(T){var W=[],S=[];if(T.tripleQuotedStrings){W.push([C,/^(?:\'\'\'(?:[^\'\\]|\\[\s\S]|\'{1,2}(?=[^\']))*(?:\'\'\'|$)|\"\"\"(?:[^\"\\]|\\[\s\S]|\"{1,2}(?=[^\"]))*(?:\"\"\"|$)|\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$))/,null,"'\""])}else{if(T.multiLineStrings){W.push([C,/^(?:\'(?:[^\\\']|\\[\s\S])*(?:\'|$)|\"(?:[^\\\"]|\\[\s\S])*(?:\"|$)|\`(?:[^\\\`]|\\[\s\S])*(?:\`|$))/,null,"'\"`"])}else{W.push([C,/^(?:\'(?:[^\\\'\r\n]|\\.)*(?:\'|$)|\"(?:[^\\\"\r\n]|\\.)*(?:\"|$))/,null,"\"'"])}}if(T.verbatimStrings){S.push([C,/^@\"(?:[^\"]|\"\")*(?:\"|$)/,null])}var Y=T.hashComments;if(Y){if(T.cStyleComments){if(Y>1){W.push([j,/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,null,"#"])}else{W.push([j,/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\r\n]*)/,null,"#"])}S.push([C,/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,null])}else{W.push([j,/^#[^\r\n]*/,null,"#"])}}if(T.cStyleComments){S.push([j,/^\/\/[^\r\n]*/,null]);S.push([j,/^\/\*[\s\S]*?(?:\*\/|$)/,null])}if(T.regexLiterals){var X=("/(?=[^/*])(?:[^/\\x5B\\x5C]|\\x5C[\\s\\S]|\\x5B(?:[^\\x5C\\x5D]|\\x5C[\\s\\S])*(?:\\x5D|$))+/");S.push(["lang-regex",new RegExp("^"+M+"("+X+")")])}var V=T.types;if(V){S.push([O,V])}var U=(""+T.keywords).replace(/^ | $/g,"");if(U.length){S.push([z,new RegExp("^(?:"+U.replace(/[\s,]+/g,"|")+")\\b"),null])}W.push([F,/^\s+/,null," \r\n\t\xA0"]);S.push([G,/^@[a-z_$][a-z_$@0-9]*/i,null],[O,/^(?:[@_]?[A-Z]+[a-z][A-Za-z_$@0-9]*|\w+_t\b)/,null],[F,/^[a-z_$][a-z_$@0-9]*/i,null],[G,new RegExp("^(?:0x[a-f0-9]+|(?:\\d(?:_\\d+)*\\d*(?:\\.\\d*)?|\\.\\d\\+)(?:e[+\\-]?\\d+)?)[a-z]*","i"),null,"0123456789"],[F,/^\\[\s\S]?/,null],[L,/^.[^\s\w\.$@\'\"\`\/\#\\]*/,null]);return g(W,S)}var K=i({keywords:A,hashComments:true,cStyleComments:true,multiLineStrings:true,regexLiterals:true});function Q(V,ag){var U=/(?:^|\s)nocode(?:\s|$)/;var ab=/\r\n?|\n/;var ac=V.ownerDocument;var S;if(V.currentStyle){S=V.currentStyle.whiteSpace}else{if(window.getComputedStyle){S=ac.defaultView.getComputedStyle(V,null).getPropertyValue("white-space")}}var Z=S&&"pre"===S.substring(0,3);var af=ac.createElement("LI");while(V.firstChild){af.appendChild(V.firstChild)}var W=[af];function ae(al){switch(al.nodeType){case 1:if(U.test(al.className)){break}if("BR"===al.nodeName){ad(al);if(al.parentNode){al.parentNode.removeChild(al)}}else{for(var an=al.firstChild;an;an=an.nextSibling){ae(an)}}break;case 3:case 4:if(Z){var am=al.nodeValue;var aj=am.match(ab);if(aj){var ai=am.substring(0,aj.index);al.nodeValue=ai;var ah=am.substring(aj.index+aj[0].length);if(ah){var ak=al.parentNode;ak.insertBefore(ac.createTextNode(ah),al.nextSibling)}ad(al);if(!ai){al.parentNode.removeChild(al)}}}break}}function ad(ak){while(!ak.nextSibling){ak=ak.parentNode;if(!ak){return}}function ai(al,ar){var aq=ar?al.cloneNode(false):al;var ao=al.parentNode;if(ao){var ap=ai(ao,1);var an=al.nextSibling;ap.appendChild(aq);for(var am=an;am;am=an){an=am.nextSibling;ap.appendChild(am)}}return aq}var ah=ai(ak.nextSibling,0);for(var aj;(aj=ah.parentNode)&&aj.nodeType===1;){ah=aj}W.push(ah)}for(var Y=0;Y=S){ah+=2}if(V>=ap){Z+=2}}}var t={};function c(U,V){for(var S=V.length;--S>=0;){var T=V[S];if(!t.hasOwnProperty(T)){t[T]=U}else{if(window.console){console.warn("cannot override language handler %s",T)}}}}function q(T,S){if(!(T&&t.hasOwnProperty(T))){T=/^\s*]*(?:>|$)/],[j,/^<\!--[\s\S]*?(?:-\->|$)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],[L,/^(?:<[%?]|[%?]>)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),["default-markup","htm","html","mxml","xhtml","xml","xsl"]);c(g([[F,/^[\s]+/,null," \t\r\n"],[n,/^(?:\"[^\"]*\"?|\'[^\']*\'?)/,null,"\"'"]],[[m,/^^<\/?[a-z](?:[\w.:-]*\w)?|\/?>$/i],[P,/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^>\'\"\s]*(?:[^>\'\"\s\/]|\/(?=\s)))/],[L,/^[=<>\/]+/],["lang-js",/^on\w+\s*=\s*\"([^\"]+)\"/i],["lang-js",/^on\w+\s*=\s*\'([^\']+)\'/i],["lang-js",/^on\w+\s*=\s*([^\"\'>\s]+)/i],["lang-css",/^style\s*=\s*\"([^\"]+)\"/i],["lang-css",/^style\s*=\s*\'([^\']+)\'/i],["lang-css",/^style\s*=\s*([^\"\'>\s]+)/i]]),["in.tag"]);c(g([],[[n,/^[\s\S]+/]]),["uq.val"]);c(i({keywords:l,hashComments:true,cStyleComments:true,types:e}),["c","cc","cpp","cxx","cyc","m"]);c(i({keywords:"null,true,false"}),["json"]);c(i({keywords:R,hashComments:true,cStyleComments:true,verbatimStrings:true,types:e}),["cs"]);c(i({keywords:x,cStyleComments:true}),["java"]);c(i({keywords:H,hashComments:true,multiLineStrings:true}),["bsh","csh","sh"]);c(i({keywords:I,hashComments:true,multiLineStrings:true,tripleQuotedStrings:true}),["cv","py"]);c(i({keywords:s,hashComments:true,multiLineStrings:true,regexLiterals:true}),["perl","pl","pm"]);c(i({keywords:f,hashComments:true,multiLineStrings:true,regexLiterals:true}),["rb"]);c(i({keywords:w,cStyleComments:true,regexLiterals:true}),["js"]);c(i({keywords:r,hashComments:3,cStyleComments:true,multilineStrings:true,tripleQuotedStrings:true,regexLiterals:true}),["coffee"]);c(g([],[[C,/^[\s\S]+/]]),["regex"]);function d(V){var U=V.langExtension;try{var S=a(V.sourceNode);var T=S.sourceCode;V.sourceCode=T;V.spans=S.spans;V.basePos=0;q(U,T)(V);D(V)}catch(W){if("console" in window){console.log(W&&W.stack?W.stack:W)}}}function y(W,V,U){var S=document.createElement("PRE");S.innerHTML=W;if(U){Q(S,U)}var T={langExtension:V,numberLines:U,sourceNode:S};d(T);return S.innerHTML}function b(ad){function Y(af){return document.getElementsByTagName(af)}var ac=[Y("pre"),Y("code"),Y("xmp")];var T=[];for(var aa=0;aa=0){var ah=ai.match(ab);var am;if(!ah&&(am=o(aj))&&"CODE"===am.tagName){ah=am.className.match(ab)}if(ah){ah=ah[1]}var al=false;for(var ak=aj.parentNode;ak;ak=ak.parentNode){if((ak.tagName==="pre"||ak.tagName==="code"||ak.tagName==="xmp")&&ak.className&&ak.className.indexOf("prettyprint")>=0){al=true;break}}if(!al){var af=aj.className.match(/\blinenums\b(?::(\d+))?/);af=af?af[1]&&af[1].length?+af[1]:true:false;if(af){Q(aj,af)}S={langExtension:ah,sourceNode:aj,numberLines:af};d(S)}}}if(X]*(?:>|$)/],[PR.PR_COMMENT,/^<\!--[\s\S]*?(?:-\->|$)/],[PR.PR_PUNCTUATION,/^(?:<[%?]|[%?]>)/],["lang-",/^<\?([\s\S]+?)(?:\?>|$)/],["lang-",/^<%([\s\S]+?)(?:%>|$)/],["lang-",/^]*>([\s\S]+?)<\/xmp\b[^>]*>/i],["lang-handlebars",/^]*type\s*=\s*['"]?text\/x-handlebars-template['"]?\b[^>]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-js",/^]*>([\s\S]*?)(<\/script\b[^>]*>)/i],["lang-css",/^]*>([\s\S]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i],[PR.PR_DECLARATION,/^{{[#^>/]?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{&?\s*[\w.][^}]*}}/],[PR.PR_DECLARATION,/^{{{>?\s*[\w.][^}]*}}}/],[PR.PR_COMMENT,/^{{![^}]*}}/]]),["handlebars","hbs"]);PR.registerLangHandler(PR.createSimpleLexer([[PR.PR_PLAIN,/^[ \t\r\n\f]+/,null," \t\r\n\f"]],[[PR.PR_STRING,/^\"(?:[^\n\r\f\\\"]|\\(?:\r\n?|\n|\f)|\\[\s\S])*\"/,null],[PR.PR_STRING,/^\'(?:[^\n\r\f\\\']|\\(?:\r\n?|\n|\f)|\\[\s\S])*\'/,null],["lang-css-str",/^url\(([^\)\"\']*)\)/i],[PR.PR_KEYWORD,/^(?:url|rgb|\!important|@import|@page|@media|@charset|inherit)(?=[^\-\w]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|(?:\\[0-9a-f]+ ?))(?:[_a-z0-9\-]|\\(?:\\[0-9a-f]+ ?))*)\s*:/i],[PR.PR_COMMENT,/^\/\*[^*]*\*+(?:[^\/*][^*]*\*+)*\//],[PR.PR_COMMENT,/^(?:)/],[PR.PR_LITERAL,/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],[PR.PR_LITERAL,/^#(?:[0-9a-f]{3}){1,2}/i],[PR.PR_PLAIN,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i],[PR.PR_PUNCTUATION,/^[^\s\w\'\"]+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_KEYWORD,/^-?(?:[_a-z]|(?:\\[\da-f]+ ?))(?:[_a-z\d\-]|\\(?:\\[\da-f]+ ?))*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[[PR.PR_STRING,/^[^\)\"\']+/]]),["css-str"]); diff --git a/artifacts/tests/coverage-gateway/lcov-report/reference-http-gateway.ts.html b/artifacts/tests/coverage-gateway/lcov-report/reference-http-gateway.ts.html new file mode 100644 index 0000000..809fef9 --- /dev/null +++ b/artifacts/tests/coverage-gateway/lcov-report/reference-http-gateway.ts.html @@ -0,0 +1,331 @@ + + + + + + Code coverage report for reference-http-gateway.ts + + + + + + + + + +
+
+

All files reference-http-gateway.ts

+
+ +
+ 100% + Statements + 11/11 +
+ + +
+ 100% + Branches + 8/8 +
+ + +
+ 100% + Functions + 5/5 +
+ + +
+ 100% + Lines + 11/11 +
+ + +
+

+ Press n or j to go to the next uncovered block, b, p or k for the previous block. +

+ +
+
+

+
1 +2 +3 +4 +5 +6 +7 +8 +9 +10 +11 +12 +13 +14 +15 +16 +17 +18 +19 +20 +21 +22 +23 +24 +25 +26 +27 +28 +29 +30 +31 +32 +33 +34 +35 +36 +37 +38 +39 +40 +41 +42 +43 +44 +45 +46 +47 +48 +49 +50 +51 +52 +53 +54 +55 +56 +57 +58 +59 +60 +61 +62 +63 +64 +65 +66 +67 +68 +69 +70 +71 +72 +73 +74 +75 +76 +77 +78 +79 +80 +81 +82 +83  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +2x +  +2x +  +  +  +  +  +  +  +  +  +  +5x +2x +  +  +  +  +3x +2x +  +  +  +  +1x +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +  +8x +  +1x +  +  +6x +  +  +3x +  +  +  +  +  +  +  + 
import {
+  defineFeatureHttpOperationForRoutes,
+  type FeatureHttpBinding,
+} from "../../../adapters/http/index.ts";
+import {
+  CREATE_REFERENCE_RESOURCE_CONTRACT,
+  GET_REFERENCE_RESOURCE_CONTRACT,
+  LIST_REFERENCE_RESOURCES_CONTRACT,
+} from "../contracts/reference-feature-contract-contribution.ts";
+import {
+  REFERENCE_FEATURE_CONTRACT,
+} from "../contracts/reference-feature-contract.ts";
+import {
+  mapReferenceResourceListPayload,
+  mapReferenceResourcePayload,
+} from "../contracts/reference-mapper.ts";
+import type {
+  ReferenceGateway,
+} from "../application/reference-feature-api.ts";
+ 
+export type ReferenceFeatureRouteId =
+  keyof typeof REFERENCE_FEATURE_CONTRACT.routes;
+ 
+const defineReferenceHttpOperation =
+  defineFeatureHttpOperationForRoutes<ReferenceFeatureRouteId>();
+ 
+export const REFERENCE_HTTP_OPERATIONS = Object.freeze({
+  LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({
+    contract: LIST_REFERENCE_RESOURCES_CONTRACT,
+    routeId: "REFERENCE_RESOURCE_LIST",
+    mapSuccess: mapReferenceResourceListPayload,
+  }),
+  CREATE_REFERENCE_RESOURCE: defineReferenceHttpOperation({
+    contract: CREATE_REFERENCE_RESOURCE_CONTRACT,
+    routeId: "REFERENCE_RESOURCE_LIST",
+    mapSuccess: mapReferenceResourcePayload,
+    mapProblem(problem, metadata) {
+      if (metadata.status === 409) {
+        return Object.freeze({
+          kind: "CONFLICT" as const,
+          code: problem.code ?? "REFERENCE_RESOURCE_CONFLICT",
+        });
+      }
+      if (metadata.status === 422) {
+        return Object.freeze({
+          kind: "VALIDATION_REJECTED" as const,
+          code: problem.code ?? "REFERENCE_RESOURCE_REJECTED",
+        });
+      }
+      return undefined;
+    },
+  }),
+  GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({
+    contract: GET_REFERENCE_RESOURCE_CONTRACT,
+    routeId: "REFERENCE_RESOURCE_DETAIL",
+    mapSuccess: mapReferenceResourcePayload,
+  }),
+} as const);
+ 
+export type ReferenceHttpBinding = FeatureHttpBinding<
+  typeof REFERENCE_HTTP_OPERATIONS
+>;
+ 
+export function createReferenceHttpGateway(
+  http: ReferenceHttpBinding,
+): ReferenceGateway {
+  return Object.freeze({
+    list(filters, context) {
+      return http.execute("LIST_REFERENCE_RESOURCES", filters, context);
+    },
+    create(command, context) {
+      return http.execute("CREATE_REFERENCE_RESOURCE", command, context);
+    },
+    get(resourceId, context) {
+      return http.execute(
+        "GET_REFERENCE_RESOURCE",
+        Object.freeze({ resourceId }),
+        context,
+      );
+    },
+  });
+}
+ 
+ +
+
+ + + + + + + + \ No newline at end of file diff --git a/artifacts/tests/coverage-gateway/lcov-report/sort-arrow-sprite.png b/artifacts/tests/coverage-gateway/lcov-report/sort-arrow-sprite.png new file mode 100644 index 0000000..6ed6831 Binary files /dev/null and b/artifacts/tests/coverage-gateway/lcov-report/sort-arrow-sprite.png differ diff --git a/artifacts/tests/coverage-gateway/lcov-report/sorter.js b/artifacts/tests/coverage-gateway/lcov-report/sorter.js new file mode 100644 index 0000000..4ed70ae --- /dev/null +++ b/artifacts/tests/coverage-gateway/lcov-report/sorter.js @@ -0,0 +1,210 @@ +/* eslint-disable */ +var addSorting = (function() { + 'use strict'; + var cols, + currentSort = { + index: 0, + desc: false + }; + + // returns the summary table element + function getTable() { + return document.querySelector('.coverage-summary'); + } + // returns the thead element of the summary table + function getTableHeader() { + return getTable().querySelector('thead tr'); + } + // returns the tbody element of the summary table + function getTableBody() { + return getTable().querySelector('tbody'); + } + // returns the th element for nth column + function getNthColumn(n) { + return getTableHeader().querySelectorAll('th')[n]; + } + + function onFilterInput() { + const searchValue = document.getElementById('fileSearch').value; + const rows = document.getElementsByTagName('tbody')[0].children; + + // Try to create a RegExp from the searchValue. If it fails (invalid regex), + // it will be treated as a plain text search + let searchRegex; + try { + searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive + } catch (error) { + searchRegex = null; + } + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + let isMatch = false; + + if (searchRegex) { + // If a valid regex was created, use it for matching + isMatch = searchRegex.test(row.textContent); + } else { + // Otherwise, fall back to the original plain text search + isMatch = row.textContent + .toLowerCase() + .includes(searchValue.toLowerCase()); + } + + row.style.display = isMatch ? '' : 'none'; + } + } + + // loads the search box + function addSearchBox() { + var template = document.getElementById('filterTemplate'); + var templateClone = template.content.cloneNode(true); + templateClone.getElementById('fileSearch').oninput = onFilterInput; + template.parentElement.appendChild(templateClone); + } + + // loads all columns + function loadColumns() { + var colNodes = getTableHeader().querySelectorAll('th'), + colNode, + cols = [], + col, + i; + + for (i = 0; i < colNodes.length; i += 1) { + colNode = colNodes[i]; + col = { + key: colNode.getAttribute('data-col'), + sortable: !colNode.getAttribute('data-nosort'), + type: colNode.getAttribute('data-type') || 'string' + }; + cols.push(col); + if (col.sortable) { + col.defaultDescSort = col.type === 'number'; + colNode.innerHTML = + colNode.innerHTML + ''; + } + } + return cols; + } + // attaches a data attribute to every tr element with an object + // of data values keyed by column name + function loadRowData(tableRow) { + var tableCols = tableRow.querySelectorAll('td'), + colNode, + col, + data = {}, + i, + val; + for (i = 0; i < tableCols.length; i += 1) { + colNode = tableCols[i]; + col = cols[i]; + val = colNode.getAttribute('data-value'); + if (col.type === 'number') { + val = Number(val); + } + data[col.key] = val; + } + return data; + } + // loads all row data + function loadData() { + var rows = getTableBody().querySelectorAll('tr'), + i; + + for (i = 0; i < rows.length; i += 1) { + rows[i].data = loadRowData(rows[i]); + } + } + // sorts the table using the data for the ith column + function sortByIndex(index, desc) { + var key = cols[index].key, + sorter = function(a, b) { + a = a.data[key]; + b = b.data[key]; + return a < b ? -1 : a > b ? 1 : 0; + }, + finalSorter = sorter, + tableBody = document.querySelector('.coverage-summary tbody'), + rowNodes = tableBody.querySelectorAll('tr'), + rows = [], + i; + + if (desc) { + finalSorter = function(a, b) { + return -1 * sorter(a, b); + }; + } + + for (i = 0; i < rowNodes.length; i += 1) { + rows.push(rowNodes[i]); + tableBody.removeChild(rowNodes[i]); + } + + rows.sort(finalSorter); + + for (i = 0; i < rows.length; i += 1) { + tableBody.appendChild(rows[i]); + } + } + // removes sort indicators for current column being sorted + function removeSortIndicators() { + var col = getNthColumn(currentSort.index), + cls = col.className; + + cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, ''); + col.className = cls; + } + // adds sort indicators for current column being sorted + function addSortIndicators() { + getNthColumn(currentSort.index).className += currentSort.desc + ? ' sorted-desc' + : ' sorted'; + } + // adds event listeners for all sorter widgets + function enableUI() { + var i, + el, + ithSorter = function ithSorter(i) { + var col = cols[i]; + + return function() { + var desc = col.defaultDescSort; + + if (currentSort.index === i) { + desc = !currentSort.desc; + } + sortByIndex(i, desc); + removeSortIndicators(); + currentSort.index = i; + currentSort.desc = desc; + addSortIndicators(); + }; + }; + for (i = 0; i < cols.length; i += 1) { + if (cols[i].sortable) { + // add the click event handler on the th so users + // dont have to click on those tiny arrows + el = getNthColumn(i).querySelector('.sorter').parentElement; + if (el.addEventListener) { + el.addEventListener('click', ithSorter(i)); + } else { + el.attachEvent('onclick', ithSorter(i)); + } + } + } + } + // adds sorting functionality to the UI + return function() { + if (!getTable()) { + return; + } + cols = loadColumns(); + loadData(); + addSearchBox(); + addSortIndicators(); + enableUI(); + }; +})(); + +window.addEventListener('load', addSorting); diff --git a/artifacts/tests/coverage-gateway/lcov.info b/artifacts/tests/coverage-gateway/lcov.info new file mode 100644 index 0000000..9b77800 --- /dev/null +++ b/artifacts/tests/coverage-gateway/lcov.info @@ -0,0 +1,38 @@ +TN: +SF:src/features/reference-feature/adapters/reference-http-gateway.ts +FN:37,mapProblem +FN:64,createReferenceHttpGateway +FN:68,list +FN:71,create +FN:74,get +FNF:5 +FNH:5 +FNDA:5,mapProblem +FNDA:8,createReferenceHttpGateway +FNDA:1,list +FNDA:6,create +FNDA:3,get +DA:25,2 +DA:27,2 +DA:38,5 +DA:39,2 +DA:44,3 +DA:45,2 +DA:50,1 +DA:67,8 +DA:69,1 +DA:72,6 +DA:75,3 +LF:11 +LH:11 +BRDA:38,0,0,2 +BRDA:38,0,1,3 +BRDA:41,1,0,2 +BRDA:41,1,1,1 +BRDA:44,2,0,2 +BRDA:44,2,1,1 +BRDA:47,3,0,2 +BRDA:47,3,1,1 +BRF:8 +BRH:8 +end_of_record diff --git a/config/ci/gates.json b/config/ci/gates.json index aae95e8..72f5de5 100644 --- a/config/ci/gates.json +++ b/config/ci/gates.json @@ -81,6 +81,55 @@ "script": "check:types:fixture:reference-operation", "expect": "fail", "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2741:" + }, + { + "id": "check-types-fixture-http-operation-input", + "script": "check:types:fixture:http-operation-input", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2353:" + }, + { + "id": "check-types-fixture-http-wire-mapper", + "script": "check:types:fixture:http-wire-mapper", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2322:" + }, + { + "id": "check-types-fixture-http-operation-id", + "script": "check:types:fixture:http-operation-id", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2345:" + }, + { + "id": "check-types-fixture-http-route-id", + "script": "check:types:fixture:http-route-id", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2322:" + }, + { + "id": "check-types-fixture-feature-contribution-input", + "script": "check:types:fixture:feature-contribution-input", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2322:" + }, + { + "id": "check-types-fixture-feature-capability-selection", + "script": "check:types:fixture:feature-capability-selection", + "expect": "fail", + "expectedExitCode": 1, + "expectedDiagnosticId": "error TS2339:" + }, + { + "id": "check-types-fixture-direct-feature-composition", + "script": "check:types:fixture:direct-feature-composition", + "expect": "fail", + "expectedExitCode": 1, "expectedDiagnosticId": "error TS2345:" }, { @@ -95,7 +144,7 @@ "script": "check:types:fixture:route-runtime", "expect": "fail", "expectedExitCode": 1, - "expectedDiagnosticId": "error TS2353:" + "expectedDiagnosticId": "error TS2740:" }, { "id": "check-types-fixture-page-action", @@ -1698,6 +1747,13 @@ "check-types-fixture-feature-input", "check-types-fixture-failure-kind", "check-types-fixture-reference-operation", + "check-types-fixture-http-operation-input", + "check-types-fixture-http-wire-mapper", + "check-types-fixture-http-operation-id", + "check-types-fixture-http-route-id", + "check-types-fixture-feature-contribution-input", + "check-types-fixture-feature-capability-selection", + "check-types-fixture-direct-feature-composition", "check-types-fixture-async-overlay", "check-types-fixture-route-runtime", "check-types-fixture-page-action", diff --git a/config/testing/risk-coverage.json b/config/testing/risk-coverage.json index e2ef978..11cbb03 100644 --- a/config/testing/risk-coverage.json +++ b/config/testing/risk-coverage.json @@ -12,97 +12,192 @@ { "path": "src/adapters/http/bounded-body-reader.ts", "owner": "http-runtime", - "minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 90 } + "minimum": { + "lines": 95, + "statements": 95, + "functions": 95, + "branches": 90 + } }, { "path": "src/adapters/http/bounded-json.ts", "owner": "http-runtime", - "minimum": { "lines": 85, "statements": 84, "functions": 95, "branches": 78 } + "minimum": { + "lines": 85, + "statements": 84, + "functions": 95, + "branches": 78 + } }, { "path": "src/adapters/http/http-execution-v3.ts", "owner": "http-runtime", - "minimum": { "lines": 75, "statements": 73, "functions": 70, "branches": 52 } + "minimum": { + "lines": 75, + "statements": 73, + "functions": 70, + "branches": 52 + } }, { "path": "src/adapters/http/request-builder.ts", "owner": "http-runtime", - "minimum": { "lines": 85, "statements": 85, "functions": 95, "branches": 82 } + "minimum": { + "lines": 85, + "statements": 85, + "functions": 95, + "branches": 82 + } }, { "path": "src/adapters/http/retry-policy.ts", "owner": "http-runtime", - "minimum": { "lines": 80, "statements": 78, "functions": 95, "branches": 78 } + "minimum": { + "lines": 80, + "statements": 78, + "functions": 95, + "branches": 78 + } }, { "path": "src/adapters/query-cache/server-state-scope-runtime.ts", "owner": "server-state-runtime", - "minimum": { "lines": 85, "statements": 85, "functions": 85, "branches": 75 } + "minimum": { + "lines": 85, + "statements": 85, + "functions": 85, + "branches": 75 + } }, { "path": "src/adapters/service-worker/service-worker-lifecycle.ts", "owner": "service-worker-runtime", - "minimum": { "lines": 64, "statements": 60, "functions": 65, "branches": 43 } + "minimum": { + "lines": 64, + "statements": 60, + "functions": 65, + "branches": 43 + } }, { "path": "src/adapters/storage/browser-storage-adapter.ts", "owner": "storage-runtime", - "minimum": { "lines": 60, "statements": 60, "functions": 70, "branches": 60 } + "minimum": { + "lines": 60, + "statements": 60, + "functions": 70, + "branches": 60 + } }, { "path": "src/adapters/telemetry/best-effort-telemetry.ts", "owner": "telemetry-runtime", - "minimum": { "lines": 85, "statements": 85, "functions": 70, "branches": 75 } + "minimum": { + "lines": 85, + "statements": 85, + "functions": 70, + "branches": 75 + } }, { "path": "src/application/create-application.ts", "owner": "application-runtime", - "minimum": { "lines": 90, "statements": 90, "functions": 80, "branches": 68 } + "minimum": { + "lines": 90, + "statements": 90, + "functions": 80, + "branches": 68 + } }, { - "path": "src/application/policies/compatibility.ts", - "owner": "application-policy", - "minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 75 } + "path": "src/contracts/compatibility.ts", + "owner": "compatibility-contracts", + "minimum": { + "lines": 95, + "statements": 95, + "functions": 95, + "branches": 75 + } }, { "path": "src/application/policies/performance-budgets.ts", "owner": "application-policy", - "minimum": { "lines": 80, "statements": 80, "functions": 80, "branches": 40 } + "minimum": { + "lines": 80, + "statements": 80, + "functions": 80, + "branches": 40 + } }, { "path": "src/application/policies/promotion-readiness.ts", "owner": "release-runtime", - "minimum": { "lines": 95, "statements": 95, "functions": 95, "branches": 95 } + "minimum": { + "lines": 95, + "statements": 95, + "functions": 95, + "branches": 95 + } }, { "path": "src/application/use-cases/decide-chunk-recovery.ts", "owner": "application-runtime", - "minimum": { "lines": 90, "statements": 90, "functions": 95, "branches": 85 } + "minimum": { + "lines": 90, + "statements": 90, + "functions": 95, + "branches": 85 + } }, { "path": "src/bootstrap/load-release-manifest.ts", "owner": "release-runtime", - "minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 } + "minimum": { + "lines": 90, + "statements": 90, + "functions": 90, + "branches": 80 + } }, { "path": "src/bootstrap/read-bounded-boot-json.ts", "owner": "bootstrap-runtime", - "minimum": { "lines": 71, "statements": 66, "functions": 48, "branches": 57 } + "minimum": { + "lines": 71, + "statements": 66, + "functions": 48, + "branches": 57 + } }, { "path": "src/contracts/diagnostics.ts", "owner": "diagnostics-contracts", - "minimum": { "lines": 68, "statements": 68, "functions": 95, "branches": 58 } + "minimum": { + "lines": 68, + "statements": 68, + "functions": 95, + "branches": 58 + } }, { "path": "src/features/reference-feature/adapters/reference-http-gateway.ts", "owner": "reference-feature", - "minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 90 } + "minimum": { + "lines": 90, + "statements": 90, + "functions": 90, + "branches": 90 + } }, { "path": "src/presentation/adapters/query/application-query.ts", "owner": "presentation-runtime", - "minimum": { "lines": 90, "statements": 90, "functions": 90, "branches": 80 } + "minimum": { + "lines": 90, + "statements": 90, + "functions": 90, + "branches": 80 + } } ], "highRiskPaths": [ @@ -116,7 +211,7 @@ "src/adapters/storage/browser-storage-adapter.ts", "src/adapters/telemetry/best-effort-telemetry.ts", "src/application/create-application.ts", - "src/application/policies/compatibility.ts", + "src/contracts/compatibility.ts", "src/application/policies/performance-budgets.ts", "src/application/policies/promotion-readiness.ts", "src/application/use-cases/decide-chunk-recovery.ts", diff --git a/docs/architecture/browser-file-and-origin-storage.md b/docs/architecture/browser-file-and-origin-storage.md index 14f7677..e3e787b 100644 --- a/docs/architecture/browser-file-and-origin-storage.md +++ b/docs/architecture/browser-file-and-origin-storage.md @@ -247,6 +247,8 @@ domain, application state, query cache, global store, diagnostics에 넣지 않 ### 3.2 picker baseline과 enhancement - 접근 가능한 ``가 모든 browser의 canonical baseline이다. + baseline adapter는 `input.click()`으로 이 경로를 활성화한다. `showPicker()` + 존재 여부는 portable baseline capability의 판정 기준으로 사용하지 않는다. - `showOpenFilePicker()`와 `showSaveFilePicker()`는 runtime method별 feature detection을 거친 progressive enhancement다. UA sniffing을 사용하지 않는다. - picker 호출은 click/keyboard handler의 첫 browser action이어야 한다. 그 전에 @@ -987,7 +989,7 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기 | `PERMISSION_DENIED` | picker/save permission | baseline/manual fallback | | `LIMIT_EXCEEDED` | count/bytes/page/buffer budget | 입력 축소 | | `POLICY_REJECTED` | type/cache/data policy | 저장·전송 금지 | -| `BLOCKED` | IndexedDB older context | 다른 탭 close/retry UI | +| `BLOCKED` | IndexedDB older context가 live open/upgrade를 막는 중 | 다른 탭 close/retry UI | | `CONFLICT` | revision/generation/idempotency conflict | authoritative re-read | | `MIGRATION_FAILED` | schema/data migration | read-only/online-only | | `QUOTA_EXCEEDED` | actual write failure | rollback, reconstructable GC, bounded retry | @@ -999,6 +1001,12 @@ recipe의 공통 code는 UI/telemetry에 raw DOMException을 노출하지 않기 | `UNAVAILABLE` | browser/worker/storage temporarily unavailable | documented fallback | | `UNSUPPORTED` | capability absence | baseline/online-only | +`IndexedDbConnectionStatus.BLOCKED`는 대기 중인 live attempt의 observable +상태다. blocked deadline이 끝나 `open()`이 `BLOCKED` failure로 settle되면 +그 attempt는 더 이상 열리는 중이 아니므로 status는 +`CLOSED / NOT_OPENED`로 전이한다. 늦게 성공한 native connection은 즉시 닫고 +settled status를 되살리지 않는다. + user dismissal은 failure가 아니라 outcome이다. browser DOMException name은 adapter에서 이 vocabulary로 mapping하고 raw message/stack은 local bounded diagnostic에도 기본 저장하지 않는다. @@ -1085,18 +1093,17 @@ Playwright Chromium, Firefox, WebKit에서 실제 secure-origin API를 검사한 `test:browser-capabilities`의 JUnit을 `verify:browser-capability-evidence`가 읽어 세 engine의 testcase 집합 동일성, 양수 실행 수, zero failure/error/skipped와 skipped/failure node 부재를 강제한다. -현재 checkout의 source suite는 engine마다 정확히 같은 14개 case(File 2, -IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2, -presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다. -promotion artifact는 Chromium/Firefox/WebKit의 14개씩, 총 42개가 모두 -실행되어야 한다. 이 host의 -WebKit은 필수 native libraries(예: -`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`, -`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았고 현재 보존 artifact도 -Chromium/Firefox 14개씩 총 28개만 통과한 상태다. 따라서 promotion evidence를 -충족하지 않으며 verifier가 실패하는 것이 정상이다. `INSTALLED` 전에는 필요한 -system dependency가 있는 CI/device에서 세 engine 전체 evidence를 새로 생성해야 -한다. +browser capability promotion은 source suite에서 관찰된 동일 testcase set을 +Chromium/Firefox/WebKit에서 모두 실행하는 규칙으로 관리한다. case 수를 문서 +상수로 복제하지 않고 `verify:browser-capability-evidence`가 Chromium 결과를 +baseline set으로 계산해 Firefox/WebKit의 set 동일성, 양수 실행 수, +failure/error/skipped 0과 failure/skipped node 부재를 검증한다. + +capability absence도 skip 사유가 아니다. 예를 들어 어떤 engine에서 +`navigator.storage`가 없다면 동일 testcase 안에서 adapter의 +`UNSUPPORTED / STORAGE_ESTIMATE / ONLINE_ONLY` 결과를 browser truth로 +검증한다. 세 engine을 실행할 수 없는 host의 artifact는 promotion evidence로 +사용하지 않는다. - native input keyboard/focus/same-file reselection/multiple/dismissal - Chromium conditional picker/save enhancement와 다른 engine fallback diff --git a/docs/architecture/capability-consumer-experience.md b/docs/architecture/capability-consumer-experience.md index 18cb7e4..f9f2e82 100644 --- a/docs/architecture/capability-consumer-experience.md +++ b/docs/architecture/capability-consumer-experience.md @@ -48,18 +48,39 @@ its business requirement. ## Scenario B — IndexedDB local draft -An executable consumer probe lives at: +The original executable consumer probe remains at: `tests/contract/consumer-experience/indexeddb-local-draft.test.ts` -with the feature-owned fixture: +with its isolated fixture: `tests/contract/consumer-experience/fixtures/local-draft-feature.ts` -The fixture models a small local-draft feature with save/find/remove and -optimistic revision checking. +The architecture is now also exercised by a real vertical slice under: -Measured result: +`src/features/local-draft-feature` + +It owns the Local Draft domain/application API and binds an +`IndexedDbRepositoryPort` in its feature adapter. The +cross-capability composition contract lives at: + +`tests/contract/reusable-capability/feature-adapter-composition.test.ts` + +That test composes one HTTP-only contribution and the IndexedDB-only Local Draft +contribution through the same generic catalog path. + +The browser-level composition proof lives at: + +`tests/browser-capabilities/local-draft-composition.spec.ts` + +It runs against native browser IndexedDB. The test creates the platform +`createIndexedDbRuntime`, exposes that runtime through the typed +`createIndexedDbRepositoryProvider`, composes +`LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION`, then executes Local Draft +save/find/remove through the feature API. The Local Draft feature still does +not import the IndexedDB runtime or native browser API. + +Measured probe result: | Metric | Result | | --- | ---: | @@ -81,8 +102,11 @@ The contract test rejects feature source that reaches for ## What this does and does not prove -The probe confirms that **feature business code does not need native IndexedDB -knowledge** once an `IndexedDbRepositoryPort` has been composed. +The contract probe confirms that **feature business code does not need native +IndexedDB knowledge** once an `IndexedDbRepositoryPort` has been composed. +The browser-capability proof additionally confirms that the same feature +contribution works when that port is backed by the repository's real +`createIndexedDbRuntime` and native IndexedDB implementation. It does not prove that composition of `createIndexedDbRuntime` is cheap. That constructor still owns substantial infrastructure policy: @@ -96,10 +120,15 @@ That constructor still owns substantial infrastructure policy: - durability, scheduling and observation. That complexity belongs at the composition/platform boundary, not in the -feature. A new `create...Repository` convenience factory should be added -only when a second real product consumer demonstrates which subset is stable -enough to become a reusable profile. Creating one now would guess at policy and -risk producing a universal storage abstraction. +feature. The Local Draft vertical slice is now the second concrete consumer, and +it confirms that the stable seam is the typed repository provider plus a +feature-owned repository identity. It does **not** show that dataset scope, +retention, migration, codec or lifecycle-authority configuration can be safely +collapsed into one universal `create...Repository` factory. + +A convenience profile should therefore be introduced only after another +IndexedDB-backed product feature repeats the same infrastructure policy, not +merely because two features consume the same repository port. ## Consumer-quality review checklist diff --git a/docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md b/docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md index 6c5e8dc..91e2c6f 100644 --- a/docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md +++ b/docs/architecture/decisions/VD-11-browser-file-and-origin-storage.md @@ -189,17 +189,18 @@ chunk별 `CapabilityResult`를 반환한다. backend upload example 실제 upload feature는 이 예시를 그대로 import하지 않고 purpose와 backend protocol에 맞게 contract를 더 좁힌다. -현재 checkout의 browser source suite는 engine마다 같은 14개 case(File 2, -IndexedDB 4, OPFS/Cache/StorageManager 각 1, cross-context invalidation 2, -presigned streaming download/multipart upload/Image CDN 각 1)를 정의한다. -promotion artifact는 Chromium/Firefox/WebKit 각각 14개, 총 42개를 모두 -실행해야 한다. WebKit은 현재 -host의 필수 native libraries(예: -`libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`, -`libavif.so.16`과 WPE 계열) 부재로 실행되지 않았다. 보존 artifact는 -Chromium/Firefox 14개씩 총 28개만 통과했으므로 -`verify:browser-capability-evidence`가 실패하는 것이 정상이다. 세 engine -evidence가 완성되기 전에는 product 상태를 `INSTALLED`로 올리지 않는다. +browser capability promotion은 source suite가 정의한 동일 testcase set을 +Chromium/Firefox/WebKit에서 모두 실행해야 한다. 구체적인 case 개수는 이 결정 +문서에 고정하지 않는다. `verify:browser-capability-evidence`가 Chromium +artifact에서 baseline set을 계산하고 Firefox/WebKit과의 set 동일성 및 +failure/error/skipped 0을 기계적으로 검증한다. + +engine별 native capability 차이는 testcase를 skip하는 이유가 아니다. capability가 +없으면 adapter의 명시적 unsupported/degraded result를 같은 testcase에서 +검증한다. baseline `` activation은 portable한 +`input.click()` 경로를 사용하고, `showOpenFilePicker()` 같은 API는 별도 +progressive enhancement로 유지한다. 세 engine evidence가 완성되기 전에는 +product 상태를 `INSTALLED`로 올리지 않는다. ## 선택 이후 필요한 구현 diff --git a/docs/architecture/frontend-application-foundation.md b/docs/architecture/frontend-application-foundation.md index 43439ca..6cb3ca2 100644 --- a/docs/architecture/frontend-application-foundation.md +++ b/docs/architecture/frontend-application-foundation.md @@ -105,6 +105,39 @@ Central installed catalogs are aggregation points only: Feature-specific composition belongs under the feature itself. Central catalogs must not grow feature-specific branching logic. +Adapter contributions declare the platform capabilities they consume through +`needs`. The generic contribution seam in +`src/features/feature-adapter-contribution.ts` derives the context from that +list, so an HTTP-only feature cannot reach IndexedDB and an IndexedDB-only +feature does not receive the HTTP executor. It also binds +`featureId -> ApplicationFeatureInputs[featureId]` at the contribution +definition site instead of recovering that relationship with a final catalog +cast. + +A contribution becomes composable only through +`defineFeatureAdapterContribution()`. That factory adds the private +contribution brand required by `composeFeatureAdapterInputs()`; a raw object +with the same visible fields is rejected by TypeScript and checked again at +runtime. The negative type fixture +`invalid-direct-feature-composition.ts` is part of FE-GATE-003 so this +authority cannot be bypassed by calling the composer directly. + +For IndexedDB-backed features, `createIndexedDbRepositoryProvider()` is the +composition helper that maps feature-owned repository identities to typed +`IndexedDbRepositoryPort` instances. It does not create a universal storage +repository or move dataset/migration/lifecycle policy into the feature. + +The repository now has two executable contribution shapes: + +- Reference feature: `needs: ["http"]` +- Local Draft feature: `needs: ["indexedDb"]` + +Local Draft is a compiled architecture consumer used to prove the second +capability shape; it is not added to the default product manifest. Its +browser-capability test composes the feature over the real +`createIndexedDbRuntime` and native IndexedDB, proving that this seam is not +limited to an in-memory contract fixture. + ## Presentation consumer surface `ApplicationProvider` remains the composition root for presentation, but new diff --git a/docs/operations/adapter-remediation-ledger.md b/docs/operations/adapter-remediation-ledger.md index 862c5e3..94e784c 100644 --- a/docs/operations/adapter-remediation-ledger.md +++ b/docs/operations/adapter-remediation-ledger.md @@ -337,7 +337,14 @@ Run on the landed tree. Only what actually passed is claimed as passing. | --- | --- | --- | | `tests/unit/ci-workflow-generation.test.ts` | 82 failed / 325 passed | Identical on the pre-change baseline (`git stash` comparison). The subprocess gates it spawns cannot run in this sandbox. | | `tests/unit/ci-artifact-contract.test.ts` | fails | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. | -| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | All three pass in a fresh process (78 passed together). They spawn and reap process groups, so their timing assertions are load sensitive. | +| `tests/unit/security-followup.test.ts`, `tests/unit/provider-guardian-transaction.test.ts`, `tests/unit/risk-coverage.test.ts` | flaky under full-suite load | Historical baseline result. All three passed in a fresh process (78 passed together). | + +> Current classification update (2026-09-18): the provider guardian transaction +> suite moved to `tests/integration/provider-guardian-transaction.test.ts`. +> It spawns child processes and exercises filesystem/IPC/process lifecycle, so it +> is not part of the pure unit pool. Its READY/PUBLISHED wait uses a dedicated +> test watchdog rather than treating a 1-second scheduler delay as a production +> protocol deadline. ### Destructive fixture hazard — fixed @@ -375,7 +382,7 @@ files above plus the two flaky-under-load ones: | `tests/unit/ci-workflow-generation.test.ts` | 82 | Identical on the pre-change baseline (`git stash` comparison). Its subprocess gates cannot run in this sandbox. | | `tests/unit/ci-artifact-contract.test.ts` | 19 | Unchanged pre-existing sandbox, cgroup and `/tmp` permission behaviour. | | `tests/unit/security-followup.test.ts` | 2 | Passes in isolation. | -| `tests/unit/provider-guardian-transaction.test.ts` | 1 | Passes in isolation. | +| `tests/unit/provider-guardian-transaction.test.ts` (historical path; now `tests/integration/provider-guardian-transaction.test.ts`) | 1 | Historical baseline: passed in isolation; current suite is classified as process integration. | 1619 passed / 1723 total, and `tests/unit/removal-fixture.test.ts`, `tests/unit/supply-chain.test.ts` and diff --git a/docs/operations/browser-file-storage-recovery.md b/docs/operations/browser-file-storage-recovery.md index 56b6623..6a58321 100644 --- a/docs/operations/browser-file-storage-recovery.md +++ b/docs/operations/browser-file-storage-recovery.md @@ -148,6 +148,9 @@ read-only나 online-only가 사용자 작성 내용을 잃게 한다면 먼저 e ### 신호 - `BLOCKED`, `UPGRADE_BLOCKED` 또는 blocked duration bucket 증가 + - live attempt가 기다리는 동안 status는 `BLOCKED` + - blocked deadline settle 뒤에는 open result가 `BLOCKED` failure여도 + runtime status는 `CLOSED / NOT_OPENED` - `versionchange` 뒤 connection이 남음 - repeated reload/update loop - open/maintenance의 `POLICY_REJECTED`: immutable dataset binding missing/mismatch @@ -500,16 +503,13 @@ browser engine/version, fixture ID, fault phase, bounded counts/buckets와 PASS/ promotion 직전에는 다음 repository evidence도 함께 보존한다. -- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이 동일 - 14개 testcase set(File 2, IndexedDB 4, OPFS/Cache/StorageManager 각 1, - cross-context invalidation 2, presigned streaming download/multipart - upload/Image CDN 각 1)을 실제 실행해 총 42개이며 failure/error/skipped가 모두 - 0이어야 한다. - `verify:browser-capability-evidence`가 engine 집합과 testcase 동일성을 - 기계적으로 검증한다. 현재 artifact는 Chromium/Firefox 14개씩 총 28개가 - 통과했지만 WebKit 실행에 필요한 native libraries(예: - `libbacktrace.so.0`, `libevent-2.1.so.7`, `libjxl.so.0.8`, - `libavif.so.16`과 WPE 계열)가 이 host에 없으므로 아직 promotion 가능 상태가 +- `test:browser-capabilities`가 만든 JUnit에서 Chromium, Firefox, WebKit이 + source suite의 동일 testcase set을 실제 실행하고 failure/error/skipped가 모두 + 0이어야 한다. 구체적인 case 수는 이 runbook에 복제하지 않는다. + `verify:browser-capability-evidence`가 Chromium을 baseline으로 testcase set과 + engine 집합을 동적으로 검증한다. native capability가 없는 engine도 testcase를 + skip하지 않고 adapter의 명시적 `UNSUPPORTED`/degraded 결과를 검증해야 한다. + 세 engine 중 하나라도 실행되지 않은 artifact는 promotion 가능 상태가 아니다. - `artifacts/quality/vite-module-inventory.json`에서 optional runtime source root가 production chunk에 없음을 `check:optional-recipes`로 검증한다. diff --git a/docs/testing/frontend-platform-testing-strategy.md b/docs/testing/frontend-platform-testing-strategy.md index e514f08..1b33612 100644 --- a/docs/testing/frontend-platform-testing-strategy.md +++ b/docs/testing/frontend-platform-testing-strategy.md @@ -105,6 +105,24 @@ component suite로 분리되어 있다. 실제 QueryClient 위에서 검증한다. HTTP 자동 retry가 소유자이므로 이 adapter의 query/mutation vendor retry는 꺼져 있다. +같은 기준을 대형 unit suite에도 적용한다. 줄 수를 기준으로 자르지 않고 실패가 +가리켜야 하는 behavior owner를 기준으로 분리한다. + +- `public-response-cache.test.ts`: 일반 stage/activate/lookup/cache contract +- `public-response-cache-repair.test.ts`: active release repair의 failure atomicity +- `resumable-upload-runtime.test.ts`: upload/reconcile/control-plane 흐름 +- `resumable-upload-runtime-teardown.test.ts`: bounded drain/raw provider teardown +- `security-followup.test.ts`: archived local evidence +- `security-provider-evidence.test.ts`: provider signature/supervision/process lifecycle +- `security-promotion-staging.test.ts`: private staging seal/replay/CLI identity +- `tests/integration/provider-guardian-transaction.test.ts`: child process, + filesystem, IPC frame, READY/PUBLISHED handshake와 process lifecycle. pure unit + pool에 두지 않으며 handshake budget은 protocol timeout이 아닌 test watchdog이다. +- 각 `*-fixture.ts`: 해당 owner들 사이에서만 공유하는 deterministic test fixture + +이 분리는 production owner와 test failure surface를 맞추기 위한 것이며, 단순 +LOC 감축 목적이 아니다. + #### Form과 route 위험 form component/reference feature test가 error summary, 첫 오류 focus, Zod diff --git a/docs/testing/taxonomy.md b/docs/testing/taxonomy.md index c808157..421a3d6 100644 --- a/docs/testing/taxonomy.md +++ b/docs/testing/taxonomy.md @@ -11,7 +11,7 @@ Each gate is blocking in its declared scope. Failures are not downgraded with | unit | `pnpm test:unit` | Node-only domain/application/pure policy/runtime units; no systemd/bwrap/cgroup prerequisite | `artifacts/tests/unit.xml` | | capability contract | `pnpm test:contract` | reusable capability consumer contracts | `artifacts/tests/contract.xml` | | component | `pnpm test:component` | React/hook/UI behavior | `artifacts/tests/component.xml` | -| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB and composed browser-runtime boundaries | `artifacts/tests/integration.xml` | +| integration | `pnpm test:integration` | HTTP/MSW, IndexedDB, composed browser-runtime boundaries, child-process/filesystem/IPC integration | `artifacts/tests/integration.xml` | | system / CI runner | `pnpm test:system` | compatible Linux host with systemd, bubblewrap, cgroup v2 and CI-provider process controls | `artifacts/tests/system.xml` | | end-to-end | `pnpm test:e2e` | pinned browser engines | `artifacts/tests/e2e/` | | accessibility | `pnpm test:a11y` | pinned browser engines | `artifacts/tests/a11y.json` | @@ -64,7 +64,11 @@ CI / release assurance: A host-level process/sandbox test must not be placed in `tests/unit` merely because it uses Vitest. The classification follows the system boundary and -prerequisites, not the test framework. +prerequisites, not the test framework. Process-heavy tests that spawn child +processes but do not require privileged host facilities belong in +`tests/integration`; for example the provider guardian transaction protocol +lives at `tests/integration/provider-guardian-transaction.test.ts`. Its +handshake timeout is a test watchdog, not a production protocol deadline. End-to-end and automated accessibility scenarios run on the pinned Chromium, Firefox, and WebKit engines. The responsive contract explicitly exercises diff --git a/package.json b/package.json index 5420e53..ab51ed9 100644 --- a/package.json +++ b/package.json @@ -30,22 +30,22 @@ "check:types:node": "tsc --project tsconfig.node.json", "check:types:test": "tsc --project tsconfig.test.json", "check:types:recipes": "node scripts/check-optional-recipe-types.ts", - "check:types:fixture": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module NodeNext --moduleResolution NodeNext tests/fixtures/typecheck/invalid-port-call.ts", - "check:types:fixture:ts-port": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-port-implementation.ts", - "check:types:fixture:ts-result": "tsc --ignoreConfig --strict --noEmit --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-result-narrowing.ts", - "check:types:fixture:application-output": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-output.ts", - "check:types:fixture:application-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-application-input.ts", - "check:types:fixture:feature-input": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-feature-input.ts", - "check:types:fixture:failure-kind": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-failure-kind.ts", - "check:types:fixture:reference-operation": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-reference-operation.ts", - "check:types:fixture:async-overlay": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-async-overlay.ts", - "check:types:fixture:route-runtime": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-route-runtime.ts", - "check:types:fixture:page-action": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-page-action.tsx", - "check:types:fixture:icon-button": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler --jsx react-jsx tests/fixtures/typecheck/invalid-icon-button.tsx", - "check:types:fixture:i18n-key": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-key.ts", - "check:types:fixture:i18n-params": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-message-params.ts", - "check:types:fixture:diagnostics": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-diagnostics-port.ts", - "check:types:fixture:image-resolve-signal": "tsc --ignoreConfig --strict --noEmit --skipLibCheck --target ES2022 --module ESNext --moduleResolution Bundler tests/fixtures/typecheck/invalid-image-cdn-resolve-signal.ts", + "check:types:fixture": "tsc -p tests/fixtures/typecheck/tsconfig.port-call.json", + "check:types:fixture:ts-port": "tsc -p tests/fixtures/typecheck/tsconfig.port-implementation.json", + "check:types:fixture:ts-result": "tsc -p tests/fixtures/typecheck/tsconfig.result-narrowing.json", + "check:types:fixture:application-output": "tsc -p tests/fixtures/typecheck/tsconfig.application-output.json", + "check:types:fixture:application-input": "tsc -p tests/fixtures/typecheck/tsconfig.application-input.json", + "check:types:fixture:feature-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-input.json", + "check:types:fixture:failure-kind": "tsc -p tests/fixtures/typecheck/tsconfig.failure-kind.json", + "check:types:fixture:reference-operation": "tsc -p tests/fixtures/typecheck/tsconfig.reference-operation.json", + "check:types:fixture:async-overlay": "tsc -p tests/fixtures/typecheck/tsconfig.async-overlay.json", + "check:types:fixture:route-runtime": "tsc -p tests/fixtures/typecheck/tsconfig.route-runtime.json", + "check:types:fixture:page-action": "tsc -p tests/fixtures/typecheck/tsconfig.page-action.json", + "check:types:fixture:icon-button": "tsc -p tests/fixtures/typecheck/tsconfig.icon-button.json", + "check:types:fixture:i18n-key": "tsc -p tests/fixtures/typecheck/tsconfig.message-key.json", + "check:types:fixture:i18n-params": "tsc -p tests/fixtures/typecheck/tsconfig.message-params.json", + "check:types:fixture:diagnostics": "tsc -p tests/fixtures/typecheck/tsconfig.diagnostics-port.json", + "check:types:fixture:image-resolve-signal": "tsc -p tests/fixtures/typecheck/tsconfig.image-cdn-resolve-signal.json", "test:runtime-schema": "node scripts/run-vitest.ts run tests/runtime-schema --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/runtime-schema.xml --passWithNoTests", "test:unit": "node scripts/run-vitest.ts run tests/unit --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/unit.xml", "test:contract": "node scripts/run-vitest.ts run tests/contract --reporter=default --reporter=junit --outputFile.junit=artifacts/tests/contract.xml", @@ -124,7 +124,14 @@ "build:app-only": "vite build && node scripts/generate-build-manifest.ts", "generate:contract-set": "node scripts/generate-contract-set.ts", "check:types:web-worker": "tsc --project tsconfig.web-worker.json", - "check:types:service-worker": "tsc --project tsconfig.service-worker.json" + "check:types:service-worker": "tsc --project tsconfig.service-worker.json", + "check:types:fixture:http-operation-input": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-input.json", + "check:types:fixture:http-wire-mapper": "tsc -p tests/fixtures/typecheck/tsconfig.http-wire-mapper.json", + "check:types:fixture:http-operation-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-operation-id.json", + "check:types:fixture:http-route-id": "tsc -p tests/fixtures/typecheck/tsconfig.http-route-id.json", + "check:types:fixture:feature-contribution-input": "tsc -p tests/fixtures/typecheck/tsconfig.feature-contribution-input.json", + "check:types:fixture:feature-capability-selection": "tsc -p tests/fixtures/typecheck/tsconfig.feature-capability-selection.json", + "check:types:fixture:direct-feature-composition": "tsc -p tests/fixtures/typecheck/tsconfig.direct-feature-composition.json" }, "dependencies": { "@tanstack/react-query": "5.101.4", diff --git a/scripts/contracts/ci-gates.ts b/scripts/contracts/ci-gates.ts index 8abb588..b6948db 100644 --- a/scripts/contracts/ci-gates.ts +++ b/scripts/contracts/ci-gates.ts @@ -443,7 +443,7 @@ export type LoadCiGateContractOptions = Readonly<{ }>; const CANONICAL_GATE_SHAPE_SHA256 = - "372959f50b9c5a228bdc85dce33ca7968c72414ce3b6d5534c9e6eaf554f6ce4"; + "43dc4ed1cdaf21a674bd069d42eb2c32276afd8f763d72e52c9f7c3adccbeb58"; function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string { const normalized = gates.map( @@ -477,9 +477,9 @@ function canonicalAuthorityBaselineFailures(contract: CiGateContract): string[] if (contract.gates.length !== 27) { failures.push(`gate authority baseline must contain exactly 27 gates; received ${contract.gates.length}`); } - if (contract.commands.length !== 84 || commandReferenceCount !== 96) { + if (contract.commands.length !== 91 || commandReferenceCount !== 103) { failures.push( - `command authority baseline must contain exactly 84 definitions and 96 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, + `command authority baseline must contain exactly 91 definitions and 103 references; received ${contract.commands.length} definitions and ${commandReferenceCount} references`, ); } if (contract.artifacts.length !== 109) { diff --git a/scripts/test-sample-removal.ts b/scripts/test-sample-removal.ts index 95a14ef..0c480b8 100644 --- a/scripts/test-sample-removal.ts +++ b/scripts/test-sample-removal.ts @@ -42,18 +42,39 @@ const featureOwnedPaths = [ featureSource, featureTests, "tests/integration/http-scenario-catalog.test.ts", + "tests/component/product-feature-switch.test.tsx", "tests/e2e/reference-form.spec.ts", "tests/e2e/reference-route.spec.ts", "tests/mocks", "tests/fixtures/typecheck/invalid-feature-input.ts", "tests/fixtures/typecheck/invalid-reference-operation.ts", + "tests/fixtures/typecheck/invalid-http-operation-input.ts", + "tests/fixtures/typecheck/invalid-http-wire-mapper.ts", + "tests/fixtures/typecheck/invalid-http-operation-id.ts", + "tests/fixtures/typecheck/invalid-http-route-id.ts", + "tests/fixtures/typecheck/invalid-feature-contribution-input.ts", + "tests/fixtures/typecheck/invalid-feature-capability-selection.ts", + "tests/fixtures/typecheck/tsconfig.feature-input.json", + "tests/fixtures/typecheck/tsconfig.reference-operation.json", + "tests/fixtures/typecheck/tsconfig.http-operation-input.json", + "tests/fixtures/typecheck/tsconfig.http-wire-mapper.json", + "tests/fixtures/typecheck/tsconfig.http-operation-id.json", + "tests/fixtures/typecheck/tsconfig.http-route-id.json", + "tests/fixtures/typecheck/tsconfig.feature-contribution-input.json", + "tests/fixtures/typecheck/tsconfig.feature-capability-selection.json", ]; +const emptyProductManifest = `export const COMPILED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]); +export const INSTALLED_PRODUCT_FEATURES: readonly never[] = Object.freeze([]); +export const INSTALLED_PRODUCT_FEATURE_IDS: readonly string[] = Object.freeze([]); +`; + const emptyContracts = `import { PLATFORM_ROUTE_RUNTIME_CONTRACT } from "../contracts/route-runtime-contract.ts"; import { PLATFORM_ROUTE_REGISTRY, type RouteDefinition } from "../contracts/routes.ts"; import { PLATFORM_SCHEMA_REGISTRY } from "../contracts/schema-registry.ts"; +import { INSTALLED_PRODUCT_FEATURES } from "./installed-product-manifest.ts"; -export const INSTALLED_FEATURE_CONTRACTS: readonly unknown[] = Object.freeze([]); +export const INSTALLED_FEATURE_CONTRACTS = INSTALLED_PRODUCT_FEATURES; export const ROUTE_REGISTRY = PLATFORM_ROUTE_REGISTRY; export const ROUTE_RUNTIME_CONTRACT = PLATFORM_ROUTE_RUNTIME_CONTRACT; export const API_OPERATIONS = Object.freeze({}); @@ -64,6 +85,7 @@ export const INVALIDATION_REGISTRY = Object.freeze({ }); export const INVALIDATION_TOPIC_VERSIONS = Object.freeze([]); export const SCHEMA_REGISTRY = PLATFORM_SCHEMA_REGISTRY; +export const ROUTE_FEATURE_OWNER: Readonly> = Object.freeze({}); export const NAVIGATION_ROUTES = Object.freeze( Object.values(ROUTE_REGISTRY) .filter((definition) => definition.navigationOrder !== null) @@ -85,14 +107,39 @@ export function routePath(routeId: string): string { const emptyRuntimes = `import { PLATFORM_ROUTE_CODECS } from "../presentation/routes/platform-route-codecs.ts"; import { PLATFORM_ROUTE_RUNTIME } from "../presentation/routes/route-runtime.tsx"; +import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; + +const COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS: readonly Readonly<{ + featureId: string; +}>[] = Object.freeze([]); + +const INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS = + COMPILED_FEATURE_RUNTIME_CONTRIBUTIONS.filter((contribution) => + INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId), + ); + +void INSTALLED_FEATURE_RUNTIME_CONTRIBUTIONS; export const ROUTE_CODECS = PLATFORM_ROUTE_CODECS; export const ROUTE_RUNTIME = PLATFORM_ROUTE_RUNTIME; `; -const emptyAdapters = `export function createInstalledFeatureInputs(_context: unknown) { - void _context; - return Object.freeze({}); +const emptyAdapters = `import { + composeFeatureAdapterInputs, + type InstalledFeatureInputs, +} from "./feature-adapter-contribution.ts"; +import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; + +const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([] as const); + +export function createInstalledFeatureInputs( + context: Readonly>, +): InstalledFeatureInputs { + return composeFeatureAdapterInputs( + COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS, + INSTALLED_PRODUCT_FEATURE_IDS, + context, + ); } `; @@ -101,9 +148,17 @@ const emptyContractContributions = `import { type InstalledContractContribution, type InstalledContractPackageIdentity, } from "../contracts/external-contract-runtime.ts"; +import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; + +const COMPILED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = + Object.freeze([]); export const INSTALLED_CONTRACT_CONTRIBUTIONS: readonly InstalledContractContribution[] = - Object.freeze([]); + Object.freeze( + COMPILED_CONTRACT_CONTRIBUTIONS.filter((contribution) => + INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId), + ), + ); export const COMPOSED_CONTRACT_CONTRIBUTIONS = composeContractContributions( INSTALLED_CONTRACT_CONTRIBUTIONS, @@ -181,6 +236,10 @@ try { force: true, }); } + await writeFile( + path.join(fixtureRoot, "src/features/installed-product-manifest.ts"), + emptyProductManifest, + ); await writeFile( path.join(fixtureRoot, "src/features/installed-feature-contracts.ts"), emptyContracts, @@ -202,6 +261,34 @@ try { emptyContractContributions, ); + // Runtime profiles are product configuration. Once the feature is physically + // removed, its override key must disappear too or the built config still + // advertises a source capability that no longer exists. + for (const profile of [ + "local", + "development", + "staging", + "production", + ] as const) { + const profilePath = path.join( + fixtureRoot, + "config/runtime", + `${profile}.json`, + ); + const runtimeConfig = JSON.parse( + await readFile(profilePath, "utf8"), + ) as { + FEATURE_OVERRIDES?: Record; + }; + if (runtimeConfig.FEATURE_OVERRIDES) { + delete runtimeConfig.FEATURE_OVERRIDES["reference-feature"]; + } + await writeFile( + profilePath, + `${JSON.stringify(runtimeConfig, null, 2)}\n`, + ); + } + const retainedCriticalModules = coveragePolicy.criticalModules.filter( (modulePolicy) => !modulePolicy.path?.startsWith(`${featureSource}/`), ); @@ -296,6 +383,12 @@ try { const removedCiScripts = new Set([ "check:types:fixture:feature-input", "check:types:fixture:reference-operation", + "check:types:fixture:http-operation-input", + "check:types:fixture:http-wire-mapper", + "check:types:fixture:http-operation-id", + "check:types:fixture:http-route-id", + "check:types:fixture:feature-contribution-input", + "check:types:fixture:feature-capability-selection", "test:http-scenario-evidence", "test:reference-feature", ]); @@ -370,13 +463,17 @@ try { ["build", runPnpm("build")], ]; const builtResidue: string[] = []; - for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { - if (!/\.(?:js|css|html|json)$/.test(file)) continue; - const content = await readFile(file, "utf8"); - if ( - /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content) - ) { - builtResidue.push(path.relative(fixtureRoot, file)); + const buildSucceeded = + checks.find(([name]) => name === "build")?.[1] === true; + if (buildSucceeded) { + for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) { + if (!/\.(?:js|css|html|json)$/.test(file)) continue; + const content = await readFile(file, "utf8"); + if ( + /REFERENCE_RESOURCE|reference-feature|reference-resource/i.test(content) + ) { + builtResidue.push(path.relative(fixtureRoot, file)); + } } } const routeCatalog = await import( diff --git a/src/adapters/browser-files/browser-file-picker.ts b/src/adapters/browser-files/browser-file-picker.ts index 00ef001..c99270c 100644 --- a/src/adapters/browser-files/browser-file-picker.ts +++ b/src/adapters/browser-files/browser-file-picker.ts @@ -108,14 +108,12 @@ export class NativeInputFilePicker implements DisposableFilePicker { const addInputEvent = options.input.addEventListener; const removeInputEvent = options.input.removeEventListener; const getInputAttribute = options.input.getAttribute; - const showPicker = options.input.showPicker; const click = options.input.click; if ( typeof addInputEvent !== "function" || typeof removeInputEvent !== "function" || typeof getInputAttribute !== "function" || - (typeof showPicker !== "function" && - typeof click !== "function") + typeof click !== "function" ) { throw new TypeError("Native file input API is invalid."); } @@ -123,10 +121,11 @@ export class NativeInputFilePicker implements DisposableFilePicker { add: addInputEvent.bind(options.input), remove: removeInputEvent.bind(options.input), getAttribute: getInputAttribute.bind(options.input), - activate: - typeof showPicker === "function" - ? showPicker.bind(options.input) - : click.bind(options.input), + // The baseline path intentionally uses click(). + // showPicker() exists in multiple engines but is not a portable + // automation/event-interception contract. Enhanced native picking is a + // separate capability owned by EnhancedFilePicker. + activate: click.bind(options.input), }); const windowHost = options.window ?? globalThis.window; if (windowHost) { diff --git a/src/adapters/http/feature-http-binding.ts b/src/adapters/http/feature-http-binding.ts index 616ac94..838a461 100644 --- a/src/adapters/http/feature-http-binding.ts +++ b/src/adapters/http/feature-http-binding.ts @@ -1,7 +1,8 @@ -import type { Result } from "../../contracts/result.ts"; import type { - MappingResult, -} from "../../contracts/boundary-mapper.ts"; + InstalledHttpContract, +} from "../../contracts/external-contract-runtime.ts"; +import type { Result } from "../../contracts/result.ts"; +import type { MappingResult } from "../../contracts/boundary-mapper.ts"; import { createFailure, kindForStatus, @@ -10,55 +11,199 @@ import { type FailureKind, } from "../../contracts/errors.ts"; import type { MutationIntent } from "../../contracts/mutation-intent.ts"; -import type { HttpExecutionOutcome } from "./http-execution-v3.ts"; +import type { + HttpExecutionOutcome, + SafeResponseMetadata, +} from "./http-execution-v3.ts"; + +type ValidatorValue = + Validator extends Readonly<{ + safeParse(value: unknown): infer ParseResult; + }> + ? Extract< + ParseResult, + Readonly<{ success: true; data: unknown }> + > extends Readonly<{ data: infer Value }> + ? Value + : never + : never; + +export type HttpContractInput = + Contract extends Readonly<{ + contract: Readonly<{ inputValidator: infer Validator }>; + }> + ? ValidatorValue + : never; + +export type HttpContractWireOutput = + Contract extends Readonly<{ + contract: Readonly<{ outputValidator: infer Validator }>; + }> + ? ValidatorValue + : never; + +export type HttpContractProblem = + Contract extends Readonly<{ + contract: Readonly<{ problemValidator: infer Validator }>; + }> + ? ValidatorValue + : never; + +export type HttpContractOperationId = + Contract extends Readonly<{ + contract: Readonly<{ operationId: infer OperationId extends string }>; + }> + ? OperationId + : never; export type InstalledHttpOperationExecutor = Readonly<{ - execute( - operationId: string, - input: unknown, + execute( + contract: InstalledHttpContract, + input: Input, context: Readonly<{ routeId: string; signal?: AbortSignal; intent?: MutationIntent; }>, - ): Promise>; + ): Promise>; }>; -type FeatureHttpOperationSpec = Readonly<{ - operationId: string; - routeId: string; - mapSuccess(value: unknown): MappingResult; +export type FeatureHttpProblemMapping = Readonly<{ + kind: FailureKind; + code: string; }>; -export type FeatureHttpOperation = - FeatureHttpOperationSpec & - Readonly<{ - /** - * Compile-time only carrier. Feature bindings remain plain frozen objects - * at runtime while preserving each operation's input/value pair. - */ - __types?: Readonly<{ - input: Input; - value: Value; - }>; +type FeatureHttpOperationSpec< + Contract, + Value, + RouteId extends string, +> = Readonly<{ + contract: Contract; + routeId: RouteId; + mapSuccess( + value: HttpContractWireOutput, + ): MappingResult; + mapProblem?( + problem: HttpContractProblem, + metadata: SafeResponseMetadata, + ): FeatureHttpProblemMapping | undefined; +}>; + +export type FeatureHttpOperation< + Contract, + Value, + RouteId extends string = string, +> = FeatureHttpOperationSpec & + Readonly<{ + operationId: HttpContractOperationId; + /** + * Compile-time only carrier. The runtime object has no extra type payload; + * the contract remains the authority for input/wire/problem types. + */ + __types?: Readonly<{ + value: Value; }>; + }>; -export function defineFeatureHttpOperation( - spec: FeatureHttpOperationSpec, -): FeatureHttpOperation { - return Object.freeze(spec) as FeatureHttpOperation; +type ContractValidity = + HttpContractOperationId extends never + ? never + : HttpContractInput extends never + ? never + : HttpContractWireOutput extends never + ? never + : HttpContractProblem extends never + ? never + : unknown; + +function defineFeatureHttpOperationForRoute< + const RouteId extends string, + const Contract, + Value, +>( + spec: FeatureHttpOperationSpec & + ContractValidity, +): FeatureHttpOperation { + return Object.freeze({ + ...spec, + operationId: (spec.contract as Readonly<{ + contract: Readonly<{ operationId: HttpContractOperationId }>; + }>).contract.operationId, + }) as FeatureHttpOperation; } -type OperationInput = - Operation extends FeatureHttpOperation ? Input : never; +export function defineFeatureHttpOperation< + const Contract, + Value, + const RouteId extends string, +>( + spec: FeatureHttpOperationSpec & + ContractValidity, +): FeatureHttpOperation { + return defineFeatureHttpOperationForRoute(spec); +} + +/** + * Feature-local route typing without introducing a dependency from the HTTP + * capability to the presentation route registry. + */ +export function defineFeatureHttpOperationForRoutes< + const RouteId extends string, +>() { + return function defineRouteBoundFeatureHttpOperation< + const Contract, + Value, + >( + spec: FeatureHttpOperationSpec & + ContractValidity, + ): FeatureHttpOperation { + return defineFeatureHttpOperationForRoute(spec); + }; +} + +type FeatureHttpOperationShape = Readonly<{ + contract: object; + operationId: string; + routeId: string; + mapSuccess: (...args: never[]) => unknown; + mapProblem?: (...args: never[]) => unknown; +}>; + +type OperationContract = + Operation extends Readonly<{ contract: infer Contract }> ? Contract : never; + +type OperationInput = HttpContractInput>; +type OperationWireOutput = + HttpContractWireOutput>; +type OperationProblem = + HttpContractProblem>; type OperationValue = - Operation extends FeatureHttpOperation ? Value : never; + Operation extends FeatureHttpOperation + ? Value + : never; + +type InvalidOperationRegistryKeys< + Operations extends Readonly>, +> = { + [OperationId in keyof Operations & string]: + OperationId extends Operations[OperationId]["operationId"] + ? Operations[OperationId]["operationId"] extends OperationId + ? never + : OperationId + : OperationId; +}[keyof Operations & string]; + +type ValidOperationRegistry< + Operations extends Readonly>, +> = InvalidOperationRegistryKeys extends never + ? unknown + : Readonly<{ + __operationRegistryKeyMismatch: InvalidOperationRegistryKeys; + }>; export type FeatureHttpBinding< - Operations extends Readonly< - Record> - >, + Operations extends Readonly>, > = Readonly<{ execute( operationId: OperationId, @@ -73,18 +218,15 @@ export type FeatureHttpBinding< /** * Capability-specific feature binding for the installed HTTP runtime. * - * The platform owns transport/outcome normalization. A feature contributes only - * operation identity, route identity, typed input and its wire-to-domain mapper. - * This keeps HTTP lifecycle/failure semantics out of feature application code - * without collapsing storage/realtime/transfer into a universal repository. + * The installed contract is the single type authority. A feature contributes + * only a route identity plus wire-to-domain/problem interpretation. Transport, + * retry, validation and effect-certainty stay in the reusable HTTP capability. */ export function createFeatureHttpBinding< - const Operations extends Readonly< - Record> - >, + const Operations extends Readonly>, >( executor: InstalledHttpOperationExecutor, - operations: Operations, + operations: Operations & ValidOperationRegistry, ): FeatureHttpBinding { for (const [registryId, operation] of Object.entries(operations)) { if (registryId !== operation.operationId) { @@ -104,28 +246,47 @@ export function createFeatureHttpBinding< }> = {}, ): Promise, ApiFailure>> { const operation = operations[operationId]; - const outcome = await executor.execute(operation.operationId, input, { + type SelectedOperation = Operations[OperationId]; + type Input = OperationInput; + type WireOutput = OperationWireOutput; + type Problem = OperationProblem; + type Value = OperationValue; + + const contract = operation.contract as InstalledHttpContract< + Input, + WireOutput, + Problem + >; + const outcome = await executor.execute(contract, input, { routeId: operation.routeId, ...(context.signal === undefined ? {} : { signal: context.signal }), ...(context.intent === undefined ? {} : { intent: context.intent }), }); - return projectExecutionOutcome< - OperationValue - >( + return projectExecutionOutcome( operation.operationId, - operation.mapSuccess as ( - value: unknown, - ) => MappingResult>, + operation.mapSuccess as (value: WireOutput) => MappingResult, + operation.mapProblem as + | (( + problem: Problem, + metadata: SafeResponseMetadata, + ) => FeatureHttpProblemMapping | undefined) + | undefined, outcome, ); }, }); } -function projectExecutionOutcome( +function projectExecutionOutcome( operationId: string, - mapSuccess: (value: unknown) => MappingResult, - outcome: HttpExecutionOutcome, + mapSuccess: (value: WireOutput) => MappingResult, + mapProblem: + | (( + problem: Problem, + metadata: SafeResponseMetadata, + ) => FeatureHttpProblemMapping | undefined) + | undefined, + outcome: HttpExecutionOutcome, ): Result { switch (outcome.kind) { case "SUCCESS": { @@ -139,13 +300,15 @@ function projectExecutionOutcome( { effect: outcome.effect }, ); } - case "PROBLEM": + case "PROBLEM": { + const mapped = mapProblem?.(outcome.problem, outcome.metadata); return failure( - kindForStatus(outcome.metadata.status), + mapped?.kind ?? kindForStatus(outcome.metadata.status), operationId, - "CONTRACT_PROBLEM", + mapped?.code ?? "CONTRACT_PROBLEM", { httpStatus: outcome.metadata.status, effect: outcome.effect }, ); + } case "UNAUTHENTICATED": return failure("AUTH_REQUIRED", operationId, "UNAUTHENTICATED", { effect: outcome.effect, diff --git a/src/adapters/http/index.ts b/src/adapters/http/index.ts index d7af490..626f326 100644 --- a/src/adapters/http/index.ts +++ b/src/adapters/http/index.ts @@ -22,8 +22,14 @@ export { export { createFeatureHttpBinding, defineFeatureHttpOperation, + defineFeatureHttpOperationForRoutes, type FeatureHttpBinding, type FeatureHttpOperation, + type FeatureHttpProblemMapping, + type HttpContractInput, + type HttpContractOperationId, + type HttpContractProblem, + type HttpContractWireOutput, type InstalledHttpOperationExecutor, } from "./feature-http-binding.ts"; /** V3 `attachCredentials` 콜백이 반환해야 하는 결과 타입. */ diff --git a/src/application/ports/browser-file-storage/indexeddb-port.ts b/src/application/ports/browser-file-storage/indexeddb-port.ts index ca73f04..98ad6eb 100644 --- a/src/application/ports/browser-file-storage/indexeddb-port.ts +++ b/src/application/ports/browser-file-storage/indexeddb-port.ts @@ -21,9 +21,23 @@ export type IndexedDbSynchronizationState = | "CONFIRMED"; export type IndexedDbConnectionStatus = - | Readonly<{ kind: "CLOSED"; reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED" }> + | Readonly<{ + kind: "CLOSED"; + /** + * NOT_OPENED means the current open attempt has settled without a live + * connection. A blocked upgrade may therefore be observed as BLOCKED + * while it is waiting, then transition to CLOSED/NOT_OPENED when its + * blocked deadline fails closed. + */ + reason: "NOT_OPENED" | "VERSION_CHANGE" | "FORCED"; + }> | Readonly<{ kind: "OPENING"; targetVersion: number }> | Readonly<{ + /** + * A live open/upgrade attempt is currently waiting for another + * IndexedDB context. BLOCKED is transient and is not the settled state + * after the blocked deadline has expired. + */ kind: "BLOCKED"; currentVersion: number; targetVersion: number; diff --git a/src/bootstrap/runtime-adapters.ts b/src/bootstrap/runtime-adapters.ts index fd39f2e..06e1672 100644 --- a/src/bootstrap/runtime-adapters.ts +++ b/src/bootstrap/runtime-adapters.ts @@ -9,6 +9,7 @@ import { createContractHttpExecutor, createHttpClient, type HttpExecutionObservation, + type InstalledHttpOperationExecutor, } from "../adapters/http/index.ts"; import { attemptBucket, @@ -480,19 +481,13 @@ export async function createRuntimeAdapters( }, observe: createHttpObservationProjector({ diagnostics, telemetry }), }); - const contractOperations = Object.freeze({ - async execute( - operationId: string, - input: unknown, - executionContext: Readonly<{ - routeId: string; - signal?: AbortSignal; - intent?: MutationIntent; - }>, - ) { - const operation = - COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get(operationId); - if (!operation) { + const contractOperations: InstalledHttpOperationExecutor = Object.freeze({ + async execute(operation, input, executionContext) { + const installed = + COMPOSED_CONTRACT_CONTRIBUTIONS.httpByOperationId.get( + operation.contract.operationId, + ); + if (!installed) { return Object.freeze({ kind: "CONTRACT_VIOLATION" as const, effect: "NOT_STARTED" as const, @@ -502,7 +497,11 @@ export async function createRuntimeAdapters( }), }); } - const outcome = await contractHttp.execute(operation, input, { + // composeContractContributions deliberately snapshots every installed + // contract. Runtime authority is therefore the validated registry entry, + // not object identity with the feature-owned source declaration. + const executableContract = installed as typeof operation; + const outcome = await contractHttp.execute(executableContract, input, { routeId: executionContext.routeId, scope: serverStateScope.getSnapshot(), ...(executionContext.signal === undefined @@ -519,7 +518,7 @@ export async function createRuntimeAdapters( }, }); const featureInputs = createInstalledFeatureInputs({ - contractOperations, + http: contractOperations, }); return Object.freeze({ diff --git a/src/features/feature-adapter-contribution.ts b/src/features/feature-adapter-contribution.ts new file mode 100644 index 0000000..46cdafc --- /dev/null +++ b/src/features/feature-adapter-contribution.ts @@ -0,0 +1,176 @@ +import type { + ApplicationFeatureId, + ApplicationFeatureInputs, +} from "../application/ports/in/application-api.ts"; +import type { + InstalledHttpOperationExecutor, +} from "../adapters/http/index.ts"; + +/** + * Features augment this registry with the repository identities they consume. + * The provider is a composition capability; feature use cases never import it. + */ +export interface FeatureIndexedDbRepositories {} + +export type FeatureIndexedDbRepositoryId = Extract< + keyof FeatureIndexedDbRepositories, + string +>; + +export type IndexedDbRepositoryProvider = Readonly<{ + get( + repositoryId: RepositoryId, + ): FeatureIndexedDbRepositories[RepositoryId]; +}>; + +export function createIndexedDbRepositoryProvider( + repositories: Readonly>, +): IndexedDbRepositoryProvider { + const snapshot = Object.freeze({ ...repositories }); + return Object.freeze({ + get( + repositoryId: RepositoryId, + ): FeatureIndexedDbRepositories[RepositoryId] { + const repository = snapshot[repositoryId]; + if (!repository) { + throw new TypeError( + `IndexedDB repository is not installed: ${repositoryId}`, + ); + } + return repository as FeatureIndexedDbRepositories[RepositoryId]; + }, + }); +} + +export type FeaturePlatformCapabilities = Readonly<{ + http: InstalledHttpOperationExecutor; + indexedDb: IndexedDbRepositoryProvider; +}>; + +export type FeaturePlatformCapabilityId = + keyof FeaturePlatformCapabilities; + +export type FeatureCapabilitySelection< + Needs extends readonly FeaturePlatformCapabilityId[], +> = Pick; + +const FEATURE_ADAPTER_CONTRIBUTION = Symbol( + "feature-adapter-contribution", +); + +type FeatureAdapterContributionDefinition< + FeatureId extends ApplicationFeatureId, + Needs extends readonly FeaturePlatformCapabilityId[], +> = Readonly<{ + featureId: FeatureId; + needs: Needs; + createInput( + context: FeatureCapabilitySelection, + ): Readonly<{ + featureId: FeatureId; + input: ApplicationFeatureInputs[FeatureId]; + }>; +}>; + +export type FeatureAdapterContribution< + FeatureId extends ApplicationFeatureId, + Needs extends readonly FeaturePlatformCapabilityId[], +> = FeatureAdapterContributionDefinition & + Readonly<{ + [FEATURE_ADAPTER_CONTRIBUTION]: true; + }>; + +export function defineFeatureAdapterContribution< + const FeatureId extends ApplicationFeatureId, + const Needs extends readonly FeaturePlatformCapabilityId[], +>( + contribution: FeatureAdapterContributionDefinition, +): FeatureAdapterContribution { + return Object.freeze({ + ...contribution, + needs: Object.freeze([...contribution.needs]) as unknown as Needs, + [FEATURE_ADAPTER_CONTRIBUTION]: true as const, + }); +} + +type ContributionShape = Readonly<{ + [FEATURE_ADAPTER_CONTRIBUTION]: true; + featureId: ApplicationFeatureId; + needs: readonly FeaturePlatformCapabilityId[]; + createInput: (...args: never[]) => unknown; +}>; + +type ContributionNeeds = + Contribution extends Readonly<{ + needs: readonly (infer Capability)[]; + }> + ? Extract + : never; + +export type ContributionsCapabilityContext< + Contributions extends readonly ContributionShape[], +> = Pick< + FeaturePlatformCapabilities, + ContributionNeeds +>; + +export type InstalledFeatureInputs = Readonly< + Partial +>; + +function setFeatureInput( + target: Partial, + featureId: FeatureId, + input: ApplicationFeatureInputs[FeatureId], +): void { + const writable = target as Record< + ApplicationFeatureId, + ApplicationFeatureInputs[ApplicationFeatureId] + >; + writable[featureId] = input; +} + +function invokeContribution< + FeatureId extends ApplicationFeatureId, + Needs extends readonly FeaturePlatformCapabilityId[], +>( + contribution: FeatureAdapterContribution, + context: FeaturePlatformCapabilities, +) { + return contribution.createInput(context); +} + +/** + * Dynamic catalog assembly stays centralized here. Type authority lives at each + * feature contribution; the only dynamic write is isolated in setFeatureInput. + */ +export function composeFeatureAdapterInputs< + const Contributions extends readonly ContributionShape[], +>( + contributions: Contributions, + enabledFeatureIds: readonly string[], + context: ContributionsCapabilityContext, +): InstalledFeatureInputs { + const enabled = new Set(enabledFeatureIds); + const result: Partial = {}; + + for (const contribution of contributions) { + if (contribution[FEATURE_ADAPTER_CONTRIBUTION] !== true) { + throw new TypeError( + "Feature adapter contribution must be defined through defineFeatureAdapterContribution()", + ); + } + if (!enabled.has(contribution.featureId)) continue; + + const installed = invokeContribution( + contribution as unknown as FeatureAdapterContribution< + ApplicationFeatureId, + readonly FeaturePlatformCapabilityId[] + >, + context as FeaturePlatformCapabilities, + ); + setFeatureInput(result, installed.featureId, installed.input); + } + + return Object.freeze(result); +} diff --git a/src/features/installed-feature-adapters.ts b/src/features/installed-feature-adapters.ts index ab01bb1..303941e 100644 --- a/src/features/installed-feature-adapters.ts +++ b/src/features/installed-feature-adapters.ts @@ -1,4 +1,8 @@ -import type { ApplicationFeatureInputs } from "../application/ports/in/application-api.ts"; +import { + composeFeatureAdapterInputs, + type ContributionsCapabilityContext, + type InstalledFeatureInputs, +} from "./feature-adapter-contribution.ts"; import { REFERENCE_FEATURE_ADAPTER_CONTRIBUTION } from "./reference-feature/adapters/create-reference-feature-input.ts"; import { INSTALLED_PRODUCT_FEATURE_IDS } from "./installed-product-manifest.ts"; @@ -6,28 +10,22 @@ const COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS = Object.freeze([ REFERENCE_FEATURE_ADAPTER_CONTRIBUTION, ] as const); -type FeatureAdapterContext = Parameters< - (typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS)[number]["createInput"] ->[0]; - -type InstalledFeatureInputs = Readonly>; +type InstalledFeatureCapabilityContext = + ContributionsCapabilityContext< + typeof COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS + >; /** * Central adapter composition only selects and aggregates feature-owned - * contributions. The feature owns how its application input is bound to - * reusable platform capabilities. + * contributions. The feature owns how its application input is bound to the + * reusable platform capabilities declared by its `needs` list. */ export function createInstalledFeatureInputs( - context: FeatureAdapterContext, + context: InstalledFeatureCapabilityContext, ): InstalledFeatureInputs { - const entries = COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS - .filter((contribution) => - INSTALLED_PRODUCT_FEATURE_IDS.includes(contribution.featureId), - ) - .map((contribution) => { - const installed = contribution.createInput(context); - return [installed.featureId, installed.input] as const; - }); - - return Object.freeze(Object.fromEntries(entries)) as InstalledFeatureInputs; + return composeFeatureAdapterInputs( + COMPILED_FEATURE_ADAPTER_CONTRIBUTIONS, + INSTALLED_PRODUCT_FEATURE_IDS, + context, + ); } diff --git a/src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts b/src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts new file mode 100644 index 0000000..93b62d1 --- /dev/null +++ b/src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts @@ -0,0 +1,41 @@ +import type { + IndexedDbRepositoryPort, +} from "../../../application/ports/browser-file-storage/index.ts"; +import { + defineFeatureAdapterContribution, +} from "../../feature-adapter-contribution.ts"; +import type {} from "../../feature-adapter-contribution.ts"; +import { + createLocalDraftFeatureInput, +} from "../application/local-draft-api.ts"; +import type { LocalDraft } from "../domain/local-draft.ts"; +import { + LOCAL_DRAFT_FEATURE_ID, + LOCAL_DRAFT_REPOSITORY_ID, +} from "../contracts/local-draft-contract.ts"; +import { createLocalDraftStore } from "./local-draft-indexeddb-store.ts"; + +declare module "../../feature-adapter-contribution.ts" { + interface FeatureIndexedDbRepositories { + "local-draft": IndexedDbRepositoryPort; + } +} + +export function createLocalDraftFeatureInstalledInput(context: Readonly<{ + indexedDb: Readonly<{ + get(repositoryId: "local-draft"): IndexedDbRepositoryPort; + }>; +}>) { + const repository = context.indexedDb.get(LOCAL_DRAFT_REPOSITORY_ID); + return Object.freeze({ + featureId: LOCAL_DRAFT_FEATURE_ID, + input: createLocalDraftFeatureInput(createLocalDraftStore(repository)), + }); +} + +export const LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION = + defineFeatureAdapterContribution({ + featureId: LOCAL_DRAFT_FEATURE_ID, + needs: ["indexedDb"] as const, + createInput: createLocalDraftFeatureInstalledInput, + }); diff --git a/src/features/local-draft-feature/adapters/local-draft-indexeddb-store.ts b/src/features/local-draft-feature/adapters/local-draft-indexeddb-store.ts new file mode 100644 index 0000000..bc777be --- /dev/null +++ b/src/features/local-draft-feature/adapters/local-draft-indexeddb-store.ts @@ -0,0 +1,105 @@ +import type { + BrowserDataFailure, + IndexedDbRepositoryPort, +} from "../../../application/ports/browser-file-storage/index.ts"; +import { + createFailure, + type AppFailure, + type FailureKind, +} from "../../../contracts/errors.ts"; +import type { Result } from "../../../contracts/result.ts"; +import type { + LocalDraftStore, + LocalDraftRecord, + RemoveLocalDraftCommand, + SaveLocalDraftCommand, +} from "../application/local-draft-api.ts"; +import type { LocalDraft } from "../domain/local-draft.ts"; + +function failureKind(failure: BrowserDataFailure): FailureKind { + if (failure.code === "QUOTA_EXCEEDED") return "STORAGE_QUOTA_EXCEEDED"; + if (failure.code === "CONFLICT") return "CONFLICT"; + if (failure.code === "ABORTED") return "REQUEST_ABORTED"; + return "STORAGE_UNAVAILABLE"; +} + +function mapFailure( + failure: BrowserDataFailure, + operationId: string, +): AppFailure { + return createFailure(failureKind(failure), operationId, 0, { + code: `INDEXEDDB_${failure.code}`, + }); +} + +function failed( + failure: BrowserDataFailure, + operationId: string, +): Result { + return Object.freeze({ + ok: false as const, + error: mapFailure(failure, operationId), + }); +} + +/** + * Feature-owned adapter over the reusable IndexedDB repository capability. + * Native IDB objects, migrations, connection leases and quota internals do not + * cross this file. + */ +export function createLocalDraftStore( + repository: IndexedDbRepositoryPort, +): LocalDraftStore { + return Object.freeze({ + async save(command: SaveLocalDraftCommand, signal?: AbortSignal) { + const result = await repository.compareAndSwap({ + key: command.draft.draftId, + value: command.draft, + expectedRevision: command.expectedRevision, + idempotencyKey: command.idempotencyKey, + signal, + }); + return result.ok + ? Object.freeze({ + ok: true as const, + value: Object.freeze({ revision: result.value.revision }), + }) + : failed>( + result.error, + "LOCAL_DRAFT_SAVE", + ); + }, + + async find(draftId: string, signal?: AbortSignal) { + const result = await repository.read(draftId, signal); + if (!result.ok) { + return failed( + result.error, + "LOCAL_DRAFT_FIND", + ); + } + return Object.freeze({ + ok: true as const, + value: + result.value === null + ? null + : Object.freeze({ + draft: result.value.value, + revision: result.value.revision, + }), + }); + }, + + async remove(command: RemoveLocalDraftCommand, signal?: AbortSignal) { + const result = await repository.remove({ + key: command.draftId, + expectedRevision: command.expectedRevision, + idempotencyKey: command.idempotencyKey, + signal, + }); + return result.ok + ? Object.freeze({ ok: true as const, value: undefined }) + : failed(result.error, "LOCAL_DRAFT_REMOVE"); + }, + }); +} diff --git a/src/features/local-draft-feature/application/local-draft-api.ts b/src/features/local-draft-feature/application/local-draft-api.ts new file mode 100644 index 0000000..bed3224 --- /dev/null +++ b/src/features/local-draft-feature/application/local-draft-api.ts @@ -0,0 +1,72 @@ +import type { Result } from "../../../contracts/result.ts"; +import type {} from "../../../application/ports/in/application-api.ts"; +import type { LocalDraft } from "../domain/local-draft.ts"; + +export type LocalDraftRecord = Readonly<{ + draft: LocalDraft; + revision: number; +}>; + +export type SaveLocalDraftCommand = Readonly<{ + draft: LocalDraft; + expectedRevision: number | null; + idempotencyKey: string; +}>; + +export type RemoveLocalDraftCommand = Readonly<{ + draftId: string; + expectedRevision: number; + idempotencyKey: string; +}>; + +export type LocalDraftStore = Readonly<{ + save( + command: SaveLocalDraftCommand, + signal?: AbortSignal, + ): Promise>>; + find( + draftId: string, + signal?: AbortSignal, + ): Promise>; + remove( + command: RemoveLocalDraftCommand, + signal?: AbortSignal, + ): Promise>; +}>; + +export type LocalDraftFeatureInput = Readonly<{ + saveDraft( + command: SaveLocalDraftCommand, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>>; + findDraft( + draftId: string, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; + removeDraft( + command: RemoveLocalDraftCommand, + context?: Readonly<{ signal?: AbortSignal }>, + ): Promise>; +}>; + +declare module "../../../application/ports/in/application-api.ts" { + interface ApplicationFeatureInputs { + "local-draft": LocalDraftFeatureInput; + } +} + +export function createLocalDraftFeatureInput( + store: LocalDraftStore, +): LocalDraftFeatureInput { + return Object.freeze({ + saveDraft(command, context) { + return store.save(command, context?.signal); + }, + findDraft(draftId, context) { + return store.find(draftId, context?.signal); + }, + removeDraft(command, context) { + return store.remove(command, context?.signal); + }, + }); +} diff --git a/src/features/local-draft-feature/contracts/local-draft-contract.ts b/src/features/local-draft-feature/contracts/local-draft-contract.ts new file mode 100644 index 0000000..d2d1983 --- /dev/null +++ b/src/features/local-draft-feature/contracts/local-draft-contract.ts @@ -0,0 +1,2 @@ +export const LOCAL_DRAFT_FEATURE_ID = "local-draft" as const; +export const LOCAL_DRAFT_REPOSITORY_ID = "local-draft" as const; diff --git a/src/features/local-draft-feature/domain/local-draft.ts b/src/features/local-draft-feature/domain/local-draft.ts new file mode 100644 index 0000000..877fe12 --- /dev/null +++ b/src/features/local-draft-feature/domain/local-draft.ts @@ -0,0 +1,21 @@ +export type LocalDraft = Readonly<{ + draftId: string; + title: string; + body: string; +}>; + +export function createLocalDraft(values: LocalDraft): LocalDraft { + const draftId = values.draftId.trim(); + const title = values.title.trim(); + if (!draftId || !title) { + throw new TypeError("Local draft requires draftId and title"); + } + if (title.length > 160 || values.body.length > 100_000) { + throw new TypeError("Local draft exceeds bounded field limits"); + } + return Object.freeze({ + draftId, + title, + body: values.body, + }); +} diff --git a/src/features/reference-feature/README.md b/src/features/reference-feature/README.md index 4dd7f7a..cc34c8d 100644 --- a/src/features/reference-feature/README.md +++ b/src/features/reference-feature/README.md @@ -19,8 +19,10 @@ augmentation으로 `"reference-feature": ReferenceFeatureInput`을 기여하므 별도 cast나 runtime shape 확인 없이 정확한 input type을 받는다. 다만 동적 호출로 설치되지 않은 ID가 들어오는 경우를 위해 `get`의 runtime guard도 유지한다. -예측 가능한 실패는 `src/application/result.ts`의 공통 -`Result`로 반환한다. `AppFailure.kind`는 +예측 가능한 실패는 canonical shared contract인 +`src/contracts/result.ts`의 `Result`로 반환한다. +`src/application/result.ts`는 기존 호출자를 위한 deprecated migration shim일 +뿐 새 코드의 import 경로가 아니다. `AppFailure.kind`는 `ERROR_REGISTRY` key에서 파생된 닫힌 vocabulary이며, transport 호환 이름인 `ApiFailure`는 같은 type의 alias다. diff --git a/src/features/reference-feature/adapters/create-reference-feature-input.ts b/src/features/reference-feature/adapters/create-reference-feature-input.ts index 38ab0e6..536d712 100644 --- a/src/features/reference-feature/adapters/create-reference-feature-input.ts +++ b/src/features/reference-feature/adapters/create-reference-feature-input.ts @@ -2,6 +2,9 @@ import { createFeatureHttpBinding, type InstalledHttpOperationExecutor, } from "../../../adapters/http/index.ts"; +import { + defineFeatureAdapterContribution, +} from "../../feature-adapter-contribution.ts"; import { createReferenceFeatureInput } from "../application/reference-feature-api.ts"; import { REFERENCE_FEATURE_ID, @@ -17,15 +20,14 @@ export type InstalledContractOperationExecutor = /** * Feature-owned composition seam. * - * The feature contributes typed operation descriptors and its application - * gateway. HTTP lifecycle/error normalization stays in the reusable capability - * binding rather than being repeated by every product feature. + * The feature receives only the HTTP capability it declares in `needs`. + * HTTP lifecycle/error normalization stays in the reusable capability binding. */ export function createReferenceFeatureInstalledInput(context: Readonly<{ - contractOperations: InstalledHttpOperationExecutor; + http: InstalledHttpOperationExecutor; }>) { const http = createFeatureHttpBinding( - context.contractOperations, + context.http, REFERENCE_HTTP_OPERATIONS, ); @@ -35,7 +37,9 @@ export function createReferenceFeatureInstalledInput(context: Readonly<{ }); } -export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = Object.freeze({ - featureId: REFERENCE_FEATURE_ID, - createInput: createReferenceFeatureInstalledInput, -}); +export const REFERENCE_FEATURE_ADAPTER_CONTRIBUTION = + defineFeatureAdapterContribution({ + featureId: REFERENCE_FEATURE_ID, + needs: ["http"] as const, + createInput: createReferenceFeatureInstalledInput, + }); diff --git a/src/features/reference-feature/adapters/reference-http-gateway.ts b/src/features/reference-feature/adapters/reference-http-gateway.ts index 553fb1a..37a5c1f 100644 --- a/src/features/reference-feature/adapters/reference-http-gateway.ts +++ b/src/features/reference-feature/adapters/reference-http-gateway.ts @@ -1,40 +1,57 @@ import { - defineFeatureHttpOperation, + defineFeatureHttpOperationForRoutes, type FeatureHttpBinding, } from "../../../adapters/http/index.ts"; -import type { - ReferenceCreateCommand, - ReferenceGateway, - ReferenceListFilters, -} from "../application/reference-feature-api.ts"; -import type { ReferenceResource } from "../domain/reference-resource.ts"; +import { + CREATE_REFERENCE_RESOURCE_CONTRACT, + GET_REFERENCE_RESOURCE_CONTRACT, + LIST_REFERENCE_RESOURCES_CONTRACT, +} from "../contracts/reference-feature-contract-contribution.ts"; +import { + REFERENCE_FEATURE_CONTRACT, +} from "../contracts/reference-feature-contract.ts"; import { mapReferenceResourceListPayload, mapReferenceResourcePayload, } from "../contracts/reference-mapper.ts"; +import type { + ReferenceGateway, +} from "../application/reference-feature-api.ts"; + +export type ReferenceFeatureRouteId = + keyof typeof REFERENCE_FEATURE_CONTRACT.routes; + +const defineReferenceHttpOperation = + defineFeatureHttpOperationForRoutes(); export const REFERENCE_HTTP_OPERATIONS = Object.freeze({ - LIST_REFERENCE_RESOURCES: defineFeatureHttpOperation< - ReferenceListFilters, - readonly ReferenceResource[] - >({ - operationId: "LIST_REFERENCE_RESOURCES", + LIST_REFERENCE_RESOURCES: defineReferenceHttpOperation({ + contract: LIST_REFERENCE_RESOURCES_CONTRACT, routeId: "REFERENCE_RESOURCE_LIST", mapSuccess: mapReferenceResourceListPayload, }), - CREATE_REFERENCE_RESOURCE: defineFeatureHttpOperation< - ReferenceCreateCommand, - ReferenceResource - >({ - operationId: "CREATE_REFERENCE_RESOURCE", + CREATE_REFERENCE_RESOURCE: defineReferenceHttpOperation({ + contract: CREATE_REFERENCE_RESOURCE_CONTRACT, routeId: "REFERENCE_RESOURCE_LIST", mapSuccess: mapReferenceResourcePayload, + mapProblem(problem, metadata) { + if (metadata.status === 409) { + return Object.freeze({ + kind: "CONFLICT" as const, + code: problem.code ?? "REFERENCE_RESOURCE_CONFLICT", + }); + } + if (metadata.status === 422) { + return Object.freeze({ + kind: "VALIDATION_REJECTED" as const, + code: problem.code ?? "REFERENCE_RESOURCE_REJECTED", + }); + } + return undefined; + }, }), - GET_REFERENCE_RESOURCE: defineFeatureHttpOperation< - Readonly<{ resourceId: string }>, - ReferenceResource - >({ - operationId: "GET_REFERENCE_RESOURCE", + GET_REFERENCE_RESOURCE: defineReferenceHttpOperation({ + contract: GET_REFERENCE_RESOURCE_CONTRACT, routeId: "REFERENCE_RESOURCE_DETAIL", mapSuccess: mapReferenceResourcePayload, }), diff --git a/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts b/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts index a16c67b..6688829 100644 --- a/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts +++ b/src/features/reference-feature/contracts/reference-feature-contract-contribution.ts @@ -2,17 +2,22 @@ import { z } from "zod"; import type { CommandEffectDescriptor, - HttpRequestProjection, InstalledContractContribution, InstalledHttpContract, RuntimeValidator, } from "../../../contracts/external-contract-runtime.ts"; import { REFERENCE_FEATURE_ID } from "./reference-feature-contract.ts"; import { + createReferenceResourceCommandSchema, + referenceProblemSchema, + referenceResourceDtoSchema, referenceResourceListQuerySchema, referenceResourceParamsSchema, + type ReferenceProblem, } from "./reference-schemas.ts"; +export type { ReferenceProblem } from "./reference-schemas.ts"; + /** * §4.8. The single `TEMPLATE_FIXTURE` contribution. It keeps the reference * HTTP vertical executable as deterministic template data and is excluded from @@ -53,33 +58,10 @@ function zodValidator( }); } -const referenceResourceDto = z - .object({ - id: z.string().min(1).max(120), - name: z.string().min(1).max(240), - createdAt: z.string().min(1).optional(), - }) - .strip(); - -const problemSchema = z - .object({ - type: z.string().min(1).max(512), - title: z.string().min(1).max(240), - status: z.int().min(100).max(599), - code: z.string().min(1).max(120).optional(), - }) - .strip(); - -export type ReferenceProblem = z.output; - -const PROBLEM_VALIDATOR = zodValidator("ReferenceProblem", problemSchema); - -const createCommandSchema = z - .object({ - name: z.string().trim().min(1).max(120), - note: z.string().trim().max(500).optional(), - }) - .strict(); +const PROBLEM_VALIDATOR = zodValidator( + "ReferenceProblem", + referenceProblemSchema, +); /** * Fixture-side classifier standing in for the package-provided pure bounded @@ -87,17 +69,17 @@ const createCommandSchema = z */ const CREATE_EFFECT: CommandEffectDescriptor = Object.freeze({ successEffect: "APPLIED_CONFIRMED" as const, - classifyProblem({ status }: Readonly<{ status: number; problem: ReferenceProblem }>) { - if (status === 400 || status === 409 || status === 422) return "NOT_APPLIED"; + classifyProblem({ + status, + }: Readonly<{ status: number; problem: ReferenceProblem }>) { + if (status === 400 || status === 409 || status === 422) { + return "NOT_APPLIED"; + } return "MAYBE_APPLIED"; }, }); -const LIST_REFERENCE_RESOURCES: InstalledHttpContract< - z.output, - readonly z.output[], - ReferenceProblem -> = Object.freeze({ +export const LIST_REFERENCE_RESOURCES_CONTRACT = Object.freeze({ contract: Object.freeze({ operationId: "LIST_REFERENCE_RESOURCES", method: "GET" as const, @@ -108,7 +90,7 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract< ), outputValidator: zodValidator( "ReferenceResourceListPayload", - z.array(referenceResourceDto).max(100), + z.array(referenceResourceDtoSchema).max(100), ), problemValidator: PROBLEM_VALIDATOR, acceptedStatuses: Object.freeze([200]), @@ -139,13 +121,13 @@ const LIST_REFERENCE_RESOURCES: InstalledHttpContract< authProfileId: "REFERENCE_EXTERNAL_BEARER", diagnosticsOperation: "reference.list", }), -}); - -const GET_REFERENCE_RESOURCE: InstalledHttpContract< - z.output, - z.output, +}) satisfies InstalledHttpContract< + z.output, + readonly z.output[], ReferenceProblem -> = Object.freeze({ +>; + +export const GET_REFERENCE_RESOURCE_CONTRACT = Object.freeze({ contract: Object.freeze({ operationId: "GET_REFERENCE_RESOURCE", method: "GET" as const, @@ -156,7 +138,7 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract< ), outputValidator: zodValidator( "ReferenceResourcePayload", - referenceResourceDto, + referenceResourceDtoSchema, ), problemValidator: PROBLEM_VALIDATOR, acceptedStatuses: Object.freeze([200]), @@ -183,24 +165,24 @@ const GET_REFERENCE_RESOURCE: InstalledHttpContract< authProfileId: "REFERENCE_EXTERNAL_BEARER", diagnosticsOperation: "reference.detail", }), -}); - -const CREATE_REFERENCE_RESOURCE: InstalledHttpContract< - z.output, - z.output, +}) satisfies InstalledHttpContract< + z.output, + z.output, ReferenceProblem -> = Object.freeze({ +>; + +export const CREATE_REFERENCE_RESOURCE_CONTRACT = Object.freeze({ contract: Object.freeze({ operationId: "CREATE_REFERENCE_RESOURCE", method: "POST" as const, pathTemplate: "/api/reference-resources", inputValidator: zodValidator( "CreateReferenceResourceCommand", - createCommandSchema, + createReferenceResourceCommandSchema, ), outputValidator: zodValidator( "ReferenceResourcePayload", - referenceResourceDto, + referenceResourceDtoSchema, ), problemValidator: PROBLEM_VALIDATOR, acceptedStatuses: Object.freeze([200, 201]), @@ -213,7 +195,7 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract< operationIdentityField: "idempotencyKey", }), commandEffect: CREATE_EFFECT, - projectRequest(input: z.output) { + projectRequest(input: z.output) { return Object.freeze({ pathValues: Object.freeze({}), queryEntries: Object.freeze([]), @@ -235,7 +217,11 @@ const CREATE_REFERENCE_RESOURCE: InstalledHttpContract< authProfileId: "REFERENCE_EXTERNAL_BEARER", diagnosticsOperation: "reference.create", }), -}); +}) satisfies InstalledHttpContract< + z.output, + z.output, + ReferenceProblem +>; export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribution = Object.freeze({ @@ -247,9 +233,9 @@ export const REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION: InstalledContractContribut revision: 1 as const, }), http: Object.freeze([ - LIST_REFERENCE_RESOURCES, - GET_REFERENCE_RESOURCE, - CREATE_REFERENCE_RESOURCE, + LIST_REFERENCE_RESOURCES_CONTRACT, + GET_REFERENCE_RESOURCE_CONTRACT, + CREATE_REFERENCE_RESOURCE_CONTRACT, ]) as readonly InstalledHttpContract[], events: Object.freeze([]), }); diff --git a/src/features/reference-feature/contracts/reference-mapper.ts b/src/features/reference-feature/contracts/reference-mapper.ts index bb05aa4..8645f7e 100644 --- a/src/features/reference-feature/contracts/reference-mapper.ts +++ b/src/features/reference-feature/contracts/reference-mapper.ts @@ -8,6 +8,7 @@ import { type InstalledBoundaryMapper, type MappingResult, } from "../../../contracts/boundary-mapper.ts"; +import type { ReferenceResourceDto } from "./reference-schemas.ts"; export type ReferenceResourceView = Readonly<{ resourceId: string; @@ -16,22 +17,19 @@ export type ReferenceResourceView = Readonly<{ optimistic?: boolean; }>; +/** + * Operation-specific mapper. The HTTP contract has already validated the wire + * shape, so this mapper owns only wire -> domain construction. + */ export function mapReferenceResourcePayload( - value: unknown, + dto: ReferenceResourceDto, ): MappingResult { - if (!value || typeof value !== "object") { - return mappingFailure("MAPPING_INVARIANT_REJECTED"); - } - const dto = value as Record; - if (typeof dto.id !== "string" || typeof dto.name !== "string") { - return mappingFailure("MAPPING_INVARIANT_REJECTED"); - } try { return mappingSuccess( createReferenceResource({ id: dto.id, displayName: dto.name, - createdAt: typeof dto.createdAt === "string" ? dto.createdAt : null, + createdAt: dto.createdAt ?? null, }), ); } catch { @@ -40,14 +38,8 @@ export function mapReferenceResourcePayload( } export function mapReferenceResourceListPayload( - payload: unknown, + payload: readonly ReferenceResourceDto[], ): MappingResult { - if (!Array.isArray(payload)) { - return mappingFailure("MAPPING_INVARIANT_REJECTED"); - } - if (payload.length > 100) { - return mappingFailure("OUTPUT_LIMIT_EXCEEDED"); - } const output: ReferenceResource[] = []; for (const item of payload) { const mapped = mapReferenceResourcePayload(item); @@ -57,22 +49,58 @@ export function mapReferenceResourceListPayload( return mappingSuccess(Object.freeze(output)); } +function isReferenceResourceDto(value: unknown): value is ReferenceResourceDto { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const dto = value as Record; + return ( + typeof dto.id === "string" && + typeof dto.name === "string" && + (dto.createdAt === undefined || typeof dto.createdAt === "string") + ); +} + +function mapUnknownReferenceResourcePayload( + value: unknown, +): MappingResult { + return isReferenceResourceDto(value) + ? mapReferenceResourcePayload(value) + : mappingFailure("MAPPING_INVARIANT_REJECTED"); +} + +function mapUnknownReferenceResourceListPayload( + value: unknown, +): MappingResult { + if ( + !Array.isArray(value) || + value.length > 100 || + !value.every(isReferenceResourceDto) + ) { + return mappingFailure( + Array.isArray(value) && value.length > 100 + ? "OUTPUT_LIMIT_EXCEEDED" + : "MAPPING_INVARIANT_REJECTED", + ); + } + return mapReferenceResourceListPayload(value); +} + /** - * Registry-facing compatibility projection. Feature adapters should prefer the - * operation-specific mapper functions above so their result type is exact. + * Registry-facing compatibility projection. The registry is intentionally + * untyped, so its adapter performs the defensive shape check. Feature HTTP + * bindings use the typed operation-specific mappers above. */ export function mapReferenceOperation( operationId: string, payload: unknown, ): MappingResult { if (operationId === "LIST_REFERENCE_RESOURCES") { - return mapReferenceResourceListPayload(payload); + return mapUnknownReferenceResourceListPayload(payload); } if ( operationId === "CREATE_REFERENCE_RESOURCE" || operationId === "GET_REFERENCE_RESOURCE" ) { - return mapReferenceResourcePayload(payload); + return mapUnknownReferenceResourcePayload(payload); } return mappingFailure("MAPPING_INVARIANT_REJECTED"); } @@ -85,7 +113,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({ outputContractId: "ReferenceResourceList", owner: "feature-frontend-reference-feature-vertical-slice", maxOutputItems: 100, - map: mapReferenceResourceListPayload, + map: mapUnknownReferenceResourceListPayload, }), ReferenceResourceMapper: Object.freeze({ mapperId: "ReferenceResourceMapper", @@ -94,7 +122,7 @@ export const REFERENCE_BOUNDARY_MAPPERS = Object.freeze({ outputContractId: "ReferenceResource", owner: "feature-frontend-reference-feature-vertical-slice", maxOutputItems: 1, - map: mapReferenceResourcePayload, + map: mapUnknownReferenceResourcePayload, }), } satisfies Readonly>); diff --git a/src/features/reference-feature/contracts/reference-schemas.ts b/src/features/reference-feature/contracts/reference-schemas.ts index 2300a49..dff64aa 100644 --- a/src/features/reference-feature/contracts/reference-schemas.ts +++ b/src/features/reference-feature/contracts/reference-schemas.ts @@ -13,7 +13,7 @@ export const referenceResourceListQuerySchema = z : Array.isArray(value) ? value : [value], - z.array(z.string().trim().min(1)), + z.array(z.string().trim().min(1)).readonly(), ) .optional(), }) @@ -25,7 +25,7 @@ export const referenceResourceParamsSchema = z }) .strict(); -const referenceResourceDtoSchema = z +export const referenceResourceDtoSchema = z .object({ id: z.string().min(1).max(120), name: z.string().min(1).max(240), @@ -33,6 +33,28 @@ const referenceResourceDtoSchema = z }) .strip(); +export type ReferenceResourceDto = z.output< + typeof referenceResourceDtoSchema +>; + +export const referenceProblemSchema = z + .object({ + type: z.string().min(1).max(512), + title: z.string().min(1).max(240), + status: z.int().min(100).max(599), + code: z.string().min(1).max(120).optional(), + }) + .strip(); + +export type ReferenceProblem = z.output; + +export const createReferenceResourceCommandSchema = z + .object({ + name: z.string().trim().min(1).max(120), + note: z.string().trim().max(500).optional(), + }) + .strict(); + const payloadSchemas = { ReferenceResourceListPayload: z.array(referenceResourceDtoSchema).max(100), ReferenceResourcePayload: referenceResourceDtoSchema, @@ -42,12 +64,7 @@ const requestSchemas = { ReferenceResourceParams: referenceResourceParamsSchema, ReferenceResourceListQuery: referenceResourceListQuerySchema, NoRequest: z.object({}).strict(), - CreateReferenceResourceCommand: z - .object({ - name: z.string().trim().min(1).max(120), - note: z.string().trim().max(500).optional(), - }) - .strict(), + CreateReferenceResourceCommand: createReferenceResourceCommandSchema, } satisfies Record; function project(result: z.ZodSafeParseResult) { diff --git a/tests/browser-capabilities/indexeddb-runtime.spec.ts b/tests/browser-capabilities/indexeddb-runtime.spec.ts index 235b493..feeba8c 100644 --- a/tests/browser-capabilities/indexeddb-runtime.spec.ts +++ b/tests/browser-capabilities/indexeddb-runtime.spec.ts @@ -18,13 +18,13 @@ function databaseName(_prefix: string): string { return `ca-idb-v1:a${token.slice(0, 31)}.n${token.slice(1)}.p${token.split("").reverse().join("").slice(0, 31)}`; } -async function installLifecycleRuntime( +async function startLifecycleRuntime( page: Page, name: string, targetVersion: 1 | 2, blockedTimeoutMs = 1_000, -) { - return await page.evaluate( +): Promise { + await page.evaluate( async ({ currentDatabaseName, currentTargetVersion, @@ -173,8 +173,12 @@ async function installLifecycleRuntime( __indexedDbLifecycleRuntime?: unknown; } ).__indexedDbLifecycleRuntime = runtime; - const opened = await runtime.open(); - return { opened, status: runtime.getStatus() }; + const opening = runtime.open(); + ( + globalThis as unknown as { + __indexedDbLifecycleOpening?: Promise; + } + ).__indexedDbLifecycleOpening = opening; }, { currentDatabaseName: name, @@ -184,6 +188,35 @@ async function installLifecycleRuntime( ); } +async function settleLifecycleRuntime(page: Page): Promise { + return await page.evaluate(async () => { + const state = globalThis as unknown as { + __indexedDbLifecycleRuntime?: { + getStatus(): unknown; + }; + __indexedDbLifecycleOpening?: Promise; + }; + if (!state.__indexedDbLifecycleRuntime || !state.__indexedDbLifecycleOpening) { + throw new Error("IndexedDB lifecycle runtime is not installed."); + } + const opened = await state.__indexedDbLifecycleOpening; + return { + opened, + status: state.__indexedDbLifecycleRuntime.getStatus(), + }; + }); +} + +async function installLifecycleRuntime( + page: Page, + name: string, + targetVersion: 1 | 2, + blockedTimeoutMs = 1_000, +): Promise { + await startLifecycleRuntime(page, name, targetVersion, blockedTimeoutMs); + return await settleLifecycleRuntime(page); +} + async function lifecycleStatus(page: Page): Promise { return await page.evaluate(() => { const runtime = ( @@ -977,12 +1010,16 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t ); expect(blocker).toBe("OPEN"); - const blocked = await installLifecycleRuntime( - page, - name, - 2, - 25, - ); + await startLifecycleRuntime(page, name, 2, 500); + await expect + .poll(async () => await lifecycleStatus(page)) + .toEqual({ + kind: "BLOCKED", + currentVersion: 1, + targetVersion: 2, + }); + + const blocked = await settleLifecycleRuntime(page); expect(blocked).toMatchObject({ opened: { ok: false, @@ -992,9 +1029,8 @@ test("fails a blocked v2 upgrade closed, then closes its late connection after t }, }, status: { - kind: "BLOCKED", - currentVersion: 1, - targetVersion: 2, + kind: "CLOSED", + reason: "NOT_OPENED", }, }); diff --git a/tests/browser-capabilities/local-draft-composition.spec.ts b/tests/browser-capabilities/local-draft-composition.spec.ts new file mode 100644 index 0000000..6319ad6 --- /dev/null +++ b/tests/browser-capabilities/local-draft-composition.spec.ts @@ -0,0 +1,244 @@ +import type { + IndexedDbRuntimeDependencies, +} from "../../src/adapters/storage/indexeddb/indexeddb-types.ts"; +import type { LocalDraft } from "../../src/features/local-draft-feature/domain/local-draft.ts"; +import { + expect, + test, +} from "../support/browser/strict-browser-test.ts"; + +test("composes the local-draft feature over the real browser IndexedDB runtime", async ({ + page, +}) => { + await page.route("**/favicon.ico", (route) => + route.fulfill({ status: 204 }), + ); + await page.goto("/config.json"); + + const result = await page.evaluate(async () => { + const indexedDbModulePath = + "/src/adapters/storage/indexeddb/index.ts"; + const contributionModulePath = + "/src/features/feature-adapter-contribution.ts"; + const localDraftModulePath = + "/src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts"; + + const { + createIndexedDbRuntime, + indexedDbPhysicalDatabaseName, + } = (await import( + /* @vite-ignore */ indexedDbModulePath + )) as typeof import("../../src/adapters/storage/indexeddb/index.ts"); + const { + composeFeatureAdapterInputs, + createIndexedDbRepositoryProvider, + } = (await import( + /* @vite-ignore */ contributionModulePath + )) as typeof import("../../src/features/feature-adapter-contribution.ts"); + const { + LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION, + } = (await import( + /* @vite-ignore */ localDraftModulePath + )) as typeof import("../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts"); + + const suffix = crypto.randomUUID().replaceAll("-", ""); + const scope = Object.freeze({ + authorityToken: `a${suffix.slice(0, 31)}`, + namespaceToken: `n${suffix.slice(1, 32)}`, + partitionToken: `p${[...suffix].reverse().join("").slice(0, 31)}`, + accountScope: "ORIGIN_SHARED" as const, + }); + const storagePolicy = Object.freeze({ + owner: "local-draft-feature", + namespace: "local-draft", + classification: "INTERNAL" as const, + authority: "LOCAL_FIRST" as const, + accountScope: "ORIGIN_SHARED" as const, + retention: Object.freeze({ kind: "EXPLICIT_DELETE" as const }), + softBudgetBytes: 1_000_000, + hardBudgetBytes: 2_000_000, + evictionPriority: "USER_AUTHORED" as const, + logoutAction: "KEEP_ORIGIN_SHARED" as const, + accountDeletionAction: "KEEP_ORIGIN_SHARED" as const, + pressureAction: "RETAIN" as const, + unavailableFallback: "READ_ONLY" as const, + }); + + const config: IndexedDbRuntimeDependencies< + LocalDraft, + LocalDraft, + never + > = { + scope, + storagePolicy, + schemaVersion: 1, + recordStore: "records", + governanceStore: "governance", + retentionStore: "retention", + retentionEligibilityIndex: "by-eligibility", + lifecycleMetadataStores: [], + idempotencyStore: "receipts", + idempotencyExpiryIndex: "by-expiry", + receiptRetentionMs: 60_000, + maxIdempotencyReceipts: 100, + migrations: [ + { + id: "local-draft-schema-v1", + fromVersion: 0, + toVersion: 1, + operations: [ + { + kind: "CREATE_STORE", + name: "governance", + keyPath: "bindingKey", + }, + { + kind: "CREATE_STORE", + name: "retention", + keyPath: "recordKey", + indexes: [ + { + name: "by-eligibility", + keyPath: "eligibleAtEpochMs", + }, + ], + }, + { + kind: "CREATE_STORE", + name: "records", + keyPath: "key", + }, + { + kind: "CREATE_STORE", + name: "receipts", + keyPath: "idempotencyKey", + indexes: [ + { + name: "by-expiry", + keyPath: "expiresAtEpochMs", + }, + ], + }, + ], + }, + ], + codec: { + currentVersion: 1, + encode: (value) => ({ ok: true, value }), + measureStoredBytes: (value) => + new TextEncoder().encode(JSON.stringify(value)).byteLength, + decode: (version, value) => { + if ( + version !== 1 || + value === null || + typeof value !== "object" + ) { + return { ok: false }; + } + const candidate = value as Partial; + return typeof candidate.draftId === "string" && + typeof candidate.title === "string" && + typeof candidate.body === "string" + ? { ok: true, value: candidate as LocalDraft } + : { ok: false }; + }, + fingerprint: async (value) => { + const bytes = new TextEncoder().encode( + JSON.stringify([value.draftId, value.title, value.body]), + ); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return Array.from( + new Uint8Array(digest), + (byte) => byte.toString(16).padStart(2, "0"), + ).join(""); + }, + }, + queryPolicy: { + plan: () => ({ limit: 1 }), + }, + authorizeLifecycle: () => ({ + authorized: true, + proofToken: "localdraftauthorityproof_001", + }), + }; + + const runtime = createIndexedDbRuntime(config); + const databaseName = indexedDbPhysicalDatabaseName(scope); + const opened = await runtime.open(); + + try { + const indexedDb = createIndexedDbRepositoryProvider( + Object.freeze({ "local-draft": runtime }), + ); + const composed = composeFeatureAdapterInputs( + Object.freeze([LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION] as const), + Object.freeze(["local-draft"]), + Object.freeze({ indexedDb }), + ); + const localDraft = composed["local-draft"]; + if (!localDraft) { + throw new Error("local-draft feature input was not composed"); + } + + const draft = Object.freeze({ + draftId: "draft-1", + title: "Architecture notes", + body: "Stored through the reusable IndexedDB capability.", + }); + + const saved = await localDraft.saveDraft({ + draft, + expectedRevision: null, + idempotencyKey: "local-draft-create-1", + }); + const found = await localDraft.findDraft(draft.draftId); + const removed = await localDraft.removeDraft({ + draftId: draft.draftId, + expectedRevision: saved.ok ? saved.value.revision : 1, + idempotencyKey: "local-draft-remove-1", + }); + const afterRemove = await localDraft.findDraft(draft.draftId); + + return { + opened, + saved, + found, + removed, + afterRemove, + composedFeatureIds: Object.keys(composed), + runtimeStatus: runtime.getStatus(), + }; + } finally { + runtime.close(); + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(databaseName); + request.onsuccess = () => resolve(); + request.onerror = () => + reject(request.error ?? new Error("IndexedDB cleanup failed")); + request.onblocked = () => + reject(new Error("IndexedDB cleanup was blocked")); + }); + } + }); + + expect(result.opened).toEqual({ ok: true, value: undefined }); + expect(result.composedFeatureIds).toEqual(["local-draft"]); + expect(result.saved).toEqual({ ok: true, value: { revision: 1 } }); + expect(result.found).toMatchObject({ + ok: true, + value: { + draft: { + draftId: "draft-1", + title: "Architecture notes", + body: "Stored through the reusable IndexedDB capability.", + }, + revision: 1, + }, + }); + expect(result.removed).toEqual({ ok: true, value: undefined }); + expect(result.afterRemove).toEqual({ ok: true, value: null }); + expect(result.runtimeStatus).toMatchObject({ + kind: "READY", + schemaVersion: 1, + }); +}); diff --git a/tests/browser-capabilities/storage-manager.spec.ts b/tests/browser-capabilities/storage-manager.spec.ts index f89f155..74a382a 100644 --- a/tests/browser-capabilities/storage-manager.spec.ts +++ b/tests/browser-capabilities/storage-manager.spec.ts @@ -1,29 +1,48 @@ import { expect, test } from "../support/browser/strict-browser-test.ts"; -test("executes the storage durability adapter against the real browser StorageManager", async ({ +test("executes the storage durability adapter against the real browser StorageManager capability", async ({ page, }) => { await page.goto("/"); - const result = await page.evaluate(async () => { + const observation = await page.evaluate(async () => { const modulePath = "/src/adapters/browser-file-storage/storage-manager-adapter.ts"; const { createStorageDurabilityAdapter } = await import( /* @vite-ignore */ modulePath ); + const hasStorageManager = navigator.storage !== undefined; const adapter = createStorageDurabilityAdapter(navigator.storage); - return adapter.inspect(); + return { + hasStorageManager, + result: await adapter.inspect(), + }; }); - expect(result.ok).toBe(true); - if (result.ok) { + if (!observation.hasStorageManager) { + expect(observation.result).toEqual({ + ok: false, + error: { + code: "UNSUPPORTED", + operation: "STORAGE_ESTIMATE", + retryable: false, + recovery: "ONLINE_ONLY", + }, + }); + return; + } + + expect(observation.result.ok).toBe(true); + if (observation.result.ok) { expect(["UNKNOWN", "NORMAL", "PRESSURE", "CRITICAL"]).toContain( - result.value.pressure, + observation.result.value.pressure, ); expect( - result.value.usageBytes === null || result.value.usageBytes >= 0, + observation.result.value.usageBytes === null || + observation.result.value.usageBytes >= 0, ).toBe(true); expect( - result.value.quotaBytes === null || result.value.quotaBytes >= 0, + observation.result.value.quotaBytes === null || + observation.result.value.quotaBytes >= 0, ).toBe(true); } }); diff --git a/tests/component/router.test.tsx b/tests/component/router.test.tsx index cb98eb0..ef18378 100644 --- a/tests/component/router.test.tsx +++ b/tests/component/router.test.tsx @@ -71,7 +71,9 @@ describe("generic application router", () => { await screen.findByRole("heading", { name: "UI 구성요소", level: 1 }), ).toBeVisible(); expect(window.location.pathname).toBe("/examples/ui"); - expect(document.title).toBe("UI 구성요소 · Frontend Skeleton"); + await waitFor(() => + expect(document.title).toBe("UI 구성요소 · Frontend Skeleton"), + ); await waitFor(() => expect( screen.getByRole("heading", { name: "UI 구성요소", level: 1 }), diff --git a/tests/contract/reusable-capability/feature-adapter-composition.test.ts b/tests/contract/reusable-capability/feature-adapter-composition.test.ts new file mode 100644 index 0000000..0db7819 --- /dev/null +++ b/tests/contract/reusable-capability/feature-adapter-composition.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import type { + IndexedDbRepositoryPort, +} from "../../../src/application/ports/browser-file-storage/index.ts"; +import type { + InstalledHttpOperationExecutor, +} from "../../../src/adapters/http/index.ts"; +import { + composeFeatureAdapterInputs, + defineFeatureAdapterContribution, + type IndexedDbRepositoryProvider, +} from "../../../src/features/feature-adapter-contribution.ts"; +import { + LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION, +} from "../../../src/features/local-draft-feature/adapters/create-local-draft-feature-input.ts"; +import type { LocalDraft } from "../../../src/features/local-draft-feature/domain/local-draft.ts"; + +type TestHttpFeatureInput = Readonly<{ + ping(): Promise<"pong">; +}>; + +declare module "../../../src/application/ports/in/application-api.ts" { + interface ApplicationFeatureInputs { + "test-http-feature": TestHttpFeatureInput; + } +} + +const TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION = + defineFeatureAdapterContribution({ + featureId: "test-http-feature", + needs: ["http"] as const, + createInput() { + return Object.freeze({ + featureId: "test-http-feature" as const, + input: Object.freeze({ + async ping() { + return "pong" as const; + }, + }), + }); + }, + }); + +function localDraftRepository() { + let stored: Readonly<{ value: LocalDraft; revision: number }> | null = null; + const repository = Object.freeze({ + async compareAndSwap(input: Readonly<{ value: LocalDraft }>) { + stored = Object.freeze({ value: input.value, revision: 1 }); + return Object.freeze({ + ok: true as const, + value: Object.freeze({ revision: 1 }), + }); + }, + async read() { + return Object.freeze({ ok: true as const, value: stored }); + }, + async remove() { + stored = null; + return Object.freeze({ + ok: true as const, + value: Object.freeze({ revision: 2 }), + }); + }, + }) as unknown as IndexedDbRepositoryPort; + return repository; +} + +describe("feature adapter composition", () => { + it("composes HTTP-only and IndexedDB-only concrete contributions together", async () => { + const repository = localDraftRepository(); + const get = ((repositoryId: string) => { + expect(repositoryId).toBe("local-draft"); + return repository; + }) as unknown as IndexedDbRepositoryProvider["get"]; + + const indexedDb = Object.freeze({ get }) satisfies IndexedDbRepositoryProvider; + const http = Object.freeze({ + execute: async () => { + throw new Error("HTTP should not execute during composition"); + }, + }) as unknown as InstalledHttpOperationExecutor; + + const inputs = composeFeatureAdapterInputs( + Object.freeze([ + TEST_HTTP_FEATURE_ADAPTER_CONTRIBUTION, + LOCAL_DRAFT_FEATURE_ADAPTER_CONTRIBUTION, + ] as const), + Object.freeze(["test-http-feature", "local-draft"]), + Object.freeze({ http, indexedDb }), + ); + + expect(Object.keys(inputs).sort()).toEqual([ + "local-draft", + "test-http-feature", + ]); + + await expect(inputs["test-http-feature"]?.ping()).resolves.toBe("pong"); + + const localDraft = inputs["local-draft"]; + if (!localDraft) throw new Error("local-draft input was not composed"); + + const draft = Object.freeze({ + draftId: "draft-1", + title: "Local draft", + body: "Body", + }); + + await expect( + localDraft.saveDraft({ + draft, + expectedRevision: null, + idempotencyKey: "save-draft-1", + }), + ).resolves.toEqual({ ok: true, value: { revision: 1 } }); + + await expect(localDraft.findDraft("draft-1")).resolves.toEqual({ + ok: true, + value: { draft, revision: 1 }, + }); + }); +}); diff --git a/tests/contract/reusable-capability/feature-http-binding.test.ts b/tests/contract/reusable-capability/feature-http-binding.test.ts index f7ff8d4..271e090 100644 --- a/tests/contract/reusable-capability/feature-http-binding.test.ts +++ b/tests/contract/reusable-capability/feature-http-binding.test.ts @@ -3,57 +3,129 @@ import { describe, expect, it, vi } from "vitest"; import { createFeatureHttpBinding, defineFeatureHttpOperation, + type HttpExecutionOutcome, type InstalledHttpOperationExecutor, } from "../../../src/adapters/http/index.ts"; import { mappingFailure, mappingSuccess, } from "../../../src/contracts/boundary-mapper.ts"; +import type { + InstalledHttpContract, + RuntimeValidator, +} from "../../../src/contracts/external-contract-runtime.ts"; +type ResourceInput = Readonly<{ resourceId: string }>; +type ResourceWire = Readonly<{ id: string; title: string }>; +type ResourceProblem = Readonly<{ code?: string }>; type Resource = Readonly<{ id: string; title: string }>; -const OPERATIONS = Object.freeze({ - LOAD_RESOURCE: defineFeatureHttpOperation< - Readonly<{ resourceId: string }>, - Resource - >({ +function validator(schemaId: string): RuntimeValidator { + return Object.freeze({ + schemaId, + safeParse(value: unknown) { + return Object.freeze({ + success: true as const, + data: value as T, + }); + }, + }); +} + +const LOAD_RESOURCE_CONTRACT = Object.freeze({ + contract: Object.freeze({ operationId: "LOAD_RESOURCE", + method: "GET" as const, + pathTemplate: "/resources/{resourceId}", + inputValidator: validator("ResourceInput"), + outputValidator: validator("ResourceWire"), + problemValidator: validator("ResourceProblem"), + acceptedStatuses: Object.freeze([200]), + emptyBodyStatuses: Object.freeze([]), + retrySemantics: "SAFE" as const, + requestBody: "NONE" as const, + responseBody: "REQUIRED_JSON" as const, + commandRecovery: null, + commandEffect: null, + projectRequest(input: ResourceInput) { + return Object.freeze({ + pathValues: Object.freeze({ resourceId: input.resourceId }), + queryEntries: Object.freeze([]), + body: null, + }); + }, + }), + frontend: Object.freeze({ + policyId: "TEST_LOAD_RESOURCE", + requestByteLimit: 0, + responseByteLimit: 8_192, + totalDeadlineMs: 1_000, + retryBudget: 0 as const, + authProfileId: "TEST", + diagnosticsOperation: "test.load-resource", + }), +}) satisfies InstalledHttpContract; + +const OPERATIONS = Object.freeze({ + LOAD_RESOURCE: defineFeatureHttpOperation({ + contract: LOAD_RESOURCE_CONTRACT, routeId: "RESOURCE_DETAIL", mapSuccess(value) { - if ( - !value || - typeof value !== "object" || - typeof (value as Record).id !== "string" || - typeof (value as Record).title !== "string" - ) { + if (value.title.length === 0) { return mappingFailure("MAPPING_INVARIANT_REJECTED"); } - const candidate = value as Readonly<{ id: string; title: string }>; return mappingSuccess( - Object.freeze({ id: candidate.id, title: candidate.title }), + Object.freeze({ id: value.id, title: value.title }), ); }, + mapProblem(problem, metadata) { + if (metadata.status !== 409) return undefined; + return Object.freeze({ + kind: "CONFLICT" as const, + code: problem.code ?? "RESOURCE_CONFLICT", + }); + }, }), } as const); +function scriptedExecutor( + outcome: HttpExecutionOutcome, +) { + const calls: Array< + Readonly<{ + contract: unknown; + input: unknown; + context: unknown; + }> + > = []; + const implementation = async ( + contract: unknown, + input: unknown, + context: unknown, + ) => { + calls.push(Object.freeze({ contract, input, context })); + return outcome; + }; + const execute = vi.fn(implementation) as unknown as + InstalledHttpOperationExecutor["execute"]; + return { + executor: Object.freeze({ execute }) satisfies InstalledHttpOperationExecutor, + calls, + execute, + }; +} + describe("feature HTTP binding", () => { - it("keeps typed feature input while platform owns route/context execution", async () => { - const execute = vi.fn( - async (_operationId, input, context) => { - expect(input).toEqual({ resourceId: "resource-1" }); - expect(context.routeId).toBe("RESOURCE_DETAIL"); - return Object.freeze({ - kind: "SUCCESS" as const, - value: Object.freeze({ id: "resource-1", title: "Reference" }), - metadata: Object.freeze({ status: 200 }), - effect: "NOT_APPLICABLE" as const, - }); - }, - ); - const binding = createFeatureHttpBinding( - Object.freeze({ execute }), - OPERATIONS, + it("keeps contract-derived input while platform owns route/context execution", async () => { + const scripted = scriptedExecutor( + Object.freeze({ + kind: "SUCCESS" as const, + value: Object.freeze({ id: "resource-1", title: "Reference" }), + metadata: Object.freeze({ status: 200 }), + effect: "NOT_APPLICABLE" as const, + }), ); + const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS); const result = await binding.execute("LOAD_RESOURCE", { resourceId: "resource-1", @@ -63,23 +135,27 @@ describe("feature HTTP binding", () => { ok: true, value: { id: "resource-1", title: "Reference" }, }); - expect(execute).toHaveBeenCalledTimes(1); + expect(scripted.calls).toEqual([ + { + contract: LOAD_RESOURCE_CONTRACT, + input: { resourceId: "resource-1" }, + context: { routeId: "RESOURCE_DETAIL" }, + }, + ]); }); it("normalizes transport failure before it crosses the feature gateway", async () => { - const executor: InstalledHttpOperationExecutor = Object.freeze({ - async execute() { - return Object.freeze({ - kind: "TRANSPORT_FAILURE" as const, - failure: Object.freeze({ - kind: "TIMEOUT" as const, - retryable: true, - }), - effect: "NOT_STARTED" as const, - }); - }, - }); - const binding = createFeatureHttpBinding(executor, OPERATIONS); + const scripted = scriptedExecutor( + Object.freeze({ + kind: "TRANSPORT_FAILURE" as const, + failure: Object.freeze({ + kind: "TIMEOUT" as const, + retryable: true, + }), + effect: "NOT_STARTED" as const, + }), + ); + const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS); const result = await binding.execute("LOAD_RESOURCE", { resourceId: "resource-1", @@ -92,18 +168,16 @@ describe("feature HTTP binding", () => { expect(result.error.effect).toBe("NOT_STARTED"); }); - it("turns feature mapper rejection into the shared mapping failure", async () => { - const executor: InstalledHttpOperationExecutor = Object.freeze({ - async execute() { - return Object.freeze({ - kind: "SUCCESS" as const, - value: Object.freeze({ unexpected: true }), - metadata: Object.freeze({ status: 200 }), - effect: "NOT_APPLICABLE" as const, - }); - }, - }); - const binding = createFeatureHttpBinding(executor, OPERATIONS); + it("turns a domain mapper rejection into the shared mapping failure", async () => { + const scripted = scriptedExecutor( + Object.freeze({ + kind: "SUCCESS" as const, + value: Object.freeze({ id: "resource-1", title: "" }), + metadata: Object.freeze({ status: 200 }), + effect: "NOT_APPLICABLE" as const, + }), + ); + const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS); const result = await binding.execute("LOAD_RESOURCE", { resourceId: "resource-1", @@ -114,4 +188,29 @@ describe("feature HTTP binding", () => { expect(result.error.kind).toBe("MAPPING_CONTRACT_VIOLATION"); expect(result.error.code).toBe("MAPPING_INVARIANT_REJECTED"); }); + + it("lets the feature interpret a typed business problem", async () => { + const scripted = scriptedExecutor( + Object.freeze({ + kind: "PROBLEM" as const, + problem: Object.freeze({ code: "RESOURCE_NAME_EXISTS" }), + metadata: Object.freeze({ status: 409 }), + effect: "NOT_APPLIED" as const, + }), + ); + const binding = createFeatureHttpBinding(scripted.executor, OPERATIONS); + + const result = await binding.execute("LOAD_RESOURCE", { + resourceId: "resource-1", + }); + + expect(result).toMatchObject({ + ok: false, + error: { + kind: "CONFLICT", + code: "RESOURCE_NAME_EXISTS", + httpStatus: 409, + }, + }); + }); }); diff --git a/tests/features/reference-feature/reference-contract.test.ts b/tests/features/reference-feature/reference-contract.test.ts index 2ba9dcc..7f01eb3 100644 --- a/tests/features/reference-feature/reference-contract.test.ts +++ b/tests/features/reference-feature/reference-contract.test.ts @@ -32,7 +32,7 @@ describe("reference feature boundary contracts", () => { effect: "MAYBE_APPLIED" as const, })); const installed = createReferenceFeatureInstalledInput({ - contractOperations: { execute }, + http: { execute }, }); await expect( diff --git a/tests/features/reference-feature/reference-diagnostics.test.ts b/tests/features/reference-feature/reference-diagnostics.test.ts index 57f5e8f..4307a40 100644 --- a/tests/features/reference-feature/reference-diagnostics.test.ts +++ b/tests/features/reference-feature/reference-diagnostics.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it, vi } from "vitest"; import { createContractHttpExecutor } from "../../../src/adapters/http/index.ts"; import { createHttpObservationProjector } from "../../../src/bootstrap/runtime-adapters.ts"; import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts"; -import { REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts"; function scopeSnapshot() { return Object.freeze({ @@ -34,17 +33,9 @@ describe("reference feature HTTP diagnostics", () => { telemetry: { emit: vi.fn() }, }), }); - const operations = new Map( - REFERENCE_FEATURE_TEMPLATE_CONTRIBUTION.http.map((operation) => [ - operation.contract.operationId, - operation, - ]), - ); const installed = createReferenceFeatureInstalledInput({ - contractOperations: Object.freeze({ - async execute(operationId, input, context) { - const operation = operations.get(operationId); - if (!operation) throw new Error("Unregistered reference operation"); + http: Object.freeze({ + async execute(operation, input, context) { return contractHttp.execute(operation, input, { routeId: context.routeId, scope: scopeSnapshot(), diff --git a/tests/features/reference-feature/reference-http-problem-mapping.test.ts b/tests/features/reference-feature/reference-http-problem-mapping.test.ts new file mode 100644 index 0000000..a42506f --- /dev/null +++ b/tests/features/reference-feature/reference-http-problem-mapping.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + createFeatureHttpBinding, + type InstalledHttpOperationExecutor, +} from "../../../src/adapters/http/index.ts"; +import { + createReferenceHttpGateway, + REFERENCE_HTTP_OPERATIONS, +} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts"; + +function executorReturning( + outcome: Readonly>, +): InstalledHttpOperationExecutor { + const execute = (async () => outcome) as unknown as + InstalledHttpOperationExecutor["execute"]; + return Object.freeze({ execute }); +} + +describe("reference HTTP business problem mapping", () => { + it("maps a typed 409 reference problem to the feature-owned business code", async () => { + const executor = executorReturning( + Object.freeze({ + kind: "PROBLEM", + problem: Object.freeze({ + type: "https://example.test/problems/reference-conflict", + title: "Reference conflict", + status: 409, + code: "REFERENCE_NAME_ALREADY_EXISTS", + }), + metadata: Object.freeze({ status: 409 }), + effect: "NOT_APPLIED", + }), + ); + const gateway = createReferenceHttpGateway( + createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS), + ); + + await expect( + gateway.create({ name: "duplicate" }), + ).resolves.toMatchObject({ + ok: false, + error: { + kind: "CONFLICT", + code: "REFERENCE_NAME_ALREADY_EXISTS", + httpStatus: 409, + }, + }); + }); + + it.each([ + { + status: 409, + code: undefined, + expectedKind: "CONFLICT", + expectedCode: "REFERENCE_RESOURCE_CONFLICT", + }, + { + status: 422, + code: "REFERENCE_NAME_REJECTED", + expectedKind: "VALIDATION_REJECTED", + expectedCode: "REFERENCE_NAME_REJECTED", + }, + { + status: 422, + code: undefined, + expectedKind: "VALIDATION_REJECTED", + expectedCode: "REFERENCE_RESOURCE_REJECTED", + }, + { + status: 400, + code: "REFERENCE_BAD_REQUEST", + expectedKind: "UNKNOWN_CLIENT_FAILURE", + expectedCode: "CONTRACT_PROBLEM", + }, + ] as const)( + "projects create problem status $status through the feature-owned mapper/fallback", + async ({ status, code, expectedKind, expectedCode }) => { + const executor = executorReturning( + Object.freeze({ + kind: "PROBLEM", + problem: Object.freeze({ + type: "https://example.test/problems/reference-" + status, + title: "Reference request rejected", + status, + ...(code === undefined ? {} : { code }), + }), + metadata: Object.freeze({ status }), + effect: "NOT_APPLIED", + }), + ); + const gateway = createReferenceHttpGateway( + createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS), + ); + + await expect( + gateway.create({ name: "rejected" }), + ).resolves.toMatchObject({ + ok: false, + error: { + kind: expectedKind, + code: expectedCode, + httpStatus: status, + }, + }); + }, + ); + + it("keeps the platform status fallback when the operation has no problem mapper", async () => { + const executor = executorReturning( + Object.freeze({ + kind: "PROBLEM", + problem: Object.freeze({ + type: "https://example.test/problems/not-found", + title: "Not found", + status: 404, + }), + metadata: Object.freeze({ status: 404 }), + effect: "NOT_APPLIED", + }), + ); + const gateway = createReferenceHttpGateway( + createFeatureHttpBinding(executor, REFERENCE_HTTP_OPERATIONS), + ); + + await expect(gateway.get("missing")).resolves.toMatchObject({ + ok: false, + error: { + kind: "NOT_FOUND", + code: "CONTRACT_PROBLEM", + httpStatus: 404, + }, + }); + }); +}); diff --git a/tests/features/reference-feature/reference-installed-executor.test.ts b/tests/features/reference-feature/reference-installed-executor.test.ts index f93ced8..fc19ee8 100644 --- a/tests/features/reference-feature/reference-installed-executor.test.ts +++ b/tests/features/reference-feature/reference-installed-executor.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; +import { + type InstalledHttpOperationExecutor, +} from "../../../src/adapters/http/index.ts"; import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts"; /** @@ -14,22 +17,31 @@ import { createReferenceFeatureInstalledInput } from "../../../src/features/refe describe("reference feature installed operation executor", () => { it("preserves the feature route id through the installed operation executor", async () => { const seen: Array>> = []; + const implementation = async ( + contract: Readonly<{ contract: Readonly<{ operationId: string }> }>, + _input: unknown, + context: Readonly>, + ) => { + seen.push(Object.freeze({ ...context })); + const value = + contract.contract.operationId === "LIST_REFERENCE_RESOURCES" + ? [] + : { + id: "resource-1", + name: "Resource", + }; + return Object.freeze({ + kind: "SUCCESS" as const, + value, + metadata: Object.freeze({ status: 200 }), + effect: "NOT_APPLICABLE" as const, + }); + }; + const execute = implementation as unknown as + InstalledHttpOperationExecutor["execute"]; + const installed = createReferenceFeatureInstalledInput({ - contractOperations: Object.freeze({ - async execute( - _operationId: string, - _input: unknown, - context?: Readonly>, - ) { - seen.push(Object.freeze({ ...(context ?? {}) })); - return Object.freeze({ - kind: "SUCCESS" as const, - value: [], - metadata: Object.freeze({ status: 200 }), - effect: "NOT_APPLICABLE" as const, - }); - }, - }), + http: Object.freeze({ execute }), }); await installed.input.listResources({ limit: 20 }); diff --git a/tests/fixtures/coverage/repository-omission.json b/tests/fixtures/coverage/repository-omission.json index dde669d..ba04a72 100644 --- a/tests/fixtures/coverage/repository-omission.json +++ b/tests/fixtures/coverage/repository-omission.json @@ -35,7 +35,7 @@ "src/application/create-application.ts": { "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, - "src/application/policies/compatibility.ts": { + "src/contracts/compatibility.ts": { "lines": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "statements": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "functions": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 }, "branches": { "total": 1, "covered": 1, "skipped": 0, "pct": 100 } }, "src/application/policies/performance-budgets.ts": { diff --git a/tests/fixtures/typecheck/invalid-direct-feature-composition.ts b/tests/fixtures/typecheck/invalid-direct-feature-composition.ts new file mode 100644 index 0000000..48a5d61 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-direct-feature-composition.ts @@ -0,0 +1,33 @@ +import type { InstalledHttpOperationExecutor } from "../../../src/adapters/http/index.ts"; +import { composeFeatureAdapterInputs } from "../../../src/features/feature-adapter-contribution.ts"; + +declare module "../../../src/application/ports/in/application-api.ts" { + interface ApplicationFeatureInputs { + "direct-compose-probe": Readonly<{ + ping(): "pong"; + }>; + } +} + +const malformedRawContribution = Object.freeze({ + featureId: "direct-compose-probe" as const, + needs: ["http"] as const, + createInput() { + return Object.freeze({ + featureId: "direct-compose-probe" as const, + input: Object.freeze({ + ping() { + return "pong" as const; + }, + }), + }); + }, +}); + +composeFeatureAdapterInputs( + Object.freeze([malformedRawContribution] as const), + Object.freeze(["direct-compose-probe"]), + Object.freeze({ + http: null as unknown as InstalledHttpOperationExecutor, + }), +); diff --git a/tests/fixtures/typecheck/invalid-feature-capability-selection.ts b/tests/fixtures/typecheck/invalid-feature-capability-selection.ts new file mode 100644 index 0000000..eba2be6 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-feature-capability-selection.ts @@ -0,0 +1,13 @@ +import { defineFeatureAdapterContribution } from "../../../src/features/feature-adapter-contribution.ts"; +import { createReferenceFeatureInstalledInput } from "../../../src/features/reference-feature/adapters/create-reference-feature-input.ts"; + +defineFeatureAdapterContribution({ + featureId: "reference-feature", + needs: ["http"] as const, + createInput(context) { + context.indexedDb; + return createReferenceFeatureInstalledInput({ + http: context.http, + }); + }, +}); diff --git a/tests/fixtures/typecheck/invalid-feature-contribution-input.ts b/tests/fixtures/typecheck/invalid-feature-contribution-input.ts new file mode 100644 index 0000000..4046dd1 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-feature-contribution-input.ts @@ -0,0 +1,15 @@ +import { defineFeatureAdapterContribution } from "../../../src/features/feature-adapter-contribution.ts"; +import type {} from "../../../src/features/reference-feature/application/reference-feature-api.ts"; + +defineFeatureAdapterContribution({ + featureId: "reference-feature", + needs: ["http"] as const, + createInput() { + return { + featureId: "reference-feature" as const, + input: { + definitelyNotReferenceFeatureInput: true, + }, + }; + }, +}); diff --git a/tests/fixtures/typecheck/invalid-http-operation-id.ts b/tests/fixtures/typecheck/invalid-http-operation-id.ts new file mode 100644 index 0000000..e41068c --- /dev/null +++ b/tests/fixtures/typecheck/invalid-http-operation-id.ts @@ -0,0 +1,19 @@ +import { + createFeatureHttpBinding, + defineFeatureHttpOperation, + type InstalledHttpOperationExecutor, +} from "../../../src/adapters/http/index.ts"; +import { GET_REFERENCE_RESOURCE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts"; +import { mapReferenceResourcePayload } from "../../../src/features/reference-feature/contracts/reference-mapper.ts"; + +declare const executor: InstalledHttpOperationExecutor; + +const operations = Object.freeze({ + TOTALLY_WRONG_OPERATION_ID: defineFeatureHttpOperation({ + contract: GET_REFERENCE_RESOURCE_CONTRACT, + routeId: "REFERENCE_RESOURCE_DETAIL", + mapSuccess: mapReferenceResourcePayload, + }), +}); + +createFeatureHttpBinding(executor, operations); diff --git a/tests/fixtures/typecheck/invalid-http-operation-input.ts b/tests/fixtures/typecheck/invalid-http-operation-input.ts new file mode 100644 index 0000000..3d58e6b --- /dev/null +++ b/tests/fixtures/typecheck/invalid-http-operation-input.ts @@ -0,0 +1,7 @@ +import type { ReferenceHttpBinding } from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts"; + +declare const http: ReferenceHttpBinding; + +http.execute("GET_REFERENCE_RESOURCE", { + name: "wrong-command-for-get", +}); diff --git a/tests/fixtures/typecheck/invalid-http-route-id.ts b/tests/fixtures/typecheck/invalid-http-route-id.ts new file mode 100644 index 0000000..20a9c96 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-http-route-id.ts @@ -0,0 +1,15 @@ +import { defineFeatureHttpOperationForRoutes } from "../../../src/adapters/http/index.ts"; +import { + type ReferenceFeatureRouteId, +} from "../../../src/features/reference-feature/adapters/reference-http-gateway.ts"; +import { GET_REFERENCE_RESOURCE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts"; +import { mapReferenceResourcePayload } from "../../../src/features/reference-feature/contracts/reference-mapper.ts"; + +const defineReferenceOperation = + defineFeatureHttpOperationForRoutes(); + +defineReferenceOperation({ + contract: GET_REFERENCE_RESOURCE_CONTRACT, + routeId: "TOTALLY_WRONG_ROUTE", + mapSuccess: mapReferenceResourcePayload, +}); diff --git a/tests/fixtures/typecheck/invalid-http-wire-mapper.ts b/tests/fixtures/typecheck/invalid-http-wire-mapper.ts new file mode 100644 index 0000000..6e8b845 --- /dev/null +++ b/tests/fixtures/typecheck/invalid-http-wire-mapper.ts @@ -0,0 +1,13 @@ +import { defineFeatureHttpOperation } from "../../../src/adapters/http/index.ts"; +import { GET_REFERENCE_RESOURCE_CONTRACT } from "../../../src/features/reference-feature/contracts/reference-feature-contract-contribution.ts"; + +defineFeatureHttpOperation({ + contract: GET_REFERENCE_RESOURCE_CONTRACT, + routeId: "REFERENCE_RESOURCE_DETAIL", + mapSuccess(value: Readonly<{ unrelatedWireField: number }>) { + return { + ok: true as const, + value, + }; + }, +}); diff --git a/tests/fixtures/typecheck/tsconfig.application-input.json b/tests/fixtures/typecheck/tsconfig.application-input.json new file mode 100644 index 0000000..481901f --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.application-input.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-application-input.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.application-output.json b/tests/fixtures/typecheck/tsconfig.application-output.json new file mode 100644 index 0000000..14ed9ff --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.application-output.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-application-output.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.async-overlay.json b/tests/fixtures/typecheck/tsconfig.async-overlay.json new file mode 100644 index 0000000..ae1dc4a --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.async-overlay.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-async-overlay.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.base.json b/tests/fixtures/typecheck/tsconfig.base.json new file mode 100644 index 0000000..377483a --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "types": [ + "node", + "vite/client" + ], + "isolatedModules": false + } +} diff --git a/tests/fixtures/typecheck/tsconfig.diagnostics-port.json b/tests/fixtures/typecheck/tsconfig.diagnostics-port.json new file mode 100644 index 0000000..e36df82 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.diagnostics-port.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-diagnostics-port.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.direct-feature-composition.json b/tests/fixtures/typecheck/tsconfig.direct-feature-composition.json new file mode 100644 index 0000000..81e5953 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.direct-feature-composition.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-direct-feature-composition.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.failure-kind.json b/tests/fixtures/typecheck/tsconfig.failure-kind.json new file mode 100644 index 0000000..713e73f --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.failure-kind.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-failure-kind.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.feature-capability-selection.json b/tests/fixtures/typecheck/tsconfig.feature-capability-selection.json new file mode 100644 index 0000000..a2fdc2c --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.feature-capability-selection.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-feature-capability-selection.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.feature-contribution-input.json b/tests/fixtures/typecheck/tsconfig.feature-contribution-input.json new file mode 100644 index 0000000..287af83 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.feature-contribution-input.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-feature-contribution-input.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.feature-input.json b/tests/fixtures/typecheck/tsconfig.feature-input.json new file mode 100644 index 0000000..ce3ac52 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.feature-input.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-feature-input.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.http-operation-id.json b/tests/fixtures/typecheck/tsconfig.http-operation-id.json new file mode 100644 index 0000000..9808303 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.http-operation-id.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-http-operation-id.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.http-operation-input.json b/tests/fixtures/typecheck/tsconfig.http-operation-input.json new file mode 100644 index 0000000..5c1b229 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.http-operation-input.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-http-operation-input.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.http-route-id.json b/tests/fixtures/typecheck/tsconfig.http-route-id.json new file mode 100644 index 0000000..c37d1f7 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.http-route-id.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-http-route-id.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.http-wire-mapper.json b/tests/fixtures/typecheck/tsconfig.http-wire-mapper.json new file mode 100644 index 0000000..b6bc81c --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.http-wire-mapper.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-http-wire-mapper.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.icon-button.json b/tests/fixtures/typecheck/tsconfig.icon-button.json new file mode 100644 index 0000000..3efcca1 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.icon-button.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-icon-button.tsx" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.image-cdn-resolve-signal.json b/tests/fixtures/typecheck/tsconfig.image-cdn-resolve-signal.json new file mode 100644 index 0000000..7fff2d6 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.image-cdn-resolve-signal.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-image-cdn-resolve-signal.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.message-key.json b/tests/fixtures/typecheck/tsconfig.message-key.json new file mode 100644 index 0000000..4e28e22 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.message-key.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-message-key.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.message-params.json b/tests/fixtures/typecheck/tsconfig.message-params.json new file mode 100644 index 0000000..809efc3 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.message-params.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-message-params.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.page-action.json b/tests/fixtures/typecheck/tsconfig.page-action.json new file mode 100644 index 0000000..4a87502 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.page-action.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-page-action.tsx" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.port-call.json b/tests/fixtures/typecheck/tsconfig.port-call.json new file mode 100644 index 0000000..00cd581 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.port-call.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-port-call.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.port-implementation.json b/tests/fixtures/typecheck/tsconfig.port-implementation.json new file mode 100644 index 0000000..5156ee8 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.port-implementation.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-port-implementation.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.reference-operation.json b/tests/fixtures/typecheck/tsconfig.reference-operation.json new file mode 100644 index 0000000..1a26faa --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.reference-operation.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-reference-operation.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.result-narrowing.json b/tests/fixtures/typecheck/tsconfig.result-narrowing.json new file mode 100644 index 0000000..2a61c5a --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.result-narrowing.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-result-narrowing.ts" + ] +} diff --git a/tests/fixtures/typecheck/tsconfig.route-runtime.json b/tests/fixtures/typecheck/tsconfig.route-runtime.json new file mode 100644 index 0000000..0f13ec7 --- /dev/null +++ b/tests/fixtures/typecheck/tsconfig.route-runtime.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.base.json", + "files": [ + "./invalid-route-runtime.ts" + ] +} diff --git a/tests/unit/provider-guardian-transaction.test.ts b/tests/integration/provider-guardian-transaction.test.ts similarity index 95% rename from tests/unit/provider-guardian-transaction.test.ts rename to tests/integration/provider-guardian-transaction.test.ts index 427360f..50543de 100644 --- a/tests/unit/provider-guardian-transaction.test.ts +++ b/tests/integration/provider-guardian-transaction.test.ts @@ -25,6 +25,13 @@ import { afterEach, describe, expect, it } from "vitest"; * longer. Raising the global default instead would hide a genuinely hung test. */ const PROCESS_HEAVY_TIMEOUT_MS = 30_000; +/** + * Test watchdog only. This is not a provider protocol deadline. Child-process + * scheduling can be delayed when the repository suite is under load, so the + * watchdog must not encode a one-second protocol requirement that does not + * exist in production. + */ +const PROCESS_HANDSHAKE_WATCHDOG_MS = 5_000; const roots: string[] = []; @@ -216,7 +223,7 @@ describe("provider guardian transaction protocol", () => { })); const ready = decodeProviderGuardianReady( - await within(readyPayload, 1_000, "guardian READY"), + await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"), nonce, ); const raw = await lstat(rawPath); @@ -231,7 +238,7 @@ describe("provider guardian transaction protocol", () => { .toEqual({ dev: ready.sealedDev, ino: ready.sealedIno, mode: 0o600 }); child.stdin!.end(); - await expect(within(completion, 1_000, "guardian abort")).resolves.toEqual({ + await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian abort")).resolves.toEqual({ code: 125, signal: null, }); @@ -254,7 +261,7 @@ describe("provider guardian transaction protocol", () => { if (input) child.stdin!.end(input); else child.stdin!.end(); - await expect(within(completion, 1_000, `${label} guardian EOF`)).resolves.toEqual({ + await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, `${label} guardian EOF`)).resolves.toEqual({ code: expectedCode, signal: null, }); @@ -289,7 +296,7 @@ describe("provider guardian transaction protocol", () => { await expect(within( waitForChild(fixture.child), - 1_000, + PROCESS_HANDSHAKE_WATCHDOG_MS, `${boundKind} partial bootstrap exit`, )).resolves.toEqual({ code: 126, signal: null }); const boundPath = boundKind === "raw" ? fixture.rawStagingPath : fixture.sealedTempPath; @@ -320,7 +327,7 @@ describe("provider guardian transaction protocol", () => { deadlineEpochMs: Date.now() + 2_000, })); - await expect(within(completion, 1_000, "guardian parent startup death")) + await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian parent startup death")) .resolves.toEqual({ code: 125, signal: null }); await expect(readdir(path.join(workspace, "provider-evidence/untrusted"))).resolves.toEqual([]); await expect(readdir(path.join(workspace, "provider-evidence"))).resolves.toEqual(["untrusted"]); @@ -348,7 +355,7 @@ describe("provider guardian transaction protocol", () => { deadlineEpochMs: Date.now() + 3_000, })); const ready = decodeProviderGuardianReady( - await within(readyPayload, 1_000, "guardian READY"), + await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"), nonce, ); const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf); @@ -379,7 +386,7 @@ describe("provider guardian transaction protocol", () => { sha256: createHash("sha256").update(bytes).digest("hex"), })); decodeProviderGuardianPublished( - await within(publishedPayload, 1_000, "guardian PUBLISHED"), + await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"), nonce, { dev: ready.sealedDev, ino: ready.sealedIno }, ); @@ -393,7 +400,7 @@ describe("provider guardian transaction protocol", () => { await new Promise((resolve) => setTimeout(resolve, 50)); expect(child.exitCode).toBeNull(); child.stdin!.end(); - await expect(within(completion, 1_000, "guardian commit EOF")).resolves.toEqual({ + await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian commit EOF")).resolves.toEqual({ code: 0, signal: null, }); @@ -456,7 +463,7 @@ describe("provider guardian transaction protocol", () => { const transaction = await establishPublishedGuardian(workspace, Buffer.alloc(32, 0x31)); transaction.child.stdin!.end(); - await expect(within(transaction.completion, 1_000, "published guardian EOF")) + await expect(within(transaction.completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "published guardian EOF")) .resolves.toEqual({ code: 125, signal: null }); await assertTransactionAbsent(transaction); @@ -487,7 +494,7 @@ describe("provider guardian transaction protocol", () => { transaction.child.stdin!.write(Buffer.from([0, 0, 0, 1, 0x7b])); transaction.child.stdin!.end(); - await expect(within(transaction.completion, 1_000, "guardian trailing frame")) + await expect(within(transaction.completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian trailing frame")) .resolves.toEqual({ code: 126, signal: null }); await assertTransactionAbsent(transaction); }); @@ -505,7 +512,7 @@ describe("provider guardian transaction protocol", () => { }); process.kill(lease.pid, "SIGKILL"); - await expect(within(lease.prematureExit, 1_000, "guardian hard death")) + await expect(within(lease.prematureExit, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian hard death")) .resolves.toEqual(expect.objectContaining({ message: expect.stringMatching(/SIGKILL/u) })); await lease.abort(); await expect(lstat(lease.rawPath)).rejects.toMatchObject({ code: "ENOENT" }); @@ -536,8 +543,8 @@ describe("provider guardian transaction protocol", () => { }); process.kill(guardianPid, "SIGCONT"); - await within(rawLink, 1_000, "pre-READY canonical raw link"); - await expect(within(starting, 2_000, "pre-READY guardian rejection")) + await within(rawLink, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY canonical raw link"); + await expect(within(starting, PROCESS_HANDSHAKE_WATCHDOG_MS, "pre-READY guardian rejection")) .rejects.toThrow(/provider guardian/u); const possibleTempLeaves = (await readdir(evidenceRoot)).filter((leaf) => leaf.startsWith(".vulnerability-report.json.guardian-") @@ -587,7 +594,7 @@ describe("provider guardian transaction protocol", () => { await canaryHandle.close(); process.kill(guardianPid, "SIGKILL"); - await expect(within(starting, 2_000, "external-canary guardian rejection")) + await expect(within(starting, PROCESS_HANDSHAKE_WATCHDOG_MS, "external-canary guardian rejection")) .rejects.toThrow(/provider guardian/u); expect(await readFile(rawPath)).toEqual(canaryBytes); expect(await lstat(rawPath)).toMatchObject({ @@ -664,14 +671,14 @@ describe("provider guardian transaction protocol", () => { deadlineEpochMs: Date.now() + 3_000, })); const ready = decodeProviderGuardianReady( - await within(readyPayload, 1_000, "closed-stderr guardian READY"), + await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "closed-stderr guardian READY"), nonce, ); child.stderr!.destroy(); await new Promise((resolve) => child.stderr!.once("close", resolve)); child.stdin!.end(); - await expect(within(completion, 1_000, "closed-stderr guardian exit")) + await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "closed-stderr guardian exit")) .resolves.toEqual({ code: 125, signal: null }); await expect(lstat(path.join(rawDirectory, "vulnerability-report.json"))) .rejects.toMatchObject({ code: "ENOENT" }); @@ -697,12 +704,12 @@ describe("provider guardian transaction protocol", () => { deadlineEpochMs: Date.now() + 350, })); const ready = decodeProviderGuardianReady( - await within(readyPayload, 1_000, "deadline guardian READY"), + await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "deadline guardian READY"), nonce, ); const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf); - await expect(within(completion, 1_000, "guardian deadline")) + await expect(within(completion, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian deadline")) .resolves.toEqual({ code: null, signal: "SIGKILL" }); await expect(lstat(rawPath)).rejects.toMatchObject({ code: "ENOENT" }); await expect(lstat(sealedTempPath)).rejects.toMatchObject({ code: "ENOENT" }); @@ -874,7 +881,7 @@ async function establishPublishedGuardian( deadlineEpochMs: Date.now() + 3_000, })); const ready = decodeProviderGuardianReady( - await within(readyPayload, 1_000, "guardian READY"), + await within(readyPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian READY"), nonce, ); const sealedTempPath = path.join(evidenceRoot, ready.sealedTempLeaf); @@ -896,7 +903,7 @@ async function establishPublishedGuardian( sha256: createHash("sha256").update(bytes).digest("hex"), })); decodeProviderGuardianPublished( - await within(publishedPayload, 1_000, "guardian PUBLISHED"), + await within(publishedPayload, PROCESS_HANDSHAKE_WATCHDOG_MS, "guardian PUBLISHED"), nonce, { dev: ready.sealedDev, ino: ready.sealedIno }, ); diff --git a/tests/unit/bounded-body-reader.test.ts b/tests/unit/bounded-body-reader.test.ts index 221dd34..dce6ae9 100644 --- a/tests/unit/bounded-body-reader.test.ts +++ b/tests/unit/bounded-body-reader.test.ts @@ -200,6 +200,53 @@ describe("bounded body reader", () => { }); }); + + it("abandons a pending bounded read when the operation is aborted", async () => { + const controller = new AbortController(); + const reader = { + read: vi.fn(() => new Promise(() => {})), + cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")), + releaseLock: vi.fn(), + }; + const response = { + headers: new Headers(), + body: { getReader: () => reader }, + } as unknown as Response; + + const pending = readBoundedBytes(response, 8, controller.signal); + controller.abort(); + + await expect(pending).resolves.toEqual({ + ok: false, + code: "RESPONSE_STREAM_FAILURE", + }); + expect(reader.cancel).toHaveBeenCalledOnce(); + expect(reader.releaseLock).toHaveBeenCalledOnce(); + }); + + it("abandons a pending forbidden-body probe when the operation is aborted", async () => { + const controller = new AbortController(); + const reader = { + read: vi.fn(() => new Promise(() => {})), + cancel: vi.fn().mockRejectedValue(new Error("cancel ignored")), + releaseLock: vi.fn(), + }; + const response = { + headers: new Headers(), + body: { getReader: () => reader }, + } as unknown as Response; + + const pending = probeForbiddenBody(response, controller.signal); + controller.abort(); + + await expect(pending).resolves.toEqual({ + ok: false, + code: "RESPONSE_STREAM_FAILURE", + }); + expect(reader.cancel).toHaveBeenCalledOnce(); + expect(reader.releaseLock).toHaveBeenCalledOnce(); + }); + it("decodes valid JSON and distinguishes UTF-8 from JSON failures", () => { expect(decodeJsonBytes(new TextEncoder().encode('{"ok":true}'))).toEqual({ ok: true, diff --git a/tests/unit/browser-file-picker.test.ts b/tests/unit/browser-file-picker.test.ts index 0f7129d..8f20399 100644 --- a/tests/unit/browser-file-picker.test.ts +++ b/tests/unit/browser-file-picker.test.ts @@ -73,12 +73,17 @@ function labelledFileInput(): HTMLInputElement { describe("browser file pickers", () => { it("treats native input cancellation as a normal dismissed outcome", async () => { const input = labelledFileInput(); - Object.defineProperty(input, "showPicker", { + Object.defineProperty(input, "click", { configurable: true, value: vi.fn(() => { queueMicrotask(() => input.dispatchEvent(new Event("cancel"))); }), }); + const baselineShowPicker = vi.fn(); + Object.defineProperty(input, "showPicker", { + configurable: true, + value: baselineShowPicker, + }); const harness = createHarness(); const picker = new NativeInputFilePicker({ input, @@ -90,6 +95,7 @@ describe("browser file pickers", () => { ok: true, value: { kind: "DISMISSED" }, }); + expect(baselineShowPicker).not.toHaveBeenCalled(); }); it("resets the native input and supports same-file reselection", async () => { @@ -102,12 +108,12 @@ describe("browser file pickers", () => { configurable: true, value: [selected], }); - const showPicker = vi.fn(() => { + const click = vi.fn(() => { queueMicrotask(() => input.dispatchEvent(new Event("change"))); }); - Object.defineProperty(input, "showPicker", { + Object.defineProperty(input, "click", { configurable: true, - value: showPicker, + value: click, }); let sequence = 0; const { policies, vault } = createHarness(policyDefinition, { @@ -130,14 +136,14 @@ describe("browser file pickers", () => { ok: true, value: { kind: "SELECTED" }, }); - expect(showPicker).toHaveBeenCalledTimes(2); + expect(click).toHaveBeenCalledTimes(2); expect(input.value).toBe(""); expect(vault.activeReferenceCount).toBe(2); }); it("snapshots selection policy before awaiting picker events", async () => { const input = labelledFileInput(); - Object.defineProperty(input, "showPicker", { + Object.defineProperty(input, "click", { configurable: true, value: vi.fn(), }); @@ -186,7 +192,7 @@ describe("browser file pickers", () => { it("honors AbortSignal while a native dialog is pending", async () => { const input = labelledFileInput(); - Object.defineProperty(input, "showPicker", { + Object.defineProperty(input, "click", { configurable: true, value: vi.fn(), }); @@ -215,7 +221,7 @@ describe("browser file pickers", () => { let fallback: (() => void) | undefined; let delay: number | undefined; const clearTimeout = vi.fn(); - Object.defineProperty(input, "showPicker", { + Object.defineProperty(input, "click", { configurable: true, value: vi.fn(() => { window.dispatchEvent(new Event("focus")); @@ -261,7 +267,7 @@ describe("browser file pickers", () => { const input = labelledFileInput(); let fallback: (() => void) | undefined; const clearTimeout = vi.fn(); - Object.defineProperty(input, "showPicker", { + Object.defineProperty(input, "click", { configurable: true, value: vi.fn(() => { window.dispatchEvent(new Event("focus")); @@ -316,14 +322,14 @@ describe("browser file pickers", () => { setTimeout: originalSetTimeout, clearTimeout: originalClearTimeout, }; - const originalShowPicker = vi.fn(() => { + const originalClick = vi.fn(() => { focus?.(new Event("focus")); }); - const replacedShowPicker = vi.fn(); - Object.defineProperty(input, "showPicker", { + const replacedClick = vi.fn(); + Object.defineProperty(input, "click", { configurable: true, writable: true, - value: originalShowPicker, + value: originalClick, }); const harness = createHarness(); const picker = new NativeInputFilePicker({ @@ -338,7 +344,7 @@ describe("browser file pickers", () => { focusFallbackGraceMs: 0, }); - input.showPicker = replacedShowPicker; + input.click = replacedClick; windowHost.addEventListener = vi.fn(); windowHost.removeEventListener = vi.fn(); scheduler.setTimeout = vi.fn(); @@ -348,8 +354,8 @@ describe("browser file pickers", () => { ok: true, value: { kind: "DISMISSED" }, }); - expect(originalShowPicker).toHaveBeenCalledOnce(); - expect(replacedShowPicker).not.toHaveBeenCalled(); + expect(originalClick).toHaveBeenCalledOnce(); + expect(replacedClick).not.toHaveBeenCalled(); expect(originalWindowAdd).toHaveBeenCalledOnce(); expect(originalWindowRemove).toHaveBeenCalledOnce(); expect(originalSetTimeout).toHaveBeenCalledOnce(); diff --git a/tests/unit/browser-file-runtime.test.ts b/tests/unit/browser-file-runtime.test.ts index 8d7c878..62b71ab 100644 --- a/tests/unit/browser-file-runtime.test.ts +++ b/tests/unit/browser-file-runtime.test.ts @@ -178,7 +178,7 @@ describe("browser file runtime hard limits and disposal", () => { it("aborts a pending native picker and prevents event resurrection", async () => { const input = fileInput(); - Object.defineProperty(input, "showPicker", { + Object.defineProperty(input, "click", { configurable: true, value: vi.fn(), }); diff --git a/tests/unit/ci-workflow-generation.test.ts b/tests/unit/ci-workflow-generation.test.ts index 95833ae..7143e00 100644 --- a/tests/unit/ci-workflow-generation.test.ts +++ b/tests/unit/ci-workflow-generation.test.ts @@ -189,9 +189,9 @@ describe("CI gate contract", () => { ), ); expect(contract.jobs).toHaveLength(9); - expect(contract.commands).toHaveLength(84); - expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(96); - expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(23); + expect(contract.commands).toHaveLength(91); + expect(contract.gates.reduce((total, gate) => total + gate.commandIds.length, 0)).toBe(103); + expect(contract.commands.filter(({ expect }) => expect === "fail")).toHaveLength(30); expect(contract.gates.reduce((total, gate) => total + gate.evidenceArtifactIds.length, 0)).toBe(88); expect(contract.artifacts).toHaveLength(109); expect(contract.stages).toHaveLength(5); diff --git a/tests/unit/product-features.test.ts b/tests/unit/product-features.test.ts index b38722d..422fd22 100644 --- a/tests/unit/product-features.test.ts +++ b/tests/unit/product-features.test.ts @@ -16,7 +16,7 @@ import { } from "../../src/features/installed-feature-contracts.ts"; const COMPILED = Object.freeze([ - Object.freeze({ featureId: "reference-feature" }), + Object.freeze({ featureId: "alpha-feature" }), Object.freeze({ featureId: "billing" }), ]); @@ -26,7 +26,7 @@ describe("build-time product selection", () => { expect( selectCompiledProductFeatures(COMPILED, declared).map((f) => f.featureId), String(declared), - ).toEqual(["reference-feature", "billing"]); + ).toEqual(["alpha-feature", "billing"]); } }); @@ -35,10 +35,10 @@ describe("build-time product selection", () => { selectCompiledProductFeatures(COMPILED, "billing").map((f) => f.featureId), ).toEqual(["billing"]); expect( - selectCompiledProductFeatures(COMPILED, " billing , reference-feature ").map( + selectCompiledProductFeatures(COMPILED, " billing , alpha-feature ").map( (f) => f.featureId, ), - ).toEqual(["reference-feature", "billing"]); + ).toEqual(["alpha-feature", "billing"]); }); it("selects nothing only when asked explicitly", () => { @@ -79,13 +79,13 @@ describe("build-time product selection", () => { describe("runtime product feature resolution", () => { it("reports active, disabled and not-installed distinctly", () => { const statuses = resolveProductFeatures( - ["reference-feature", "billing"], - ["reference-feature"], - { "reference-feature": "DISABLED" }, + ["alpha-feature", "billing"], + ["alpha-feature"], + { "alpha-feature": "DISABLED" }, ); expect(statuses).toEqual([ + { featureId: "alpha-feature", state: "DISABLED_BY_CONFIG" }, { featureId: "billing", state: "NOT_INSTALLED" }, - { featureId: "reference-feature", state: "DISABLED_BY_CONFIG" }, ]); expect(activeProductFeatureIds(statuses)).toEqual([]); }); @@ -103,11 +103,11 @@ describe("runtime product feature resolution", () => { // A shared runtime document may cover several builds, so a stale key is // inert rather than fatal. const statuses = resolveProductFeatures( - ["reference-feature"], - ["reference-feature"], + ["alpha-feature"], + ["alpha-feature"], { analytics: "DISABLED" }, ); - expect(activeProductFeatureIds(statuses)).toEqual(["reference-feature"]); + expect(activeProductFeatureIds(statuses)).toEqual(["alpha-feature"]); }); it("leaves an installed feature active without an override", () => { @@ -140,15 +140,15 @@ describe("runtime config carries the switch", () => { expect( runtimeConfigV2ArtifactSchema.parse({ ...base, - FEATURE_OVERRIDES: { "reference-feature": "DISABLED" }, + FEATURE_OVERRIDES: { "alpha-feature": "DISABLED" }, }).FEATURE_OVERRIDES, - ).toEqual({ "reference-feature": "DISABLED" }); + ).toEqual({ "alpha-feature": "DISABLED" }); // There is no "ENABLED": the vocabulary itself is what makes the rule // unbreakable, not a check somewhere downstream. expect( runtimeConfigV2ArtifactSchema.safeParse({ ...base, - FEATURE_OVERRIDES: { "reference-feature": "ENABLED" }, + FEATURE_OVERRIDES: { "alpha-feature": "ENABLED" }, }).success, ).toBe(false); }); @@ -212,11 +212,13 @@ describe("route ownership", () => { } }); - it("owns exactly the routes the registry received from features", () => { + it("owns exactly routes that exist in the composed registry", () => { const owned = Object.keys(ROUTE_FEATURE_OWNER); - expect(owned.length).toBeGreaterThan(0); for (const routeId of owned) { expect(Object.keys(ROUTE_REGISTRY), routeId).toContain(routeId); } + expect( + owned.every((routeId) => ROUTE_FEATURE_OWNER[routeId] !== undefined), + ).toBe(true); }); }); diff --git a/tests/unit/public-response-cache-fixture.ts b/tests/unit/public-response-cache-fixture.ts new file mode 100644 index 0000000..313f025 --- /dev/null +++ b/tests/unit/public-response-cache-fixture.ts @@ -0,0 +1,137 @@ +import type { + PublicCacheAsset, + PublicCacheReleaseManifest, +} from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts"; +import { + createDefaultPublicCachePolicy, + type PublicCacheRuntimePolicy, +} from "../../src/adapters/cache-storage/public-cache-policy.ts"; +import { + computePublicCacheManifestDigestHex, + type PublicCacheMutationLock, +} from "../../src/adapters/cache-storage/public-response-cache-adapter.ts"; + +export class MemoryCache { + readonly responses: Array> = []; + + async match(request: RequestInfo | URL): Promise { + const url = + request instanceof Request ? request.url : new URL(String(request)).href; + const nativeRequest = + request instanceof Request ? request : new Request(url); + return this.responses + .find( + (entry) => + entry.request.url === url && + varyMatches(entry.request, nativeRequest, entry.response), + ) + ?.response.clone(); + } + + async put(request: RequestInfo | URL, response: Response): Promise { + const url = + request instanceof Request ? request.url : new URL(String(request)).href; + const nativeRequest = + request instanceof Request ? request.clone() : new Request(url); + const existing = this.responses.findIndex( + (entry) => + entry.request.url === url && + varyMatches(entry.request, nativeRequest, response), + ); + const entry = { + request: nativeRequest, + response: response.clone(), + }; + if (existing >= 0) this.responses.splice(existing, 1, entry); + else this.responses.push(entry); + } +} + +function varyMatches( + storedRequest: Request, + incomingRequest: Request, + response: Response, +): boolean { + const vary = response.headers.get("vary"); + if (!vary) return true; + return vary + .split(",") + .map((name) => name.trim().toLowerCase()) + .every( + (name) => + storedRequest.headers.get(name) === + incomingRequest.headers.get(name), + ); +} + +export class MemoryCacheStorage { + readonly caches = new Map(); + + async open(name: string): Promise { + let cache = this.caches.get(name); + if (!cache) { + cache = new MemoryCache(); + this.caches.set(name, cache); + } + return cache as unknown as Cache; + } + + async keys(): Promise { + return [...this.caches.keys()]; + } + + async delete(name: string): Promise { + return this.caches.delete(name); + } +} + +export const immediateLock: PublicCacheMutationLock = { + async run(_signal, task) { + return await task(); + }, +}; + +export function deferred() { + let settle: ((value: Value) => void) | undefined; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return Object.freeze({ + promise, + resolve(value: Value): void { + settle?.(value); + }, + }); +} + +export async function digestHex(bytes: Uint8Array): Promise { + const digest = await globalThis.crypto.subtle.digest( + "SHA-256", + Uint8Array.from(bytes), + ); + return [...new Uint8Array(digest)] + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); +} + +export async function manifestFor( + releaseRegistryId: string, + assets: readonly PublicCacheAsset[], + policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy( + "https://assets.example.test", + ), +): Promise { + return { + releaseRegistryId, + assets, + manifestDigestHex: await computePublicCacheManifestDigestHex( + globalThis.crypto, + releaseRegistryId, + assets, + policy, + ), + }; +} diff --git a/tests/unit/public-response-cache-repair.test.ts b/tests/unit/public-response-cache-repair.test.ts new file mode 100644 index 0000000..1ad2757 --- /dev/null +++ b/tests/unit/public-response-cache-repair.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; + +import type { PublicCacheAsset } from "../../src/application/ports/browser-file-storage/cache-storage-ports.ts"; +import { createDefaultPublicCachePolicy } from "../../src/adapters/cache-storage/public-cache-policy.ts"; +import { createPublicResponseCacheAdapter } from "../../src/adapters/cache-storage/public-response-cache-adapter.ts"; +import { + MemoryCacheStorage, + digestHex, + immediateLock, + manifestFor, +} from "./public-response-cache-fixture.ts"; + +/** + * STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is + * the last thing a repair may destroy. A transient marker read failure is not + * evidence of damage, and a repair that has not yet fetched anything has not + * yet earned the right to delete what still works. + */ +describe("public response cache repair is failure-atomic", () => { + async function stagedRelease(releaseRegistryId: string) { + const policy = createDefaultPublicCachePolicy( + "https://assets.example.test", + ); + const firstBytes = new Uint8Array([1, 1, 1, 1]); + const secondBytes = new Uint8Array([2, 2, 2, 2]); + const assets: readonly PublicCacheAsset[] = [ + { + absoluteUrl: "https://assets.example.test/first.js", + expectedByteLength: firstBytes.byteLength, + expectedContentType: "application/javascript", + integrity: { + algorithm: "SHA-256", + digestHex: await digestHex(firstBytes), + }, + }, + { + absoluteUrl: "https://assets.example.test/second.js", + expectedByteLength: secondBytes.byteLength, + expectedContentType: "application/javascript", + integrity: { + algorithm: "SHA-256", + digestHex: await digestHex(secondBytes), + }, + }, + ]; + const bodies = new Map([ + [assets[0]!.absoluteUrl, firstBytes], + [assets[1]!.absoluteUrl, secondBytes], + ]); + const cacheStorage = new MemoryCacheStorage(); + const fetchLog: string[] = []; + let failFrom: string | null = null; + const adapter = createPublicResponseCacheAdapter({ + cacheStorage: cacheStorage as unknown as CacheStorage, + crypto: globalThis.crypto, + mutationLock: immediateLock, + policy, + fetcher: async (request: Request) => { + fetchLog.push(request.url); + if (failFrom !== null && request.url === failFrom) { + throw new TypeError("network is down"); + } + const body = bodies.get(request.url); + if (!body) throw new TypeError(`unknown asset ${request.url}`); + return new Response(Uint8Array.from(body), { + headers: { + "cache-control": "public", + "content-type": "application/javascript", + }, + }); + }, + }); + const manifest = await manifestFor(releaseRegistryId, assets, policy); + expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ + ok: true, + }); + expect( + await adapter.admin.activateRelease( + manifest.releaseRegistryId, + manifest.manifestDigestHex, + ), + ).toMatchObject({ ok: true }); + const cacheName = [...cacheStorage.caches.keys()].find((name) => + name.includes(releaseRegistryId), + ); + if (!cacheName) throw new Error("staged cache missing"); + + return { + adapter, + assets, + cacheName, + cacheStorage, + fetchLog, + manifest, + setFailure(url: string | null) { + failFrom = url; + }, + }; + } + + it("does not delete an active candidate when the marker read fails transiently", async () => { + const release = await stagedRelease("transient-marker"); + const cache = release.cacheStorage.caches.get(release.cacheName)!; + const realMatch = cache.match.bind(cache); + let markerReads = 0; + const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl)); + cache.match = async (request: RequestInfo | URL) => { + const url = + request instanceof Request ? request.url : String(request); + if (!assetUrls.has(url)) { + markerReads += 1; + throw new DOMException("Storage is busy", "InvalidStateError"); + } + return await realMatch(request); + }; + + const restaged = await release.adapter.admin.stageRelease(release.manifest); + + expect(markerReads).toBeGreaterThan(0); + expect(restaged.ok).toBe(false); + expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true); + cache.match = realMatch; + expect( + await release.adapter.responses.matchActiveExact({ + absoluteUrl: release.assets[0]!.absoluteUrl, + }), + ).toMatchObject({ ok: true }); + }); + + it("keeps every healthy asset when one repair fetch fails", async () => { + const release = await stagedRelease("partial-repair"); + const cache = release.cacheStorage.caches.get(release.cacheName)!; + // Corrupt only the first asset's stored bytes. + const corrupted = cache.responses.findIndex( + (entry) => entry.request.url === release.assets[0]!.absoluteUrl, + ); + expect(corrupted).toBeGreaterThanOrEqual(0); + cache.responses.splice(corrupted, 1); + + release.setFailure(release.assets[0]!.absoluteUrl); + const restaged = await release.adapter.admin.stageRelease(release.manifest); + expect(restaged.ok).toBe(false); + + // The cache still exists and the healthy asset is still served. + expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true); + expect( + await release.adapter.responses.matchActiveExact({ + absoluteUrl: release.assets[1]!.absoluteUrl, + }), + ).toMatchObject({ ok: true }); + }); + + it("still removes a candidate this call created when staging fails", async () => { + const policy = createDefaultPublicCachePolicy( + "https://assets.example.test", + ); + const bytes = new Uint8Array([7, 7, 7, 7]); + const asset: PublicCacheAsset = { + absoluteUrl: "https://assets.example.test/fresh.js", + expectedByteLength: bytes.byteLength, + expectedContentType: "application/javascript", + integrity: { + algorithm: "SHA-256", + digestHex: await digestHex(bytes), + }, + }; + const cacheStorage = new MemoryCacheStorage(); + const adapter = createPublicResponseCacheAdapter({ + cacheStorage: cacheStorage as unknown as CacheStorage, + crypto: globalThis.crypto, + mutationLock: immediateLock, + policy, + fetcher: async () => { + throw new TypeError("network is down"); + }, + }); + const manifest = await manifestFor("fresh-release", [asset], policy); + + expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ + ok: false, + }); + expect(await cacheStorage.keys()).toEqual([]); + }); + + it("repairs an evicted asset in place and keeps the release usable", async () => { + const release = await stagedRelease("in-place-repair"); + const cache = release.cacheStorage.caches.get(release.cacheName)!; + const evicted = cache.responses.findIndex( + (entry) => entry.request.url === release.assets[1]!.absoluteUrl, + ); + cache.responses.splice(evicted, 1); + + expect( + await release.adapter.admin.stageRelease(release.manifest), + ).toMatchObject({ ok: true }); + expect( + await release.adapter.responses.matchActiveExact({ + absoluteUrl: release.assets[1]!.absoluteUrl, + }), + ).toMatchObject({ ok: true }); + expect( + await release.adapter.responses.matchActiveExact({ + absoluteUrl: release.assets[0]!.absoluteUrl, + }), + ).toMatchObject({ ok: true }); + }); +}); diff --git a/tests/unit/public-response-cache.test.ts b/tests/unit/public-response-cache.test.ts index 5f97fd1..d6e9456 100644 --- a/tests/unit/public-response-cache.test.ts +++ b/tests/unit/public-response-cache.test.ts @@ -10,135 +10,17 @@ import { type PublicCacheRuntimePolicy, } from "../../src/adapters/cache-storage/public-cache-policy.ts"; import { - computePublicCacheManifestDigestHex, createPublicResponseCacheAdapter, type PublicCacheMutationLock, } from "../../src/adapters/cache-storage/public-response-cache-adapter.ts"; -class MemoryCache { - readonly responses: Array> = []; - - async match(request: RequestInfo | URL): Promise { - const url = - request instanceof Request ? request.url : new URL(String(request)).href; - const nativeRequest = - request instanceof Request ? request : new Request(url); - return this.responses - .find( - (entry) => - entry.request.url === url && - varyMatches(entry.request, nativeRequest, entry.response), - ) - ?.response.clone(); - } - - async put(request: RequestInfo | URL, response: Response): Promise { - const url = - request instanceof Request ? request.url : new URL(String(request)).href; - const nativeRequest = - request instanceof Request ? request.clone() : new Request(url); - const existing = this.responses.findIndex( - (entry) => - entry.request.url === url && - varyMatches(entry.request, nativeRequest, response), - ); - const entry = { - request: nativeRequest, - response: response.clone(), - }; - if (existing >= 0) this.responses.splice(existing, 1, entry); - else this.responses.push(entry); - } -} - -function varyMatches( - storedRequest: Request, - incomingRequest: Request, - response: Response, -): boolean { - const vary = response.headers.get("vary"); - if (!vary) return true; - return vary - .split(",") - .map((name) => name.trim().toLowerCase()) - .every( - (name) => - storedRequest.headers.get(name) === - incomingRequest.headers.get(name), - ); -} - -class MemoryCacheStorage { - readonly caches = new Map(); - - async open(name: string): Promise { - let cache = this.caches.get(name); - if (!cache) { - cache = new MemoryCache(); - this.caches.set(name, cache); - } - return cache as unknown as Cache; - } - - async keys(): Promise { - return [...this.caches.keys()]; - } - - async delete(name: string): Promise { - return this.caches.delete(name); - } -} - -const immediateLock: PublicCacheMutationLock = { - async run(_signal, task) { - return await task(); - }, -}; - -function deferred() { - let settle: ((value: Value) => void) | undefined; - const promise = new Promise((resolve) => { - settle = resolve; - }); - return Object.freeze({ - promise, - resolve(value: Value): void { - settle?.(value); - }, - }); -} - -async function digestHex(bytes: Uint8Array): Promise { - const digest = await globalThis.crypto.subtle.digest( - "SHA-256", - Uint8Array.from(bytes), - ); - return [...new Uint8Array(digest)] - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); -} - -async function manifestFor( - releaseRegistryId: string, - assets: readonly PublicCacheAsset[], - policy: PublicCacheRuntimePolicy = createDefaultPublicCachePolicy( - "https://assets.example.test", - ), -): Promise { - return { - releaseRegistryId, - assets, - manifestDigestHex: await computePublicCacheManifestDigestHex( - globalThis.crypto, - releaseRegistryId, - assets, - policy, - ), - }; -} +import { + MemoryCacheStorage, + deferred, + digestHex, + immediateLock, + manifestFor, +} from "./public-response-cache-fixture.ts"; describe("public response Cache Storage adapter", () => { it("rejects a policy that enables variants but strips Vary", () => { @@ -1513,199 +1395,3 @@ describe("public response Cache Storage adapter", () => { expect(await cacheStorage.keys()).toEqual([]); }); }); - -/** - * STO-RR-04 / STO-RR-05. A release cache that is currently serving traffic is - * the last thing a repair may destroy. A transient marker read failure is not - * evidence of damage, and a repair that has not yet fetched anything has not - * yet earned the right to delete what still works. - */ -describe("public response cache repair is failure-atomic", () => { - async function stagedRelease(releaseRegistryId: string) { - const policy = createDefaultPublicCachePolicy( - "https://assets.example.test", - ); - const firstBytes = new Uint8Array([1, 1, 1, 1]); - const secondBytes = new Uint8Array([2, 2, 2, 2]); - const assets: readonly PublicCacheAsset[] = [ - { - absoluteUrl: "https://assets.example.test/first.js", - expectedByteLength: firstBytes.byteLength, - expectedContentType: "application/javascript", - integrity: { - algorithm: "SHA-256", - digestHex: await digestHex(firstBytes), - }, - }, - { - absoluteUrl: "https://assets.example.test/second.js", - expectedByteLength: secondBytes.byteLength, - expectedContentType: "application/javascript", - integrity: { - algorithm: "SHA-256", - digestHex: await digestHex(secondBytes), - }, - }, - ]; - const bodies = new Map([ - [assets[0]!.absoluteUrl, firstBytes], - [assets[1]!.absoluteUrl, secondBytes], - ]); - const cacheStorage = new MemoryCacheStorage(); - const fetchLog: string[] = []; - let failFrom: string | null = null; - const adapter = createPublicResponseCacheAdapter({ - cacheStorage: cacheStorage as unknown as CacheStorage, - crypto: globalThis.crypto, - mutationLock: immediateLock, - policy, - fetcher: async (request: Request) => { - fetchLog.push(request.url); - if (failFrom !== null && request.url === failFrom) { - throw new TypeError("network is down"); - } - const body = bodies.get(request.url); - if (!body) throw new TypeError(`unknown asset ${request.url}`); - return new Response(Uint8Array.from(body), { - headers: { - "cache-control": "public", - "content-type": "application/javascript", - }, - }); - }, - }); - const manifest = await manifestFor(releaseRegistryId, assets, policy); - expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ - ok: true, - }); - expect( - await adapter.admin.activateRelease( - manifest.releaseRegistryId, - manifest.manifestDigestHex, - ), - ).toMatchObject({ ok: true }); - const cacheName = [...cacheStorage.caches.keys()].find((name) => - name.includes(releaseRegistryId), - ); - if (!cacheName) throw new Error("staged cache missing"); - - return { - adapter, - assets, - cacheName, - cacheStorage, - fetchLog, - manifest, - setFailure(url: string | null) { - failFrom = url; - }, - }; - } - - it("does not delete an active candidate when the marker read fails transiently", async () => { - const release = await stagedRelease("transient-marker"); - const cache = release.cacheStorage.caches.get(release.cacheName)!; - const realMatch = cache.match.bind(cache); - let markerReads = 0; - const assetUrls = new Set(release.assets.map((asset) => asset.absoluteUrl)); - cache.match = async (request: RequestInfo | URL) => { - const url = - request instanceof Request ? request.url : String(request); - if (!assetUrls.has(url)) { - markerReads += 1; - throw new DOMException("Storage is busy", "InvalidStateError"); - } - return await realMatch(request); - }; - - const restaged = await release.adapter.admin.stageRelease(release.manifest); - - expect(markerReads).toBeGreaterThan(0); - expect(restaged.ok).toBe(false); - expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true); - cache.match = realMatch; - expect( - await release.adapter.responses.matchActiveExact({ - absoluteUrl: release.assets[0]!.absoluteUrl, - }), - ).toMatchObject({ ok: true }); - }); - - it("keeps every healthy asset when one repair fetch fails", async () => { - const release = await stagedRelease("partial-repair"); - const cache = release.cacheStorage.caches.get(release.cacheName)!; - // Corrupt only the first asset's stored bytes. - const corrupted = cache.responses.findIndex( - (entry) => entry.request.url === release.assets[0]!.absoluteUrl, - ); - expect(corrupted).toBeGreaterThanOrEqual(0); - cache.responses.splice(corrupted, 1); - - release.setFailure(release.assets[0]!.absoluteUrl); - const restaged = await release.adapter.admin.stageRelease(release.manifest); - expect(restaged.ok).toBe(false); - - // The cache still exists and the healthy asset is still served. - expect(release.cacheStorage.caches.has(release.cacheName)).toBe(true); - expect( - await release.adapter.responses.matchActiveExact({ - absoluteUrl: release.assets[1]!.absoluteUrl, - }), - ).toMatchObject({ ok: true }); - }); - - it("still removes a candidate this call created when staging fails", async () => { - const policy = createDefaultPublicCachePolicy( - "https://assets.example.test", - ); - const bytes = new Uint8Array([7, 7, 7, 7]); - const asset: PublicCacheAsset = { - absoluteUrl: "https://assets.example.test/fresh.js", - expectedByteLength: bytes.byteLength, - expectedContentType: "application/javascript", - integrity: { - algorithm: "SHA-256", - digestHex: await digestHex(bytes), - }, - }; - const cacheStorage = new MemoryCacheStorage(); - const adapter = createPublicResponseCacheAdapter({ - cacheStorage: cacheStorage as unknown as CacheStorage, - crypto: globalThis.crypto, - mutationLock: immediateLock, - policy, - fetcher: async () => { - throw new TypeError("network is down"); - }, - }); - const manifest = await manifestFor("fresh-release", [asset], policy); - - expect(await adapter.admin.stageRelease(manifest)).toMatchObject({ - ok: false, - }); - expect(await cacheStorage.keys()).toEqual([]); - }); - - it("repairs an evicted asset in place and keeps the release usable", async () => { - const release = await stagedRelease("in-place-repair"); - const cache = release.cacheStorage.caches.get(release.cacheName)!; - const evicted = cache.responses.findIndex( - (entry) => entry.request.url === release.assets[1]!.absoluteUrl, - ); - cache.responses.splice(evicted, 1); - - expect( - await release.adapter.admin.stageRelease(release.manifest), - ).toMatchObject({ ok: true }); - expect( - await release.adapter.responses.matchActiveExact({ - absoluteUrl: release.assets[1]!.absoluteUrl, - }), - ).toMatchObject({ ok: true }); - expect( - await release.adapter.responses.matchActiveExact({ - absoluteUrl: release.assets[0]!.absoluteUrl, - }), - ).toMatchObject({ ok: true }); - }); -}); diff --git a/tests/unit/resumable-upload-runtime-fixture.ts b/tests/unit/resumable-upload-runtime-fixture.ts new file mode 100644 index 0000000..79e84b7 --- /dev/null +++ b/tests/unit/resumable-upload-runtime-fixture.ts @@ -0,0 +1,341 @@ +import { vi } from "vitest"; + +import type { + ResumableUploadCheckpoint, + ResumableUploadCheckpointStore, + ResumableUploadControlPlane, + ResumableUploadSource, + UploadPartExecutor, + UploadPartReceipt, + UploadProviderResult, + UploadSession, +} from "../../src/application/ports/browser-transfer/resumable-upload.ts"; +import { RESUMABLE_UPLOAD_PROTOCOL } from "../../src/application/ports/browser-transfer/resumable-upload.ts"; +import type { + BrowserDataResult, +} from "../../src/application/ports/browser-file-storage/shared.ts"; +import { + browserDataFailure, + browserDataSuccess, +} from "../../src/adapters/browser-file-storage/result.ts"; +import { resolveResumableUploadRuntimePolicy } from "../../src/adapters/browser-transfer/resumable-upload/runtime-policy.ts"; +import type { + UploadCancellationChannel, + UploadCancellationListener, +} from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts"; +import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts"; + +export type TestCapability = Readonly<{ id: string }>; + +export const activeSignal = new AbortController().signal; +export const noContentionLock: UploadMutationLock = Object.freeze({ + async run( + _uploadKey: string, + _signal: AbortSignal, + task: () => Promise, + ): Promise { + return await task(); + }, +}); + +export function createSerialMutationLock(): UploadMutationLock { + let tail = Promise.resolve(); + return Object.freeze({ + run( + _uploadKey: string, + signal: AbortSignal, + task: () => Promise, + ): Promise { + const result = tail.then(async () => { + if (signal.aborted) { + throw new DOMException( + "The operation was aborted.", + "AbortError", + ); + } + return await task(); + }); + tail = result.then( + () => undefined, + () => undefined, + ); + return result; + }, + }); +} + +export function createMemoryCancellationPair(): readonly [ + UploadCancellationChannel, + UploadCancellationChannel, +] { + const listeners = [ + new Set(), + new Set(), + ] as const; + const channels = listeners.map((ownListeners, ownIndex) => { + let closed = false; + return Object.freeze({ + publish(uploadKey: string) { + if (closed) return false; + for (const [index, peerListeners] of listeners.entries()) { + if (index === ownIndex) continue; + for (const listener of [...peerListeners]) { + listener(uploadKey); + } + } + return true; + }, + subscribe(listener: UploadCancellationListener) { + if (closed) throw new TypeError("closed"); + ownListeners.add(listener); + return () => ownListeners.delete(listener); + }, + close() { + closed = true; + ownListeners.clear(); + }, + }); + }); + return channels as unknown as readonly [ + UploadCancellationChannel, + UploadCancellationChannel, + ]; +} + +export class MemoryCheckpointStore implements ResumableUploadCheckpointStore { + readonly rows = new Map(); + closed = false; + + async read( + uploadKey: string, + ): Promise> { + return browserDataSuccess( + structuredClone(this.rows.get(uploadKey) ?? null), + ); + } + + async compareAndSwap( + input: Parameters< + ResumableUploadCheckpointStore["compareAndSwap"] + >[0], + ): Promise> { + const current = this.rows.get(input.checkpoint.uploadKey); + if ( + (input.expectedRevision === null && current) || + (input.expectedRevision !== null && + current?.revision !== input.expectedRevision) + ) { + return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", { + recovery: "RECONCILE", + }); + } + const snapshot = structuredClone(input.checkpoint); + this.rows.set(snapshot.uploadKey, snapshot); + return browserDataSuccess(snapshot); + } + + async remove( + input: Parameters[0], + ): Promise> { + const current = this.rows.get(input.uploadKey); + if (current?.revision !== input.expectedRevision) { + return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", { + recovery: "RECONCILE", + }); + } + this.rows.delete(input.uploadKey); + return browserDataSuccess(undefined); + } + + close(): void { + this.closed = true; + } +} + +export function rangeSource(bytes: Uint8Array): ResumableUploadSource { + return Object.freeze({ + kind: "RANGE_READER" as const, + reader: Object.freeze({ + byteLength: bytes.byteLength, + async readRange(input: Readonly<{ + offset: number; + length: number; + signal: AbortSignal; + }>) { + if (input.signal.aborted) { + return browserDataFailure("ABORTED", "FILE_READ"); + } + return browserDataSuccess( + bytes.slice(input.offset, input.offset + input.length), + ); + }, + }), + }); +} + +export function byteStreamSource(bytes: Uint8Array): ResumableUploadSource { + return Object.freeze({ + kind: "FILE_BYTE_SOURCE" as const, + bytes: Object.freeze({ + byteLength: bytes.byteLength, + async *stream(signal: AbortSignal) { + if (signal.aborted) { + yield browserDataFailure("ABORTED", "FILE_READ"); + return; + } + yield browserDataSuccess(bytes.slice(0, 3)); + yield browserDataSuccess(bytes.slice(3)); + }, + }), + }); +} + +export function runtimePolicy( + overrides: Partial< + Parameters[0] + > = {}, +) { + return { + partSizeBytes: 4, + maxFileBytes: 100, + maxPartCount: 25, + maxConcurrency: 3, + maxInFlightBytes: 48, + partBufferCopyFactor: 4, + maxSourceChunkBytes: 8, + maxRetries: 2, + retryBaseDelayMs: 1, + retryMaxDelayMs: 10, + maxRetryAfterMs: 100, + capabilityRefreshSkewMs: 5, + maxSessionLifetimeMs: 10_000, + providerAttemptTimeoutMs: 100, + ...overrides, + }; +} + +export type ControlHarness = Readonly<{ + control: ResumableUploadControlPlane; + accepted: Map; + issued: ReturnType; + completedParts: UploadPartReceipt[][]; + getSession(): UploadSession | null; +}>; + +export function createControlHarness(options: Readonly<{ + now?: number; + serverMaxConcurrency?: number; + sessionId?: (createIndex: number) => string; + statusParts?: ( + session: UploadSession, + accepted: Map, + ) => readonly UploadPartReceipt[]; + issueCapability?: ( + input: Parameters< + ResumableUploadControlPlane["issuePartCapability"] + >[0], + callIndex: number, + ) => UploadProviderResult>; +}> = {}): ControlHarness { + const now = options.now ?? 1_000; + const accepted = new Map(); + const completedParts: UploadPartReceipt[][] = []; + let session: UploadSession | null = null; + let createCount = 0; + let issueCount = 0; + const issued = vi.fn(); + const control: ResumableUploadControlPlane = { + async createSession(input) { + createCount += 1; + session = Object.freeze({ + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: + options.sessionId?.(createCount) ?? "session_01", + requestBindingSha256: input.requestBindingSha256, + fingerprint: input.fingerprint, + partSizeBytes: input.requestedPartSizeBytes, + partCount: input.fingerprint.partCount, + maxConcurrency: options.serverMaxConcurrency ?? 2, + expiresAtEpochMs: now + 5_000, + }); + return browserDataSuccess(session); + }, + async getStatus() { + if (!session) { + return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE"); + } + const parts = + options.statusParts?.(session, accepted) ?? + [...accepted.values()].sort( + (left, right) => left.partNumber - right.partNumber, + ); + return browserDataSuccess({ + state: "ACTIVE", + session, + acceptedParts: parts, + }); + }, + async issuePartCapability(input) { + issueCount += 1; + issued(input); + return ( + options.issueCapability?.(input, issueCount) ?? + browserDataSuccess({ + capability: Object.freeze({ id: `cap-${issueCount}` }), + uploadBindingSha256: input.uploadBindingSha256, + expiresAtEpochMs: now + 4_000, + }) + ); + }, + async complete(input) { + completedParts.push([...input.orderedParts]); + return browserDataSuccess({ + state: "QUARANTINED", + protocol: RESUMABLE_UPLOAD_PROTOCOL, + sessionId: input.sessionId, + requestBindingSha256: input.requestBindingSha256, + fingerprint: input.fingerprint, + resourceId: "resource_01", + }); + }, + async abort() { + return browserDataSuccess({ state: "ABORTED" }); + }, + }; + return { + control, + accepted, + issued, + completedParts, + getSession: () => session, + }; +} + +export function executorFor( + harness: ControlHarness, + options: Readonly<{ + delay?: () => Promise; + onActive?: (active: number) => void; + }> = {}, +): UploadPartExecutor { + let active = 0; + return { + async uploadPart(input) { + active += 1; + options.onActive?.(active); + await options.delay?.(); + active -= 1; + const receipt = Object.freeze({ + ...input.part, + receiptToken: `etag-part-${input.part.partNumber}`, + }); + harness.accepted.set(input.part.partNumber, receipt); + return browserDataSuccess(receipt); + }, + }; +} + diff --git a/tests/unit/resumable-upload-runtime-teardown.test.ts b/tests/unit/resumable-upload-runtime-teardown.test.ts new file mode 100644 index 0000000..d15b5c7 --- /dev/null +++ b/tests/unit/resumable-upload-runtime-teardown.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it, vi } from "vitest"; + +import { browserDataFailure } from "../../src/adapters/browser-file-storage/result.ts"; +import { createResumableUploadRuntime } from "../../src/adapters/browser-transfer/resumable-upload/resumable-upload-runtime.ts"; +import { + activeSignal, + byteStreamSource, + createControlHarness, + executorFor, + MemoryCheckpointStore, + noContentionLock, + runtimePolicy, +} from "./resumable-upload-runtime-fixture.ts"; + +/** + * TR-RR-06. A non-cooperative mutation lock or provider must not make teardown + * unbounded: `dispose()` bounds its drain and reports honestly when the runtime + * is still CLOSING, and an abort is admitted physical work it cannot step over. + */ +describe("TR-RR-06 bounded resumable teardown", () => { + it("reports an unproved drain instead of waiting forever", async () => { + const checkpoints = new MemoryCheckpointStore(); + const harness = createControlHarness(); + const runtime = createResumableUploadRuntime({ + controlPlane: harness.control, + partExecutor: executorFor(harness, { delay: async () => {} }), + checkpoints, + // A lock that never grants: dispose must still be bounded. + mutationLock: Object.freeze({ + async run(): Promise { + return await new Promise(() => {}); + }, + }), + crypto, + policy: runtimePolicy({ cleanupDeadlineMs: 20 }), + now: () => 1_000, + random: () => 0, + sleep: async () => {}, + }); + + void runtime.upload({ + uploadKey: "upload_key_hung", + purpose: "attachment", + mediaType: "application/octet-stream", + source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])), + signal: activeSignal, + }); + await Promise.resolve(); + await Promise.resolve(); + + const disposed = await runtime.dispose(); + expect(disposed.ok).toBe(false); + // Still CLOSING: physical work the caller must not treat as finished. + expect(runtime.lifecycle()).toBe("CLOSING"); + expect(checkpoints.closed).toBe(false); + }); + + it("closes once every admitted operation settles", async () => { + const checkpoints = new MemoryCheckpointStore(); + const harness = createControlHarness(); + const runtime = createResumableUploadRuntime({ + controlPlane: harness.control, + partExecutor: executorFor(harness, { delay: async () => {} }), + checkpoints, + mutationLock: noContentionLock, + crypto, + policy: runtimePolicy({ cleanupDeadlineMs: 200 }), + now: () => 1_000, + random: () => 0, + sleep: async () => {}, + }); + const disposed = await runtime.dispose(); + expect(disposed).toMatchObject({ ok: true }); + expect(runtime.lifecycle()).toBe("CLOSED"); + expect(checkpoints.closed).toBe(true); + }); +}); + +/** + * TR-04. `dispose()` proved quiescence from the wrapper registry only. A + * provider that ignored its attempt deadline let the wrapper settle first and + * leave the set empty, so teardown reported a drained runtime — and closed the + * checkpoint store — while the provider was still running. + */ +describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => { + it("refuses to report a drained runtime while a provider is still running", async () => { + const harness = createControlHarness(); + const checkpoints = new MemoryCheckpointStore(); + const closeStore = vi.spyOn(checkpoints, "close"); + let releaseProvider: (() => void) | undefined; + // Ignores the attempt signal entirely and outlives its own deadline. + harness.control.createSession = () => + new Promise((resolve) => { + releaseProvider = () => + resolve( + browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", { + retryable: true, + recovery: "RESUME", + }), + ); + }); + + const runtime = createResumableUploadRuntime({ + controlPlane: harness.control, + partExecutor: executorFor(harness), + checkpoints, + mutationLock: noContentionLock, + crypto, + policy: runtimePolicy({ + providerAttemptTimeoutMs: 5, + cleanupDeadlineMs: 25, + maxRetries: 0, + }), + now: () => 1_000, + random: () => 0, + sleep: async () => {}, + }); + + const uploading = runtime.upload({ + uploadKey: "upload_key_raw", + purpose: "attachment", + mediaType: "application/octet-stream", + source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])), + signal: activeSignal, + }); + await vi.waitFor(() => expect(releaseProvider).toBeDefined()); + // The wrapper has already given up on the attempt. + await uploading; + + const disposed = await runtime.dispose(); + expect(disposed.ok).toBe(false); + if (!disposed.ok) { + expect(disposed.error.code).toBe("UNAVAILABLE"); + expect(disposed.error.recovery).toBe("RESUME"); + } + // The store stays open while something could still write a checkpoint. + expect(closeStore).not.toHaveBeenCalled(); + + releaseProvider?.(); + }); + + it("reports a drained runtime once the raw provider settles", async () => { + const harness = createControlHarness(); + const checkpoints = new MemoryCheckpointStore(); + let releaseProvider: (() => void) | undefined; + harness.control.createSession = () => + new Promise((resolve) => { + releaseProvider = () => + resolve( + browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", { + retryable: true, + recovery: "RESUME", + }), + ); + }); + + const runtime = createResumableUploadRuntime({ + controlPlane: harness.control, + partExecutor: executorFor(harness), + checkpoints, + mutationLock: noContentionLock, + crypto, + policy: runtimePolicy({ + providerAttemptTimeoutMs: 5, + cleanupDeadlineMs: 1_000, + maxRetries: 0, + }), + now: () => 1_000, + random: () => 0, + sleep: async () => {}, + }); + + const uploading = runtime.upload({ + uploadKey: "upload_key_raw_2", + purpose: "attachment", + mediaType: "application/octet-stream", + source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])), + signal: activeSignal, + }); + await vi.waitFor(() => expect(releaseProvider).toBeDefined()); + await uploading; + + const disposing = runtime.dispose(); + releaseProvider?.(); + await expect(disposing).resolves.toMatchObject({ ok: true }); + expect(runtime.lifecycle()).toBe("CLOSED"); + }); +}); diff --git a/tests/unit/resumable-upload-runtime.test.ts b/tests/unit/resumable-upload-runtime.test.ts index bf4e015..9a5f5c4 100644 --- a/tests/unit/resumable-upload-runtime.test.ts +++ b/tests/unit/resumable-upload-runtime.test.ts @@ -32,319 +32,19 @@ import type { } from "../../src/adapters/browser-transfer/resumable-upload/upload-cancellation-channel.ts"; import type { UploadMutationLock } from "../../src/adapters/browser-transfer/resumable-upload/upload-mutation-lock.ts"; -type TestCapability = Readonly<{ id: string }>; - -const activeSignal = new AbortController().signal; -const noContentionLock: UploadMutationLock = Object.freeze({ - async run( - _uploadKey: string, - _signal: AbortSignal, - task: () => Promise, - ): Promise { - return await task(); - }, -}); - -function createSerialMutationLock(): UploadMutationLock { - let tail = Promise.resolve(); - return Object.freeze({ - run( - _uploadKey: string, - signal: AbortSignal, - task: () => Promise, - ): Promise { - const result = tail.then(async () => { - if (signal.aborted) { - throw new DOMException( - "The operation was aborted.", - "AbortError", - ); - } - return await task(); - }); - tail = result.then( - () => undefined, - () => undefined, - ); - return result; - }, - }); -} - -function createMemoryCancellationPair(): readonly [ - UploadCancellationChannel, - UploadCancellationChannel, -] { - const listeners = [ - new Set(), - new Set(), - ] as const; - const channels = listeners.map((ownListeners, ownIndex) => { - let closed = false; - return Object.freeze({ - publish(uploadKey: string) { - if (closed) return false; - for (const [index, peerListeners] of listeners.entries()) { - if (index === ownIndex) continue; - for (const listener of [...peerListeners]) { - listener(uploadKey); - } - } - return true; - }, - subscribe(listener: UploadCancellationListener) { - if (closed) throw new TypeError("closed"); - ownListeners.add(listener); - return () => ownListeners.delete(listener); - }, - close() { - closed = true; - ownListeners.clear(); - }, - }); - }); - return channels as unknown as readonly [ - UploadCancellationChannel, - UploadCancellationChannel, - ]; -} - -class MemoryCheckpointStore implements ResumableUploadCheckpointStore { - readonly rows = new Map(); - closed = false; - - async read( - uploadKey: string, - ): Promise> { - return browserDataSuccess( - structuredClone(this.rows.get(uploadKey) ?? null), - ); - } - - async compareAndSwap( - input: Parameters< - ResumableUploadCheckpointStore["compareAndSwap"] - >[0], - ): Promise> { - const current = this.rows.get(input.checkpoint.uploadKey); - if ( - (input.expectedRevision === null && current) || - (input.expectedRevision !== null && - current?.revision !== input.expectedRevision) - ) { - return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", { - recovery: "RECONCILE", - }); - } - const snapshot = structuredClone(input.checkpoint); - this.rows.set(snapshot.uploadKey, snapshot); - return browserDataSuccess(snapshot); - } - - async remove( - input: Parameters[0], - ): Promise> { - const current = this.rows.get(input.uploadKey); - if (current?.revision !== input.expectedRevision) { - return browserDataFailure("CONFLICT", "UPLOAD_RECONCILE", { - recovery: "RECONCILE", - }); - } - this.rows.delete(input.uploadKey); - return browserDataSuccess(undefined); - } - - close(): void { - this.closed = true; - } -} - -function rangeSource(bytes: Uint8Array): ResumableUploadSource { - return Object.freeze({ - kind: "RANGE_READER" as const, - reader: Object.freeze({ - byteLength: bytes.byteLength, - async readRange(input: Readonly<{ - offset: number; - length: number; - signal: AbortSignal; - }>) { - if (input.signal.aborted) { - return browserDataFailure("ABORTED", "FILE_READ"); - } - return browserDataSuccess( - bytes.slice(input.offset, input.offset + input.length), - ); - }, - }), - }); -} - -function byteStreamSource(bytes: Uint8Array): ResumableUploadSource { - return Object.freeze({ - kind: "FILE_BYTE_SOURCE" as const, - bytes: Object.freeze({ - byteLength: bytes.byteLength, - async *stream(signal: AbortSignal) { - if (signal.aborted) { - yield browserDataFailure("ABORTED", "FILE_READ"); - return; - } - yield browserDataSuccess(bytes.slice(0, 3)); - yield browserDataSuccess(bytes.slice(3)); - }, - }), - }); -} - -function runtimePolicy( - overrides: Partial< - Parameters[0] - > = {}, -) { - return { - partSizeBytes: 4, - maxFileBytes: 100, - maxPartCount: 25, - maxConcurrency: 3, - maxInFlightBytes: 48, - partBufferCopyFactor: 4, - maxSourceChunkBytes: 8, - maxRetries: 2, - retryBaseDelayMs: 1, - retryMaxDelayMs: 10, - maxRetryAfterMs: 100, - capabilityRefreshSkewMs: 5, - maxSessionLifetimeMs: 10_000, - providerAttemptTimeoutMs: 100, - ...overrides, - }; -} - -type ControlHarness = Readonly<{ - control: ResumableUploadControlPlane; - accepted: Map; - issued: ReturnType; - completedParts: UploadPartReceipt[][]; - getSession(): UploadSession | null; -}>; - -function createControlHarness(options: Readonly<{ - now?: number; - serverMaxConcurrency?: number; - sessionId?: (createIndex: number) => string; - statusParts?: ( - session: UploadSession, - accepted: Map, - ) => readonly UploadPartReceipt[]; - issueCapability?: ( - input: Parameters< - ResumableUploadControlPlane["issuePartCapability"] - >[0], - callIndex: number, - ) => UploadProviderResult>; -}> = {}): ControlHarness { - const now = options.now ?? 1_000; - const accepted = new Map(); - const completedParts: UploadPartReceipt[][] = []; - let session: UploadSession | null = null; - let createCount = 0; - let issueCount = 0; - const issued = vi.fn(); - const control: ResumableUploadControlPlane = { - async createSession(input) { - createCount += 1; - session = Object.freeze({ - protocol: RESUMABLE_UPLOAD_PROTOCOL, - sessionId: - options.sessionId?.(createCount) ?? "session_01", - requestBindingSha256: input.requestBindingSha256, - fingerprint: input.fingerprint, - partSizeBytes: input.requestedPartSizeBytes, - partCount: input.fingerprint.partCount, - maxConcurrency: options.serverMaxConcurrency ?? 2, - expiresAtEpochMs: now + 5_000, - }); - return browserDataSuccess(session); - }, - async getStatus() { - if (!session) { - return browserDataFailure("NOT_FOUND", "UPLOAD_RECONCILE"); - } - const parts = - options.statusParts?.(session, accepted) ?? - [...accepted.values()].sort( - (left, right) => left.partNumber - right.partNumber, - ); - return browserDataSuccess({ - state: "ACTIVE", - session, - acceptedParts: parts, - }); - }, - async issuePartCapability(input) { - issueCount += 1; - issued(input); - return ( - options.issueCapability?.(input, issueCount) ?? - browserDataSuccess({ - capability: Object.freeze({ id: `cap-${issueCount}` }), - uploadBindingSha256: input.uploadBindingSha256, - expiresAtEpochMs: now + 4_000, - }) - ); - }, - async complete(input) { - completedParts.push([...input.orderedParts]); - return browserDataSuccess({ - state: "QUARANTINED", - protocol: RESUMABLE_UPLOAD_PROTOCOL, - sessionId: input.sessionId, - requestBindingSha256: input.requestBindingSha256, - fingerprint: input.fingerprint, - resourceId: "resource_01", - }); - }, - async abort() { - return browserDataSuccess({ state: "ABORTED" }); - }, - }; - return { - control, - accepted, - issued, - completedParts, - getSession: () => session, - }; -} - -function executorFor( - harness: ControlHarness, - options: Readonly<{ - delay?: () => Promise; - onActive?: (active: number) => void; - }> = {}, -): UploadPartExecutor { - let active = 0; - return { - async uploadPart(input) { - active += 1; - options.onActive?.(active); - await options.delay?.(); - active -= 1; - const receipt = Object.freeze({ - ...input.part, - receiptToken: `etag-part-${input.part.partNumber}`, - }); - harness.accepted.set(input.part.partNumber, receipt); - return browserDataSuccess(receipt); - }, - }; -} +import { + activeSignal, + byteStreamSource, + createControlHarness, + createMemoryCancellationPair, + createSerialMutationLock, + executorFor, + MemoryCheckpointStore, + noContentionLock, + rangeSource, + runtimePolicy, + type TestCapability, +} from "./resumable-upload-runtime-fixture.ts"; describe("production resumable upload runtime", () => { it("disposes through one drain that proves quiescence", async () => { @@ -1280,177 +980,3 @@ describe("production resumable upload runtime", () => { } }); }); -/** - * TR-RR-06. A non-cooperative mutation lock or provider must not make teardown - * unbounded: `dispose()` bounds its drain and reports honestly when the runtime - * is still CLOSING, and an abort is admitted physical work it cannot step over. - */ -describe("TR-RR-06 bounded resumable teardown", () => { - it("reports an unproved drain instead of waiting forever", async () => { - const checkpoints = new MemoryCheckpointStore(); - const harness = createControlHarness(); - const runtime = createResumableUploadRuntime({ - controlPlane: harness.control, - partExecutor: executorFor(harness, { delay: async () => {} }), - checkpoints, - // A lock that never grants: dispose must still be bounded. - mutationLock: Object.freeze({ - async run(): Promise { - return await new Promise(() => {}); - }, - }), - crypto, - policy: runtimePolicy({ cleanupDeadlineMs: 20 }), - now: () => 1_000, - random: () => 0, - sleep: async () => {}, - }); - - void runtime.upload({ - uploadKey: "upload_key_hung", - purpose: "attachment", - mediaType: "application/octet-stream", - source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])), - signal: activeSignal, - }); - await Promise.resolve(); - await Promise.resolve(); - - const disposed = await runtime.dispose(); - expect(disposed.ok).toBe(false); - // Still CLOSING: physical work the caller must not treat as finished. - expect(runtime.lifecycle()).toBe("CLOSING"); - expect(checkpoints.closed).toBe(false); - }); - - it("closes once every admitted operation settles", async () => { - const checkpoints = new MemoryCheckpointStore(); - const harness = createControlHarness(); - const runtime = createResumableUploadRuntime({ - controlPlane: harness.control, - partExecutor: executorFor(harness, { delay: async () => {} }), - checkpoints, - mutationLock: noContentionLock, - crypto, - policy: runtimePolicy({ cleanupDeadlineMs: 200 }), - now: () => 1_000, - random: () => 0, - sleep: async () => {}, - }); - const disposed = await runtime.dispose(); - expect(disposed).toMatchObject({ ok: true }); - expect(runtime.lifecycle()).toBe("CLOSED"); - expect(checkpoints.closed).toBe(true); - }); -}); - -/** - * TR-04. `dispose()` proved quiescence from the wrapper registry only. A - * provider that ignored its attempt deadline let the wrapper settle first and - * leave the set empty, so teardown reported a drained runtime — and closed the - * checkpoint store — while the provider was still running. - */ -describe("TR-04 teardown waits for raw provider work, not only its wrapper", () => { - it("refuses to report a drained runtime while a provider is still running", async () => { - const harness = createControlHarness(); - const checkpoints = new MemoryCheckpointStore(); - const closeStore = vi.spyOn(checkpoints, "close"); - let releaseProvider: (() => void) | undefined; - // Ignores the attempt signal entirely and outlives its own deadline. - harness.control.createSession = () => - new Promise((resolve) => { - releaseProvider = () => - resolve( - browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", { - retryable: true, - recovery: "RESUME", - }), - ); - }); - - const runtime = createResumableUploadRuntime({ - controlPlane: harness.control, - partExecutor: executorFor(harness), - checkpoints, - mutationLock: noContentionLock, - crypto, - policy: runtimePolicy({ - providerAttemptTimeoutMs: 5, - cleanupDeadlineMs: 25, - maxRetries: 0, - }), - now: () => 1_000, - random: () => 0, - sleep: async () => {}, - }); - - const uploading = runtime.upload({ - uploadKey: "upload_key_raw", - purpose: "attachment", - mediaType: "application/octet-stream", - source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])), - signal: activeSignal, - }); - await vi.waitFor(() => expect(releaseProvider).toBeDefined()); - // The wrapper has already given up on the attempt. - await uploading; - - const disposed = await runtime.dispose(); - expect(disposed.ok).toBe(false); - if (!disposed.ok) { - expect(disposed.error.code).toBe("UNAVAILABLE"); - expect(disposed.error.recovery).toBe("RESUME"); - } - // The store stays open while something could still write a checkpoint. - expect(closeStore).not.toHaveBeenCalled(); - - releaseProvider?.(); - }); - - it("reports a drained runtime once the raw provider settles", async () => { - const harness = createControlHarness(); - const checkpoints = new MemoryCheckpointStore(); - let releaseProvider: (() => void) | undefined; - harness.control.createSession = () => - new Promise((resolve) => { - releaseProvider = () => - resolve( - browserDataFailure("UNAVAILABLE", "UPLOAD_SESSION", { - retryable: true, - recovery: "RESUME", - }), - ); - }); - - const runtime = createResumableUploadRuntime({ - controlPlane: harness.control, - partExecutor: executorFor(harness), - checkpoints, - mutationLock: noContentionLock, - crypto, - policy: runtimePolicy({ - providerAttemptTimeoutMs: 5, - cleanupDeadlineMs: 1_000, - maxRetries: 0, - }), - now: () => 1_000, - random: () => 0, - sleep: async () => {}, - }); - - const uploading = runtime.upload({ - uploadKey: "upload_key_raw_2", - purpose: "attachment", - mediaType: "application/octet-stream", - source: byteStreamSource(new Uint8Array([1, 2, 3, 4, 5])), - signal: activeSignal, - }); - await vi.waitFor(() => expect(releaseProvider).toBeDefined()); - await uploading; - - const disposing = runtime.dispose(); - releaseProvider?.(); - await expect(disposing).resolves.toMatchObject({ ok: true }); - expect(runtime.lifecycle()).toBe("CLOSED"); - }); -}); diff --git a/tests/unit/security-followup-fixture.ts b/tests/unit/security-followup-fixture.ts new file mode 100644 index 0000000..930bf07 --- /dev/null +++ b/tests/unit/security-followup-fixture.ts @@ -0,0 +1,541 @@ +import { + createHash, + generateKeyPairSync, + sign, + type KeyObject, +} from "node:crypto"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts"; +import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts"; +import { + providerEvidenceSignaturePayload, + trustPolicySha256, +} from "../../scripts/lib/provider-evidence.ts"; +import { + LOCAL_EVIDENCE_ASSESSMENT_PATH, + distSha256, + type ReleaseCandidateManifest, +} from "../../scripts/lib/release-candidate.ts"; +import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts"; + +export const PROCESS_HEAVY_TIMEOUT_MS = 30_000; + +export const digest = (value: string): string => + createHash("sha256").update(value).digest("hex"); +export const digestBytes = (value: Buffer): string => + createHash("sha256").update(value).digest("hex"); + +export function passingAssessment(): any { + return { + schemaVersion: 1 as const, + artifactType: "local-evidence-assessment" as const, + generatedAt: "2026-08-02T00:00:00.000Z", + status: "PASS" as const, + verifier: { + id: "clean-architecture-frontend-template/local-evidence-verifier", + version: "1", + sourceSha256: digest("verifier source"), + }, + source: { + revision: "a".repeat(40), + sourceSetSha256: digest("source set"), + }, + candidate: { + distSha256: digest("dist"), + lockfileSha256: digest("lockfile"), + sbomSha256: digest("sbom"), + }, + secretScan: { + policySha256: digest("secret policy"), + sarifSha256: digest("secret sarif"), + scanInputSha256: digest("secret scan input"), + }, + policyInputs: [ + { + path: "config/security/dependency-policy.json", + bytes: 3, + sha256: digest("{}\n"), + }, + ], + evidenceInputs: [ + { path: "pnpm-lock.yaml", bytes: 9, sha256: digest("lockfile\n") }, + ], + checks: { + release: "PASS" as const, + supplyChain: "PASS" as const, + dependencyPolicy: "PASS" as const, + licensePolicy: "PASS" as const, + vulnerabilityPolicy: "PASS" as const, + secretScan: "PASS" as const, + }, + failures: [] as string[], + }; +} + + +export function providerExpectedContext() { + return { + run: { id: "run-42", attempt: 1 }, + source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") }, + candidate: { + archiveSha256: digest("archive"), + bundleSha256: digest("bundle"), + distSha256: digest("provider dist"), + lockfileSha256: digest("provider lockfile"), + }, + secretScanAttestation: { + status: "PASS" as const, + localEvidenceAssessmentSha256: digest("provider assessment"), + sourceSetSha256: digest("provider source"), + policySha256: digest("provider secret policy"), + sarifSha256: digest("provider secret sarif"), + scanInputSha256: digest("provider secret input"), + }, + } as const; +} + +export function privatePromotionFiles() { + return PROMOTED_FILE_NAMES.map((name) => { + const bytes = Buffer.from(`${name}\n`); + return { name, bytes, sha256: digestBytes(bytes) }; + }); +} + +export function syntheticSignedPromotionBundle() { + const vulnerabilityKeys = generateKeyPairSync("ed25519"); + const provenanceKeys = generateKeyPairSync("ed25519"); + const archiveBytes = Buffer.from("synthetic signed replay archive\n"); + const run = { id: "signed-run", attempt: 1 } as const; + const source = { + revision: "a".repeat(40), + sourceSetSha256: digest("synthetic-source-set"), + } as const; + const candidate = { + archiveSha256: digestBytes(archiveBytes), + bundleSha256: digest("synthetic-bundle"), + distSha256: digest("synthetic-dist"), + lockfileSha256: digest("synthetic-lock"), + } as const; + const vulnerability = signedProviderV2( + { + schemaVersion: 2, + evidenceType: "vulnerability-report", + provider: "synthetic-vulnerability-provider", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...run, invocationNonce: "1".repeat(64) }, + source, + candidate, + secretScanAttestation: { + status: "PASS", + localEvidenceAssessmentSha256: digest("synthetic-assessment"), + sourceSetSha256: source.sourceSetSha256, + policySha256: digest("synthetic-policy"), + sarifSha256: digest("synthetic-sarif"), + scanInputSha256: digest("synthetic-scan-input"), + }, + findings: [], + }, + "synthetic-vulnerability", + vulnerabilityKeys.publicKey, + vulnerabilityKeys.privateKey, + ); + const provenance = signedProviderV2( + { + schemaVersion: 2, + evidenceType: "provenance-attestation", + provider: "synthetic-provenance-provider", + signer: "synthetic-signer", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...run, invocationNonce: "2".repeat(64) }, + source, + candidate, + subject: { name: "dist", digest: { sha256: candidate.distSha256 } }, + }, + "synthetic-provenance", + provenanceKeys.publicKey, + provenanceKeys.privateKey, + ); + const vulnerabilityBytes = Buffer.from(`${JSON.stringify(vulnerability)}\n`); + const provenanceBytes = Buffer.from(`${JSON.stringify(provenance)}\n`); + const vulnerabilityTrust = trust("synthetic-vulnerability", vulnerabilityKeys.publicKey); + const provenanceTrust = trust("synthetic-provenance", provenanceKeys.publicKey); + const providerEvidence = { + vulnerabilityReportSha256: digestBytes(vulnerabilityBytes), + provenanceAttestationSha256: digestBytes(provenanceBytes), + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + vulnerabilityKeyId: vulnerabilityTrust.keyId, + vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint, + provenanceKeyId: provenanceTrust.keyId, + provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint, + secretScanAttestation: vulnerability.secretScanAttestation, + }; + const common = { + schemaVersion: 3, + verifiedAt: "2026-08-02T01:00:00.000Z", + status: "PASS", + verifier: { + id: "clean-architecture-frontend-template/promotion-verifier", + version: "3", + }, + run, + source, + candidate, + providerEvidence, + trustPolicySha256: trustPolicySha256({ vulnerabilityTrust, provenanceTrust }), + failures: [], + }; + const providerBytes = Buffer.from( + `${JSON.stringify({ + ...common, + artifactType: "provider-verification", + vulnerabilityStatus: "PASS", + provenanceAttestationStatus: "PASS", + }, null, 2)}\n`, + ); + const promotionBytes = Buffer.from( + `${JSON.stringify({ + ...common, + artifactType: "promotion-verification", + localEvidenceStatus: "PASS", + localEvidenceAssessmentSha256: digest("synthetic-assessment"), + providerVerificationSha256: digestBytes(providerBytes), + }, null, 2)}\n`, + ); + return { + files: { + "release-candidate.tar.gz": archiveBytes, + "vulnerability-report.json": vulnerabilityBytes, + "provenance-attestation.json": provenanceBytes, + "provider-verification.json": providerBytes, + "promotion-verification.json": promotionBytes, + }, + verification: { + vulnerabilityTrust, + provenanceTrust, + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + expected: { + run, + sourceRevision: source.revision, + sourceSetSha256: source.sourceSetSha256, + archiveSha256: candidate.archiveSha256, + bundleSha256: candidate.bundleSha256, + distSha256: candidate.distSha256, + lockfileSha256: candidate.lockfileSha256, + }, + }, + vulnerabilityPem: vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }), + provenancePem: provenanceKeys.publicKey.export({ type: "spki", format: "pem" }), + }; +} + +export function fingerprint(publicKey: KeyObject): string { + return `sha256:${createHash("sha256") + .update(publicKey.export({ type: "spki", format: "der" })) + .digest("hex")}`; +} + +export function trust(keyId: string, publicKey: KeyObject) { + return { keyId, publicKey, publicKeyFingerprint: fingerprint(publicKey) }; +} + +export function signedProviderV2( + unsigned: Record, + keyId: string, + publicKey: KeyObject, + privateKey: KeyObject, + fingerprintOverride?: string, +): Record { + const { signature: existingSignature, ...payload } = unsigned; + const value = { + ...payload, + signature: { + algorithm: "Ed25519" as const, + keyId, + publicKeyFingerprint: + fingerprintOverride ?? + (existingSignature && typeof existingSignature === "object" && + "publicKeyFingerprint" in existingSignature + ? String(existingSignature.publicKeyFingerprint) + : fingerprint(publicKey)), + value: "", + }, + }; + value.signature.value = sign( + null, + providerEvidenceSignaturePayload(value), + privateKey, + ).toString("base64"); + return value; +} + +export function providerUnsigned( + kind: "vulnerability" | "provenance", + expected: ReturnType, +): Record { + const common = { + source: expected.source, + candidate: expected.candidate, + schemaVersion: 2, + evidenceType: + kind === "vulnerability" + ? "vulnerability-report" + : "provenance-attestation", + provider: `fixture-${kind}`, + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { + ...expected.run, + invocationNonce: kind === "vulnerability" ? "1".repeat(64) : "2".repeat(64), + }, + }; + return kind === "vulnerability" + ? { + ...common, + secretScanAttestation: expected.secretScanAttestation, + findings: [], + } + : { + ...common, + signer: "fixture-workload", + subject: { + name: "dist", + digest: { sha256: expected.candidate.distSha256 }, + }, + }; +} + +export async function createArchivedAssessmentFixture(): Promise<{ + root: string; + manifest: ReleaseCandidateManifest; + assessmentSha256: string; +}> { + const root = await mkdtemp(path.join(tmpdir(), "archived-assessment-")); + const sourceRevision = "a".repeat(40); + const sourceSetSha256 = digest("source set"); + const releaseManifestBytes = Buffer.from( + `${JSON.stringify({ + schemaVersion: 1, + appVersion: "1.0.0", + buildId: "build-1", + commitSha: sourceRevision, + configSchemaVersion: "1", + apiContractVersion: "1", + assetManifestHash: digest("vite manifest"), + releaseId: "release-1", + builtAt: "2026-08-02T00:00:00.000Z", + routeChunks: { home: "assets/home.js" }, + })}\n`, + ); + const distInputs = [ + { path: "dist/app.js", bytes: Buffer.byteLength("app\n"), sha256: digest("app\n"), gzipBytes: 0 }, + { + path: "dist/release-manifest.json", + bytes: releaseManifestBytes.byteLength, + sha256: digestBytes(releaseManifestBytes), + gzipBytes: 0, + }, + ]; + const candidateDist = distSha256(distInputs); + const sbomBytes = Buffer.from( + `${JSON.stringify({ + bomFormat: "CycloneDX", + specVersion: "1.6", + serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001", + version: 1, + metadata: { + component: { type: "application", name: "fixture", version: "1.0.0" }, + properties: [], + }, + components: [], + dependencies: [], + })}\n`, + ); + const sbomSha256 = digestBytes(sbomBytes); + const lockfileBytes = Buffer.from("lockfile\n"); + const lockfileDigest = digestBytes(lockfileBytes); + const buildManifest = { + schemaVersion: 1, + buildId: "build-1", + commitSha: sourceRevision, + releaseId: "release-1", + moduleInventoryHash: digest("module inventory"), + generatedAt: "2026-08-02T00:00:00.000Z", + buildContext: { + nodeVersion: "v24.0.0", + packageManagerVersion: "11.0.0", + runnerImage: "linux-x64", + sourceDateEpoch: "1785638400", + }, + outputs: { + directory: "dist", + viteManifest: "dist/.vite/manifest.json", + moduleInventory: "artifacts/quality/vite-module-inventory.json", + routeChunks: { home: "assets/home.js" }, + runtimeConfigSchema: "dist/runtime-config.schema.json", + }, + }; + const provenance = { + _type: "https://in-toto.io/Statement/v1", + subject: [{ name: "dist", digest: { sha256: candidateDist } }], + predicateType: "https://slsa.dev/provenance/v1", + predicate: { + buildDefinition: { + buildType: "https://vite.dev/build/v1", + externalParameters: {}, + internalParameters: {}, + resolvedDependencies: [ + { uri: "pnpm-lock.yaml", digest: { sha256: lockfileDigest } }, + ], + }, + runDetails: { + builder: { id: "fixture-builder" }, + metadata: { invocationId: "LOCAL_UNSIGNED" }, + }, + materials: { lockfileSha256: lockfileDigest, sourceSetSha256, sbomSha256 }, + }, + }; + const supplyVerification = { + schemaVersion: 1, + localStatus: "PASS", + promotionStatus: "FAIL_UNVERIFIED", + lockfileSha256: lockfileDigest, + sourceSetSha256, + distSha256: candidateDist, + sbomSha256, + dependencyDiff: { added: [], removed: [], changed: [], upgrades: [] }, + highRiskReview: [], + vulnerabilityStatus: "FAIL_UNVERIFIED", + provenanceAttestationStatus: "FAIL_UNVERIFIED", + failures: [], + }; + const members = new Map([ + ["dist/app.js", Buffer.from("app\n")], + ["dist/release-manifest.json", releaseManifestBytes], + ["pnpm-lock.yaml", lockfileBytes], + ["artifacts/release/build-manifest.json", Buffer.from(`${JSON.stringify(buildManifest)}\n`)], + ["artifacts/release/provenance.json", Buffer.from(`${JSON.stringify(provenance)}\n`)], + [ + "artifacts/security/supply-chain-verification.json", + Buffer.from(`${JSON.stringify(supplyVerification)}\n`), + ], + ["artifacts/release/sbom.cdx.json", sbomBytes], + ]); + const evidenceInputs = [...members.entries()] + .map(([memberPath, bytes]) => ({ + path: memberPath, + bytes: bytes.byteLength, + sha256: digestBytes(bytes), + })) + .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + const policyPaths = [ + "config/security/dependency-baseline.approval.json", + "config/security/dependency-baseline.json", + "config/security/dependency-change-evidence.json", + "config/security/dependency-policy.json", + "config/security/secret-scan-policy.json", + "config/security/vulnerability-exceptions.json", + "config/security/vulnerability-policy.json", + "schemas/artifacts/build-manifest.schema.json", + "schemas/artifacts/dependency-inventory.schema.json", + "schemas/artifacts/supply-chain-verification.schema.json", + "scripts/contracts/release-artifacts.ts", + "scripts/create-release-candidate.ts", + "scripts/generate-supply-chain.ts", + "scripts/lib/build-manifest-outputs.ts", + "scripts/lib/json-schema.ts", + "scripts/lib/local-policy-evidence.ts", + "scripts/lib/local-release-evidence.ts", + "scripts/lib/release-candidate.ts", + "scripts/lib/release-input-evidence.ts", + "scripts/lib/release-runtime-coherence.ts", + "scripts/lib/repository-file-inventory.ts", + "scripts/lib/secret-scan-evaluator.ts", + "scripts/lib/secret-scan-policy.ts", + "scripts/lib/secret-scan.ts", + "scripts/lib/supply-chain.ts", + "scripts/lib/validated-json-artifact.ts", + "src/contracts/release-artifacts.ts", + "src/features/installed-contract-contributions.ts", + "src/features/installed-feature-contracts.ts", + ]; + const sbomRow = evidenceInputs.find( + ({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json", + )!; + const policyInputs = policyPaths.map((policyPath) => ({ + path: policyPath, + bytes: 2, + sha256: digest(`policy:${policyPath}`), + })); + const verifierPaths = new Set([ + "scripts/contracts/release-artifacts.ts", + "scripts/create-release-candidate.ts", + "scripts/generate-supply-chain.ts", + "scripts/lib/build-manifest-outputs.ts", + "scripts/lib/json-schema.ts", + "scripts/lib/local-policy-evidence.ts", + "scripts/lib/local-release-evidence.ts", + "scripts/lib/release-candidate.ts", + "scripts/lib/release-input-evidence.ts", + "scripts/lib/release-runtime-coherence.ts", + "scripts/lib/repository-file-inventory.ts", + "scripts/lib/secret-scan-evaluator.ts", + "scripts/lib/secret-scan-policy.ts", + "scripts/lib/secret-scan.ts", + "scripts/lib/supply-chain.ts", + "scripts/lib/validated-json-artifact.ts", + "src/contracts/release-artifacts.ts", + "src/features/installed-contract-contributions.ts", + "src/features/installed-feature-contracts.ts", + ]); + const assessment = localEvidenceAssessmentArtifactSchema.parse({ + ...passingAssessment(), + verifier: { + id: "clean-architecture-frontend-template/local-evidence-verifier", + version: "1", + sourceSha256: supplyChainDigest( + policyInputs.filter(({ path: policyPath }) => verifierPaths.has(policyPath)), + ), + }, + source: { revision: sourceRevision, sourceSetSha256 }, + candidate: { + distSha256: candidateDist, + lockfileSha256: evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml")! + .sha256, + sbomSha256: sbomRow.sha256, + }, + policyInputs, + evidenceInputs, + }); + const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`); + members.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes); + for (const [memberPath, bytes] of members) { + await mkdir(path.dirname(path.join(root, memberPath)), { recursive: true }); + await writeFile(path.join(root, memberPath), bytes); + } + const files = [...members.entries()] + .map(([memberPath, bytes]) => ({ + path: memberPath, + bytes: bytes.byteLength, + sha256: digestBytes(bytes), + })) + .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); + const manifest: ReleaseCandidateManifest = { + schemaVersion: 1, + distSha256: assessment.candidate.distSha256, + lockfileSha256: assessment.candidate.lockfileSha256, + bundleSha256: supplyChainDigest(files), + files, + }; + await mkdir(path.join(root, "artifacts/release"), { recursive: true }); + await writeFile( + path.join(root, "artifacts/release/release-candidate.json"), + `${JSON.stringify(manifest)}\n`, + ); + return { root, manifest, assessmentSha256: digestBytes(assessmentBytes) }; +} diff --git a/tests/unit/security-followup.test.ts b/tests/unit/security-followup.test.ts index fb0ae71..937f75c 100644 --- a/tests/unit/security-followup.test.ts +++ b/tests/unit/security-followup.test.ts @@ -1,113 +1,28 @@ import { - createHash, - generateKeyPairSync, - sign, - type KeyObject, -} from "node:crypto"; -import { spawnSync } from "node:child_process"; -import { - chmod, - lstat, mkdir, - mkdtemp, readFile, - readdir, - rename, rm, writeFile, } from "node:fs/promises"; -import { tmpdir } from "node:os"; import path from "node:path"; -import { pathToFileURL } from "node:url"; -import { EventEmitter } from "node:events"; import { describe, expect, it } from "vitest"; import { localEvidenceAssessmentArtifactSchema } from "../../scripts/contracts/release-artifacts.ts"; -import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts"; import { verifyArchivedLocalEvidence } from "../../scripts/lib/local-release-evidence.ts"; -import { - evaluatePromotionEvidence, - providerEvidenceSignaturePayload, - providerPublicKeyFingerprint, - trustPolicySha256, - validateProviderEvidence, -} from "../../scripts/lib/provider-evidence.ts"; -import { readProviderTrust } from "../../scripts/lib/provider-trust.ts"; -import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts"; -import { runProviderProcess } from "../../scripts/lib/provider-process-runner.ts"; -import { runStageVerifiedPromotionCli } from "../../scripts/lib/stage-verified-promotion-cli.ts"; -import { publishPrivatePromotionStaging } from "../../scripts/lib/promotion-stager.ts"; -import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts"; import { LOCAL_EVIDENCE_ASSESSMENT_PATH, LOCAL_EVIDENCE_VERIFIER_SOURCE_PATHS, - distSha256, - type ReleaseCandidateManifest, } from "../../scripts/lib/release-candidate.ts"; import { supplyChainDigest } from "../../scripts/lib/supply-chain.ts"; +import { + createArchivedAssessmentFixture, + digest, + digestBytes, + passingAssessment, +} from "./security-followup-fixture.ts"; -/** - * This suite's budget, not the file's. The 10s default is sized for pure-JS - * unit tests; these spawn processes, build archives and sign evidence, and on a - * machine running the rest of the suite in parallel they legitimately need - * longer. Raising the global default instead would hide a genuinely hung test. - */ -const PROCESS_HEAVY_TIMEOUT_MS = 30_000; - -const digest = (value: string): string => - createHash("sha256").update(value).digest("hex"); -const digestBytes = (value: Buffer): string => - createHash("sha256").update(value).digest("hex"); - -function passingAssessment(): any { - return { - schemaVersion: 1 as const, - artifactType: "local-evidence-assessment" as const, - generatedAt: "2026-08-02T00:00:00.000Z", - status: "PASS" as const, - verifier: { - id: "clean-architecture-frontend-template/local-evidence-verifier", - version: "1", - sourceSha256: digest("verifier source"), - }, - source: { - revision: "a".repeat(40), - sourceSetSha256: digest("source set"), - }, - candidate: { - distSha256: digest("dist"), - lockfileSha256: digest("lockfile"), - sbomSha256: digest("sbom"), - }, - secretScan: { - policySha256: digest("secret policy"), - sarifSha256: digest("secret sarif"), - scanInputSha256: digest("secret scan input"), - }, - policyInputs: [ - { - path: "config/security/dependency-policy.json", - bytes: 3, - sha256: digest("{}\n"), - }, - ], - evidenceInputs: [ - { path: "pnpm-lock.yaml", bytes: 9, sha256: digest("lockfile\n") }, - ], - checks: { - release: "PASS" as const, - supplyChain: "PASS" as const, - dependencyPolicy: "PASS" as const, - licensePolicy: "PASS" as const, - vulnerabilityPolicy: "PASS" as const, - secretScan: "PASS" as const, - }, - failures: [] as string[], - }; -} - -describe("security follow-up contracts", () => { +describe("security local evidence contracts", () => { it("rejects a PASS local assessment with a failed check or failure diagnostic", () => { const failedCheck = passingAssessment(); failedCheck.checks.secretScan = "FAIL"; @@ -342,1488 +257,4 @@ describe("security follow-up contracts", () => { } }); - it("accepts signed provider v2 evidence only for the exact run, source, archive, and nonce", () => { - const now = Date.parse("2026-08-02T01:00:00.000Z"); - const vulnerabilityKeys = generateKeyPairSync("ed25519"); - const provenanceKeys = generateKeyPairSync("ed25519"); - const expected = providerExpectedContext(); - const vulnerability = signedProviderV2( - { - source: expected.source, - candidate: expected.candidate, - schemaVersion: 2, - evidenceType: "vulnerability-report", - provider: "fixture-vulnerability", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", - run: { ...expected.run, invocationNonce: "1".repeat(64) }, - secretScanAttestation: expected.secretScanAttestation, - findings: [], - }, - "vulnerability-key", - vulnerabilityKeys.publicKey, - vulnerabilityKeys.privateKey, - ); - const provenance = signedProviderV2( - { - source: expected.source, - candidate: expected.candidate, - schemaVersion: 2, - evidenceType: "provenance-attestation", - provider: "fixture-provenance", - signer: "fixture-workload", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", - run: { ...expected.run, invocationNonce: "2".repeat(64) }, - subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } }, - }, - "provenance-key", - provenanceKeys.publicKey, - provenanceKeys.privateKey, - ); - const result = evaluatePromotionEvidence({ - expected: { - ...expected, - vulnerabilityInvocationNonce: "1".repeat(64), - provenanceInvocationNonce: "2".repeat(64), - }, - localStatus: "PASS", - vulnerabilityReport: vulnerability, - provenanceAttestation: provenance, - vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey), - provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), - nowEpochMs: () => now, - }); - - expect(result).toEqual({ - status: "PASS", - vulnerabilityStatus: "PASS", - provenanceAttestationStatus: "PASS", - failures: [], - }); - const replayed = evaluatePromotionEvidence({ - expected: { - ...expected, - run: { id: expected.run.id, attempt: 2 }, - vulnerabilityInvocationNonce: "1".repeat(64), - provenanceInvocationNonce: "2".repeat(64), - }, - localStatus: "PASS", - vulnerabilityReport: vulnerability, - provenanceAttestation: provenance, - vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey), - provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), - nowEpochMs: () => now, - }); - expect(replayed.status).toBe("FAIL_UNVERIFIED"); - expect(replayed.failures).toEqual( - expect.arrayContaining([ - "vulnerability report run identity mismatch", - "provenance attestation run identity mismatch", - ]), - ); - }); - - it("rejects a signed vulnerability PASS when the captured SARIF attestation differs", () => { - const keys = generateKeyPairSync("ed25519"); - const expected = providerExpectedContext(); - const secretScanAttestation = { - status: "PASS" as const, - localEvidenceAssessmentSha256: digest("assessment"), - sourceSetSha256: expected.source.sourceSetSha256, - policySha256: digest("secret policy"), - sarifSha256: digest("real sarif"), - scanInputSha256: digest("scan input"), - }; - const report = signedProviderV2( - { - schemaVersion: 2, - evidenceType: "vulnerability-report", - provider: "fixture-vulnerability", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", - run: { ...expected.run, invocationNonce: "1".repeat(64) }, - source: expected.source, - candidate: expected.candidate, - secretScanAttestation, - findings: [], - }, - "vulnerability-key", - keys.publicKey, - keys.privateKey, - ); - const validated = validateProviderEvidence({ - kind: "vulnerability", - value: report, - expected: { - ...expected, - vulnerabilityInvocationNonce: "1".repeat(64), - provenanceInvocationNonce: "2".repeat(64), - secretScanAttestation: { - ...secretScanAttestation, - sarifSha256: digest("forged empty sarif"), - }, - }, - trust: trust("vulnerability-key", keys.publicKey), - nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), - }); - expect(validated.status).toBe("FAIL_UNVERIFIED"); - expect(validated.failures).toContain( - "vulnerability report secret scan attestation mismatch", - ); - const forged = structuredClone(report); - forged.secretScanAttestation.sarifSha256 = digest("forged empty sarif"); - const forgedValidation = validateProviderEvidence({ - kind: "vulnerability", - value: forged, - expected: { - ...expected, - vulnerabilityInvocationNonce: "1".repeat(64), - provenanceInvocationNonce: "2".repeat(64), - secretScanAttestation: forged.secretScanAttestation, - }, - trust: trust("vulnerability-key", keys.publicKey), - nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), - }); - expect(forgedValidation.failures).toContain( - "vulnerability report signature verification failed", - ); - - const provenanceKeys = generateKeyPairSync("ed25519"); - const provenance = signedProviderV2( - { - schemaVersion: 2, - evidenceType: "provenance-attestation", - provider: "fixture-provenance", - signer: "fixture-workload", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", - run: { ...expected.run, invocationNonce: "2".repeat(64) }, - source: expected.source, - candidate: expected.candidate, - subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } }, - }, - "provenance-key", - provenanceKeys.publicKey, - provenanceKeys.privateKey, - ); - const evaluated = evaluatePromotionEvidence({ - expected: { - ...expected, - vulnerabilityInvocationNonce: "1".repeat(64), - provenanceInvocationNonce: "2".repeat(64), - secretScanAttestation: { - ...secretScanAttestation, - sarifSha256: digest("forged empty sarif"), - }, - }, - localStatus: "PASS", - vulnerabilityReport: report, - provenanceAttestation: provenance, - vulnerabilityTrust: trust("vulnerability-key", keys.publicKey), - provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), - nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), - }); - expect(evaluated.vulnerabilityStatus).toBe("FAIL_UNVERIFIED"); - expect(evaluated.failures).toContain( - "vulnerability report secret scan attestation mismatch", - ); - }); - - it.each(["vulnerability", "provenance"] as const)( - "rejects correctly re-signed %s v2 context/time/replay drift", - (kind) => { - const now = Date.parse("2026-08-02T01:00:00.000Z"); - const vulnerabilityKeys = generateKeyPairSync("ed25519"); - const provenanceKeys = generateKeyPairSync("ed25519"); - const expected = providerExpectedContext(); - const baseVulnerability = providerUnsigned("vulnerability", expected); - const baseProvenance = providerUnsigned("provenance", expected); - const validVulnerability = signedProviderV2( - baseVulnerability, - "vulnerability-key", - vulnerabilityKeys.publicKey, - vulnerabilityKeys.privateKey, - ); - const validProvenance = signedProviderV2( - baseProvenance, - "provenance-key", - provenanceKeys.publicKey, - provenanceKeys.privateKey, - ); - const rawCases: Array) => Record, - RegExp, - ]> = [ - ["schema v1", (value) => ({ ...value, schemaVersion: 1 }), /missing or invalid/u], - [ - "evidence type", - (value) => ({ - ...value, - evidenceType: - kind === "vulnerability" - ? "provenance-attestation" - : "vulnerability-report", - }), - /missing or invalid/u, - ], - ...(["archiveSha256", "bundleSha256", "distSha256", "lockfileSha256"] as const).map( - (field) => [ - `candidate ${field}`, - (value: Record) => ({ - ...value, - candidate: { ...value.candidate, [field]: "f".repeat(64) }, - ...(kind === "provenance" && field === "distSha256" - ? { - subject: { - name: "dist", - digest: { sha256: "f".repeat(64) }, - }, - } - : {}), - }), - /candidate identity|subject dist/u, - ] as const, - ), - [ - "different archive with same dist and lockfile", - (value) => ({ - ...value, - candidate: { ...value.candidate, archiveSha256: "e".repeat(64) }, - }), - /candidate identity/u, - ], - [ - "source revision", - (value) => ({ ...value, source: { ...value.source, revision: "c".repeat(40) } }), - /source identity/u, - ], - [ - "source set", - (value) => ({ ...value, source: { ...value.source, sourceSetSha256: "c".repeat(64) } }), - /source identity/u, - ], - [ - "run id", - (value) => ({ ...value, run: { ...value.run, id: "other-run" } }), - /run identity/u, - ], - [ - "run attempt replay", - (value) => ({ ...value, run: { ...value.run, attempt: 2 } }), - /run identity/u, - ], - [ - "different nonce", - (value) => ({ ...value, run: { ...value.run, invocationNonce: "3".repeat(64) } }), - /invocation nonce/u, - ], - [ - "missing nonce", - (value) => { - const run = { ...value.run }; - delete run.invocationNonce; - return { ...value, run }; - }, - /missing or invalid/u, - ], - [ - "uppercase nonce", - (value) => ({ ...value, run: { ...value.run, invocationNonce: "A".repeat(64) } }), - /missing or invalid/u, - ], - [ - "short nonce", - (value) => ({ ...value, run: { ...value.run, invocationNonce: "1".repeat(62) } }), - /missing or invalid/u, - ], - [ - "issued future boundary", - (value) => ({ ...value, issuedAt: "2026-08-02T01:05:00.001Z" }), - /future skew/u, - ], - [ - "expiry equality", - (value) => ({ ...value, expiresAt: "2026-08-02T01:00:00.000Z" }), - /expired/u, - ], - [ - "expiry past", - (value) => ({ ...value, expiresAt: "2026-08-02T00:59:59.999Z" }), - /expired/u, - ], - [ - "zero lifetime", - (value) => ({ - ...value, - issuedAt: "2026-08-02T01:01:00.000Z", - expiresAt: "2026-08-02T01:01:00.000Z", - }), - /not positive/u, - ], - [ - "negative lifetime", - (value) => ({ - ...value, - issuedAt: "2026-08-02T01:02:00.000Z", - expiresAt: "2026-08-02T01:01:59.999Z", - }), - /not positive/u, - ], - [ - "lifetime above two hours", - (value) => ({ - ...value, - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T03:00:00.001Z", - }), - /exceeds two hours/u, - ], - [ - "wrong fingerprint", - (value) => ({ - ...value, - signature: { - ...value.signature, - publicKeyFingerprint: `sha256:${"d".repeat(64)}`, - }, - }), - /trust identity/u, - ], - ]; - const cases = rawCases.map(([name, mutate, failure]) => ({ - name, - mutate, - failure, - })); - - for (const testCase of cases) { - const base = kind === "vulnerability" ? baseVulnerability : baseProvenance; - const mutated = testCase.mutate(structuredClone(base)); - const resigned = signedProviderV2( - mutated, - kind === "vulnerability" ? "vulnerability-key" : "provenance-key", - kind === "vulnerability" ? vulnerabilityKeys.publicKey : provenanceKeys.publicKey, - kind === "vulnerability" ? vulnerabilityKeys.privateKey : provenanceKeys.privateKey, - "signature" in mutated && mutated.signature?.publicKeyFingerprint - ? mutated.signature.publicKeyFingerprint - : undefined, - ); - const result = evaluatePromotionEvidence({ - expected: { - ...expected, - vulnerabilityInvocationNonce: "1".repeat(64), - provenanceInvocationNonce: "2".repeat(64), - }, - localStatus: "PASS", - vulnerabilityReport: - kind === "vulnerability" ? resigned : validVulnerability, - provenanceAttestation: - kind === "provenance" ? resigned : validProvenance, - vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey), - provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), - nowEpochMs: () => now, - }); - expect(result.status, testCase.name).toBe("FAIL_UNVERIFIED"); - expect(result.failures.join("\n"), testCase.name).toMatch(testCase.failure); - } - }, - ); - - it("canonicalizes provider fingerprints from DER SPKI across PEM wrapping and rejects Ed448", async () => { - const root = await mkdtemp(path.join(tmpdir(), "provider-fingerprint-")); - try { - const ed25519 = generateKeyPairSync("ed25519").publicKey; - const pem = ed25519.export({ type: "spki", format: "pem" }).toString(); - const body = pem.replace(/-----[^-]+-----|\s/gu, ""); - const wrapped = (width: number) => - `-----BEGIN PUBLIC KEY-----\n${body.match(new RegExp(`.{1,${width}}`, "gu"))!.join("\n")}\n-----END PUBLIC KEY-----\n`; - await writeFile(path.join(root, "a.pem"), wrapped(64)); - await writeFile(path.join(root, "b.pem"), wrapped(32)); - const first = await readProviderTrust(root, "a.pem", "fixture-key"); - const second = await readProviderTrust(root, "b.pem", "fixture-key"); - expect(first?.publicKeyFingerprint).toBe(providerPublicKeyFingerprint(ed25519)); - expect(second?.publicKeyFingerprint).toBe(first?.publicKeyFingerprint); - - const ed448 = generateKeyPairSync("ed448").publicKey; - await writeFile(root + "/ed448.pem", ed448.export({ type: "spki", format: "pem" })); - await expect(readProviderTrust(root, "ed448.pem", "fixture-key")).resolves.toBeNull(); - expect(() => providerPublicKeyFingerprint(ed448)).toThrow(/must be Ed25519/u); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("captures the downloaded archive pathname exactly once in the provider supervisor", async () => { - const keys = generateKeyPairSync("ed25519"); - const expected = providerExpectedContext(); - let captureCount = 0; - let receivedEnvironment: Readonly> | undefined; - const manifest: ReleaseCandidateManifest = { - schemaVersion: 1, - distSha256: expected.candidate.distSha256, - lockfileSha256: expected.candidate.lockfileSha256, - bundleSha256: expected.candidate.bundleSha256, - files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }], - }; - const result = await superviseProviderEvidence( - { - kind: "vulnerability", - archivePath: "/downloads/candidate.tar.gz", - expectedArchiveSha256: expected.candidate.archiveSha256, - expectedRun: { - id: expected.run.id, - attempt: expected.run.attempt, - sourceRevision: expected.source.revision, - }, - trust: trust("vulnerability-key", keys.publicKey), - executeProvider: async ({ environment }) => { - receivedEnvironment = environment; - }, - captureReport: async () => Buffer.from("{}\n"), - }, - { - captureArchive: async (input) => { - captureCount += 1; - expect(input).toEqual({ - archivePath: "/downloads/candidate.tar.gz", - expectedSha256: expected.candidate.archiveSha256, - }); - return { - bytes: Buffer.from("captured archive"), - archiveSha256: expected.candidate.archiveSha256, - }; - }, - withVerifiedCandidate: (async (input: any) => - input.verify({ extractionRoot: "/captured/extraction", manifest })) as any, - verifyLocalEvidence: async () => ({ - status: "PASS", - identity: { - sourceRevision: expected.source.revision, - sourceSetSha256: expected.source.sourceSetSha256, - assessmentSha256: digest("assessment"), - secretScan: { - policySha256: digest("provider secret policy"), - sarifSha256: digest("provider secret sarif"), - scanInputSha256: digest("provider secret input"), - }, - }, - failures: [], - }), - validateUpload: (async (input: any) => { - expect("archivePath" in input).toBe(false); - return { sealed: true }; - }) as any, - randomBytes: () => Buffer.alloc(32, 0x11), - nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), - }, - ); - - expect(captureCount).toBe(1); - expect(receivedEnvironment).toEqual( - expect.objectContaining({ - PROVIDER_EVIDENCE_SCHEMA_VERSION: "2", - PROVIDER_INVOCATION_NONCE: "11".repeat(32), - PROVIDER_ISSUED_AT: "2026-08-02T01:00:00.000Z", - PROVIDER_EXPIRES_AT: "2026-08-02T02:00:00.000Z", - CI_RUN_ID: expected.run.id, - CI_RUN_ATTEMPT: "1", - SOURCE_REVISION: expected.source.revision, - CANDIDATE_ARCHIVE_SHA256: expected.candidate.archiveSha256, - }), - ); - expect(result.evidence).toEqual({ sealed: true }); - }); - - it("samples provider freshness after report capture instead of reusing issuance time", async () => { - const keys = generateKeyPairSync("ed25519"); - const expected = providerExpectedContext(); - const manifest: ReleaseCandidateManifest = { - schemaVersion: 1, - distSha256: expected.candidate.distSha256, - lockfileSha256: expected.candidate.lockfileSha256, - bundleSha256: expected.candidate.bundleSha256, - files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }], - }; - const issuedSample = Date.parse("2026-08-02T01:00:00.000Z"); - const validationSample = Date.parse("2026-08-02T02:00:00.001Z"); - const samples = [issuedSample, validationSample]; - let issuedAt = ""; - await expect( - superviseProviderEvidence( - { - kind: "vulnerability", - archivePath: "/downloads/candidate.tar.gz", - expectedArchiveSha256: expected.candidate.archiveSha256, - expectedRun: { - id: expected.run.id, - attempt: expected.run.attempt, - sourceRevision: expected.source.revision, - }, - trust: trust("vulnerability-key", keys.publicKey), - executeProvider: async ({ environment }) => { - issuedAt = environment.PROVIDER_ISSUED_AT!; - }, - captureReport: async () => Buffer.from("{}\n"), - }, - { - captureArchive: async () => ({ - bytes: Buffer.from("captured archive"), - archiveSha256: expected.candidate.archiveSha256, - }), - withVerifiedCandidate: (async (input: any) => - input.verify({ extractionRoot: "/captured/extraction", manifest })) as any, - verifyLocalEvidence: async () => ({ - status: "PASS", - identity: { - sourceRevision: expected.source.revision, - sourceSetSha256: expected.source.sourceSetSha256, - assessmentSha256: digest("assessment"), - secretScan: { - policySha256: digest("provider secret policy"), - sarifSha256: digest("provider secret sarif"), - scanInputSha256: digest("provider secret input"), - }, - }, - failures: [], - }), - validateUpload: (async (input: any) => { - expect(input.nowEpochMs()).toBe(validationSample); - throw new Error("provider report expired during execution"); - }) as any, - randomBytes: () => Buffer.alloc(32, 0x33), - nowEpochMs: () => samples.shift()!, - }, - ), - ).rejects.toThrow(/expired during execution/u); - expect(issuedAt).toBe("2026-08-02T01:00:00.000Z"); - }); - - it("kills a timed-out provider but settles only after the child closes", async () => { - const child = new EventEmitter() as EventEmitter & { - kill(signal: NodeJS.Signals): boolean; - }; - let killedWith: NodeJS.Signals | undefined; - child.kill = (signal) => { - killedWith = signal; - return true; - }; - let fireTimeout: (() => void) | undefined; - let settled = false; - const running = runProviderProcess( - { executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 }, - { - spawnChild: () => child as any, - setTimer: (callback) => { - fireTimeout = callback; - return 1 as any; - }, - clearTimer: () => undefined, - }, - ).finally(() => { - settled = true; - }); - fireTimeout?.(); - await Promise.resolve(); - expect(killedWith).toBe("SIGKILL"); - expect(settled).toBe(false); - child.emit("close", null, "SIGKILL"); - await expect(running).rejects.toThrow(/timed out/u); - expect(settled).toBe(true); - }); - - it("captures process-group kill errors, attempts child fallback, and settles after close", async () => { - const child = new EventEmitter() as EventEmitter & { - pid: number; - kill(signal: NodeJS.Signals): boolean; - }; - child.pid = 12_346; - let fallbackSignal: NodeJS.Signals | undefined; - child.kill = (signal) => { - fallbackSignal = signal; - return true; - }; - let fireTimeout: (() => void) | undefined; - const running = runProviderProcess( - { executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 }, - { - spawnChild: () => child as any, - setTimer: (callback) => { - fireTimeout = callback; - return 1 as any; - }, - clearTimer: () => undefined, - killProcessGroup: () => { - throw Object.assign(new Error("group kill denied"), { code: "EPERM" }); - }, - }, - ); - expect(() => fireTimeout?.()).not.toThrow(); - expect(fallbackSignal).toBe("SIGKILL"); - child.emit("close", null, "SIGKILL"); - await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u); - }); - - it.each(["open failure", "partial write failure"])( - "cleans finalized staging from memory when GITHUB_OUTPUT has a %s", - async (failureKind) => { - const finalized = { - stagingRoot: "/runner/promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - cleanupToken: "promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - runnerTempIdentity: { dev: 10, ino: 20 }, - stagingIdentity: { dev: 30, ino: 40 }, - files: [], - } as const; - let cleanupInput: unknown; - let appendCalls = 0; - const environment = { - CANDIDATE_ARCHIVE_PATH: "candidate.tar.gz", - CANDIDATE_ARCHIVE_SHA256: "a".repeat(64), - VULNERABILITY_REPORT_PATH: "vulnerability.json", - PROVENANCE_ATTESTATION_PATH: "provenance.json", - VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem", - VULNERABILITY_KEY_ID: "vulnerability-key", - PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem", - PROVENANCE_KEY_ID: "provenance-key", - CI_RUN_ID: "run", - CI_RUN_ATTEMPT: "1", - VITE_COMMIT_SHA: "b".repeat(40), - VULNERABILITY_INVOCATION_NONCE: "c".repeat(64), - PROVENANCE_INVOCATION_NONCE: "d".repeat(64), - RUNNER_TEMP: "/runner", - GITHUB_OUTPUT: "/runner/github-output", - }; - await expect( - runStageVerifiedPromotionCli(environment, { - cwd: () => "/workspace", - finalize: async () => finalized as any, - appendOutput: async () => { - appendCalls += 1; - if (failureKind === "partial write failure") { - // The output sink accepted an unspecified prefix before rejecting. - } - throw new Error(failureKind); - }, - cleanup: async (input) => { - cleanupInput = input; - }, - writeStdout: () => undefined, - }), - ).rejects.toThrow(new RegExp(failureKind, "u")); - expect(appendCalls).toBe(1); - expect(cleanupInput).toEqual({ - runnerTempRoot: "/runner", - stagingRoot: finalized.stagingRoot, - cleanupToken: finalized.cleanupToken, - runnerTempIdentity: finalized.runnerTempIdentity, - stagingIdentity: finalized.stagingIdentity, - }); - }, - ); - - it("forces exact private staging modes in an isolated child with umask 077", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-umask-")); - try { - const stagerUrl = pathToFileURL( - path.join(process.cwd(), "scripts/lib/promotion-stager.ts"), - ).href; - const contractsUrl = pathToFileURL( - path.join(process.cwd(), "scripts/contracts/promotion-artifacts.ts"), - ).href; - const childPath = path.join(root, "umask-child.mjs"); - const resultPath = path.join(root, "result.json"); - await writeFile(resultPath, "{}\n", { mode: 0o600 }); - await writeFile( - childPath, - [ - `import { lstat, writeFile } from "node:fs/promises";`, - `import path from "node:path";`, - `import { createHash } from "node:crypto";`, - `import { cleanupFinalizedPromotion, publishPrivatePromotionStaging } from ${JSON.stringify(stagerUrl)};`, - `import { PROMOTED_FILE_NAMES } from ${JSON.stringify(contractsUrl)};`, - `process.umask(Number.parseInt(process.argv[2], 8));`, - `const runnerTempRoot = process.argv[3];`, - `const files = PROMOTED_FILE_NAMES.map((name) => { const bytes = Buffer.from(name); return { name, bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; });`, - `const finalized = await publishPrivatePromotionStaging(runnerTempRoot, { id: "umask", attempt: 1 }, files, () => Buffer.alloc(16, 1));`, - `const directoryMode = (await lstat(finalized.stagingRoot)).mode & 0o777;`, - `const fileModes = await Promise.all(PROMOTED_FILE_NAMES.map(async (name) => (await lstat(path.join(finalized.stagingRoot, name))).mode & 0o777));`, - `await cleanupFinalizedPromotion({ runnerTempRoot, stagingRoot: finalized.stagingRoot, cleanupToken: finalized.cleanupToken, runnerTempIdentity: finalized.runnerTempIdentity, stagingIdentity: finalized.stagingIdentity });`, - `await writeFile(process.argv[4], JSON.stringify({ directoryMode, fileModes }));`, - ].join("\n"), - ); - const child = spawnSync(process.execPath, [childPath, "077", root, resultPath], { - cwd: root, - encoding: "utf8", - timeout: 30_000, - }); - expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); - expect(JSON.parse(await readFile(resultPath, "utf8"))).toEqual({ - directoryMode: 0o700, - fileModes: [0o400, 0o400, 0o400, 0o400, 0o400], - }); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("rejects a staged file unlinked and recreated after its original write", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-recreate-")); - const files = privatePromotionFiles(); - const token = `promotion-seal-1-${"11".repeat(16)}`; - try { - await expect( - publishPrivatePromotionStaging( - root, - { id: "seal", attempt: 1 }, - files, - () => Buffer.alloc(16, 0x11), - undefined, - async (name) => { - if (name !== PROMOTED_FILE_NAMES.at(-1)) return; - const first = path.join(root, token, PROMOTED_FILE_NAMES[0]); - await rm(first); - await writeFile(first, "replacement bytes\n", { mode: 0o400 }); - }, - ), - ).rejects.toThrow(/staged.*digest|inode|seal/u); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("rejects staged mode drift before returning the upload root", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-mode-")); - const files = privatePromotionFiles(); - const token = `promotion-seal-1-${"12".repeat(16)}`; - try { - await expect( - publishPrivatePromotionStaging( - root, - { id: "seal", attempt: 1 }, - files, - () => Buffer.alloc(16, 0x12), - undefined, - async (name) => { - if (name === PROMOTED_FILE_NAMES.at(-1)) { - await chmod(path.join(root, token, PROMOTED_FILE_NAMES[0]), 0o600); - } - }, - ), - ).rejects.toThrow(/mode.*0400|staged.*mode|seal/u); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("rejects staging leaf replacement between mkdir and descriptor open", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-replace-")); - const files = privatePromotionFiles(); - const token = `promotion-preopen-1-${"13".repeat(16)}`; - const displaced = path.join(root, `${token}-displaced`); - const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); - try { - await expect( - publishPrivatePromotionStaging( - root, - { id: "preopen", attempt: 1 }, - files, - () => Buffer.alloc(16, 0x13), - undefined, - undefined, - undefined, - async (stagingRoot) => { - await rename(stagingRoot, displaced); - await mkdir(stagingRoot, { mode: 0o700 }); - await writeFile(replacementCanary, "external replacement canary\n"); - }, - ), - ).rejects.toThrow(/staging leaf.*changed|mkdir.*open|identity/u); - await expect(readFile(replacementCanary, "utf8")).resolves.toBe( - "external replacement canary\n", - ); - await expect(readdir(displaced)).resolves.toEqual([]); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("does not scan a crowded parent to recover an unverified pre-open leaf", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-bounded-")); - const files = privatePromotionFiles(); - const token = `promotion-preopen-bound-1-${"14".repeat(16)}`; - const displaced = path.join(root, `${token}-displaced`); - const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); - try { - for (let offset = 0; offset < 4_097; offset += 128) { - await Promise.all( - Array.from( - { length: Math.min(128, 4_097 - offset) }, - (_, index) => - mkdir( - path.join( - root, - `noise-${String(offset + index).padStart(4, "0")}`, - ), - ), - ), - ); - } - let failure: unknown; - try { - await publishPrivatePromotionStaging( - root, - { id: "preopen-bound", attempt: 1 }, - files, - () => Buffer.alloc(16, 0x14), - undefined, - undefined, - undefined, - async (stagingRoot) => { - await rename(stagingRoot, displaced); - await mkdir(stagingRoot, { mode: 0o700 }); - await writeFile(replacementCanary, "external replacement canary\n"); - }, - ); - } catch (error) { - failure = error; - } - expect(failure).toBeInstanceOf(Error); - expect(failure).not.toBeInstanceOf(AggregateError); - expect((failure as Error).message).toMatch(/staging leaf.*identity/i); - await expect(readdir(displaced)).resolves.toEqual([]); - await expect(readFile(replacementCanary, "utf8")).resolves.toBe( - "external replacement canary\n", - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }, 20_000); - - it("leaves a non-empty moved original untouched after pre-open mismatch", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-nonempty-")); - const files = privatePromotionFiles(); - const token = `promotion-preopen-nonempty-1-${"15".repeat(16)}`; - const displaced = path.join(root, `${token}-displaced`); - const ownedResidual = path.join(displaced, "owned-residual"); - const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); - try { - let failure: unknown; - try { - await publishPrivatePromotionStaging( - root, - { id: "preopen-nonempty", attempt: 1 }, - files, - () => Buffer.alloc(16, 0x15), - undefined, - undefined, - undefined, - async (stagingRoot) => { - await rename(stagingRoot, displaced); - await writeFile(ownedResidual, "owned residual\n"); - await mkdir(stagingRoot, { mode: 0o700 }); - await writeFile(replacementCanary, "external replacement canary\n"); - }, - ); - } catch (error) { - failure = error; - } - expect(failure).toBeInstanceOf(Error); - expect(failure).not.toBeInstanceOf(AggregateError); - expect((failure as Error).message).toMatch(/staging leaf.*identity/i); - await expect(readFile(ownedResidual, "utf8")).resolves.toBe( - "owned residual\n", - ); - await expect(readFile(replacementCanary, "utf8")).resolves.toBe( - "external replacement canary\n", - ); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("does not search outside the parent for a moved unverified original", async () => { - const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-missing-")); - const outside = await mkdtemp(path.join(tmpdir(), "promotion-preopen-moved-")); - const files = privatePromotionFiles(); - const token = `promotion-preopen-missing-1-${"16".repeat(16)}`; - const displaced = path.join(outside, token); - const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); - try { - let failure: unknown; - try { - await publishPrivatePromotionStaging( - root, - { id: "preopen-missing", attempt: 1 }, - files, - () => Buffer.alloc(16, 0x16), - undefined, - undefined, - undefined, - async (stagingRoot) => { - await rename(stagingRoot, displaced); - await mkdir(stagingRoot, { mode: 0o700 }); - await writeFile(replacementCanary, "external replacement canary\n"); - }, - ); - } catch (error) { - failure = error; - } - expect(failure).toBeInstanceOf(Error); - expect(failure).not.toBeInstanceOf(AggregateError); - expect((failure as Error).message).toMatch(/staging leaf.*identity/i); - await expect(lstat(displaced)).resolves.toEqual( - expect.objectContaining({ dev: expect.any(Number), ino: expect.any(Number) }), - ); - await expect(readFile(replacementCanary, "utf8")).resolves.toBe( - "external replacement canary\n", - ); - } finally { - await rm(root, { recursive: true, force: true }); - await rm(outside, { recursive: true, force: true }); - } - }); - - it("rejects a fresh signed exact-five bundle replayed under a different expected run", async () => { - const fixture = syntheticSignedPromotionBundle(); - await expect( - verifyExactPromotionBundle(fixture.files, { - ...fixture.verification, - expected: { - ...fixture.verification.expected, - run: { id: "different-run", attempt: 1 }, - }, - }), - ).rejects.toThrow(/external expected run.*mismatch|expected promotion run/u); - }); - - it("requires every external expected identity variable at the exact promotion CLI", async () => { - const fixture = syntheticSignedPromotionBundle(); - const root = await mkdtemp(path.join(tmpdir(), "promotion-replay-cli-")); - const bundleRoot = path.join(root, "bundle"); - try { - await mkdir(bundleRoot); - for (const [name, bytes] of Object.entries(fixture.files)) { - await writeFile(path.join(bundleRoot, name), bytes); - } - await writeFile(path.join(root, "vulnerability.pem"), fixture.vulnerabilityPem); - await writeFile(path.join(root, "provenance.pem"), fixture.provenancePem); - const cliPath = path.join(process.cwd(), "scripts/verify-exact-promotion-bundle.ts"); - const baseEnvironment: NodeJS.ProcessEnv = { - ...process.env, - PROMOTION_BUNDLE_ROOT: bundleRoot, - VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem", - VULNERABILITY_KEY_ID: "synthetic-vulnerability", - PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem", - PROVENANCE_KEY_ID: "synthetic-provenance", - EXPECTED_PROMOTION_RUN_ID: fixture.verification.expected.run.id, - EXPECTED_PROMOTION_RUN_ATTEMPT: String( - fixture.verification.expected.run.attempt, - ), - EXPECTED_PROMOTION_SOURCE_REVISION: - fixture.verification.expected.sourceRevision, - EXPECTED_PROMOTION_ARCHIVE_SHA256: - fixture.verification.expected.archiveSha256, - }; - const requiredExpected = [ - "EXPECTED_PROMOTION_RUN_ID", - "EXPECTED_PROMOTION_RUN_ATTEMPT", - "EXPECTED_PROMOTION_SOURCE_REVISION", - "EXPECTED_PROMOTION_ARCHIVE_SHA256", - ] as const; - for (const missing of requiredExpected) { - const environment = { ...baseEnvironment }; - delete environment[missing]; - const result = spawnSync(process.execPath, [cliPath], { - cwd: root, - encoding: "utf8", - env: environment, - }); - expect(result.status, missing).not.toBe(0); - expect(result.stderr, missing).toContain( - `exact promotion verification environment is missing ${missing}`, - ); - } - for (const [name, value, diagnostic] of [ - ["EXPECTED_PROMOTION_RUN_ID", "different-run", /external expected run.*mismatch/u], - ["EXPECTED_PROMOTION_RUN_ATTEMPT", "2", /external expected run.*mismatch/u], - ["EXPECTED_PROMOTION_SOURCE_REVISION", "f".repeat(40), /external expected source revision.*mismatch/u], - ["EXPECTED_PROMOTION_ARCHIVE_SHA256", "0".repeat(64), /external expected archive digest.*mismatch/u], - ] as const) { - const result = spawnSync(process.execPath, [cliPath], { - cwd: root, - encoding: "utf8", - env: { ...baseEnvironment, [name]: value }, - }); - expect(result.status, name).not.toBe(0); - expect(result.stderr, name).toMatch(diagnostic); - } - } finally { - await rm(root, { recursive: true, force: true }); - } - }, PROCESS_HEAVY_TIMEOUT_MS); }); - -function providerExpectedContext() { - return { - run: { id: "run-42", attempt: 1 }, - source: { revision: "b".repeat(40), sourceSetSha256: digest("provider source") }, - candidate: { - archiveSha256: digest("archive"), - bundleSha256: digest("bundle"), - distSha256: digest("provider dist"), - lockfileSha256: digest("provider lockfile"), - }, - secretScanAttestation: { - status: "PASS" as const, - localEvidenceAssessmentSha256: digest("provider assessment"), - sourceSetSha256: digest("provider source"), - policySha256: digest("provider secret policy"), - sarifSha256: digest("provider secret sarif"), - scanInputSha256: digest("provider secret input"), - }, - } as const; -} - -function privatePromotionFiles() { - return PROMOTED_FILE_NAMES.map((name) => { - const bytes = Buffer.from(`${name}\n`); - return { name, bytes, sha256: digestBytes(bytes) }; - }); -} - -function syntheticSignedPromotionBundle() { - const vulnerabilityKeys = generateKeyPairSync("ed25519"); - const provenanceKeys = generateKeyPairSync("ed25519"); - const archiveBytes = Buffer.from("synthetic signed replay archive\n"); - const run = { id: "signed-run", attempt: 1 } as const; - const source = { - revision: "a".repeat(40), - sourceSetSha256: digest("synthetic-source-set"), - } as const; - const candidate = { - archiveSha256: digestBytes(archiveBytes), - bundleSha256: digest("synthetic-bundle"), - distSha256: digest("synthetic-dist"), - lockfileSha256: digest("synthetic-lock"), - } as const; - const vulnerability = signedProviderV2( - { - schemaVersion: 2, - evidenceType: "vulnerability-report", - provider: "synthetic-vulnerability-provider", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", - run: { ...run, invocationNonce: "1".repeat(64) }, - source, - candidate, - secretScanAttestation: { - status: "PASS", - localEvidenceAssessmentSha256: digest("synthetic-assessment"), - sourceSetSha256: source.sourceSetSha256, - policySha256: digest("synthetic-policy"), - sarifSha256: digest("synthetic-sarif"), - scanInputSha256: digest("synthetic-scan-input"), - }, - findings: [], - }, - "synthetic-vulnerability", - vulnerabilityKeys.publicKey, - vulnerabilityKeys.privateKey, - ); - const provenance = signedProviderV2( - { - schemaVersion: 2, - evidenceType: "provenance-attestation", - provider: "synthetic-provenance-provider", - signer: "synthetic-signer", - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", - run: { ...run, invocationNonce: "2".repeat(64) }, - source, - candidate, - subject: { name: "dist", digest: { sha256: candidate.distSha256 } }, - }, - "synthetic-provenance", - provenanceKeys.publicKey, - provenanceKeys.privateKey, - ); - const vulnerabilityBytes = Buffer.from(`${JSON.stringify(vulnerability)}\n`); - const provenanceBytes = Buffer.from(`${JSON.stringify(provenance)}\n`); - const vulnerabilityTrust = trust("synthetic-vulnerability", vulnerabilityKeys.publicKey); - const provenanceTrust = trust("synthetic-provenance", provenanceKeys.publicKey); - const providerEvidence = { - vulnerabilityReportSha256: digestBytes(vulnerabilityBytes), - provenanceAttestationSha256: digestBytes(provenanceBytes), - vulnerabilityInvocationNonce: "1".repeat(64), - provenanceInvocationNonce: "2".repeat(64), - vulnerabilityKeyId: vulnerabilityTrust.keyId, - vulnerabilityKeyFingerprint: vulnerabilityTrust.publicKeyFingerprint, - provenanceKeyId: provenanceTrust.keyId, - provenanceKeyFingerprint: provenanceTrust.publicKeyFingerprint, - secretScanAttestation: vulnerability.secretScanAttestation, - }; - const common = { - schemaVersion: 3, - verifiedAt: "2026-08-02T01:00:00.000Z", - status: "PASS", - verifier: { - id: "clean-architecture-frontend-template/promotion-verifier", - version: "3", - }, - run, - source, - candidate, - providerEvidence, - trustPolicySha256: trustPolicySha256({ vulnerabilityTrust, provenanceTrust }), - failures: [], - }; - const providerBytes = Buffer.from( - `${JSON.stringify({ - ...common, - artifactType: "provider-verification", - vulnerabilityStatus: "PASS", - provenanceAttestationStatus: "PASS", - }, null, 2)}\n`, - ); - const promotionBytes = Buffer.from( - `${JSON.stringify({ - ...common, - artifactType: "promotion-verification", - localEvidenceStatus: "PASS", - localEvidenceAssessmentSha256: digest("synthetic-assessment"), - providerVerificationSha256: digestBytes(providerBytes), - }, null, 2)}\n`, - ); - return { - files: { - "release-candidate.tar.gz": archiveBytes, - "vulnerability-report.json": vulnerabilityBytes, - "provenance-attestation.json": provenanceBytes, - "provider-verification.json": providerBytes, - "promotion-verification.json": promotionBytes, - }, - verification: { - vulnerabilityTrust, - provenanceTrust, - nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), - expected: { - run, - sourceRevision: source.revision, - sourceSetSha256: source.sourceSetSha256, - archiveSha256: candidate.archiveSha256, - bundleSha256: candidate.bundleSha256, - distSha256: candidate.distSha256, - lockfileSha256: candidate.lockfileSha256, - }, - }, - vulnerabilityPem: vulnerabilityKeys.publicKey.export({ type: "spki", format: "pem" }), - provenancePem: provenanceKeys.publicKey.export({ type: "spki", format: "pem" }), - }; -} - -function fingerprint(publicKey: KeyObject): string { - return `sha256:${createHash("sha256") - .update(publicKey.export({ type: "spki", format: "der" })) - .digest("hex")}`; -} - -function trust(keyId: string, publicKey: KeyObject) { - return { keyId, publicKey, publicKeyFingerprint: fingerprint(publicKey) }; -} - -function signedProviderV2( - unsigned: Record, - keyId: string, - publicKey: KeyObject, - privateKey: KeyObject, - fingerprintOverride?: string, -): Record { - const { signature: existingSignature, ...payload } = unsigned; - const value = { - ...payload, - signature: { - algorithm: "Ed25519" as const, - keyId, - publicKeyFingerprint: - fingerprintOverride ?? - (existingSignature && typeof existingSignature === "object" && - "publicKeyFingerprint" in existingSignature - ? String(existingSignature.publicKeyFingerprint) - : fingerprint(publicKey)), - value: "", - }, - }; - value.signature.value = sign( - null, - providerEvidenceSignaturePayload(value), - privateKey, - ).toString("base64"); - return value; -} - -function providerUnsigned( - kind: "vulnerability" | "provenance", - expected: ReturnType, -): Record { - const common = { - source: expected.source, - candidate: expected.candidate, - schemaVersion: 2, - evidenceType: - kind === "vulnerability" - ? "vulnerability-report" - : "provenance-attestation", - provider: `fixture-${kind}`, - issuedAt: "2026-08-02T01:00:00.000Z", - expiresAt: "2026-08-02T02:00:00.000Z", - run: { - ...expected.run, - invocationNonce: kind === "vulnerability" ? "1".repeat(64) : "2".repeat(64), - }, - }; - return kind === "vulnerability" - ? { - ...common, - secretScanAttestation: expected.secretScanAttestation, - findings: [], - } - : { - ...common, - signer: "fixture-workload", - subject: { - name: "dist", - digest: { sha256: expected.candidate.distSha256 }, - }, - }; -} - -async function createArchivedAssessmentFixture(): Promise<{ - root: string; - manifest: ReleaseCandidateManifest; - assessmentSha256: string; -}> { - const root = await mkdtemp(path.join(tmpdir(), "archived-assessment-")); - const sourceRevision = "a".repeat(40); - const sourceSetSha256 = digest("source set"); - const releaseManifestBytes = Buffer.from( - `${JSON.stringify({ - schemaVersion: 1, - appVersion: "1.0.0", - buildId: "build-1", - commitSha: sourceRevision, - configSchemaVersion: "1", - apiContractVersion: "1", - assetManifestHash: digest("vite manifest"), - releaseId: "release-1", - builtAt: "2026-08-02T00:00:00.000Z", - routeChunks: { home: "assets/home.js" }, - })}\n`, - ); - const distInputs = [ - { path: "dist/app.js", bytes: Buffer.byteLength("app\n"), sha256: digest("app\n"), gzipBytes: 0 }, - { - path: "dist/release-manifest.json", - bytes: releaseManifestBytes.byteLength, - sha256: digestBytes(releaseManifestBytes), - gzipBytes: 0, - }, - ]; - const candidateDist = distSha256(distInputs); - const sbomBytes = Buffer.from( - `${JSON.stringify({ - bomFormat: "CycloneDX", - specVersion: "1.6", - serialNumber: "urn:uuid:00000000-0000-4000-8000-000000000001", - version: 1, - metadata: { - component: { type: "application", name: "fixture", version: "1.0.0" }, - properties: [], - }, - components: [], - dependencies: [], - })}\n`, - ); - const sbomSha256 = digestBytes(sbomBytes); - const lockfileBytes = Buffer.from("lockfile\n"); - const lockfileDigest = digestBytes(lockfileBytes); - const buildManifest = { - schemaVersion: 1, - buildId: "build-1", - commitSha: sourceRevision, - releaseId: "release-1", - moduleInventoryHash: digest("module inventory"), - generatedAt: "2026-08-02T00:00:00.000Z", - buildContext: { - nodeVersion: "v24.0.0", - packageManagerVersion: "11.0.0", - runnerImage: "linux-x64", - sourceDateEpoch: "1785638400", - }, - outputs: { - directory: "dist", - viteManifest: "dist/.vite/manifest.json", - moduleInventory: "artifacts/quality/vite-module-inventory.json", - routeChunks: { home: "assets/home.js" }, - runtimeConfigSchema: "dist/runtime-config.schema.json", - }, - }; - const provenance = { - _type: "https://in-toto.io/Statement/v1", - subject: [{ name: "dist", digest: { sha256: candidateDist } }], - predicateType: "https://slsa.dev/provenance/v1", - predicate: { - buildDefinition: { - buildType: "https://vite.dev/build/v1", - externalParameters: {}, - internalParameters: {}, - resolvedDependencies: [ - { uri: "pnpm-lock.yaml", digest: { sha256: lockfileDigest } }, - ], - }, - runDetails: { - builder: { id: "fixture-builder" }, - metadata: { invocationId: "LOCAL_UNSIGNED" }, - }, - materials: { lockfileSha256: lockfileDigest, sourceSetSha256, sbomSha256 }, - }, - }; - const supplyVerification = { - schemaVersion: 1, - localStatus: "PASS", - promotionStatus: "FAIL_UNVERIFIED", - lockfileSha256: lockfileDigest, - sourceSetSha256, - distSha256: candidateDist, - sbomSha256, - dependencyDiff: { added: [], removed: [], changed: [], upgrades: [] }, - highRiskReview: [], - vulnerabilityStatus: "FAIL_UNVERIFIED", - provenanceAttestationStatus: "FAIL_UNVERIFIED", - failures: [], - }; - const members = new Map([ - ["dist/app.js", Buffer.from("app\n")], - ["dist/release-manifest.json", releaseManifestBytes], - ["pnpm-lock.yaml", lockfileBytes], - ["artifacts/release/build-manifest.json", Buffer.from(`${JSON.stringify(buildManifest)}\n`)], - ["artifacts/release/provenance.json", Buffer.from(`${JSON.stringify(provenance)}\n`)], - [ - "artifacts/security/supply-chain-verification.json", - Buffer.from(`${JSON.stringify(supplyVerification)}\n`), - ], - ["artifacts/release/sbom.cdx.json", sbomBytes], - ]); - const evidenceInputs = [...members.entries()] - .map(([memberPath, bytes]) => ({ - path: memberPath, - bytes: bytes.byteLength, - sha256: digestBytes(bytes), - })) - .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); - const policyPaths = [ - "config/security/dependency-baseline.approval.json", - "config/security/dependency-baseline.json", - "config/security/dependency-change-evidence.json", - "config/security/dependency-policy.json", - "config/security/secret-scan-policy.json", - "config/security/vulnerability-exceptions.json", - "config/security/vulnerability-policy.json", - "schemas/artifacts/build-manifest.schema.json", - "schemas/artifacts/dependency-inventory.schema.json", - "schemas/artifacts/supply-chain-verification.schema.json", - "scripts/contracts/release-artifacts.ts", - "scripts/create-release-candidate.ts", - "scripts/generate-supply-chain.ts", - "scripts/lib/build-manifest-outputs.ts", - "scripts/lib/json-schema.ts", - "scripts/lib/local-policy-evidence.ts", - "scripts/lib/local-release-evidence.ts", - "scripts/lib/release-candidate.ts", - "scripts/lib/release-input-evidence.ts", - "scripts/lib/release-runtime-coherence.ts", - "scripts/lib/repository-file-inventory.ts", - "scripts/lib/secret-scan-evaluator.ts", - "scripts/lib/secret-scan-policy.ts", - "scripts/lib/secret-scan.ts", - "scripts/lib/supply-chain.ts", - "scripts/lib/validated-json-artifact.ts", - "src/contracts/release-artifacts.ts", - "src/features/installed-contract-contributions.ts", - "src/features/installed-feature-contracts.ts", - ]; - const sbomRow = evidenceInputs.find( - ({ path: memberPath }) => memberPath === "artifacts/release/sbom.cdx.json", - )!; - const policyInputs = policyPaths.map((policyPath) => ({ - path: policyPath, - bytes: 2, - sha256: digest(`policy:${policyPath}`), - })); - const verifierPaths = new Set([ - "scripts/contracts/release-artifacts.ts", - "scripts/create-release-candidate.ts", - "scripts/generate-supply-chain.ts", - "scripts/lib/build-manifest-outputs.ts", - "scripts/lib/json-schema.ts", - "scripts/lib/local-policy-evidence.ts", - "scripts/lib/local-release-evidence.ts", - "scripts/lib/release-candidate.ts", - "scripts/lib/release-input-evidence.ts", - "scripts/lib/release-runtime-coherence.ts", - "scripts/lib/repository-file-inventory.ts", - "scripts/lib/secret-scan-evaluator.ts", - "scripts/lib/secret-scan-policy.ts", - "scripts/lib/secret-scan.ts", - "scripts/lib/supply-chain.ts", - "scripts/lib/validated-json-artifact.ts", - "src/contracts/release-artifacts.ts", - "src/features/installed-contract-contributions.ts", - "src/features/installed-feature-contracts.ts", - ]); - const assessment = localEvidenceAssessmentArtifactSchema.parse({ - ...passingAssessment(), - verifier: { - id: "clean-architecture-frontend-template/local-evidence-verifier", - version: "1", - sourceSha256: supplyChainDigest( - policyInputs.filter(({ path: policyPath }) => verifierPaths.has(policyPath)), - ), - }, - source: { revision: sourceRevision, sourceSetSha256 }, - candidate: { - distSha256: candidateDist, - lockfileSha256: evidenceInputs.find(({ path: memberPath }) => memberPath === "pnpm-lock.yaml")! - .sha256, - sbomSha256: sbomRow.sha256, - }, - policyInputs, - evidenceInputs, - }); - const assessmentBytes = Buffer.from(`${JSON.stringify(assessment)}\n`); - members.set(LOCAL_EVIDENCE_ASSESSMENT_PATH, assessmentBytes); - for (const [memberPath, bytes] of members) { - await mkdir(path.dirname(path.join(root, memberPath)), { recursive: true }); - await writeFile(path.join(root, memberPath), bytes); - } - const files = [...members.entries()] - .map(([memberPath, bytes]) => ({ - path: memberPath, - bytes: bytes.byteLength, - sha256: digestBytes(bytes), - })) - .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0)); - const manifest: ReleaseCandidateManifest = { - schemaVersion: 1, - distSha256: assessment.candidate.distSha256, - lockfileSha256: assessment.candidate.lockfileSha256, - bundleSha256: supplyChainDigest(files), - files, - }; - await mkdir(path.join(root, "artifacts/release"), { recursive: true }); - await writeFile( - path.join(root, "artifacts/release/release-candidate.json"), - `${JSON.stringify(manifest)}\n`, - ); - return { root, manifest, assessmentSha256: digestBytes(assessmentBytes) }; -} diff --git a/tests/unit/security-promotion-staging.test.ts b/tests/unit/security-promotion-staging.test.ts new file mode 100644 index 0000000..69ee88f --- /dev/null +++ b/tests/unit/security-promotion-staging.test.ts @@ -0,0 +1,370 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { PROMOTED_FILE_NAMES } from "../../scripts/contracts/promotion-artifacts.ts"; +import { publishPrivatePromotionStaging } from "../../scripts/lib/promotion-stager.ts"; +import { verifyExactPromotionBundle } from "../../scripts/lib/exact-promotion-bundle.ts"; +import { + PROCESS_HEAVY_TIMEOUT_MS, + privatePromotionFiles, + syntheticSignedPromotionBundle, +} from "./security-followup-fixture.ts"; + +describe("security private promotion staging contracts", () => { + it("forces exact private staging modes in an isolated child with umask 077", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-umask-")); + try { + const stagerUrl = pathToFileURL( + path.join(process.cwd(), "scripts/lib/promotion-stager.ts"), + ).href; + const contractsUrl = pathToFileURL( + path.join(process.cwd(), "scripts/contracts/promotion-artifacts.ts"), + ).href; + const childPath = path.join(root, "umask-child.mjs"); + const resultPath = path.join(root, "result.json"); + await writeFile(resultPath, "{}\n", { mode: 0o600 }); + await writeFile( + childPath, + [ + `import { lstat, writeFile } from "node:fs/promises";`, + `import path from "node:path";`, + `import { createHash } from "node:crypto";`, + `import { cleanupFinalizedPromotion, publishPrivatePromotionStaging } from ${JSON.stringify(stagerUrl)};`, + `import { PROMOTED_FILE_NAMES } from ${JSON.stringify(contractsUrl)};`, + `process.umask(Number.parseInt(process.argv[2], 8));`, + `const runnerTempRoot = process.argv[3];`, + `const files = PROMOTED_FILE_NAMES.map((name) => { const bytes = Buffer.from(name); return { name, bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; });`, + `const finalized = await publishPrivatePromotionStaging(runnerTempRoot, { id: "umask", attempt: 1 }, files, () => Buffer.alloc(16, 1));`, + `const directoryMode = (await lstat(finalized.stagingRoot)).mode & 0o777;`, + `const fileModes = await Promise.all(PROMOTED_FILE_NAMES.map(async (name) => (await lstat(path.join(finalized.stagingRoot, name))).mode & 0o777));`, + `await cleanupFinalizedPromotion({ runnerTempRoot, stagingRoot: finalized.stagingRoot, cleanupToken: finalized.cleanupToken, runnerTempIdentity: finalized.runnerTempIdentity, stagingIdentity: finalized.stagingIdentity });`, + `await writeFile(process.argv[4], JSON.stringify({ directoryMode, fileModes }));`, + ].join("\n"), + ); + const child = spawnSync(process.execPath, [childPath, "077", root, resultPath], { + cwd: root, + encoding: "utf8", + timeout: 30_000, + }); + expect(child.status, `${child.stdout}\n${child.stderr}`).toBe(0); + expect(JSON.parse(await readFile(resultPath, "utf8"))).toEqual({ + directoryMode: 0o700, + fileModes: [0o400, 0o400, 0o400, 0o400, 0o400], + }); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects a staged file unlinked and recreated after its original write", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-recreate-")); + const files = privatePromotionFiles(); + const token = `promotion-seal-1-${"11".repeat(16)}`; + try { + await expect( + publishPrivatePromotionStaging( + root, + { id: "seal", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x11), + undefined, + async (name) => { + if (name !== PROMOTED_FILE_NAMES.at(-1)) return; + const first = path.join(root, token, PROMOTED_FILE_NAMES[0]); + await rm(first); + await writeFile(first, "replacement bytes\n", { mode: 0o400 }); + }, + ), + ).rejects.toThrow(/staged.*digest|inode|seal/u); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects staged mode drift before returning the upload root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-seal-mode-")); + const files = privatePromotionFiles(); + const token = `promotion-seal-1-${"12".repeat(16)}`; + try { + await expect( + publishPrivatePromotionStaging( + root, + { id: "seal", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x12), + undefined, + async (name) => { + if (name === PROMOTED_FILE_NAMES.at(-1)) { + await chmod(path.join(root, token, PROMOTED_FILE_NAMES[0]), 0o600); + } + }, + ), + ).rejects.toThrow(/mode.*0400|staged.*mode|seal/u); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("rejects staging leaf replacement between mkdir and descriptor open", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-replace-")); + const files = privatePromotionFiles(); + const token = `promotion-preopen-1-${"13".repeat(16)}`; + const displaced = path.join(root, `${token}-displaced`); + const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); + try { + await expect( + publishPrivatePromotionStaging( + root, + { id: "preopen", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x13), + undefined, + undefined, + undefined, + async (stagingRoot) => { + await rename(stagingRoot, displaced); + await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(replacementCanary, "external replacement canary\n"); + }, + ), + ).rejects.toThrow(/staging leaf.*changed|mkdir.*open|identity/u); + await expect(readFile(replacementCanary, "utf8")).resolves.toBe( + "external replacement canary\n", + ); + await expect(readdir(displaced)).resolves.toEqual([]); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("does not scan a crowded parent to recover an unverified pre-open leaf", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-bounded-")); + const files = privatePromotionFiles(); + const token = `promotion-preopen-bound-1-${"14".repeat(16)}`; + const displaced = path.join(root, `${token}-displaced`); + const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); + try { + for (let offset = 0; offset < 4_097; offset += 128) { + await Promise.all( + Array.from( + { length: Math.min(128, 4_097 - offset) }, + (_, index) => + mkdir( + path.join( + root, + `noise-${String(offset + index).padStart(4, "0")}`, + ), + ), + ), + ); + } + let failure: unknown; + try { + await publishPrivatePromotionStaging( + root, + { id: "preopen-bound", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x14), + undefined, + undefined, + undefined, + async (stagingRoot) => { + await rename(stagingRoot, displaced); + await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(replacementCanary, "external replacement canary\n"); + }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(AggregateError); + expect((failure as Error).message).toMatch(/staging leaf.*identity/i); + await expect(readdir(displaced)).resolves.toEqual([]); + await expect(readFile(replacementCanary, "utf8")).resolves.toBe( + "external replacement canary\n", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, 20_000); + + it("leaves a non-empty moved original untouched after pre-open mismatch", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-nonempty-")); + const files = privatePromotionFiles(); + const token = `promotion-preopen-nonempty-1-${"15".repeat(16)}`; + const displaced = path.join(root, `${token}-displaced`); + const ownedResidual = path.join(displaced, "owned-residual"); + const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); + try { + let failure: unknown; + try { + await publishPrivatePromotionStaging( + root, + { id: "preopen-nonempty", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x15), + undefined, + undefined, + undefined, + async (stagingRoot) => { + await rename(stagingRoot, displaced); + await writeFile(ownedResidual, "owned residual\n"); + await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(replacementCanary, "external replacement canary\n"); + }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(AggregateError); + expect((failure as Error).message).toMatch(/staging leaf.*identity/i); + await expect(readFile(ownedResidual, "utf8")).resolves.toBe( + "owned residual\n", + ); + await expect(readFile(replacementCanary, "utf8")).resolves.toBe( + "external replacement canary\n", + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("does not search outside the parent for a moved unverified original", async () => { + const root = await mkdtemp(path.join(tmpdir(), "promotion-preopen-missing-")); + const outside = await mkdtemp(path.join(tmpdir(), "promotion-preopen-moved-")); + const files = privatePromotionFiles(); + const token = `promotion-preopen-missing-1-${"16".repeat(16)}`; + const displaced = path.join(outside, token); + const replacementCanary = path.join(root, token, PROMOTED_FILE_NAMES[0]); + try { + let failure: unknown; + try { + await publishPrivatePromotionStaging( + root, + { id: "preopen-missing", attempt: 1 }, + files, + () => Buffer.alloc(16, 0x16), + undefined, + undefined, + undefined, + async (stagingRoot) => { + await rename(stagingRoot, displaced); + await mkdir(stagingRoot, { mode: 0o700 }); + await writeFile(replacementCanary, "external replacement canary\n"); + }, + ); + } catch (error) { + failure = error; + } + expect(failure).toBeInstanceOf(Error); + expect(failure).not.toBeInstanceOf(AggregateError); + expect((failure as Error).message).toMatch(/staging leaf.*identity/i); + await expect(lstat(displaced)).resolves.toEqual( + expect.objectContaining({ dev: expect.any(Number), ino: expect.any(Number) }), + ); + await expect(readFile(replacementCanary, "utf8")).resolves.toBe( + "external replacement canary\n", + ); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } + }); + + it("rejects a fresh signed exact-five bundle replayed under a different expected run", async () => { + const fixture = syntheticSignedPromotionBundle(); + await expect( + verifyExactPromotionBundle(fixture.files, { + ...fixture.verification, + expected: { + ...fixture.verification.expected, + run: { id: "different-run", attempt: 1 }, + }, + }), + ).rejects.toThrow(/external expected run.*mismatch|expected promotion run/u); + }); + + it("requires every external expected identity variable at the exact promotion CLI", async () => { + const fixture = syntheticSignedPromotionBundle(); + const root = await mkdtemp(path.join(tmpdir(), "promotion-replay-cli-")); + const bundleRoot = path.join(root, "bundle"); + try { + await mkdir(bundleRoot); + for (const [name, bytes] of Object.entries(fixture.files)) { + await writeFile(path.join(bundleRoot, name), bytes); + } + await writeFile(path.join(root, "vulnerability.pem"), fixture.vulnerabilityPem); + await writeFile(path.join(root, "provenance.pem"), fixture.provenancePem); + const cliPath = path.join(process.cwd(), "scripts/verify-exact-promotion-bundle.ts"); + const baseEnvironment: NodeJS.ProcessEnv = { + ...process.env, + PROMOTION_BUNDLE_ROOT: bundleRoot, + VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem", + VULNERABILITY_KEY_ID: "synthetic-vulnerability", + PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem", + PROVENANCE_KEY_ID: "synthetic-provenance", + EXPECTED_PROMOTION_RUN_ID: fixture.verification.expected.run.id, + EXPECTED_PROMOTION_RUN_ATTEMPT: String( + fixture.verification.expected.run.attempt, + ), + EXPECTED_PROMOTION_SOURCE_REVISION: + fixture.verification.expected.sourceRevision, + EXPECTED_PROMOTION_ARCHIVE_SHA256: + fixture.verification.expected.archiveSha256, + }; + const requiredExpected = [ + "EXPECTED_PROMOTION_RUN_ID", + "EXPECTED_PROMOTION_RUN_ATTEMPT", + "EXPECTED_PROMOTION_SOURCE_REVISION", + "EXPECTED_PROMOTION_ARCHIVE_SHA256", + ] as const; + for (const missing of requiredExpected) { + const environment = { ...baseEnvironment }; + delete environment[missing]; + const result = spawnSync(process.execPath, [cliPath], { + cwd: root, + encoding: "utf8", + env: environment, + }); + expect(result.status, missing).not.toBe(0); + expect(result.stderr, missing).toContain( + `exact promotion verification environment is missing ${missing}`, + ); + } + for (const [name, value, diagnostic] of [ + ["EXPECTED_PROMOTION_RUN_ID", "different-run", /external expected run.*mismatch/u], + ["EXPECTED_PROMOTION_RUN_ATTEMPT", "2", /external expected run.*mismatch/u], + ["EXPECTED_PROMOTION_SOURCE_REVISION", "f".repeat(40), /external expected source revision.*mismatch/u], + ["EXPECTED_PROMOTION_ARCHIVE_SHA256", "0".repeat(64), /external expected archive digest.*mismatch/u], + ] as const) { + const result = spawnSync(process.execPath, [cliPath], { + cwd: root, + encoding: "utf8", + env: { ...baseEnvironment, [name]: value }, + }); + expect(result.status, name).not.toBe(0); + expect(result.stderr, name).toMatch(diagnostic); + } + } finally { + await rm(root, { recursive: true, force: true }); + } + }, PROCESS_HEAVY_TIMEOUT_MS); +}); diff --git a/tests/unit/security-provider-evidence.test.ts b/tests/unit/security-provider-evidence.test.ts new file mode 100644 index 0000000..778498b --- /dev/null +++ b/tests/unit/security-provider-evidence.test.ts @@ -0,0 +1,708 @@ +import { generateKeyPairSync } from "node:crypto"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { EventEmitter } from "node:events"; + +import { describe, expect, it } from "vitest"; + +import { + evaluatePromotionEvidence, + providerPublicKeyFingerprint, + validateProviderEvidence, +} from "../../scripts/lib/provider-evidence.ts"; +import { readProviderTrust } from "../../scripts/lib/provider-trust.ts"; +import { superviseProviderEvidence } from "../../scripts/lib/provider-supervisor.ts"; +import { runProviderProcess } from "../../scripts/lib/provider-process-runner.ts"; +import { runStageVerifiedPromotionCli } from "../../scripts/lib/stage-verified-promotion-cli.ts"; +import type { ReleaseCandidateManifest } from "../../scripts/lib/release-candidate.ts"; +import { + digest, + providerExpectedContext, + providerUnsigned, + signedProviderV2, + trust, +} from "./security-followup-fixture.ts"; + +describe("security provider evidence contracts", () => { + it("accepts signed provider v2 evidence only for the exact run, source, archive, and nonce", () => { + const now = Date.parse("2026-08-02T01:00:00.000Z"); + const vulnerabilityKeys = generateKeyPairSync("ed25519"); + const provenanceKeys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + const vulnerability = signedProviderV2( + { + source: expected.source, + candidate: expected.candidate, + schemaVersion: 2, + evidenceType: "vulnerability-report", + provider: "fixture-vulnerability", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expected.run, invocationNonce: "1".repeat(64) }, + secretScanAttestation: expected.secretScanAttestation, + findings: [], + }, + "vulnerability-key", + vulnerabilityKeys.publicKey, + vulnerabilityKeys.privateKey, + ); + const provenance = signedProviderV2( + { + source: expected.source, + candidate: expected.candidate, + schemaVersion: 2, + evidenceType: "provenance-attestation", + provider: "fixture-provenance", + signer: "fixture-workload", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expected.run, invocationNonce: "2".repeat(64) }, + subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } }, + }, + "provenance-key", + provenanceKeys.publicKey, + provenanceKeys.privateKey, + ); + const result = evaluatePromotionEvidence({ + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + }, + localStatus: "PASS", + vulnerabilityReport: vulnerability, + provenanceAttestation: provenance, + vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey), + provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => now, + }); + + expect(result).toEqual({ + status: "PASS", + vulnerabilityStatus: "PASS", + provenanceAttestationStatus: "PASS", + failures: [], + }); + const replayed = evaluatePromotionEvidence({ + expected: { + ...expected, + run: { id: expected.run.id, attempt: 2 }, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + }, + localStatus: "PASS", + vulnerabilityReport: vulnerability, + provenanceAttestation: provenance, + vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey), + provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => now, + }); + expect(replayed.status).toBe("FAIL_UNVERIFIED"); + expect(replayed.failures).toEqual( + expect.arrayContaining([ + "vulnerability report run identity mismatch", + "provenance attestation run identity mismatch", + ]), + ); + }); + + it("rejects a signed vulnerability PASS when the captured SARIF attestation differs", () => { + const keys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + const secretScanAttestation = { + status: "PASS" as const, + localEvidenceAssessmentSha256: digest("assessment"), + sourceSetSha256: expected.source.sourceSetSha256, + policySha256: digest("secret policy"), + sarifSha256: digest("real sarif"), + scanInputSha256: digest("scan input"), + }; + const report = signedProviderV2( + { + schemaVersion: 2, + evidenceType: "vulnerability-report", + provider: "fixture-vulnerability", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expected.run, invocationNonce: "1".repeat(64) }, + source: expected.source, + candidate: expected.candidate, + secretScanAttestation, + findings: [], + }, + "vulnerability-key", + keys.publicKey, + keys.privateKey, + ); + const validated = validateProviderEvidence({ + kind: "vulnerability", + value: report, + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + secretScanAttestation: { + ...secretScanAttestation, + sarifSha256: digest("forged empty sarif"), + }, + }, + trust: trust("vulnerability-key", keys.publicKey), + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + }); + expect(validated.status).toBe("FAIL_UNVERIFIED"); + expect(validated.failures).toContain( + "vulnerability report secret scan attestation mismatch", + ); + const forged = structuredClone(report); + forged.secretScanAttestation.sarifSha256 = digest("forged empty sarif"); + const forgedValidation = validateProviderEvidence({ + kind: "vulnerability", + value: forged, + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + secretScanAttestation: forged.secretScanAttestation, + }, + trust: trust("vulnerability-key", keys.publicKey), + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + }); + expect(forgedValidation.failures).toContain( + "vulnerability report signature verification failed", + ); + + const provenanceKeys = generateKeyPairSync("ed25519"); + const provenance = signedProviderV2( + { + schemaVersion: 2, + evidenceType: "provenance-attestation", + provider: "fixture-provenance", + signer: "fixture-workload", + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T02:00:00.000Z", + run: { ...expected.run, invocationNonce: "2".repeat(64) }, + source: expected.source, + candidate: expected.candidate, + subject: { name: "dist", digest: { sha256: expected.candidate.distSha256 } }, + }, + "provenance-key", + provenanceKeys.publicKey, + provenanceKeys.privateKey, + ); + const evaluated = evaluatePromotionEvidence({ + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + secretScanAttestation: { + ...secretScanAttestation, + sarifSha256: digest("forged empty sarif"), + }, + }, + localStatus: "PASS", + vulnerabilityReport: report, + provenanceAttestation: provenance, + vulnerabilityTrust: trust("vulnerability-key", keys.publicKey), + provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + }); + expect(evaluated.vulnerabilityStatus).toBe("FAIL_UNVERIFIED"); + expect(evaluated.failures).toContain( + "vulnerability report secret scan attestation mismatch", + ); + }); + + it.each(["vulnerability", "provenance"] as const)( + "rejects correctly re-signed %s v2 context/time/replay drift", + (kind) => { + const now = Date.parse("2026-08-02T01:00:00.000Z"); + const vulnerabilityKeys = generateKeyPairSync("ed25519"); + const provenanceKeys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + const baseVulnerability = providerUnsigned("vulnerability", expected); + const baseProvenance = providerUnsigned("provenance", expected); + const validVulnerability = signedProviderV2( + baseVulnerability, + "vulnerability-key", + vulnerabilityKeys.publicKey, + vulnerabilityKeys.privateKey, + ); + const validProvenance = signedProviderV2( + baseProvenance, + "provenance-key", + provenanceKeys.publicKey, + provenanceKeys.privateKey, + ); + const rawCases: Array) => Record, + RegExp, + ]> = [ + ["schema v1", (value) => ({ ...value, schemaVersion: 1 }), /missing or invalid/u], + [ + "evidence type", + (value) => ({ + ...value, + evidenceType: + kind === "vulnerability" + ? "provenance-attestation" + : "vulnerability-report", + }), + /missing or invalid/u, + ], + ...(["archiveSha256", "bundleSha256", "distSha256", "lockfileSha256"] as const).map( + (field) => [ + `candidate ${field}`, + (value: Record) => ({ + ...value, + candidate: { ...value.candidate, [field]: "f".repeat(64) }, + ...(kind === "provenance" && field === "distSha256" + ? { + subject: { + name: "dist", + digest: { sha256: "f".repeat(64) }, + }, + } + : {}), + }), + /candidate identity|subject dist/u, + ] as const, + ), + [ + "different archive with same dist and lockfile", + (value) => ({ + ...value, + candidate: { ...value.candidate, archiveSha256: "e".repeat(64) }, + }), + /candidate identity/u, + ], + [ + "source revision", + (value) => ({ ...value, source: { ...value.source, revision: "c".repeat(40) } }), + /source identity/u, + ], + [ + "source set", + (value) => ({ ...value, source: { ...value.source, sourceSetSha256: "c".repeat(64) } }), + /source identity/u, + ], + [ + "run id", + (value) => ({ ...value, run: { ...value.run, id: "other-run" } }), + /run identity/u, + ], + [ + "run attempt replay", + (value) => ({ ...value, run: { ...value.run, attempt: 2 } }), + /run identity/u, + ], + [ + "different nonce", + (value) => ({ ...value, run: { ...value.run, invocationNonce: "3".repeat(64) } }), + /invocation nonce/u, + ], + [ + "missing nonce", + (value) => { + const run = { ...value.run }; + delete run.invocationNonce; + return { ...value, run }; + }, + /missing or invalid/u, + ], + [ + "uppercase nonce", + (value) => ({ ...value, run: { ...value.run, invocationNonce: "A".repeat(64) } }), + /missing or invalid/u, + ], + [ + "short nonce", + (value) => ({ ...value, run: { ...value.run, invocationNonce: "1".repeat(62) } }), + /missing or invalid/u, + ], + [ + "issued future boundary", + (value) => ({ ...value, issuedAt: "2026-08-02T01:05:00.001Z" }), + /future skew/u, + ], + [ + "expiry equality", + (value) => ({ ...value, expiresAt: "2026-08-02T01:00:00.000Z" }), + /expired/u, + ], + [ + "expiry past", + (value) => ({ ...value, expiresAt: "2026-08-02T00:59:59.999Z" }), + /expired/u, + ], + [ + "zero lifetime", + (value) => ({ + ...value, + issuedAt: "2026-08-02T01:01:00.000Z", + expiresAt: "2026-08-02T01:01:00.000Z", + }), + /not positive/u, + ], + [ + "negative lifetime", + (value) => ({ + ...value, + issuedAt: "2026-08-02T01:02:00.000Z", + expiresAt: "2026-08-02T01:01:59.999Z", + }), + /not positive/u, + ], + [ + "lifetime above two hours", + (value) => ({ + ...value, + issuedAt: "2026-08-02T01:00:00.000Z", + expiresAt: "2026-08-02T03:00:00.001Z", + }), + /exceeds two hours/u, + ], + [ + "wrong fingerprint", + (value) => ({ + ...value, + signature: { + ...value.signature, + publicKeyFingerprint: `sha256:${"d".repeat(64)}`, + }, + }), + /trust identity/u, + ], + ]; + const cases = rawCases.map(([name, mutate, failure]) => ({ + name, + mutate, + failure, + })); + + for (const testCase of cases) { + const base = kind === "vulnerability" ? baseVulnerability : baseProvenance; + const mutated = testCase.mutate(structuredClone(base)); + const resigned = signedProviderV2( + mutated, + kind === "vulnerability" ? "vulnerability-key" : "provenance-key", + kind === "vulnerability" ? vulnerabilityKeys.publicKey : provenanceKeys.publicKey, + kind === "vulnerability" ? vulnerabilityKeys.privateKey : provenanceKeys.privateKey, + "signature" in mutated && mutated.signature?.publicKeyFingerprint + ? mutated.signature.publicKeyFingerprint + : undefined, + ); + const result = evaluatePromotionEvidence({ + expected: { + ...expected, + vulnerabilityInvocationNonce: "1".repeat(64), + provenanceInvocationNonce: "2".repeat(64), + }, + localStatus: "PASS", + vulnerabilityReport: + kind === "vulnerability" ? resigned : validVulnerability, + provenanceAttestation: + kind === "provenance" ? resigned : validProvenance, + vulnerabilityTrust: trust("vulnerability-key", vulnerabilityKeys.publicKey), + provenanceTrust: trust("provenance-key", provenanceKeys.publicKey), + nowEpochMs: () => now, + }); + expect(result.status, testCase.name).toBe("FAIL_UNVERIFIED"); + expect(result.failures.join("\n"), testCase.name).toMatch(testCase.failure); + } + }, + ); + + it("canonicalizes provider fingerprints from DER SPKI across PEM wrapping and rejects Ed448", async () => { + const root = await mkdtemp(path.join(tmpdir(), "provider-fingerprint-")); + try { + const ed25519 = generateKeyPairSync("ed25519").publicKey; + const pem = ed25519.export({ type: "spki", format: "pem" }).toString(); + const body = pem.replace(/-----[^-]+-----|\s/gu, ""); + const wrapped = (width: number) => + `-----BEGIN PUBLIC KEY-----\n${body.match(new RegExp(`.{1,${width}}`, "gu"))!.join("\n")}\n-----END PUBLIC KEY-----\n`; + await writeFile(path.join(root, "a.pem"), wrapped(64)); + await writeFile(path.join(root, "b.pem"), wrapped(32)); + const first = await readProviderTrust(root, "a.pem", "fixture-key"); + const second = await readProviderTrust(root, "b.pem", "fixture-key"); + expect(first?.publicKeyFingerprint).toBe(providerPublicKeyFingerprint(ed25519)); + expect(second?.publicKeyFingerprint).toBe(first?.publicKeyFingerprint); + + const ed448 = generateKeyPairSync("ed448").publicKey; + await writeFile(root + "/ed448.pem", ed448.export({ type: "spki", format: "pem" })); + await expect(readProviderTrust(root, "ed448.pem", "fixture-key")).resolves.toBeNull(); + expect(() => providerPublicKeyFingerprint(ed448)).toThrow(/must be Ed25519/u); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("captures the downloaded archive pathname exactly once in the provider supervisor", async () => { + const keys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + let captureCount = 0; + let receivedEnvironment: Readonly> | undefined; + const manifest: ReleaseCandidateManifest = { + schemaVersion: 1, + distSha256: expected.candidate.distSha256, + lockfileSha256: expected.candidate.lockfileSha256, + bundleSha256: expected.candidate.bundleSha256, + files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }], + }; + const result = await superviseProviderEvidence( + { + kind: "vulnerability", + archivePath: "/downloads/candidate.tar.gz", + expectedArchiveSha256: expected.candidate.archiveSha256, + expectedRun: { + id: expected.run.id, + attempt: expected.run.attempt, + sourceRevision: expected.source.revision, + }, + trust: trust("vulnerability-key", keys.publicKey), + executeProvider: async ({ environment }) => { + receivedEnvironment = environment; + }, + captureReport: async () => Buffer.from("{}\n"), + }, + { + captureArchive: async (input) => { + captureCount += 1; + expect(input).toEqual({ + archivePath: "/downloads/candidate.tar.gz", + expectedSha256: expected.candidate.archiveSha256, + }); + return { + bytes: Buffer.from("captured archive"), + archiveSha256: expected.candidate.archiveSha256, + }; + }, + withVerifiedCandidate: (async (input: any) => + input.verify({ extractionRoot: "/captured/extraction", manifest })) as any, + verifyLocalEvidence: async () => ({ + status: "PASS", + identity: { + sourceRevision: expected.source.revision, + sourceSetSha256: expected.source.sourceSetSha256, + assessmentSha256: digest("assessment"), + secretScan: { + policySha256: digest("provider secret policy"), + sarifSha256: digest("provider secret sarif"), + scanInputSha256: digest("provider secret input"), + }, + }, + failures: [], + }), + validateUpload: (async (input: any) => { + expect("archivePath" in input).toBe(false); + return { sealed: true }; + }) as any, + randomBytes: () => Buffer.alloc(32, 0x11), + nowEpochMs: () => Date.parse("2026-08-02T01:00:00.000Z"), + }, + ); + + expect(captureCount).toBe(1); + expect(receivedEnvironment).toEqual( + expect.objectContaining({ + PROVIDER_EVIDENCE_SCHEMA_VERSION: "2", + PROVIDER_INVOCATION_NONCE: "11".repeat(32), + PROVIDER_ISSUED_AT: "2026-08-02T01:00:00.000Z", + PROVIDER_EXPIRES_AT: "2026-08-02T02:00:00.000Z", + CI_RUN_ID: expected.run.id, + CI_RUN_ATTEMPT: "1", + SOURCE_REVISION: expected.source.revision, + CANDIDATE_ARCHIVE_SHA256: expected.candidate.archiveSha256, + }), + ); + expect(result.evidence).toEqual({ sealed: true }); + }); + + it("samples provider freshness after report capture instead of reusing issuance time", async () => { + const keys = generateKeyPairSync("ed25519"); + const expected = providerExpectedContext(); + const manifest: ReleaseCandidateManifest = { + schemaVersion: 1, + distSha256: expected.candidate.distSha256, + lockfileSha256: expected.candidate.lockfileSha256, + bundleSha256: expected.candidate.bundleSha256, + files: [{ path: "pnpm-lock.yaml", bytes: 1, sha256: expected.candidate.lockfileSha256 }], + }; + const issuedSample = Date.parse("2026-08-02T01:00:00.000Z"); + const validationSample = Date.parse("2026-08-02T02:00:00.001Z"); + const samples = [issuedSample, validationSample]; + let issuedAt = ""; + await expect( + superviseProviderEvidence( + { + kind: "vulnerability", + archivePath: "/downloads/candidate.tar.gz", + expectedArchiveSha256: expected.candidate.archiveSha256, + expectedRun: { + id: expected.run.id, + attempt: expected.run.attempt, + sourceRevision: expected.source.revision, + }, + trust: trust("vulnerability-key", keys.publicKey), + executeProvider: async ({ environment }) => { + issuedAt = environment.PROVIDER_ISSUED_AT!; + }, + captureReport: async () => Buffer.from("{}\n"), + }, + { + captureArchive: async () => ({ + bytes: Buffer.from("captured archive"), + archiveSha256: expected.candidate.archiveSha256, + }), + withVerifiedCandidate: (async (input: any) => + input.verify({ extractionRoot: "/captured/extraction", manifest })) as any, + verifyLocalEvidence: async () => ({ + status: "PASS", + identity: { + sourceRevision: expected.source.revision, + sourceSetSha256: expected.source.sourceSetSha256, + assessmentSha256: digest("assessment"), + secretScan: { + policySha256: digest("provider secret policy"), + sarifSha256: digest("provider secret sarif"), + scanInputSha256: digest("provider secret input"), + }, + }, + failures: [], + }), + validateUpload: (async (input: any) => { + expect(input.nowEpochMs()).toBe(validationSample); + throw new Error("provider report expired during execution"); + }) as any, + randomBytes: () => Buffer.alloc(32, 0x33), + nowEpochMs: () => samples.shift()!, + }, + ), + ).rejects.toThrow(/expired during execution/u); + expect(issuedAt).toBe("2026-08-02T01:00:00.000Z"); + }); + + it("kills a timed-out provider but settles only after the child closes", async () => { + const child = new EventEmitter() as EventEmitter & { + kill(signal: NodeJS.Signals): boolean; + }; + let killedWith: NodeJS.Signals | undefined; + child.kill = (signal) => { + killedWith = signal; + return true; + }; + let fireTimeout: (() => void) | undefined; + let settled = false; + const running = runProviderProcess( + { executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 }, + { + spawnChild: () => child as any, + setTimer: (callback) => { + fireTimeout = callback; + return 1 as any; + }, + clearTimer: () => undefined, + }, + ).finally(() => { + settled = true; + }); + fireTimeout?.(); + await Promise.resolve(); + expect(killedWith).toBe("SIGKILL"); + expect(settled).toBe(false); + child.emit("close", null, "SIGKILL"); + await expect(running).rejects.toThrow(/timed out/u); + expect(settled).toBe(true); + }); + + it("captures process-group kill errors, attempts child fallback, and settles after close", async () => { + const child = new EventEmitter() as EventEmitter & { + pid: number; + kill(signal: NodeJS.Signals): boolean; + }; + child.pid = 12_346; + let fallbackSignal: NodeJS.Signals | undefined; + child.kill = (signal) => { + fallbackSignal = signal; + return true; + }; + let fireTimeout: (() => void) | undefined; + const running = runProviderProcess( + { executable: "/usr/bin/bwrap", arguments: [], environment: {}, timeoutMs: 1 }, + { + spawnChild: () => child as any, + setTimer: (callback) => { + fireTimeout = callback; + return 1 as any; + }, + clearTimer: () => undefined, + killProcessGroup: () => { + throw Object.assign(new Error("group kill denied"), { code: "EPERM" }); + }, + }, + ); + expect(() => fireTimeout?.()).not.toThrow(); + expect(fallbackSignal).toBe("SIGKILL"); + child.emit("close", null, "SIGKILL"); + await expect(running).rejects.toThrow(/timed out.*kill failed.*close/u); + }); + + it.each(["open failure", "partial write failure"])( + "cleans finalized staging from memory when GITHUB_OUTPUT has a %s", + async (failureKind) => { + const finalized = { + stagingRoot: "/runner/promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + cleanupToken: "promotion-run-1-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + runnerTempIdentity: { dev: 10, ino: 20 }, + stagingIdentity: { dev: 30, ino: 40 }, + files: [], + } as const; + let cleanupInput: unknown; + let appendCalls = 0; + const environment = { + CANDIDATE_ARCHIVE_PATH: "candidate.tar.gz", + CANDIDATE_ARCHIVE_SHA256: "a".repeat(64), + VULNERABILITY_REPORT_PATH: "vulnerability.json", + PROVENANCE_ATTESTATION_PATH: "provenance.json", + VULNERABILITY_PUBLIC_KEY_PATH: "vulnerability.pem", + VULNERABILITY_KEY_ID: "vulnerability-key", + PROVENANCE_PUBLIC_KEY_PATH: "provenance.pem", + PROVENANCE_KEY_ID: "provenance-key", + CI_RUN_ID: "run", + CI_RUN_ATTEMPT: "1", + VITE_COMMIT_SHA: "b".repeat(40), + VULNERABILITY_INVOCATION_NONCE: "c".repeat(64), + PROVENANCE_INVOCATION_NONCE: "d".repeat(64), + RUNNER_TEMP: "/runner", + GITHUB_OUTPUT: "/runner/github-output", + }; + await expect( + runStageVerifiedPromotionCli(environment, { + cwd: () => "/workspace", + finalize: async () => finalized as any, + appendOutput: async () => { + appendCalls += 1; + if (failureKind === "partial write failure") { + // The output sink accepted an unspecified prefix before rejecting. + } + throw new Error(failureKind); + }, + cleanup: async (input) => { + cleanupInput = input; + }, + writeStdout: () => undefined, + }), + ).rejects.toThrow(new RegExp(failureKind, "u")); + expect(appendCalls).toBe(1); + expect(cleanupInput).toEqual({ + runnerTempRoot: "/runner", + stagingRoot: finalized.stagingRoot, + cleanupToken: finalized.cleanupToken, + runnerTempIdentity: finalized.runnerTempIdentity, + stagingIdentity: finalized.stagingIdentity, + }); + }, + ); + +}); diff --git a/tests/unit/task3-selective-integration.test.ts b/tests/unit/task3-selective-integration.test.ts index c0370e0..12b9b6b 100644 --- a/tests/unit/task3-selective-integration.test.ts +++ b/tests/unit/task3-selective-integration.test.ts @@ -182,8 +182,8 @@ describe("selective Task 3 contract closure", () => { it.skipIf(isReducedCiContractRun())("accepts only the canonical exact-count authority and rejects orphan retention", async () => { const canonical = await loadCiGateContract(process.cwd()); expect(canonical.gates).toHaveLength(27); - expect(canonical.commands).toHaveLength(84); - expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(96); + expect(canonical.commands).toHaveLength(91); + expect(canonical.gates.reduce((sum, gate) => sum + gate.commandIds.length, 0)).toBe(103); expect(canonical.artifacts).toHaveLength(109); expect(canonical.stages).toHaveLength(5); expect(canonical.retention.classes).toHaveLength(5);