Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions .github/workflows/deployment-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ env:
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET || '' }}
STRIPE_PRICE_FOUNDATION: ${{ secrets.STRIPE_PRICE_FOUNDATION || vars.STRIPE_PRICE_FOUNDATION || '' }}
STRIPE_PRICE_GROWTH: ${{ secrets.STRIPE_PRICE_GROWTH || vars.STRIPE_PRICE_GROWTH || '' }}
# audit M9: required by scripts/check-env.js productionRequiredKeys but was
# absent here, so the production-config gate could never pass clean. Set the
# STRIPE_PRICE_SCALE secret/var in the repo for this to resolve.
STRIPE_PRICE_SCALE: ${{ secrets.STRIPE_PRICE_SCALE || vars.STRIPE_PRICE_SCALE || '' }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY || '' }}
RESEND_FROM_EMAIL: ${{ secrets.RESEND_FROM_EMAIL || vars.RESEND_FROM_EMAIL || '' }}
UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL || vars.UPSTASH_REDIS_REST_URL || '' }}
Expand Down Expand Up @@ -265,28 +269,40 @@ jobs:
needs: [deploy_to_vercel]
steps:
- name: Health check
# audit M10: these checks used `curl -f … || echo "⚠️"`, so a real
# outage only printed a warning and the step still passed. Now they
# fail the job. `-L` follows the apex→www 308 redirect
# (next.config.ts), `--retry` rides out cold-start latency.
run: |
set -euo pipefail
echo "🏥 Running post-deployment health checks..."

# Check if site is accessible
curl -f https://formaos.com.au || echo "⚠️ Site accessibility check failed"
# Site root (follows apex → www redirect)
curl -fsSL --retry 3 --retry-delay 5 --retry-connrefused \
-o /dev/null https://formaos.com.au

# Check specific endpoints
curl -f https://formaos.com.au/pricing || echo "⚠️ Pricing page check failed"
# Pricing page
curl -fsSL --retry 3 --retry-delay 5 --retry-connrefused \
-o /dev/null https://formaos.com.au/pricing

echo "✅ Basic health checks completed"
echo "✅ Basic health checks passed"

- name: Security verification
run: |
set -euo pipefail
echo "🔒 Verifying security after deployment..."

# Check that admin routes are protected (basic test)
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" https://formaos.com.au/admin || echo "000")
if [ "$RESPONSE" == "302" ] || [ "$RESPONSE" == "401" ] || [ "$RESPONSE" == "403" ]; then
echo "✅ Admin routes properly protected (HTTP $RESPONSE)"
else
echo "⚠️ Admin route protection may be compromised (HTTP $RESPONSE)"
fi
# Admin routes must redirect/deny unauthenticated traffic. A 200 OR
# a curl failure (000) both fail the gate — a publicly-reachable
# admin route or an unreachable site are both deployment failures.
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -L --max-redirs 0 https://formaos.com.au/admin || echo "000")
case "$RESPONSE" in
301|302|303|307|308|401|403)
echo "✅ Admin routes properly protected (HTTP $RESPONSE)" ;;
*)
echo "❌ Admin route protection check failed (HTTP $RESPONSE)"
exit 1 ;;
esac

- name: Deployment summary
run: |
Expand Down
7 changes: 6 additions & 1 deletion .github/workflows/formaos-quality-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,12 @@ jobs:

- run: npm ci
- run: npm run typecheck
- run: npm run lint
# --max-warnings 25 matches the deployment-gates ceiling so lint
# warnings can't accumulate unbounded through the PR gate (audit H8).
- run: npm run lint -- --max-warnings 25
# Jest unit suite runs on every PR — previously it only ran nightly
# / on push-to-main, so PRs merged with no unit-test signal (audit H8).
- run: npm test -- --ci
- run: SECURITY_BASELINE_STRICT=1 npm run check:security-baseline
- run: npm run build
- run: npm run check:app-links
Expand Down
185 changes: 184 additions & 1 deletion __tests__/lib/compliance-graph.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/** @jest-environment node */

import {
getComplianceGraph,
initializeComplianceGraph,
rebuildOrgGraph,
repairComplianceGraph,
validateComplianceGraph,
} from '@/lib/compliance-graph';
Expand Down Expand Up @@ -33,7 +35,15 @@ describe('compliance-graph', () => {
it('initializes the default graph nodes, wires, and audit event for a new org', async () => {
adminSupabase.setResolver((operation) => {
if (operation.table === 'org_members' && operation.action === 'select') {
return { data: { id: 'membership-1', role: 'owner' }, error: null };
// initialize() looks up the single membership; rebuildOrgGraph()
// reads all memberships as an array.
if (operation.expects === 'maybeSingle') {
return { data: { id: 'membership-1', role: 'owner' }, error: null };
}
return {
data: [{ id: 'membership-1', user_id: 'user-1', role: 'owner' }],
error: null,
};
}
if (operation.table === 'org_policies' && operation.action === 'insert') {
return {
Expand All @@ -44,12 +54,38 @@ describe('compliance-graph', () => {
error: null,
};
}
if (operation.table === 'org_policies' && operation.action === 'select') {
return {
data: [
{ id: 'policy-1', title: 'Information Security Policy' },
{ id: 'policy-2', title: 'Data Privacy Framework' },
],
error: null,
};
}
if (operation.table === 'org_entities' && operation.action === 'insert') {
return {
data: { id: 'entity-1', created_at: '2026-03-14T00:00:02.000Z' },
error: null,
};
}
if (operation.table === 'org_entities' && operation.action === 'select') {
return { data: [{ id: 'entity-1', name: 'Primary Site' }], error: null };
}
if (operation.table === 'graph_nodes' && operation.action === 'upsert') {
// Echo back ids for the nodes derived by rebuildOrgGraph so wires
// can be resolved.
return {
data: [
{ id: 'node-org', node_type: 'organization', source_id: 'org-a' },
{ id: 'node-role', node_type: 'role', source_id: 'membership-1' },
{ id: 'node-policy-1', node_type: 'policy', source_id: 'policy-1' },
{ id: 'node-policy-2', node_type: 'policy', source_id: 'policy-2' },
{ id: 'node-entity-1', node_type: 'entity', source_id: 'entity-1' },
],
error: null,
};
}
return { data: null, error: null };
});

Expand All @@ -72,6 +108,19 @@ describe('compliance-graph', () => {
operation.table === 'org_audit_events' && operation.action === 'insert',
),
).toBe(true);
// The graph is now persisted: graph_nodes/graph_wires upserts fired.
expect(
adminSupabase.operations.some(
(operation) =>
operation.table === 'graph_nodes' && operation.action === 'upsert',
),
).toBe(true);
expect(
adminSupabase.operations.some(
(operation) =>
operation.table === 'graph_wires' && operation.action === 'upsert',
),
).toBe(true);
});

it('returns a failure result when the user membership is missing', async () => {
Expand Down Expand Up @@ -223,5 +272,139 @@ describe('compliance-graph', () => {
),
).toBe(true);
});

it('rebuildOrgGraph derives nodes/wires and upserts them via the admin client', async () => {
adminSupabase.setResolver((operation) => {
switch (operation.table) {
case 'org_members':
return {
data: [{ id: 'member-1', user_id: 'user-1', role: 'owner' }],
error: null,
};
case 'org_policies':
return { data: [{ id: 'policy-1', title: 'ISMS' }], error: null };
case 'org_tasks':
return {
data: [{ id: 'task-1', title: 'Task', policy_id: 'policy-1' }],
error: null,
};
case 'org_evidence':
return {
data: [{ id: 'evidence-1', title: 'Doc', task_id: 'task-1' }],
error: null,
};
case 'org_audit_events':
return { data: [{ id: 'audit-1' }], error: null };
case 'org_entities':
return { data: [{ id: 'entity-1', name: 'Site' }], error: null };
case 'graph_nodes':
return {
data: [
{ id: 'n-org', node_type: 'organization', source_id: 'org-a' },
{ id: 'n-role', node_type: 'role', source_id: 'member-1' },
{ id: 'n-policy', node_type: 'policy', source_id: 'policy-1' },
{ id: 'n-task', node_type: 'task', source_id: 'task-1' },
{ id: 'n-evidence', node_type: 'evidence', source_id: 'evidence-1' },
{ id: 'n-audit', node_type: 'audit', source_id: 'audit-1' },
{ id: 'n-entity', node_type: 'entity', source_id: 'entity-1' },
],
error: null,
};
default:
return { data: null, error: null };
}
});

const result = await rebuildOrgGraph('org-a', 'user-1');

expect(result.success).toBe(true);
// org + role + policy + task + evidence + audit + entity = 7 nodes.
expect(result.nodeCount).toBe(7);
// user_role + policy_task + task_evidence = 3 wires.
expect(result.wireCount).toBe(3);

const nodeUpsert = adminSupabase.operations.find(
(op) => op.table === 'graph_nodes' && op.action === 'upsert',
);
expect(nodeUpsert?.actionOptions).toEqual({
onConflict: 'organization_id,node_type,source_id',
});
const wireUpsert = adminSupabase.operations.find(
(op) => op.table === 'graph_wires' && op.action === 'upsert',
);
expect(wireUpsert?.actionOptions).toEqual({
onConflict: 'organization_id,wire_type,from_node_id,to_node_id',
});
});

it('getComplianceGraph reads persisted nodes/wires via the session client', async () => {
serverSupabase.setResolver((operation) => {
if (operation.table === 'graph_nodes') {
return {
data: [
{
id: 'n-1',
organization_id: 'org-a',
node_type: 'organization',
source_id: 'org-a',
label: null,
metadata: {},
created_by: 'user-1',
created_at: '2026-06-01T00:00:00.000Z',
refreshed_at: '2026-06-01T00:00:00.000Z',
},
],
error: null,
};
}
if (operation.table === 'graph_wires') {
return {
data: [
{
id: 'w-1',
organization_id: 'org-a',
from_node_id: 'n-1',
to_node_id: 'n-2',
wire_type: 'user_role',
metadata: {},
created_at: '2026-06-01T00:00:00.000Z',
refreshed_at: '2026-06-01T00:00:00.000Z',
},
],
error: null,
};
}
return { data: null, error: null };
});

const result = await getComplianceGraph('org-a');

expect(result.nodes).toHaveLength(1);
expect(result.nodes[0]).toEqual(
expect.objectContaining({
id: 'n-1',
nodeType: 'organization',
sourceId: 'org-a',
organizationId: 'org-a',
}),
);
expect(result.wires).toHaveLength(1);
expect(result.wires[0]).toEqual(
expect.objectContaining({
id: 'w-1',
wireType: 'user_role',
fromNodeId: 'n-1',
toNodeId: 'n-2',
}),
);
// Reads must go through the session (server) client, never the admin
// client.
expect(
serverSupabase.operations.some((op) => op.table === 'graph_nodes'),
).toBe(true);
expect(
adminSupabase.operations.some((op) => op.table === 'graph_nodes'),
).toBe(false);
});
});

Loading
Loading