-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathetl-stack.test.ts
More file actions
2201 lines (2073 loc) · 99.2 KB
/
Copy pathetl-stack.test.ts
File metadata and controls
2201 lines (2073 loc) · 99.2 KB
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
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { Template } from "aws-cdk-lib/assertions";
import { AppStack } from "../lib/app-stack";
import type { SpsEnvConfig } from "../lib/config";
import { EtlStack } from "../lib/etl-stack";
import { NetworkStack } from "../lib/network-stack";
import { makeFixture } from "./test-utils";
function buildEtlStack(
envName: "staging" | "prod",
envConfigOverride: Partial<SpsEnvConfig> = {},
): {
template: Template;
stack: EtlStack;
} {
const fixture = makeFixture(envName);
const envConfig = { ...fixture.envConfig, ...envConfigOverride };
const network = new NetworkStack(fixture.app, `Sps-Network-${envName}`, {
env: fixture.env,
envConfig,
});
const appStack = new AppStack(fixture.app, `Sps-App-${envName}`, {
env: fixture.env,
envConfig,
vpc: network.vpc,
});
const stack = new EtlStack(fixture.app, `Sps-Etl-${envName}`, {
env: fixture.env,
envConfig,
vpc: network.vpc,
ecsCluster: appStack.ecsCluster,
etlEcrRepository: appStack.etlEcrRepository,
});
return { template: Template.fromStack(stack), stack };
}
/**
* Read an alarm's metric shape regardless of HOW the alarm expresses it.
*
* A single-metric alarm carries `MetricName`/`Statistic`/`Period` at the top
* level. A metric-math alarm carries NONE of those: it has a `Metrics` array
* whose entries each nest a `MetricStat`, plus one entry holding the
* `Expression`. So an assertion written against the top-level properties does
* not fail when an alarm is converted to an expression -- it silently matches
* nothing and stops checking. That is a live hazard for the <=604800s
* evaluation-window guard below, which reads `Period` and would have gone
* blind on all six status alarms the moment they started summing
* failed + timedOut + aborted.
*/
function alarmMetricShape(props: Record<string, unknown> | undefined): {
names: string[];
stats: string[];
periods: number[];
expression: string | undefined;
} {
const metrics: unknown[] = Array.isArray(props?.Metrics) ? (props.Metrics as unknown[]) : [];
if (metrics.length === 0) {
const name = props?.MetricName;
const stat = props?.Statistic;
const period = props?.Period;
return {
names: typeof name === "string" ? [name] : [],
stats: typeof stat === "string" ? [stat] : [],
periods: typeof period === "number" ? [period] : [],
expression: undefined,
};
}
const entries = metrics as ReadonlyArray<{
Expression?: unknown;
MetricStat?: { Period?: unknown; Stat?: unknown; Metric?: { MetricName?: unknown } };
}>;
const stats = entries.map((m) => m.MetricStat?.Stat);
return {
names: entries
.map((m) => m.MetricStat?.Metric?.MetricName)
.filter((n): n is string => typeof n === "string")
.sort(),
stats: stats.filter((s): s is string => typeof s === "string"),
periods: entries
.map((m) => m.MetricStat?.Period)
.filter((p): p is number => typeof p === "number"),
expression: entries.map((m) => m.Expression).find((e): e is string => typeof e === "string"),
};
}
/**
* The three ways a Step Functions execution can end without succeeding.
*
* ExecutionsFailed alone -- what these alarms watched before -- is the only
* one of the three that ALREADY notifies by another route (a Catch publishes
* to etl-failures before the Fail state). TIMED_OUT runs no Catch at all, and
* ABORTED is an operator StopExecution; both were entirely silent.
*/
const UNSUCCESSFUL_METRICS = [
"ExecutionsAborted",
"ExecutionsFailed",
"ExecutionsTimedOut",
] as const;
/**
* Every state machine in the template, vs. the ones a status alarm actually
* watches -- both as CloudFormation logical ids, so a mismatch names the
* offender.
*
* The gap this closes: three short-lived machines (curated-tables backup,
* opportunity projection, ED email-visibility bridge) shipped with ONLY a
* cadence alarm. A cadence alarm watches `ExecutionsStarted < 1`, and a
* TIMED_OUT execution STARTED -- so it stays green. The machine's own Catch
* does not run on a timeout either, so nothing publishes to etl-failures.
* Those runs failed in complete silence. Comparing the two SETS rather than
* asserting a count means the next short-lived machine cannot ship without a
* status alarm: it shows up in `machines` and not in `covered`.
*
* The dimension value on a state-machine metric is `{ Ref: <logical id> }` --
* Ref on AWS::StepFunctions::StateMachine returns the ARN.
*/
function statusAlarmCoverage(template: Template): {
machines: string[];
covered: string[];
} {
const machines = Object.keys(
template.findResources("AWS::StepFunctions::StateMachine"),
).sort();
const covered = new Set<string>();
for (const a of Object.values(template.findResources("AWS::CloudWatch::Alarm"))) {
// Only the status alarms: duration and cadence alarms are single-metric and
// legitimately watch something else.
if (alarmMetricShape(a.Properties).expression !== "failed + timedOut + aborted") continue;
// An alarm that notifies nobody is the same silence in a different costume,
// so coverage requires a wired action, not merely a defined alarm.
if (!Array.isArray(a.Properties?.AlarmActions) || a.Properties.AlarmActions.length === 0) {
continue;
}
const metrics = (Array.isArray(a.Properties?.Metrics) ? a.Properties.Metrics : []) as
ReadonlyArray<{
MetricStat?: {
Metric?: { Dimensions?: ReadonlyArray<{ Name?: unknown; Value?: unknown }> };
};
}>;
for (const m of metrics) {
for (const d of m.MetricStat?.Metric?.Dimensions ?? []) {
const ref = (d.Value as { Ref?: unknown } | undefined)?.Ref;
if (d.Name === "StateMachineArn" && typeof ref === "string") covered.add(ref);
}
}
}
return { machines, covered: [...covered].sort() };
}
// Re-asserted per Footgun #6 / feedback_ec2_descriptions_ascii_only.
// The allow-set matches the regex documented in app-stack.test.ts.
const EC2_DESCRIPTION_ALLOWED = /^[a-zA-Z0-9. _\-:/()#,@[\]+=&;{}!$*]+$/;
// EventBridge cron expressions confirmed in plan D7.
const EXPECTED_CRONS: Readonly<Record<string, string>> = {
nightly: "cron(0 7 * * ? *)",
weekly: "cron(0 12 ? * SUN *)",
annual: "cron(0 9 1 7 ? *)",
};
// #442 -- the task container injects each credentialed source's granular
// SCHOLARS_* keys (plus the three shared secrets), NOT a blob ETL_*_SECRET.
// #1508 -- grouped by the task def that injects them. The three base secrets
// ride EVERY def; each per-source group rides ONLY its own def so no step gets
// a credential it doesn't use.
const BASE_SECRET_ENV_VARS = [
"DATABASE_URL",
"OPENSEARCH_USER",
"OPENSEARCH_PASS",
// #447 -- renamed from REVALIDATE_TOKEN; etl/orchestrate.ts reads
// SCHOLARS_REVALIDATE_TOKEN.
"SCHOLARS_REVALIDATE_TOKEN",
] as const;
// sources def -- the five WCM-DB sources (asms/infoed/coi/reciter/jenzabar).
const SOURCES_SECRET_ENV_VARS = [
"SCHOLARS_ASMS_HOST",
"SCHOLARS_ASMS_PORT",
"SCHOLARS_ASMS_DATABASE",
"SCHOLARS_ASMS_USERNAME",
"SCHOLARS_ASMS_PASSWORD",
"SCHOLARS_INFOED_DB_URL",
"SCHOLARS_INFOED_USERNAME",
"SCHOLARS_INFOED_PASSWORD",
"SCHOLARS_COI_URL",
"SCHOLARS_COI_PORT",
"SCHOLARS_COI_DATABASE",
"SCHOLARS_COI_USERNAME",
"SCHOLARS_COI_PASSWORD",
"SCHOLARS_RECITERDB_HOST",
"SCHOLARS_RECITERDB_PORT",
"SCHOLARS_RECITERDB_DATABASE",
"SCHOLARS_RECITERDB_USERNAME",
"SCHOLARS_RECITERDB_PASSWORD",
"SCHOLARS_JENZABAR_SERVER",
"SCHOLARS_JENZABAR_PORT",
"SCHOLARS_JENZABAR_DATABASE",
"SCHOLARS_JENZABAR_USERNAME",
"SCHOLARS_JENZABAR_PASSWORD",
] as const;
// ldap def -- the LDAP simple bind (only etl:ed + the ed-export bridge).
const LDAP_SECRET_ENV_VARS = [
"SCHOLARS_LDAP_URL",
"SCHOLARS_LDAP_BIND_DN",
"SCHOLARS_LDAP_BIND_PASSWORD",
] as const;
// reciter-api def -- the #746 ADMIN api-key, used ONLY by the operator-run
// etl:reciter-refresh (no cadence step), kept off every other def.
const RECITER_API_SECRET_ENV_VARS = [
"RECITER_API_BASE_URL",
"RECITER_API_KEY",
] as const;
// Which prod task-def family carries which secret group (#1508).
const SECRETS_BY_TASK_DEF: ReadonlyArray<{
label: string;
family: string;
vars: readonly string[];
}> = [
{ label: "base", family: "sps-etl-prod", vars: BASE_SECRET_ENV_VARS },
{ label: "sources", family: "sps-etl-sources-prod", vars: SOURCES_SECRET_ENV_VARS },
{ label: "ldap", family: "sps-etl-ldap-prod", vars: LDAP_SECRET_ENV_VARS },
{
label: "reciter-api",
family: "sps-etl-reciter-api-prod",
vars: RECITER_API_SECRET_ENV_VARS,
},
];
// Every per-source var (the three non-base defs' secrets) -- none of these may
// appear on the base def's container.
const NON_BASE_SECRET_ENV_VARS = [
...SOURCES_SECRET_ENV_VARS,
...LDAP_SECRET_ENV_VARS,
...RECITER_API_SECRET_ENV_VARS,
];
// IAM-based sources read these as plaintext config from the environment
// block (values mirror the source-script defaults).
const EXPECTED_ENV_CONFIG: Readonly<Record<string, string>> = {
SCHOLARS_DYNAMODB_TABLE: "reciterai",
ARTIFACTS_BUCKET: "wcmc-reciterai-artifacts",
ARTIFACT_PREFIX: "spotlight",
HIERARCHY_BUCKET: "wcmc-reciterai-hierarchy",
// #794 — A2 tools taxonomy (etl:scholar-tool) + the reversible producer switch.
TOOLS_BUCKET: "wcmc-reciterai-artifacts",
TOOLS_PREFIX: "tools",
// #794 cutover complete for BOTH envs (prod signed off 2026-07-06). Asserted
// against the prod template below; guards that prod now reads "s3" (A2 tools
// taxonomy via etl:scholar-tool populates scholar_tool + scholar_family).
// Rollback = "ddb".
SCHOLAR_TOOL_SOURCE: "s3",
// #1258 — env-conditional like SCHOLAR_TOOL_SOURCE (staging "0.9", covered by
// the staging snapshot). Asserted against prod here to guard that the derived
// MeSH-anchor producer stays gated off (">1" kill-switch) until sign-off.
MESH_ANCHOR_SCORE_MIN: "2",
};
function getStateMachineDefinitionText(
template: Template,
stateMachineName: string,
): string {
const sms = template.findResources("AWS::StepFunctions::StateMachine");
const match = Object.values(sms).find(
(r) => r.Properties?.StateMachineName === stateMachineName,
);
expect(match).toBeDefined();
// DefinitionString materialises as Fn::Join over alternating literal +
// intrinsic chunks; flatten to a single string so we can grep for tokens.
const def = match?.Properties?.DefinitionString as
| { "Fn::Join"?: [string, unknown[]] }
| string
| undefined;
if (typeof def === "string") {
return def;
}
const parts = def?.["Fn::Join"]?.[1] ?? [];
return parts
.map((p) => (typeof p === "string" ? p : JSON.stringify(p)))
.join("");
}
describe("EtlStack", () => {
// Cutover de-coupling (§8.4): both the ETL container and the reconcile task
// move OPENSEARCH_NODE off the Data→Etl cross-stack export onto the opensearch
// secret's `node` key, so the OpenSearch-domain replace at cutover isn't
// blocked by the export-lock. The internal-ALB DNS is a separate edge (SSM).
describe("OPENSEARCH_NODE de-coupling (openSearchNodeFromSecret)", () => {
it("off (explicit): node is baked from the DataStack export, not a secret", () => {
const json = JSON.stringify(
buildEtlStack("staging", { openSearchNodeFromSecret: false }).template.toJSON(),
);
expect(json).toContain("Sps-Data-staging-OpenSearchDomainEndpoint");
expect(json).not.toContain(":node::");
});
it("on: node comes from the opensearch secret `node` key; the OpenSearch export is gone but SCHOLARS_BASE_URL still resolves", () => {
const json = JSON.stringify(
buildEtlStack("staging", { openSearchNodeFromSecret: true }).template.toJSON(),
);
expect(json).not.toContain("Sps-Data-staging-OpenSearchDomainEndpoint");
expect(json).toContain(":node::");
// SCHOLARS_BASE_URL rides the App internal-ALB DNS SSM param (item-3 pass 2b),
// a separate edge unaffected by openSearchNodeFromSecret.
expect(json).toContain("/sps/staging/app/internal-alb-dns");
});
});
// Estate consolidation (plan §4.4): with useSharedVpc on, every ETL task ENI
// lands in the app2 subnets (the cross-VPC relocation branch is gone — §8.8).
describe("shared VPC placement (useSharedVpc on)", () => {
const { template } = buildEtlStack("staging", { useSharedVpc: true });
const APP2 = ["subnet-0c6593fb9c9a165c3", "subnet-070cbc242efbddc3c"];
it("routes ETL task ENIs into the app2 subnets", () => {
const sms = Object.values(
template.findResources("AWS::StepFunctions::StateMachine"),
);
const allDefs = sms
.map((s) => JSON.stringify(s.Properties?.DefinitionString ?? ""))
.join("");
for (const subnet of APP2) expect(allDefs).toContain(subnet);
});
});
describe("prod", () => {
const { template } = buildEtlStack("prod");
it("matches the snapshot", () => {
expect(template.toJSON()).toMatchSnapshot();
});
describe("Resource counts (B08 / B20 acceptance)", () => {
it("creates six state machines (3 cadence + #595 heartbeat + #393 reconciler + #353 cdn reconciler), six EventBridge rules, two SNS topics", () => {
// 3 cadence machines + the #595 heartbeat + the #393 reconciler +
// the #353 cdn reconciler (PR-2).
template.resourceCountIs("AWS::StepFunctions::StateMachine", 6);
template.resourceCountIs("AWS::Events::Rule", 6);
// The heartbeat + both reconcilers reuse the cadence failure topic; PR-7
// adds the etl-page P1 topic, so two total: etl-failures + etl-page.
template.resourceCountIs("AWS::SNS::Topic", 2);
template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "etl-failures-prod" });
template.hasResourceProperties("AWS::SNS::Topic", { TopicName: "etl-page-prod" });
});
it("creates fourteen CloudWatch alarms (4 status + 3 cadence + 3 duration + reconciler status/cadence + cdn reconciler status/cadence)", () => {
// 10 cadence-machine alarms (4 status + 3 cadence: nightly/weekly/heartbeat
// + 3 duration: nightly/weekly/heartbeat, #2190 -- annual is excluded, its
// ExecutionTime is approval-gate wait) + 2 reconciler alarms (#393)
// + 2 cdn reconciler alarms (#353).
template.resourceCountIs("AWS::CloudWatch::Alarm", 14);
});
it("creates six ECS task definitions (4 ETL credential-split defs + lean reconciler + lean cdn reconciler) and one SG-to-SG ingress rule on the internal ALB SG", () => {
// #1508 split the single ETL task def into four by credential need:
// base / sources / ldap / reciter-api. Plus the lean #393 reconcile
// task def + the lean #353 cdn reconcile task def.
template.resourceCountIs("AWS::ECS::TaskDefinition", 6);
template.resourceCountIs("AWS::EC2::SecurityGroupIngress", 1);
});
it("the SG-to-SG ingress admits :80 from the ETL SG (no CIDR)", () => {
const ingress = template.findResources(
"AWS::EC2::SecurityGroupIngress",
);
expect(Object.keys(ingress)).toHaveLength(1);
const rule = Object.values(ingress)[0];
expect(rule.Properties?.IpProtocol).toBe("tcp");
expect(rule.Properties?.FromPort).toBe(80);
expect(rule.Properties?.ToPort).toBe(80);
expect(rule.Properties?.CidrIp).toBeUndefined();
expect(rule.Properties?.SourceSecurityGroupId).toBeDefined();
});
it("prod does NOT create the curated-tables backup schedule (curationBackupScheduleEnabled=false until prod is activated; #1032)", () => {
const rules = template.findResources("AWS::Events::Rule");
expect(
Object.values(rules).some(
(r) => r.Properties?.Name === "sps-curation-backup-prod",
),
).toBe(false);
const sms = template.findResources("AWS::StepFunctions::StateMachine");
expect(
Object.values(sms).some(
(s) =>
s.Properties?.StateMachineName === "scholars-curation-backup-prod",
),
).toBe(false);
});
it("prod does NOT create the opportunity-projection schedule (opportunityProjectionScheduleEnabled=false until the prod corpus is published; #1218)", () => {
const rules = template.findResources("AWS::Events::Rule");
expect(
Object.values(rules).some(
(r) => r.Properties?.Name === "sps-opportunity-projection-prod",
),
).toBe(false);
const sms = template.findResources("AWS::StepFunctions::StateMachine");
expect(
Object.values(sms).some(
(s) =>
s.Properties?.StateMachineName === "scholars-opportunity-projection-prod",
),
).toBe(false);
});
it("prod does NOT create the ED email-visibility bridge (edEmailVisibilityBridgeEnabled=false until scholars-prod is verified; #443)", () => {
const rules = template.findResources("AWS::Events::Rule");
expect(
Object.values(rules).some(
(r) => r.Properties?.Name === "sps-ed-email-visibility-prod",
),
).toBe(false);
const sms = template.findResources("AWS::StepFunctions::StateMachine");
expect(
Object.values(sms).some(
(s) =>
s.Properties?.StateMachineName ===
"scholars-ed-email-visibility-prod",
),
).toBe(false);
// No imported-VPC export SG leaks into prod.
const sgs = template.findResources("AWS::EC2::SecurityGroup");
expect(
Object.values(sgs).some((s) =>
String(s.Properties?.GroupDescription ?? "").includes(
"ED email-visibility export",
),
),
).toBe(false);
// The ed/* GetObject grant (the import-side read) already exists in
// prod; assert the WRITE half -- s3:PutObject on ed/* -- does NOT, since
// the export is not created here.
const policies = template.findResources("AWS::IAM::Policy");
const hasEdPut = Object.values(policies).some((p) => {
const stmts =
(p.Properties?.PolicyDocument?.Statement as
| Array<{ Action?: unknown; Resource?: unknown }>
| undefined) ?? [];
return stmts.some((s) => {
const res = Array.isArray(s.Resource) ? s.Resource : [s.Resource];
const act = Array.isArray(s.Action) ? s.Action : [s.Action];
return (
res.includes("arn:aws:s3:::wcmc-reciterai-artifacts/ed/*") &&
act.includes("s3:PutObject")
);
});
});
expect(hasEdPut).toBe(false);
});
});
describe("State machines (D2 -- Choice on $.startFrom)", () => {
it.each(Object.keys(EXPECTED_CRONS))(
"%s state-machine definition routes on $.startFrom",
(cadence) => {
const text = getStateMachineDefinitionText(
template,
`scholars-${cadence}-prod`,
);
expect(text).toMatch(/startFrom/);
},
);
// Regression: the EventBridge schedules invoke with `{}` (no startFrom).
// Without an isPresent guard the top-level Choice raises
// `States.Runtime: Invalid path '$.startFrom'` and every scheduled
// execution fails before the first step. Assert the value test is
// guarded so an absent key falls through to step[0] instead of erroring.
it.each(Object.keys(EXPECTED_CRONS))(
"%s Choice guards $.startFrom with isPresent (empty {} schedule input falls through, never errors)",
(cadence) => {
const text = getStateMachineDefinitionText(
template,
`scholars-${cadence}-prod`,
);
// The guarded branch synthesises as an And pairing IsPresent with
// StringEquals on the same $.startFrom path.
expect(text).toMatch(/"IsPresent":\s*true/);
expect(text).toMatch(/"And":/);
expect(text).toMatch(/"Variable":\s*"\$\.startFrom"/);
},
);
it.each(Object.keys(EXPECTED_CRONS))(
"%s state machine has per-step retry (MaxAttempts=2, BackoffRate=2)",
(cadence) => {
const text = getStateMachineDefinitionText(
template,
`scholars-${cadence}-prod`,
);
// Both numbers should appear in every Retry block.
expect(text).toMatch(/"MaxAttempts":\s*2/);
expect(text).toMatch(/"BackoffRate":\s*2/);
},
);
it.each(Object.keys(EXPECTED_CRONS))(
"%s state machine has Catch blocks (failure paths publish to SNS)",
(cadence) => {
const text = getStateMachineDefinitionText(
template,
`scholars-${cadence}-prod`,
);
expect(text).toMatch(/"Catch"/);
// Per-step failure handler is an SNS publish task. CDK
// synthesizes the ARN as arn:{Partition}:states:::sns:publish
// (Fn::Join over AWS::Partition); match the partition-agnostic
// tail.
expect(text).toMatch(/states:::sns:publish/);
},
);
it("annual state machine has a waitForTaskToken approval gate", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-annual-prod",
);
expect(text).toMatch(/states:::sns:publish\.waitForTaskToken/);
});
// #451 -- the cadence steps once labelled "SearchIndex"/"Revalidate"
// ran etl:mesh-coverage / etl:vivo-redirect, so the OpenSearch index
// was never rebuilt by any machine and vivo-redirect (a manual
// cutover-prep file generator) ran as a no-op Fargate task. Lock in
// the corrected command overrides.
describe("#451 -- cadences run search:index, never vivo-redirect", () => {
it("nightly rebuilds the index (search:index) and keeps mesh-coverage", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-nightly-prod",
);
expect(text).toMatch(/"search:index"/);
expect(text).toMatch(/"etl:mesh-coverage"/);
});
it("weekly rebuilds the index (search:index); mesh-coverage dropped (nightly-only)", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-weekly-prod",
);
expect(text).toMatch(/"search:index"/);
expect(text).not.toMatch(/"etl:mesh-coverage"/);
});
it.each(["nightly", "weekly"])(
"%s machine no longer wires the vivo-redirect cutover tool",
(cadence) => {
const text = getStateMachineDefinitionText(
template,
`scholars-${cadence}-prod`,
);
expect(text).not.toMatch(/vivo-redirect/);
},
);
});
describe("#479 -- cadences POST /api/revalidate after search:index", () => {
it.each(["nightly", "weekly"])(
"%s machine closes with `etl:revalidate` after `search:index`",
(cadence) => {
const text = getStateMachineDefinitionText(
template,
`scholars-${cadence}-prod`,
);
expect(text).toMatch(/"etl:revalidate"/);
const lastSearchIndex = text.lastIndexOf("search:index");
const lastRevalidate = text.lastIndexOf("etl:revalidate");
expect(lastSearchIndex).toBeGreaterThan(-1);
expect(lastRevalidate).toBeGreaterThan(lastSearchIndex);
},
);
});
// #608 -- RePORTER / NSF are wired onto the WEEKLY machine ahead of its
// closing search:index/revalidate tail. Jenzabar moved to the NIGHTLY
// machine (operator request) so grad-school mentoring chips refresh daily.
describe("#608 -- weekly runs RePORTER/NSF; Jenzabar runs nightly", () => {
it("weekly runs etl:reporter and etl:nsf", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-weekly-prod",
);
expect(text).toMatch(/"etl:reporter"/);
expect(text).toMatch(/"etl:nsf"/);
});
it("RePORTER + NSF precede the weekly search:index (funding index carries the refreshed abstracts/keywords)", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-weekly-prod",
);
const idxSearch = text.indexOf("search:index");
expect(idxSearch).toBeGreaterThan(-1);
expect(text.indexOf("etl:reporter")).toBeGreaterThan(-1);
expect(text.indexOf("etl:reporter")).toBeLessThan(idxSearch);
expect(text.indexOf("etl:nsf")).toBeLessThan(idxSearch);
});
it("Jenzabar runs on the nightly machine, not the weekly one", () => {
const nightly = getStateMachineDefinitionText(
template,
"scholars-nightly-prod",
);
const weekly = getStateMachineDefinitionText(
template,
"scholars-weekly-prod",
);
expect(nightly).toMatch(/"etl:jenzabar"/);
expect(weekly).not.toMatch(/"etl:jenzabar"/);
});
it("RePORTER + NSF do not leak onto the nightly machine", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-nightly-prod",
);
expect(text).not.toMatch(/"etl:reporter"/);
expect(text).not.toMatch(/"etl:nsf"/);
});
});
// #658 -- gates + nih-profile complete the grant/PI enrichment set on the
// weekly machine. Both read public sources (no credential), external: false.
describe("#658 -- weekly machine runs gates + nih-profile", () => {
it("weekly runs etl:gates and etl:nih-profile", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-weekly-prod",
);
expect(text).toMatch(/"etl:gates"/);
expect(text).toMatch(/"etl:nih-profile"/);
});
it("Gates abstracts precede the weekly search:index", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-weekly-prod",
);
const idxSearch = text.indexOf("search:index");
expect(idxSearch).toBeGreaterThan(-1);
expect(text.indexOf("etl:gates")).toBeGreaterThan(-1);
expect(text.indexOf("etl:gates")).toBeLessThan(idxSearch);
});
it("gates + nih-profile do not leak onto the nightly machine", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-nightly-prod",
);
expect(text).not.toMatch(/"etl:gates"/);
expect(text).not.toMatch(/"etl:nih-profile"/);
});
});
});
describe("EventBridge schedules (D7)", () => {
it.each(Object.entries(EXPECTED_CRONS))(
"%s rule uses cron expression %s",
(cadence, expression) => {
template.hasResourceProperties("AWS::Events::Rule", {
Name: `sps-etl-${cadence}-prod`,
ScheduleExpression: expression,
});
},
);
it("prod CADENCE schedules ship ENABLED (etlSchedulesEnabled=true)", () => {
const rules = template.findResources("AWS::Events::Rule");
// The #393 reconciler runs on its own flag (reconcileScheduleEnabled),
// enabled in prod -- so scope this to the four sps-etl-* rules
// (3 cadence + the #595 heartbeat), all gated on etlSchedulesEnabled.
const cadenceRules = Object.entries(rules).filter(([, rule]) => {
const name = rule.Properties?.Name as string | undefined;
return typeof name === "string" && name.startsWith("sps-etl-");
});
expect(cadenceRules).toHaveLength(4);
for (const [id, rule] of cadenceRules) {
const state = rule.Properties?.State as string | undefined;
// Prod cadences went live 2026-07-07. This asserts the TEMPLATE says so
// too: while it said DISABLED, the first deploy whose changeset touched
// these rules would have silently switched the prod ETL off (#1512).
expect({ id, state }).toEqual({ id, state: "ENABLED" });
}
});
it("the #393 reconciler schedule ships ENABLED in prod (continuous backstop, not runbook-gated)", () => {
template.hasResourceProperties("AWS::Events::Rule", {
Name: "sps-reconcile-prod",
ScheduleExpression: "rate(5 minutes)",
State: "ENABLED",
});
});
});
describe("Alarms (D4 -- ExecutionsFailed sum>0 + ExecutionsStarted sum<1)", () => {
it("every alarm publishes to the etl-failures-${env} SNS topic", () => {
const alarms = template.findResources("AWS::CloudWatch::Alarm");
// 10 cadence-machine alarms (4 status + 3 cadence + 3 duration, #2190)
// + 2 reconciler alarms (#393) + 2 cdn reconciler alarms (#353); all
// share the topic -- a duration alarm that routed elsewhere would be
// invisible, so it is covered by the same loop below.
expect(Object.keys(alarms)).toHaveLength(14);
for (const [id, alarm] of Object.entries(alarms)) {
const actions = (alarm.Properties?.AlarmActions ?? []) as unknown[];
expect({ id, hasAction: actions.length > 0 }).toEqual({
id,
hasAction: true,
});
}
});
// The test ABOVE asserts an alarm has an action. That is not the same as
// the action being deliverable, and the difference was a real prod outage:
// `grantPublish` materializes an explicit AWS::SNS::TopicPolicy which
// REPLACES SNS's implicit default policy, so granting only `states` revoked
// CloudWatch's ability to publish. Every alarm still had an action and
// every send returned "Failed to execute action" — 30 alarms mute, six real
// prod transitions dropped 07-13..08-04, and `hasAction: true` stayed green
// throughout. Assert the principal, not the wiring.
it("both alarm topics let cloudwatch.amazonaws.com publish, or every alarm action fails", () => {
const policies = template.findResources("AWS::SNS::TopicPolicy");
expect(Object.keys(policies).length).toBeGreaterThan(0);
const principalsOf = (policy: Record<string, unknown>): string[] => {
const doc = (policy.Properties as Record<string, unknown> | undefined)
?.PolicyDocument as { Statement?: unknown[] } | undefined;
return (doc?.Statement ?? []).flatMap((raw) => {
const st = raw as { Principal?: { Service?: unknown } };
const svc = st.Principal?.Service;
return typeof svc === "string" ? [svc] : Array.isArray(svc) ? (svc as string[]) : [];
});
};
for (const [id, policy] of Object.entries(policies)) {
const services = principalsOf(policy as Record<string, unknown>);
expect({ id, cloudwatch: services.includes("cloudwatch.amazonaws.com") }).toEqual({
id,
cloudwatch: true,
});
// states must survive the fix — it is what publishes step-failure
// notifications from the state machines themselves.
expect({ id, states: services.includes("states.amazonaws.com") }).toEqual({
id,
states: true,
});
}
});
it("cadence status alarms watch failed + timed-out + aborted, sum > 0", () => {
const alarms = template.findResources("AWS::CloudWatch::Alarm");
// Scope to the cadence machines (sps-etl-*); the #393 reconciler's
// status alarm has its own focused test below.
const statusAlarms = Object.entries(alarms).filter(([, a]) => {
const name = a.Properties?.AlarmName as string | undefined;
return (
typeof name === "string" &&
name.startsWith("sps-etl-") &&
name.includes("-status-")
);
});
expect(statusAlarms).toHaveLength(4);
for (const [, a] of statusAlarms) {
const shape = alarmMetricShape(a.Properties);
// Dropping any one of the three re-opens a silent terminal state:
// a machine that hits its own `timeout:` is killed as TIMED_OUT
// with no Catch and no SNS publish, so ExecutionsFailed stays 0 and
// the run disappears entirely.
expect({
alarm: a.Properties?.AlarmName as string,
watches: shape.names,
}).toEqual({
alarm: a.Properties?.AlarmName as string,
watches: [...UNSUCCESSFUL_METRICS],
});
expect(shape.expression).toBe("failed + timedOut + aborted");
expect(shape.stats).toEqual(["Sum", "Sum", "Sum"]);
expect(a.Properties?.ComparisonOperator).toBe(
"GreaterThanThreshold",
);
expect(a.Properties?.Threshold).toBe(0);
}
});
it("cadence alarms watch ExecutionsStarted sum < 1 with treatMissingData=breaching (nightly + weekly + heartbeat)", () => {
const alarms = template.findResources("AWS::CloudWatch::Alarm");
// Scope to the cadence machines (sps-etl-*); the #393 reconciler's
// cadence alarm has its own focused test below.
const cadenceAlarms = Object.entries(alarms).filter(([, a]) => {
const name = a.Properties?.AlarmName as string | undefined;
return (
typeof name === "string" &&
name.startsWith("sps-etl-") &&
name.includes("-cadence-")
);
});
// Annual has no cadence alarm -- CloudWatch can't express a yearly
// no-execution window (see EtlStack alarm note + the guard below).
// Nightly + weekly + the #595 daily heartbeat each get one.
expect(cadenceAlarms).toHaveLength(3);
const labels = cadenceAlarms
.map(([, a]) => a.Properties?.AlarmName as string)
.sort();
expect(labels).toEqual([
"sps-etl-heartbeat-cadence-prod",
"sps-etl-nightly-cadence-prod",
"sps-etl-weekly-cadence-prod",
]);
for (const [, a] of cadenceAlarms) {
expect(a.Properties?.MetricName).toBe("ExecutionsStarted");
expect(a.Properties?.Statistic).toBe("Sum");
expect(a.Properties?.ComparisonOperator).toBe("LessThanThreshold");
expect(a.Properties?.Threshold).toBe(1);
expect(a.Properties?.TreatMissingData).toBe("breaching");
}
});
// Synth-time guard for the CloudWatch deploy-only constraint that
// rolled staging back: for any alarm whose Period >= 3600s,
// EvaluationPeriods * Period must be <= 604800s (one week). cdk synth
// and snapshots don't enforce this -- only the CFN create does.
it("no alarm violates the CloudWatch <=604800s evaluation-window cap (period>=3600)", () => {
const alarms = template.findResources("AWS::CloudWatch::Alarm");
const violations: string[] = [];
for (const [id, a] of Object.entries(alarms)) {
const evals = a.Properties?.EvaluationPeriods as number | undefined;
// Every period the alarm actually evaluates on -- top-level for a
// single-metric alarm, per-MetricStat for a metric-math one. Reading
// only `Properties.Period` would skip all six status alarms, which
// includes the heartbeat's 86400s one, and this guard exists because
// a violation rolled staging back once.
for (const period of new Set(alarmMetricShape(a.Properties).periods)) {
if (period < 3600) continue;
const window = period * (evals ?? 1);
if (window > 604800) {
violations.push(
`${id}: ${a.Properties?.AlarmName} -- ${evals ?? 1} * ${period}s = ${window}s > 604800s`,
);
}
}
}
expect(violations).toEqual([]);
});
// One shared description served all four cadences, and on the heartbeat
// every clause of it misled: the heartbeat is a SINGLE-step machine so
// "find the step marked red" points at the only step; it goes red because
// a data source is past its SLA, not because a run failed to finish; and
// "started by hand" invites re-running a check that will report exactly
// the same thing. It is also the status alarm most likely to fire first.
it("the heartbeat status alarm does not reuse the cadence machines' description", () => {
const descriptionOf = (name: string): string =>
String(
Object.values(template.findResources("AWS::CloudWatch::Alarm")).find(
(a) => a.Properties?.AlarmName === name,
)?.Properties?.AlarmDescription ?? "",
);
const nightly = descriptionOf("sps-etl-nightly-status-prod");
const heartbeat = descriptionOf("sps-etl-heartbeat-status-prod");
// Non-vacuity: the shared string is still in use where it reads well.
expect(nightly).toMatch(/find the step marked red/);
expect(heartbeat).not.toBe(nightly);
expect(heartbeat).not.toMatch(/find the step marked red/);
expect(heartbeat).not.toMatch(/started by hand/);
// ...and says what is actually wrong, plus where to read the detail.
expect(heartbeat).toMatch(/have not refreshed within their deadline/);
expect(heartbeat).toMatch(/\[freshness\] FAIL/);
});
// No state machine may ship with only a cadence alarm: a TIMED_OUT run
// STARTED, so ExecutionsStarted stays >= 1 and the cadence alarm never
// fires, while the timeout skips the Catch so nothing publishes either.
it("every state machine is watched by a failed+timedOut+aborted status alarm", () => {
const { machines, covered } = statusAlarmCoverage(template);
expect(machines.length).toBeGreaterThan(0);
expect(covered).toEqual(machines);
});
// The guard above is only as good as its reach. If a future change
// converts an alarm to a shape neither branch of alarmMetricShape
// understands, the loop reads zero periods and passes vacuously.
it("the <=604800s guard can read a period for every alarm", () => {
const alarms = template.findResources("AWS::CloudWatch::Alarm");
const unreadable = Object.entries(alarms)
.filter(([, a]) => alarmMetricShape(a.Properties).periods.length === 0)
.map(([id, a]) => `${id}: ${a.Properties?.AlarmName}`);
expect(unreadable).toEqual([]);
});
});
describe("#393 reconciler (PR-2 -- schedule + lean task + alarms)", () => {
it("fires the reconciler on a rate(5 minutes) EventBridge rule", () => {
template.hasResourceProperties("AWS::Events::Rule", {
Name: "sps-reconcile-prod",
ScheduleExpression: "rate(5 minutes)",
});
});
it("the reconcile state machine runs `npm run search:reconcile`", () => {
const text = getStateMachineDefinitionText(
template,
"scholars-reconcile-prod",
);
expect(text).toMatch(/"search:reconcile"/);
// Single-step machine: no $.startFrom Choice, no cadence steps.
expect(text).not.toMatch(/"etl:ed"/);
expect(text).not.toMatch(/search:index/);
});
function reconcileTaskDef() {
const tds = template.findResources("AWS::ECS::TaskDefinition");
const td = Object.values(tds).find(
(t) => t.Properties?.Family === "sps-reconcile-prod",
);
expect(td).toBeDefined();
return td!;
}
it("uses a lean 256/512 task def (not the 8 GB ETL task def)", () => {
const td = reconcileTaskDef();
expect(td.Properties?.Cpu).toBe("256");
expect(td.Properties?.Memory).toBe("512");
});
it("injects exactly the four secrets the worker reads (incl OPENSEARCH_NODE from secret), and no SCHOLARS_* / ETL_*_SECRET", () => {
const td = reconcileTaskDef();
const container = (
td.Properties?.ContainerDefinitions as
| Array<Record<string, unknown>>
| undefined
)?.find((c) => c.Name === "reconcile");
expect(container).toBeDefined();
const secretNames = (
container?.Secrets as Array<{ Name?: string }> | undefined
)?.map((s) => s.Name);
expect((secretNames ?? []).sort()).toEqual([
"DATABASE_URL",
"OPENSEARCH_NODE",
"OPENSEARCH_PASS",
"OPENSEARCH_USER",
]);
// No per-source ETL credentials leak onto the reconcile task.
const leaked = (secretNames ?? []).filter(
(n) => /^SCHOLARS_/.test(n ?? "") || /^ETL_.*_SECRET$/.test(n ?? ""),
);
expect(leaked).toEqual([]);
// Cutover (openSearchNodeFromSecret on): OPENSEARCH_NODE rides in the
// Secrets block (opensearch/etl `node` key), not the plaintext env.
const envNames = (
container?.Environment as Array<{ Name?: string }> | undefined
)?.map((e) => e.Name);
expect(envNames ?? []).not.toContain("OPENSEARCH_NODE");
});
it("the reconcile exec role lists exactly the 2 consumer ARNs (db/etl + opensearch/etl; no *)", () => {
const policies = template.findResources("AWS::IAM::Policy");
const execPolicy = Object.values(policies).find((p) => {
const roles = p.Properties?.Roles as
| Array<{ Ref?: string }>
| undefined;
return roles?.some(
(r) =>
typeof r.Ref === "string" &&
// Exclude the #353 CdnReconcileTaskExecutionRole, whose Ref also
// contains the "ReconcileTaskExecutionRole" substring.
!r.Ref.includes("CdnReconcile") &&
r.Ref.includes("ReconcileTaskExecutionRole"),
);
});
expect(execPolicy).toBeDefined();
const statements = execPolicy?.Properties?.PolicyDocument
?.Statement as Array<Record<string, unknown>> | undefined;
const secretsStmt = statements?.find((s) => {
const action = s.Action;
return Array.isArray(action)
? action.includes("secretsmanager:GetSecretValue")
: action === "secretsmanager:GetSecretValue";
});
expect(secretsStmt).toBeDefined();
const resourceList = Array.isArray(secretsStmt?.Resource)
? (secretsStmt?.Resource as unknown[])
: [secretsStmt?.Resource];
expect(resourceList).toHaveLength(2);
for (const r of resourceList) {
expect(JSON.stringify(r)).not.toMatch(/^"\*"$/);
}
});
it("the reconcile task role has zero secretsmanager:* actions", () => {
const policies = template.findResources("AWS::IAM::Policy");
const taskRolePolicy = Object.values(policies).find((p) => {
const roles = p.Properties?.Roles as
| Array<{ Ref?: string }>
| undefined;
return roles?.some(
(r) =>
typeof r.Ref === "string" &&
// Exclude the #353 CdnReconcileTaskRole, whose Ref also contains
// the "ReconcileTaskRole" substring.
!r.Ref.includes("CdnReconcile") &&
r.Ref.includes("ReconcileTaskRole") &&
!r.Ref.includes("ReconcileTaskExecutionRole"),
);
});