11import { execFile } from "node:child_process" ;
22import { existsSync } from "node:fs" ;
3- import { mkdir , mkdtemp , readFile , rm , writeFile } from "node:fs/promises" ;
3+ import {
4+ chmod ,
5+ mkdir ,
6+ mkdtemp ,
7+ readFile ,
8+ rm ,
9+ writeFile ,
10+ } from "node:fs/promises" ;
411import { createServer , type Server } from "node:http" ;
512import { tmpdir } from "node:os" ;
613import { join , resolve } from "node:path" ;
714import { promisify } from "node:util" ;
815
916import { afterEach , beforeEach , describe , expect , it } from "vitest" ;
1017
18+ import { restoreRule } from "../src/api/restore" ;
1119import { canonicalHash } from "../src/rules/rule-hash" ;
1220import { migrateFixture } from "./support/current-project" ;
1321
@@ -33,6 +41,28 @@ interface Mock {
3341 close : ( ) => Promise < void > ;
3442}
3543
44+ /**
45+ * A reconcile handler normally returns the response body directly (always
46+ * HTTP 200). A handler that instead needs to exercise a non-2xx reconcile
47+ * response (e.g. the unauthorized branch of `planRuntime`) returns this
48+ * wrapper, distinguished by its `statusCode` field — no ordinary reconcile
49+ * response body (`{ run, unsafe, unknown, missing }`) has one.
50+ */
51+ interface HttpOverride {
52+ statusCode : number ;
53+ body : unknown ;
54+ }
55+
56+ function isHttpOverride ( value : unknown ) : value is HttpOverride {
57+ return (
58+ typeof value === "object" &&
59+ value !== null &&
60+ "statusCode" in value &&
61+ typeof ( value as { statusCode : unknown } ) . statusCode === "number" &&
62+ "body" in value
63+ ) ;
64+ }
65+
3666function startMock ( handlers : {
3767 reconcile : ( body : {
3868 files : { file : string ; signature : string } [ ] ;
@@ -46,10 +76,13 @@ function startMock(handlers: {
4676 request . on ( "end" , ( ) => {
4777 const url = request . url ?? "" ;
4878 if ( url === "/cli/api/reconcile" ) {
49- const body = handlers . reconcile (
79+ const result = handlers . reconcile (
5080 JSON . parse ( raw ) as { files : { file : string ; signature : string } [ ] }
5181 ) ;
52- response . writeHead ( 200 , { "content-type" : "application/json" } ) ;
82+ const { statusCode, body } = isHttpOverride ( result )
83+ ? result
84+ : { statusCode : 200 , body : result } ;
85+ response . writeHead ( statusCode , { "content-type" : "application/json" } ) ;
5386 response . end ( JSON . stringify ( body ) ) ;
5487 return ;
5588 }
@@ -449,4 +482,287 @@ describe("repairing a drifted runtime rule, end to end", () => {
449482 await mock . close ( ) ;
450483 }
451484 } ) ;
485+
486+ it ( "says '1 stale entry ... it' when the repair leaves exactly one behind" , async ( ) => {
487+ // plan.ts's `repairWithheldRules` wraps `PurgeIncompleteError` in its own
488+ // wording rather than reusing the error's message, and pluralizes it by
489+ // hand — a seam `deliver.test.ts` cannot see, since it only ever throws
490+ // the error, never renders this notice.
491+ const rule = join ( directory , ".taskless" , "rules" , "runtime" , "demo" ) ;
492+ await mkdir ( join ( rule , "blocked" ) , { recursive : true } ) ;
493+ await writeFile ( join ( rule , "blocked" , "a.txt" ) , "stuck\n" , "utf8" ) ;
494+ await chmod ( join ( rule , "blocked" ) , 0o500 ) ;
495+
496+ const blessed = await canonicalHash ( BLESSED ) ;
497+ const mock = await startMock ( {
498+ reconcile : driftedReconcile ( blessed ) ,
499+ restore : ( ) => ( {
500+ statusCode : 200 ,
501+ body : {
502+ ruleId : "demo" ,
503+ rules : [
504+ {
505+ id : "demo" ,
506+ engine : "runtime" ,
507+ files : [
508+ { path : "check.ts" , content : BLESSED } ,
509+ { path : "captures/logs.yml" , content : CAPTURE } ,
510+ ] ,
511+ signature : blessed ,
512+ } ,
513+ ] ,
514+ } ,
515+ } ) ,
516+ } ) ;
517+ try {
518+ const { stdout } = await runCli ( [ "check" , "-d" , directory , "--json" ] , {
519+ TASKLESS_TOKEN : "fake.token" ,
520+ TASKLESS_API_URL : mock . apiUrl ,
521+ } ) ;
522+ const notices = ( envelope ( stdout ) . notices ?? [ ] ) . join ( "\n" ) ;
523+ expect ( notices ) . toMatch (
524+ / w a s r e w r i t t e n w i t h t h e b y t e s t h e s e r v i c e b l e s s e d , b u t 1 s t a l e e n t r y c o u l d n o t b e r e m o v e d a n d a n e n g i n e s t i l l r e a d s i t /
525+ ) ;
526+ expect ( notices ) . toContain ( "blocked/a.txt" ) ;
527+ // The bytes still landed — a failed cleanup is not a failed write.
528+ await expect ( readFile ( checkFile , "utf8" ) ) . resolves . toBe ( BLESSED ) ;
529+ } finally {
530+ await chmod ( join ( rule , "blocked" ) , 0o700 ) ;
531+ await mock . close ( ) ;
532+ }
533+ } ) ;
534+
535+ it ( "says '<n> stale entries ... them' when the repair leaves several behind" , async ( ) => {
536+ const rule = join ( directory , ".taskless" , "rules" , "runtime" , "demo" ) ;
537+ await mkdir ( join ( rule , "blocked-one" ) , { recursive : true } ) ;
538+ await writeFile ( join ( rule , "blocked-one" , "a.txt" ) , "stuck\n" , "utf8" ) ;
539+ await chmod ( join ( rule , "blocked-one" ) , 0o500 ) ;
540+ await mkdir ( join ( rule , "blocked-two" ) , { recursive : true } ) ;
541+ await writeFile ( join ( rule , "blocked-two" , "b.txt" ) , "stuck\n" , "utf8" ) ;
542+ await chmod ( join ( rule , "blocked-two" ) , 0o500 ) ;
543+
544+ const blessed = await canonicalHash ( BLESSED ) ;
545+ const mock = await startMock ( {
546+ reconcile : driftedReconcile ( blessed ) ,
547+ restore : ( ) => ( {
548+ statusCode : 200 ,
549+ body : {
550+ ruleId : "demo" ,
551+ rules : [
552+ {
553+ id : "demo" ,
554+ engine : "runtime" ,
555+ files : [
556+ { path : "check.ts" , content : BLESSED } ,
557+ { path : "captures/logs.yml" , content : CAPTURE } ,
558+ ] ,
559+ signature : blessed ,
560+ } ,
561+ ] ,
562+ } ,
563+ } ) ,
564+ } ) ;
565+ try {
566+ const { stdout } = await runCli ( [ "check" , "-d" , directory , "--json" ] , {
567+ TASKLESS_TOKEN : "fake.token" ,
568+ TASKLESS_API_URL : mock . apiUrl ,
569+ } ) ;
570+ const notices = ( envelope ( stdout ) . notices ?? [ ] ) . join ( "\n" ) ;
571+ expect ( notices ) . toMatch (
572+ / w a s r e w r i t t e n w i t h t h e b y t e s t h e s e r v i c e b l e s s e d , b u t 2 s t a l e e n t r i e s c o u l d n o t b e r e m o v e d a n d a n e n g i n e s t i l l r e a d s t h e m /
573+ ) ;
574+ expect ( notices ) . toContain ( "blocked-one/a.txt" ) ;
575+ expect ( notices ) . toContain ( "blocked-two/b.txt" ) ;
576+ await expect ( readFile ( checkFile , "utf8" ) ) . resolves . toBe ( BLESSED ) ;
577+ } finally {
578+ await chmod ( join ( rule , "blocked-one" ) , 0o700 ) ;
579+ await chmod ( join ( rule , "blocked-two" ) , 0o700 ) ;
580+ await mock . close ( ) ;
581+ }
582+ } ) ;
583+
584+ it ( "skips every runtime rule when reconcile's own authentication is rejected" , async ( ) => {
585+ // `planRuntime`'s `unauthorized` branch (plan.ts) — distinct from
586+ // restore's `unauthorized`, and reachable only when the reconcile call
587+ // itself, not the later restore call, gets a 401.
588+ const mock = await startMock ( {
589+ reconcile : ( ) => ( { statusCode : 401 , body : { } } ) ,
590+ restore : ( ) => ( { statusCode : 500 , body : { } } ) ,
591+ } ) ;
592+ try {
593+ const { stdout } = await runCli ( [ "check" , "-d" , directory , "--json" ] , {
594+ TASKLESS_TOKEN : "fake.token" ,
595+ TASKLESS_API_URL : mock . apiUrl ,
596+ } ) ;
597+ const output = envelope ( stdout ) as {
598+ skipped ?: { rule : string ; reason : string } [ ] ;
599+ } ;
600+ expect ( output . skipped ) . toHaveLength ( 1 ) ;
601+ expect ( output . skipped ?. [ 0 ] ?. rule ) . toBe ( "demo" ) ;
602+ expect ( output . skipped ?. [ 0 ] ?. reason ) . toContain (
603+ "authentication was rejected"
604+ ) ;
605+ // Rejected before any restore could even be considered.
606+ expect ( mock . restoreCalls ) . toEqual ( [ ] ) ;
607+ await expect ( readFile ( checkFile , "utf8" ) ) . resolves . toBe ( DRIFTED ) ;
608+ } finally {
609+ await mock . close ( ) ;
610+ }
611+ } ) ;
612+
613+ it ( "skips every runtime rule when materialization fails, without failing the run" , async ( ) => {
614+ // plan.ts's materialize-failure catch. Blessing whatever `check.ts` was
615+ // actually reported sidesteps needing to know `signRuleFile`'s exact
616+ // envelope format — the point of this test is the catch, not the sign.
617+ //
618+ // `.taskless/.gitignore` is replaced with a directory so `addToGitignore`'s
619+ // `writeFile` (called from `materializeRuntimeRules`) fails with EISDIR.
620+ // The scaffold migration (`migrateFixture`, inside `runCli`) writes that
621+ // file itself as part of bringing the fixture current, so the block has to
622+ // go on AFTER migration — doing it first makes the migration itself throw,
623+ // before `check` ever runs.
624+ await migrateFixture ( [ "check" , "-d" , directory ] ) ;
625+ await rm ( join ( directory , ".taskless" , ".gitignore" ) , { force : true } ) ;
626+ await mkdir ( join ( directory , ".taskless" , ".gitignore" ) ) ;
627+
628+ const mock = await startMock ( {
629+ reconcile : ( body ) => ( {
630+ run : body . files . map ( ( file ) => ( {
631+ ruleId : "demo" ,
632+ file : file . file ,
633+ signature : file . signature ,
634+ } ) ) ,
635+ unsafe : [ ] ,
636+ unknown : [ ] ,
637+ missing : [ ] ,
638+ } ) ,
639+ restore : ( ) => ( { statusCode : 500 , body : { } } ) ,
640+ } ) ;
641+ try {
642+ let stdout : string ;
643+ let exitCode : number ;
644+ try {
645+ ( { stdout } = await execFileAsync (
646+ "node" ,
647+ [ binPath , "check" , "-d" , directory , "--json" ] ,
648+ {
649+ env : {
650+ ...process . env ,
651+ TASKLESS_TOKEN : "fake.token" ,
652+ TASKLESS_API_URL : mock . apiUrl ,
653+ } ,
654+ }
655+ ) ) ;
656+ exitCode = 0 ;
657+ } catch ( error ) {
658+ const failure = error as { stdout : string ; code : number } ;
659+ stdout = failure . stdout ?? "" ;
660+ exitCode = failure . code ;
661+ }
662+ // A materialize failure is a notice, never a failed run.
663+ expect ( exitCode ) . toBe ( 0 ) ;
664+ const output = envelope ( stdout ) as {
665+ skipped ?: { rule : string ; reason : string } [ ] ;
666+ } ;
667+ expect ( output . skipped ) . toHaveLength ( 1 ) ;
668+ expect ( output . skipped ?. [ 0 ] ?. rule ) . toBe ( "demo" ) ;
669+ expect ( output . skipped ?. [ 0 ] ?. reason ) . toContain (
670+ "runtime rules could not be materialized"
671+ ) ;
672+ expect ( mock . restoreCalls ) . toEqual ( [ ] ) ;
673+ } finally {
674+ await mock . close ( ) ;
675+ }
676+ } ) ;
677+ } ) ;
678+
679+ describe ( "restoreRule's HTTP contract" , ( ) => {
680+ let server : Server ;
681+ let originalApiUrl : string | undefined ;
682+ let respond : ( response : import ( "node:http" ) . ServerResponse ) => void ;
683+
684+ beforeEach ( async ( ) => {
685+ originalApiUrl = process . env . TASKLESS_API_URL ;
686+ server = createServer ( ( request , response ) => {
687+ request . resume ( ) ;
688+ request . on ( "end" , ( ) => respond ( response ) ) ;
689+ } ) ;
690+ await new Promise < void > ( ( done ) => {
691+ server . listen ( 0 , "127.0.0.1" , ( ) => {
692+ const address = server . address ( ) ;
693+ const port = typeof address === "object" && address ? address . port : 0 ;
694+ process . env . TASKLESS_API_URL = `http://127.0.0.1:${ String ( port ) } /cli` ;
695+ done ( ) ;
696+ } ) ;
697+ } ) ;
698+ } ) ;
699+
700+ afterEach ( async ( ) => {
701+ await new Promise < void > ( ( done ) => server . close ( ( ) => done ( ) ) ) ;
702+ if ( originalApiUrl === undefined ) {
703+ delete process . env . TASKLESS_API_URL ;
704+ } else {
705+ process . env . TASKLESS_API_URL = originalApiUrl ;
706+ }
707+ } ) ;
708+
709+ const request = {
710+ ruleId : "demo" ,
711+ repositoryUrl : "https://github.com/acme/widgets.git" ,
712+ } ;
713+
714+ it ( "maps HTTP 401 to `unauthorized`" , async ( ) => {
715+ respond = ( response ) => {
716+ response . writeHead ( 401 , { "content-type" : "application/json" } ) ;
717+ response . end ( "{}" ) ;
718+ } ;
719+ await expect ( restoreRule ( "token" , request ) ) . resolves . toEqual ( {
720+ status : "unauthorized" ,
721+ } ) ;
722+ } ) ;
723+
724+ it ( "maps a non-ok status to `unavailable`" , async ( ) => {
725+ respond = ( response ) => {
726+ response . writeHead ( 500 , { "content-type" : "application/json" } ) ;
727+ response . end ( "{}" ) ;
728+ } ;
729+ await expect ( restoreRule ( "token" , request ) ) . resolves . toEqual ( {
730+ status : "unavailable" ,
731+ reason : "HTTP 500" ,
732+ } ) ;
733+ } ) ;
734+
735+ it ( "maps a body `response.json()` cannot parse to `unavailable`" , async ( ) => {
736+ respond = ( response ) => {
737+ response . writeHead ( 200 , { "content-type" : "application/json" } ) ;
738+ response . end ( "not json" ) ;
739+ } ;
740+ await expect ( restoreRule ( "token" , request ) ) . resolves . toEqual ( {
741+ status : "unavailable" ,
742+ reason : "invalid response body" ,
743+ } ) ;
744+ } ) ;
745+
746+ it ( "maps a body without an array `rules` to `unavailable`" , async ( ) => {
747+ respond = ( response ) => {
748+ response . writeHead ( 200 , { "content-type" : "application/json" } ) ;
749+ response . end ( JSON . stringify ( { ruleId : "demo" } ) ) ;
750+ } ;
751+ await expect ( restoreRule ( "token" , request ) ) . resolves . toEqual ( {
752+ status : "unavailable" ,
753+ reason : "response carried no `rules`" ,
754+ } ) ;
755+ } ) ;
756+
757+ it ( "maps a fetch that throws to `unavailable`" , async ( ) => {
758+ // No handler is registered on the running mock server for this one — the
759+ // request is pointed at a closed local port instead, so `fetch` itself
760+ // rejects (ECONNREFUSED) rather than the server answering.
761+ process . env . TASKLESS_API_URL = "http://127.0.0.1:1/cli" ;
762+ const outcome = await restoreRule ( "token" , request ) ;
763+ expect ( outcome . status ) . toBe ( "unavailable" ) ;
764+ expect (
765+ ( outcome as { status : "unavailable" ; reason : string } ) . reason
766+ ) . toMatch ( / n e t w o r k e r r o r / ) ;
767+ } ) ;
452768} ) ;
0 commit comments