Skip to content

Instantly share code, notes, and snippets.

@rexwhitten
Created January 26, 2026 21:46
Show Gist options
  • Select an option

  • Save rexwhitten/634ca7b64e5ab3ab86f92495b67618fa to your computer and use it in GitHub Desktop.

Select an option

Save rexwhitten/634ca7b64e5ab3ab86f92495b67618fa to your computer and use it in GitHub Desktop.
Wf
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>M&A Cloud Posture Assessment - Onboarding</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
max-width: 1100px;
width: 100%;
overflow: hidden;
min-height: 500px;
display: flex;
flex-direction: column;
transition: max-width 0.3s;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
position: relative;
}
.header h1 {
font-size: 28px;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 14px;
}
.view-container {
padding: 30px;
flex: 1;
display: none;
}
.view-container.active {
display: block;
animation: fadeIn 0.4s;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Form Styles */
.form-group {
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 8px;
color: #2d3748;
font-weight: 500;
font-size: 14px;
}
input[type="text"],
input[type="email"],
select {
width: 100%;
padding: 12px 15px;
border: 2px solid #e2e8f0;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s;
}
input:focus,
select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* Grid Layouts */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
}
.full-width {
grid-column: 1 / -1;
}
@media (max-width: 600px) {
.form-grid {
grid-template-columns: 1fr;
}
.view-container {
padding: 20px;
}
.header {
padding: 24px;
}
}
/* Buttons */
button {
padding: 12px 30px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #e2e8f0;
color: #4a5568;
}
.btn-secondary:hover {
background: #cbd5e0;
}
.btn-block {
width: 100%;
}
.btn-sm {
padding: 8px 12px;
font-size: 12px;
}
.error {
color: #e53e3e;
font-size: 12px;
margin-top: 5px;
display: none;
}
/* Spinner */
.spinner {
border: 4px solid rgba(0, 0, 0, 0.1);
width: 36px;
height: 36px;
border-radius: 50%;
border-left-color: #667eea;
animation: spin 1s linear infinite;
margin: 50px auto;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Org List Grid */
.org-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 20px;
margin-top: 20px;
}
.org-item {
padding: 20px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
display: flex;
flex-direction: column;
justify-content: space-between;
cursor: pointer;
transition: all 0.2s;
height: 100%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02);
}
.org-item:hover {
border-color: #667eea;
background-color: #f8fafc;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.org-name {
font-weight: bold;
color: #2d3748;
font-size: 16px;
margin-bottom: 5px;
}
.org-industry {
font-size: 13px;
color: #718096;
margin-bottom: 15px;
background: #edf2f7;
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
}
.org-meta {
font-size: 12px;
color: #a0aec0;
border-top: 1px solid #edf2f7;
padding-top: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.org-id-badge {
font-family: monospace;
font-size: 10px;
background: #eee;
padding: 2px 5px;
border-radius: 3px;
color: #555;
}
/* Floating Back Button in Header */
.back-btn {
position: absolute;
left: 20px;
top: 20px;
background: rgba(255, 255, 255, 0.2);
color: white;
border: none;
padding: 5px 15px;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
display: none;
}
.back-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
/* Account Item List */
.item-list {
margin-top: 20px;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.item-row {
padding: 15px 20px;
border-bottom: 1px solid #e2e8f0;
display: grid;
grid-template-columns: 60px 1fr minmax(180px, 220px);
align-items: start;
gap: 15px;
}
@media (max-width: 700px) {
.item-row {
grid-template-columns: 1fr;
align-items: start;
}
.account-actions {
flex-direction: row;
flex-wrap: wrap;
}
.account-actions button {
flex: 1 1 auto;
}
}
.item-row:last-child {
border-bottom: none;
}
.provider-badge {
font-weight: bold;
text-align: center;
padding: 4px;
border-radius: 4px;
font-size: 12px;
color: white;
}
.badge-AWS {
background-color: #ff9900;
}
.badge-Azure {
background-color: #0078d4;
}
.badge-GCP {
background-color: #4285f4;
}
.account-actions {
display: flex;
flex-direction: column;
gap: 8px;
justify-content: center;
}
.workflow-status {
margin-top: 8px;
font-size: 12px;
color: #4a5568;
}
.workflow-pill {
display: inline-flex;
align-items: center;
font-size: 11px;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
margin-right: 8px;
}
.workflow-pill.idle {
background: #edf2f7;
color: #4a5568;
}
.workflow-pill.running {
background: #ebf8ff;
color: #2b6cb0;
}
.workflow-pill.success {
background: #c6f6d5;
color: #22543d;
}
.workflow-pill.error {
background: #fed7d7;
color: #9b2c2c;
}
.workflow-results {
margin-top: 6px;
padding-left: 18px;
color: #2d3748;
}
.workflow-results li {
margin-bottom: 4px;
word-break: break-all;
font-size: 12px;
}
/* Add Account Grid */
.add-account-box {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 20px;
margin-bottom: 25px;
}
.account-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
align-items: end;
}
.account-form-grid button {
justify-self: start;
}
@media (max-width: 768px) {
.account-form-grid {
grid-template-columns: 1fr;
}
.account-form-grid button {
width: 100%;
}
}
.landing-header,
.accounts-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.landing-header button {
white-space: nowrap;
}
.accounts-header > div {
min-width: 220px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<button id="backBtn" class="back-btn" onclick="showLanding()">
← Back
</button>
<h1>M&A Cloud Posture</h1>
<p id="headerSubtitle">
Setup your organization for cloud security assessment
</p>
</div>
<!-- VIEW: Landing Page -->
<div id="view-landing" class="view-container">
<div id="landingSpinner" class="spinner"></div>
<div id="landingContent" style="display: none">
<div class="landing-header" style="margin-bottom: 20px">
<div>
<h2 style="font-size: 20px; color: #2d3748">Organizations</h2>
<p style="font-size: 13px; color: #718096; margin-top: 4px">
Select an organization to manage
</p>
</div>
<button class="btn-primary" onclick="showNewOrgForm()">
+ New Org
</button>
</div>
<div id="orgList" class="org-list">
<!-- Orgs will be populated here -->
</div>
</div>
</div>
<!-- VIEW: New Organization Form -->
<div id="view-new-org" class="view-container">
<div id="onboardingForm">
<div style="text-align: center; margin-bottom: 30px">
<h2 style="font-size: 24px; color: #2d3748">New Organization</h2>
<p style="color: #718096">
Enter the details of the company being acquired
</p>
</div>
<div class="form-grid">
<div class="form-group full-width">
<label for="companyName">Company Name *</label>
<input
type="text"
id="companyName"
required
placeholder="Acme Corp"
/>
<div class="error" id="companyNameError">
Company name is required
</div>
</div>
<div class="form-group full-width">
<label for="maName">M&A Target Name (Acquisition ID) *</label>
<input
type="text"
id="maName"
required
placeholder="e.g. Acme, ProjectFalcon"
/>
<div class="error" id="maNameError">
M&A Target Name is required (used for unique identifier)
</div>
</div>
<div class="form-group full-width">
<label for="industry">Industry</label>
<input
type="text"
id="industry"
placeholder="e.g., Technology, Healthcare, Finance"
/>
</div>
<div class="form-group">
<label for="cisoName">CISO Name *</label>
<input
type="text"
id="cisoName"
required
placeholder="John Doe"
/>
<div class="error" id="cisoNameError">CISO name is required</div>
</div>
<div class="form-group">
<label for="cisoEmail">CISO Email *</label>
<input
type="email"
id="cisoEmail"
required
placeholder="[email protected]"
/>
<div class="error" id="cisoEmailError">
Valid CISO email is required
</div>
</div>
<div class="form-group">
<label for="remediationName">Remediation Contact Name</label>
<input
type="text"
id="remediationName"
placeholder="Jane Smith"
/>
</div>
<div class="form-group">
<label for="remediationEmail">Remediation Contact Email</label>
<input
type="email"
id="remediationEmail"
placeholder="[email protected]"
/>
</div>
<div class="form-group full-width">
<label
style="
display: flex;
align-items: center;
cursor: pointer;
background: #f7fafc;
padding: 15px;
border-radius: 8px;
"
>
<input
type="checkbox"
id="scoringEnabled"
checked
style="width: auto; margin-right: 15px"
/>
<div>
<span style="display: block; font-weight: 600"
>Enable Automated Scoring</span
>
<span
style="font-size: 12px; color: #718096; font-weight: normal"
>Automatically run posture assessments on connected
accounts.</span
>
</div>
</label>
</div>
</div>
<div
style="margin-top: 30px; display: flex; justify-content: flex-end"
>
<button
class="btn-secondary"
style="margin-right: 15px"
onclick="showLanding()"
>
Cancel
</button>
<button
class="btn-primary"
style="min-width: 150px"
onclick="submitOrgForm()"
>
Create Organization
</button>
</div>
</div>
</div>
<!-- VIEW: Success / Next Step -->
<div
id="view-success"
class="view-container"
style="text-align: center; padding-top: 50px"
>
<div
style="
width: 80px;
height: 80px;
background: #28a745;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
color: white;
font-size: 40px;
box-shadow: 0 10px 20px rgba(40, 167, 69, 0.3);
"
>
</div>
<h2 style="font-size: 28px; color: #2d3748; margin-bottom: 15px">
Organization Created!
</h2>
<p style="color: #718096; margin-bottom: 30px">
Organization <strong id="successOrgName"></strong> has been
successfully onboarded.
</p>
<button class="btn-primary" id="btnGoToAccounts">
Connect Cloud Accounts →
</button>
</div>
<!-- VIEW: Cloud Accounts -->
<div id="view-accounts" class="view-container">
<div class="accounts-header" style="margin-bottom: 25px">
<div>
<h2 style="font-size: 24px; color: #2d3748">Cloud Accounts</h2>
<p style="color: #718096; font-size: 14px">
Manage connected environments
</p>
</div>
<div
style="
text-align: right;
background: #f0fff4;
border: 1px solid #c6f6d5;
padding: 8px 15px;
border-radius: 8px;
"
>
<strong style="color: #276749" id="currentOrgName"
>Loading...</strong
>
<div style="font-size: 11px; color: #718096">
ID: <span id="currentOrgId"></span>
</div>
</div>
</div>
<div class="add-account-box">
<h3 style="font-size: 16px; margin-bottom: 15px; color: #4a5568">
Add New Account
</h3>
<div class="account-form-grid">
<div class="form-group" style="margin-bottom: 0">
<label for="accProvider">Provider</label>
<select id="accProvider">
<option value="AWS">AWS</option>
<option value="Azure">Azure</option>
<option value="GCP">GCP</option>
</select>
</div>
<div class="form-group" style="margin-bottom: 0">
<label for="accId">Account / Sub ID</label>
<input type="text" id="accId" placeholder="123456789012" />
</div>
<div class="form-group" style="margin-bottom: 0">
<label for="accName">Account Name</label>
<input type="text" id="accName" placeholder="Production Env" />
</div>
<button class="btn-primary" onclick="addAccount()">+ Add</button>
</div>
<div id="accError" class="error" style="margin-top: 10px"></div>
</div>
<h3
style="
font-size: 16px;
margin-top: 30px;
margin-bottom: 15px;
color: #4a5568;
"
>
Linked Accounts
</h3>
<div id="accountsList" class="item-list">
<!-- Accounts loaded here -->
</div>
</div>
</div>
<script src="config.js"></script>
<script>
const API_ENDPOINT = window.config?.onboardingUrl || "/onboarding";
const PAAS_API_ENDPOINT = window.config?.paasUrl || "/inventory_crud";
const WORKFLOW_API_ENDPOINT = (
window.config?.workflowUrl || "/api/workflow"
).replace(/\/$/, "");
const WORKFLOW_POLL_INTERVAL = 1000;
// State
let allOrgs = [];
let currentOrg = null;
let accountsCache = [];
const workflowRuns = {};
// Initialization
document.addEventListener("DOMContentLoaded", () => {
showLanding();
});
// Navigation
function showView(viewId) {
document
.querySelectorAll(".view-container")
.forEach((el) => el.classList.remove("active"));
document.getElementById(viewId).classList.add("active");
const backBtn = document.getElementById("backBtn");
if (viewId === "view-landing") {
backBtn.style.display = "none";
} else {
backBtn.style.display = "block";
}
}
function showLanding() {
showView("view-landing");
loadOrganizations();
}
function showNewOrgForm() {
// Reset form
document.querySelectorAll("input").forEach((i) => (i.value = ""));
document.getElementById("scoringEnabled").checked = true;
document
.querySelectorAll(".error")
.forEach((e) => (e.style.display = "none"));
showView("view-new-org");
}
function showSuccess(org) {
document.getElementById("successOrgName").textContent =
org.organization_name || "Unknown";
document.getElementById("btnGoToAccounts").onclick = () =>
showAccounts(org);
showView("view-success");
}
function showAccounts(org) {
currentOrg = org;
document.getElementById("currentOrgName").textContent =
org.organization_name || "Unknown";
document.getElementById("currentOrgId").textContent = org.ma_name;
showView("view-accounts");
loadAccounts();
// Pre-cleanup error
document.getElementById("accError").style.display = "none";
}
function sanitizeId(value) {
return (value || "").replace(/[^a-zA-Z0-9_-]/g, "_");
}
function getWorkflowKey(maName, accountId) {
return `${maName || "unknown"}::${accountId || "unknown"}`;
}
function getWorkflowStatusElementId(maName, accountId) {
return `workflow-status-${sanitizeId(maName)}-${sanitizeId(accountId)}`;
}
function renderWorkflowStatus(maName, accountId) {
const key = getWorkflowKey(maName, accountId);
const state = workflowRuns[key];
if (!state) {
return '<span class="workflow-pill idle">Idle</span>Ready to run workflow.';
}
if (state.status === "RUNNING") {
return '<span class="workflow-pill running">Running…</span>Gathering posture results.';
}
if (state.status === "COMPLETED") {
const results = state.results || [];
const listItems = results
.map((keyName) => `<li>${keyName}</li>`)
.join("");
const listMarkup = results.length
? `<ul class="workflow-results">${listItems}</ul>`
: "";
return `
<div>
<span class="workflow-pill success">Completed</span>
Results available below.
</div>
${listMarkup}
`;
}
if (state.status === "ERROR") {
return `<span class="workflow-pill error">Error</span>${state.message || "Workflow failed"}`;
}
return '<span class="workflow-pill idle">Idle</span>Ready to run workflow.';
}
function updateWorkflowStatusUI(maName, accountId) {
const el = document.getElementById(
getWorkflowStatusElementId(maName, accountId),
);
if (el) {
el.innerHTML = renderWorkflowStatus(maName, accountId);
}
}
function getAccountDetails(maName, accountId) {
return accountsCache.find(
(acc) => acc.ma_name === maName && acc.account_id === accountId,
);
}
async function startWorkflow(maName, accountId, triggerBtn) {
if (!currentOrg) return;
const account = getAccountDetails(maName, accountId);
if (!account) {
alert("Unable to locate account details");
return;
}
const btn = triggerBtn;
const originalLabel = btn?.textContent;
if (btn) {
btn.disabled = true;
btn.textContent = "Starting...";
}
const payload = {
org: {
id: currentOrg.ma_name,
name: currentOrg.organization_name,
},
account: {
id: account.account_id,
provider: account.provider,
alias: account.account_alias,
},
};
try {
const res = await fetch(WORKFLOW_API_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Workflow failed to start");
}
const key = getWorkflowKey(maName, accountId);
workflowRuns[key] = {
status: data.status || "RUNNING",
runId: data.runId,
results: [],
};
updateWorkflowStatusUI(maName, accountId);
beginWorkflowPolling(maName, accountId, data.runId);
} catch (err) {
const key = getWorkflowKey(maName, accountId);
workflowRuns[key] = {
status: "ERROR",
message: err.message,
};
updateWorkflowStatusUI(maName, accountId);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = originalLabel;
}
}
}
function beginWorkflowPolling(maName, accountId, runId) {
const key = getWorkflowKey(maName, accountId);
stopWorkflowPolling(maName, accountId);
let isPolling = false;
const poll = async () => {
if (isPolling) return;
isPolling = true;
try {
const res = await fetch(
`${WORKFLOW_API_ENDPOINT}/${encodeURIComponent(runId)}`,
);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Polling failed");
}
workflowRuns[key] = {
...(workflowRuns[key] || {}),
status: data.status,
runId: data.runId,
results: data.results || [],
};
updateWorkflowStatusUI(maName, accountId);
if (data.status === "COMPLETED") {
stopWorkflowPolling(maName, accountId);
}
} catch (err) {
workflowRuns[key] = {
status: "ERROR",
message: err.message,
};
updateWorkflowStatusUI(maName, accountId);
stopWorkflowPolling(maName, accountId);
}
isPolling = false;
};
poll();
const intervalId = setInterval(poll, WORKFLOW_POLL_INTERVAL);
workflowRuns[key] = {
...(workflowRuns[key] || {}),
poller: intervalId,
};
}
function stopWorkflowPolling(maName, accountId) {
const key = getWorkflowKey(maName, accountId);
const poller = workflowRuns[key]?.poller;
if (poller) {
clearInterval(poller);
delete workflowRuns[key].poller;
}
}
// Logic - Landing
async function loadOrganizations() {
const spinner = document.getElementById("landingSpinner");
const content = document.getElementById("landingContent");
const list = document.getElementById("orgList");
spinner.style.display = "block";
content.style.display = "none";
list.innerHTML = "";
try {
const res = await fetch(API_ENDPOINT);
const json = await res.json();
if (res.status === 200 && json.data) {
allOrgs = json.data;
if (allOrgs.length === 0) {
list.innerHTML =
'<div style="grid-column: 1/-1; text-align: center; color: #718096; padding: 40px; background: #f8fafc; border-radius: 8px;">No organizations found. Click "+ New Org" to start.</div>';
} else {
allOrgs.sort(
(a, b) =>
new Date(b.created_at || 0) - new Date(a.created_at || 0),
); // Sort newest first
allOrgs.forEach((org) => {
const item = document.createElement("div");
item.className = "org-item";
item.onclick = () => showAccounts(org); // Going to detail/accounts view
const name = org.organization_name || "Unnamed Organization";
const ma = org.ma_name || "Unnamed Target";
const industry = org.company_info?.industry || "Unspecified";
const created = org.created_at
? new Date(org.created_at).toLocaleDateString()
: "Unknown";
const id = org.pk ? org.pk.replace("ORG#", "") : "";
item.innerHTML = `
<div>
<div class="org-name">${name}</div>
<div style="font-size: 13px; color: #667eea; font-weight: 600;">${ma}</div>
<span class="org-industry">${industry}</span>
</div>
<div class="org-meta">
<span class="org-id-badge">${id}</span>
<span>${created}</span>
</div>
`;
list.appendChild(item);
});
}
} else {
list.innerHTML =
'<div style="color: red; text-align: center;">Failed to load organizations.</div>';
}
} catch (e) {
console.error(e);
list.innerHTML =
'<div style="color: red; text-align: center;">Network error.</div>';
} finally {
spinner.style.display = "none";
content.style.display = "block";
}
}
// Logic - New Org
function validateField(fieldId) {
const field = document.getElementById(fieldId);
const error = document.getElementById(fieldId + "Error");
if (!field.value.trim()) {
error.style.display = "block";
field.style.borderColor = "#e53e3e";
return false;
}
error.style.display = "none";
field.style.borderColor = "#e2e8f0";
return true;
}
function validateEmail(fieldId) {
const field = document.getElementById(fieldId);
const error = document.getElementById(fieldId + "Error");
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!field.value.trim() || !emailRegex.test(field.value)) {
error.style.display = "block";
field.style.borderColor = "#e53e3e";
return false;
}
error.style.display = "none";
field.style.borderColor = "#e2e8f0";
return true;
}
async function submitOrgForm() {
if (
!validateField("companyName") ||
!validateField("maName") ||
!validateField("cisoName") ||
!validateEmail("cisoEmail")
)
return;
const payload = {
organization_name: document.getElementById("companyName").value,
ma_name: document.getElementById("maName").value,
industry: document.getElementById("industry").value || "",
contact_name: document.getElementById("cisoName").value,
contact_email: document.getElementById("cisoEmail").value,
scoring_enabled: document.getElementById("scoringEnabled").checked,
};
const rName = document.getElementById("remediationName").value;
const rEmail = document.getElementById("remediationEmail").value;
if (rName.trim() || rEmail.trim()) {
payload.remediation_contact = { name: rName, email: rEmail };
}
const btn = event.target;
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = "Creating...";
try {
const res = await fetch(API_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const result = await res.json();
if ((res.status === 200 || res.status === 201) && result.data) {
// Success
showSuccess(result.data); // data contains the item including PK
} else {
alert("Error: " + (result.error || "Unknown error"));
}
} catch (e) {
console.error(e);
alert("Network error occurred.");
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
// Logic - Accounts
async function loadAccounts() {
if (!currentOrg) return;
const maName = currentOrg.ma_name;
const list = document.getElementById("accountsList");
list.innerHTML =
'<div style="padding:15px; text-align:center;">Loading...</div>';
accountsCache = [];
try {
const res = await fetch(
`${PAAS_API_ENDPOINT}?ma_name=${encodeURIComponent(maName)}`,
);
const json = await res.json();
const items = json.data;
accountsCache = Array.isArray(items) ? items : [];
list.innerHTML = "";
if (res.status === 200 && Array.isArray(items)) {
if (items.length === 0) {
list.innerHTML =
'<div style="padding:15px; text-align:center; color:#718096;">No accounts connected.</div>';
} else {
items.forEach((item) => {
const div = document.createElement("div");
div.className = "item-row";
const statusId = getWorkflowStatusElementId(
item.ma_name,
item.account_id,
);
div.innerHTML = `
<div class="provider-badge badge-${item.provider}">${item.provider}</div>
<div>
<strong style="color: #2d3748;">${item.account_id}</strong>
<div style="font-size:12px; color:#718096;">${item.account_alias || "No alias"}</div>
<div class="workflow-status" id="${statusId}">${renderWorkflowStatus(item.ma_name, item.account_id)}</div>
</div>
<div class="account-actions">
<button class="btn-sm btn-primary" onclick="startWorkflow('${item.ma_name}', '${item.account_id}', this)">Run Workflow</button>
<button class="btn-sm btn-secondary" style="border: 1px solid #e2e8f0; color: #e53e3e;" onclick="deleteAccount('${item.ma_name}', '${item.account_id}')">Remove</button>
</div>
`;
list.appendChild(div);
});
}
} else {
list.innerHTML =
'<div style="color:red; text-align:center; padding:10px;">Failed to load accounts.</div>';
}
} catch (e) {
console.error(e);
list.innerHTML =
'<div style="color:red; text-align:center; padding:10px;">Error loading accounts.</div>';
}
}
async function addAccount() {
if (!currentOrg) return;
const maName = currentOrg.ma_name;
const provider = document.getElementById("accProvider").value;
const accId = document.getElementById("accId").value.trim();
const accAlias = document.getElementById("accName").value.trim();
const errorDiv = document.getElementById("accError");
if (!accId) {
errorDiv.style.display = "block";
errorDiv.textContent = "Account ID is required.";
return;
}
errorDiv.style.display = "none";
const payload = {
ma_name: maName,
provider: provider,
account_id: accId,
account_alias: accAlias,
};
const btn = event.target;
btn.disabled = true;
try {
const res = await fetch(PAAS_API_ENDPOINT, {
method: "POST",
body: JSON.stringify(payload),
});
if (res.status === 201 || res.status === 200) {
document.getElementById("accId").value = "";
document.getElementById("accName").value = "";
loadAccounts();
} else {
const data = await res.json();
errorDiv.style.display = "block";
errorDiv.textContent = data.error || "Failed to add account";
}
} catch (e) {
console.error(e);
errorDiv.style.display = "block";
errorDiv.textContent = "Error adding account";
} finally {
btn.disabled = false;
}
}
async function deleteAccount(maName, accountId) {
if (!confirm("Remove this account?")) return;
const workflowKeyValue = getWorkflowKey(maName, accountId);
try {
// Using Path parameters or Query?
// Backend supports DELETE via path variables or ma_name/account_id query/body?
// Let's use path: /inventory/{ma}/{acc}
// Or query if strictly following crud.py structure: expects ma_name and account_id from query/body
// Frontend logic previously used query `?id=...`.
// Updated backend `delete_item` checks ma_name/account_id.
// Let's use REST path if supported, or params.
// Backend inventory_crud logic splits path.
const res = await fetch(
`${PAAS_API_ENDPOINT}/${encodeURIComponent(maName)}/${encodeURIComponent(accountId)}`,
{
method: "DELETE",
},
);
if (res.status === 200) {
stopWorkflowPolling(maName, accountId);
delete workflowRuns[workflowKeyValue];
loadAccounts();
} else {
alert("Failed to delete");
}
} catch (e) {
console.error(e);
alert("Error deleting");
}
}
</script>
</body>
</html>
import pytest
from unittest.mock import MagicMock, patch
import json
import os
import sys
# Helper to ensure we start with a fresh module for top-level code execution
def clean_imports():
if "lambdas.workflow" in sys.modules:
del sys.modules["lambdas.workflow"]
class TestStartWorkflow:
"""Tests for start_workflow function"""
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_start_workflow_success(self, mock_boto_resource, mock_boto_client):
"""Test successful workflow start"""
clean_imports()
# Mock DynamoDB
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
# Mock Step Functions
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.start_execution.return_value = {
"executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123",
"startDate": "2024-01-01T00:00:00Z",
}
from lambdas.workflow import start_workflow
event = {
"body": json.dumps(
{
"org": {"id": "test-org", "name": "Test Organization"},
"account": {
"id": "123456789012",
"provider": "AWS",
"alias": "test-account",
},
}
)
}
response = start_workflow(event)
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "RUNNING"
assert "runId" in body
assert body["runId"].startswith("workflow-")
mock_stepfunctions.start_execution.assert_called_once()
@patch.dict(
os.environ,
{
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_start_workflow_missing_arn(self, mock_boto_resource):
"""Test workflow start with missing state machine ARN"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import start_workflow
event = {
"body": json.dumps(
{
"org": {"id": "test-org"},
"account": {"id": "123456789012"},
}
)
}
response = start_workflow(event)
assert response["statusCode"] == 500
body = json.loads(response["body"])
assert "State machine ARN not configured" in body["details"]
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_start_workflow_invalid_json(self, mock_boto_resource):
"""Test workflow start with invalid JSON body"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import start_workflow
event = {"body": "invalid json"}
response = start_workflow(event)
assert response["statusCode"] == 400
body = json.loads(response["body"])
assert "Invalid JSON payload" in body["error"]
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_start_workflow_missing_org_id(self, mock_boto_resource):
"""Test workflow start with missing org.id"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import start_workflow
event = {
"body": json.dumps(
{
"org": {},
"account": {"id": "123456789012"},
}
)
}
response = start_workflow(event)
assert response["statusCode"] == 400
body = json.loads(response["body"])
assert "org.id and account.id are required" in body["error"]
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_start_workflow_missing_account_id(self, mock_boto_resource):
"""Test workflow start with missing account.id"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import start_workflow
event = {
"body": json.dumps(
{
"org": {"id": "test-org"},
"account": {},
}
)
}
response = start_workflow(event)
assert response["statusCode"] == 400
body = json.loads(response["body"])
assert "org.id and account.id are required" in body["error"]
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_start_workflow_with_fallback_fields(self, mock_boto_resource):
"""Test workflow start using fallback field names (ma_name, account_id)"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
with patch("boto3.client") as mock_boto_client:
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.start_execution.return_value = {
"executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123",
}
from lambdas.workflow import start_workflow
# Use ma_name and account_id instead of id
event = {
"body": json.dumps(
{
"org": {"ma_name": "test-org-alt"},
"account": {"account_id": "123456789012"},
}
)
}
response = start_workflow(event)
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "RUNNING"
class TestCheckRunStatus:
"""Tests for check_run_status function"""
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
"WORKFLOW_RESULT_PREFIX": "scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_check_run_status_running(self, mock_boto_resource, mock_boto_client):
"""Test checking status of running execution"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.describe_execution.return_value = {
"status": "RUNNING",
"executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123",
}
from lambdas.workflow import check_run_status
response = check_run_status("workflow-abc-123")
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "RUNNING"
assert body["runId"] == "workflow-abc-123"
assert "results" not in body or not body.get("results")
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
"WORKFLOW_RESULT_PREFIX": "scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_check_run_status_succeeded(self, mock_boto_resource, mock_boto_client):
"""Test checking status of succeeded execution"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_table.query.return_value = {"Items": []}
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.describe_execution.return_value = {
"status": "SUCCEEDED",
"executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123",
"output": json.dumps(
{
"org": {"id": "test-org"},
"account": {"id": "123456789012"},
}
),
}
from lambdas.workflow import check_run_status
response = check_run_status("workflow-abc-123")
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "SUCCEEDED"
assert "results" in body
assert isinstance(body["results"], list)
mock_table.query.assert_called_once()
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
"WORKFLOW_RESULT_PREFIX": "scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_check_run_status_succeeded_with_dynamodb_results(
self, mock_boto_resource, mock_boto_client
):
"""Test checking status of succeeded execution with DynamoDB results"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_table.query.return_value = {
"Items": [
{
"pk": "ORG#test-org",
"sk": "RUN#workflow-abc-123",
"s3_keys": [
"s3://bucket/scoring/test-org/123456789012/workflow-abc-123/summary.json",
"s3://bucket/scoring/test-org/123456789012/workflow-abc-123/evidence.csv",
],
}
]
}
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.describe_execution.return_value = {
"status": "SUCCEEDED",
"executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123",
"output": json.dumps(
{
"org": {"id": "test-org"},
"account": {"id": "123456789012"},
}
),
}
from lambdas.workflow import check_run_status
response = check_run_status("workflow-abc-123")
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "SUCCEEDED"
assert len(body["results"]) == 2
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
"WORKFLOW_RESULT_PREFIX": "scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_check_run_status_failed(self, mock_boto_resource, mock_boto_client):
"""Test checking status of failed execution"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.describe_execution.return_value = {
"status": "FAILED",
"cause": "Lambda function failed with error: Invalid input",
"executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123",
}
from lambdas.workflow import check_run_status
response = check_run_status("workflow-abc-123")
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "FAILED"
assert "error" in body
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
"WORKFLOW_RESULT_PREFIX": "scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_check_run_status_aborted(self, mock_boto_resource, mock_boto_client):
"""Test checking status of aborted execution"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.describe_execution.return_value = {
"status": "ABORTED",
"executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123",
}
from lambdas.workflow import check_run_status
response = check_run_status("workflow-abc-123")
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "ABORTED"
assert "error" in body
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
"WORKFLOW_RESULT_PREFIX": "scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_check_run_status_timed_out(self, mock_boto_resource, mock_boto_client):
"""Test checking status of timed out execution"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.describe_execution.return_value = {
"status": "TIMED_OUT",
"executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123",
}
from lambdas.workflow import check_run_status
response = check_run_status("workflow-abc-123")
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "TIMED_OUT"
assert "error" in body
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_check_run_status_not_found(self, mock_boto_resource, mock_boto_client):
"""Test checking status of non-existent execution"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
# Simulate ExecutionDoesNotExist exception
class ExecutionDoesNotExistException(Exception):
pass
mock_stepfunctions.exceptions.ExecutionDoesNotExist = (
ExecutionDoesNotExistException
)
mock_stepfunctions.describe_execution.side_effect = (
ExecutionDoesNotExistException()
)
from lambdas.workflow import check_run_status
response = check_run_status("nonexistent-workflow-123")
assert response["statusCode"] == 404
body = json.loads(response["body"])
assert "Run not found" in body["error"]
@patch.dict(
os.environ,
{
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_check_run_status_missing_arn(self, mock_boto_resource):
"""Test checking status with missing state machine ARN"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import check_run_status
response = check_run_status("workflow-abc-123")
assert response["statusCode"] == 500
body = json.loads(response["body"])
assert "State machine ARN not configured" in body["details"]
class TestWorkflowLogic:
"""Tests for workflow_logic main handler"""
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_workflow_logic_post_root(self, mock_boto_resource, mock_boto_client):
"""Test POST to / routes to start_workflow"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.start_execution.return_value = {
"executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123",
}
from lambdas.workflow import workflow_logic
event = {
"httpMethod": "POST",
"rawPath": "/api/workflow",
"body": json.dumps(
{
"org": {"id": "test-org"},
"account": {"id": "123456789012"},
}
),
}
response = workflow_logic(event, {})
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "RUNNING"
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_workflow_logic_get_runid(self, mock_boto_resource, mock_boto_client):
"""Test GET /{runid} routes to check_run_status"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.describe_execution.return_value = {
"status": "RUNNING",
}
from lambdas.workflow import workflow_logic
event = {
"httpMethod": "GET",
"rawPath": "/api/workflow/workflow-abc-123",
}
response = workflow_logic(event, {})
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["status"] == "RUNNING"
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_workflow_logic_options(self, mock_boto_resource):
"""Test OPTIONS request returns 200"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import workflow_logic
event = {
"requestContext": {"http": {"method": "OPTIONS"}},
"rawPath": "/api/workflow",
}
response = workflow_logic(event, {})
assert response["statusCode"] == 200
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_workflow_logic_invalid_method(self, mock_boto_resource):
"""Test invalid route returns 404"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import workflow_logic
event = {
"httpMethod": "DELETE",
"rawPath": "/api/workflow",
}
response = workflow_logic(event, {})
assert response["statusCode"] == 404
body = json.loads(response["body"])
assert "Not Found" in body["error"]
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
"WORKFLOW_BASE_PATH": "/api/workflow",
},
)
@patch("boto3.client")
@patch("boto3.resource")
def test_workflow_logic_with_query_string(
self, mock_boto_resource, mock_boto_client
):
"""Test that query string is stripped from path"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
mock_stepfunctions = MagicMock()
mock_boto_client.return_value = mock_stepfunctions
mock_stepfunctions.describe_execution.return_value = {
"status": "RUNNING",
}
from lambdas.workflow import workflow_logic
event = {
"httpMethod": "GET",
"rawPath": "/api/workflow/workflow-abc-123?foo=bar",
}
response = workflow_logic(event, {})
assert response["statusCode"] == 200
class TestNormalizePath:
"""Tests for _normalize_path helper function"""
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
"WORKFLOW_BASE_PATH": "/api/workflow",
},
)
@patch("boto3.resource")
def test_normalize_path_root(self, mock_boto_resource):
"""Test normalizing root path"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import _normalize_path
assert _normalize_path("/api/workflow") == "/"
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
"WORKFLOW_BASE_PATH": "/api/workflow",
},
)
@patch("boto3.resource")
def test_normalize_path_with_runid(self, mock_boto_resource):
"""Test normalizing path with run ID"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import _normalize_path
assert _normalize_path("/api/workflow/workflow-abc-123") == "/workflow-abc-123"
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_normalize_path_trailing_slash(self, mock_boto_resource):
"""Test that trailing slashes are removed"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import _normalize_path
assert _normalize_path("/test/") == "/test"
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_normalize_path_missing_leading_slash(self, mock_boto_resource):
"""Test that missing leading slash is added"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import _normalize_path
assert _normalize_path("test").startswith("/")
class TestResponseFormat:
"""Tests for response format and headers"""
@patch.dict(
os.environ,
{
"WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow",
"CMAPP_SCORING_TABLE": "test-scoring",
},
)
@patch("boto3.resource")
def test_response_has_cors_headers(self, mock_boto_resource):
"""Test that response includes CORS headers"""
clean_imports()
mock_dynamodb = MagicMock()
mock_table = MagicMock()
mock_boto_resource.return_value = mock_dynamodb
mock_dynamodb.Table.return_value = mock_table
from lambdas.workflow import response
resp = response(200, {"test": "data"})
assert "headers" in resp
assert "Access-Control-Allow-Origin" in resp["headers"]
assert resp["headers"]["Access-Control-Allow-Origin"] == "*"
assert "Content-Type" in resp["headers"]
assert resp["headers"]["Content-Type"] == "application/json"
import json
import logging
import os
import time
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from typing import Any, Dict
import boto3
# Configure structured logging
logger = logging.getLogger()
logger.setLevel(logging.INFO)
# Initialize AWS clients
dynamodb = boto3.resource("dynamodb")
stepfunctions = boto3.client("stepfunctions")
SCORING_TABLE = os.environ.get("CMAPP_SCORING_TABLE", "cmapp_scoring")
WORKFLOW_STATE_MACHINE_ARN = os.environ.get("WORKFLOW_STATE_MACHINE_ARN")
RESULT_PREFIX = os.environ.get("WORKFLOW_RESULT_PREFIX", "scoring")
BASE_PATH = os.environ.get("WORKFLOW_BASE_PATH", "/api/workflow")
STATE_SK = "STATE"
scoring_table = dynamodb.Table(SCORING_TABLE)
class DecimalEncoder(json.JSONEncoder):
"""Helper class to convert DynamoDB Decimal types to JSON"""
def default(self, obj: Any) -> Any:
if isinstance(obj, Decimal):
return float(obj)
return super(DecimalEncoder, self).default(obj)
def response(status_code: int, body: Dict[str, Any]) -> Dict[str, Any]:
"""Generate API Gateway response with consistent headers"""
return {
"statusCode": status_code,
"headers": {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key",
"Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS",
},
"body": json.dumps(body, cls=DecimalEncoder),
}
def start_workflow(event: Dict[str, Any]) -> Dict[str, Any]:
"""Invoke Step Function state machine and return execution ID."""
try:
if not WORKFLOW_STATE_MACHINE_ARN:
logger.error("WORKFLOW_STATE_MACHINE_ARN environment variable not set")
return response(
500,
{
"error": "Configuration error",
"details": "State machine ARN not configured",
},
)
try:
body = json.loads(event.get("body") or "{}")
except json.JSONDecodeError:
return response(400, {"error": "Invalid JSON payload"})
org = body.get("org") or {}
account = body.get("account") or {}
org_id = org.get("id") or org.get("ma_name") or org.get("name")
account_id = account.get("id") or account.get("account_id")
if not org_id or not account_id:
logger.warning(
"Missing required workflow fields",
extra={"org_id": org_id, "account_id": account_id},
)
return response(400, {"error": "org.id and account.id are required"})
# Build execution input
execution_input = {"org": org, "account": account}
# Start Step Function execution
execution_name = f"workflow-{uuid.uuid4()}"
execution_response = stepfunctions.start_execution(
stateMachineArn=WORKFLOW_STATE_MACHINE_ARN,
name=execution_name,
input=json.dumps(execution_input),
)
execution_arn = execution_response.get("executionArn")
run_id = execution_name
logger.info(
"Step Function execution started",
extra={
"run_id": run_id,
"execution_arn": execution_arn,
"org_id": org_id,
"account_id": account_id,
},
)
return response(200, {"runId": run_id, "status": "RUNNING"})
except stepfunctions.exceptions.StateMachineDoesNotExist as e:
logger.error(f"State machine not found: {str(e)}", exc_info=True)
return response(
500,
{"error": "State machine not found", "details": str(e)},
)
except stepfunctions.exceptions.ExecutionLimitExceeded as e:
logger.error(f"Execution limit exceeded: {str(e)}", exc_info=True)
return response(
503,
{"error": "Too many concurrent executions", "details": str(e)},
)
except Exception as e:
logger.error(f"Error starting workflow: {str(e)}", exc_info=True)
return response(500, {"error": "Failed to start workflow", "details": str(e)})
def check_run_status(run_id: str) -> Dict[str, Any]:
"""Check Step Function execution status and return results if complete."""
try:
if not WORKFLOW_STATE_MACHINE_ARN:
logger.error("WORKFLOW_STATE_MACHINE_ARN environment variable not set")
return response(
500,
{
"error": "Configuration error",
"details": "State machine ARN not configured",
},
)
# Build execution ARN from run_id (execution name)
state_machine_name = WORKFLOW_STATE_MACHINE_ARN.split(":")[-1]
account_id = WORKFLOW_STATE_MACHINE_ARN.split(":")[4]
region = WORKFLOW_STATE_MACHINE_ARN.split(":")[3]
execution_arn = f"arn:aws:states:{region}:{account_id}:execution:{state_machine_name}:{run_id}"
# Describe execution to get current status
exec_response = stepfunctions.describe_execution(executionArn=execution_arn)
status = exec_response.get(
"status"
) # RUNNING, SUCCEEDED, FAILED, TIMED_OUT, ABORTED
execution_output = exec_response.get("output")
payload = {"runId": run_id, "status": status}
# If execution is complete, fetch results from DynamoDB
if status == "SUCCEEDED":
try:
# Parse execution output to get org and account info
output_data = {}
if execution_output:
output_data = json.loads(execution_output)
org = output_data.get("org", {})
account = output_data.get("account", {})
# Extract identifiers
org_id = (
org.get("id") or org.get("ma_name") or org.get("name") or "unknown"
)
account_id_val = (
account.get("id") or account.get("account_id") or "unknown"
)
# Query DynamoDB for scoring results
# Results are stored with PK = org_id, SK begins with run_id
try:
query_response = scoring_table.query(
KeyConditionExpression="pk = :pk AND begins_with(sk, :sk)",
ExpressionAttributeValues={
":pk": f"ORG#{org_id}",
":sk": f"RUN#{run_id}",
},
)
items = query_response.get("Items", [])
if items:
# Extract S3 keys from results
results = []
for item in items:
if "s3_keys" in item:
s3_keys = item.get("s3_keys", [])
if isinstance(s3_keys, list):
results.extend(s3_keys)
else:
results.append(s3_keys)
if results:
payload["results"] = results
else:
# Fallback: construct expected S3 keys based on pattern
prefix = (
f"{RESULT_PREFIX}/{org_id}/{account_id_val}/{run_id}"
)
payload["results"] = [
f"{prefix}/summary.json",
f"{prefix}/evidence.csv",
f"{prefix}/report.pdf",
]
else:
# No DynamoDB results yet, construct fallback keys
prefix = f"{RESULT_PREFIX}/{org_id}/{account_id_val}/{run_id}"
payload["results"] = [
f"{prefix}/summary.json",
f"{prefix}/evidence.csv",
f"{prefix}/report.pdf",
]
except Exception as db_err:
logger.warning(
"Could not fetch results from DynamoDB",
extra={"run_id": run_id, "error": str(db_err)},
)
# Fallback: construct expected S3 keys
prefix = f"{RESULT_PREFIX}/{org_id}/{account_id_val}/{run_id}"
payload["results"] = [
f"{prefix}/summary.json",
f"{prefix}/evidence.csv",
f"{prefix}/report.pdf",
]
except json.JSONDecodeError:
logger.warning(
"Could not parse execution output",
extra={"run_id": run_id},
)
# Fallback: provide generic result paths
payload["results"] = [
f"{RESULT_PREFIX}/unknown/unknown/{run_id}/summary.json",
f"{RESULT_PREFIX}/unknown/unknown/{run_id}/evidence.csv",
f"{RESULT_PREFIX}/unknown/unknown/{run_id}/report.pdf",
]
elif status == "FAILED":
payload["error"] = exec_response.get("cause", "Execution failed")
elif status == "ABORTED":
payload["error"] = "Execution was aborted"
elif status == "TIMED_OUT":
payload["error"] = "Execution timed out"
logger.info(
"Execution status retrieved",
extra={"run_id": run_id, "status": status},
)
return response(200, payload)
except stepfunctions.exceptions.ExecutionDoesNotExist:
logger.warning("Execution not found", extra={"run_id": run_id})
return response(404, {"error": "Run not found", "runId": run_id})
except Exception as e:
logger.error(f"Error checking run status: {str(e)}", exc_info=True)
return response(500, {"error": "Failed to check status", "details": str(e)})
def _normalize_path(path: str) -> str:
"""Strip optional base path and ensure a single leading slash."""
clean_path = path or "/"
if not clean_path.startswith("/"):
clean_path = f"/{clean_path}"
if BASE_PATH and clean_path.startswith(BASE_PATH):
suffix = clean_path[len(BASE_PATH) :]
clean_path = suffix or "/"
if clean_path != "/" and clean_path.endswith("/"):
clean_path = clean_path[:-1]
return clean_path
def workflow_logic(event: Dict[str, Any], context: Any) -> Dict[str, Any]:
"""Main handler for workflow API requests."""
try:
method = (
event.get("requestContext", {}).get("http", {}).get("method")
or event.get("httpMethod")
or "GET"
)
raw_path = (event.get("rawPath") or event.get("path") or "/").split("?")[0]
normalized_path = _normalize_path(raw_path)
logger.info(
"Workflow request received",
extra={
"method": method,
"path": raw_path,
"normalized_path": normalized_path,
},
)
if method == "OPTIONS":
return response(200, {"message": "ok"})
# POST /api/workflow - Start new workflow
if normalized_path == "/" and method == "POST":
return start_workflow(event)
# GET /api/workflow/{runid} - Check workflow status
if normalized_path != "/" and method == "GET":
run_id = normalized_path.lstrip("/")
return check_run_status(run_id)
logger.warning("No matching route", extra={"path": raw_path, "method": method})
return response(404, {"error": "Not Found", "path": raw_path})
except Exception as e:
logger.error(f"Unhandled exception in workflow_logic: {str(e)}", exc_info=True)
return response(500, {"error": "Internal Server Error", "details": str(e)})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment