Skip to content

Commit 1e877e2

Browse files
authored
Merge pull request #178 from hman38705/feat/analytics-and-playwright
feat: add Plausible analytics and Playwright e2e tests
2 parents f69e28d + 91910a1 commit 1e877e2

38 files changed

Lines changed: 2030 additions & 14 deletions

.github/workflows/load-tests.yml

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
name: Load Tests (Staging)
2+
3+
# Runs on a schedule against staging, or manually via workflow_dispatch.
4+
# NEVER targets production endpoints.
5+
#
6+
# Secrets required (set in GitHub repo settings → Secrets):
7+
# STAGING_BASE_URL — e.g. https://staging.niffyinsur.com/api
8+
# STAGING_TEST_JWT — short-lived JWT for authenticated write flows
9+
# (see backend/loadtests/README.md for generation)
10+
#
11+
# Failure alerts: the job fails when k6 thresholds are breached.
12+
# GitHub will send a notification to the repo watchers.
13+
# For Slack/PagerDuty alerts, add a notification step after the k6 run.
14+
15+
on:
16+
# Run every Monday at 08:00 UTC
17+
schedule:
18+
- cron: '0 8 * * 1'
19+
# Allow manual trigger from the Actions tab
20+
workflow_dispatch:
21+
inputs:
22+
target_url:
23+
description: 'Override staging base URL (default: STAGING_BASE_URL secret)'
24+
required: false
25+
type: string
26+
27+
jobs:
28+
load-test:
29+
name: k6 Load Tests
30+
runs-on: ubuntu-latest
31+
# Only run on the default branch to avoid accidental staging hammering from PRs
32+
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
33+
34+
steps:
35+
- uses: actions/checkout@v4
36+
37+
- name: Install k6
38+
run: |
39+
sudo gpg -k
40+
sudo gpg --no-default-keyring \
41+
--keyring /usr/share/keyrings/k6-archive-keyring.gpg \
42+
--keyserver hkp://keyserver.ubuntu.com:80 \
43+
--recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
44+
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] \
45+
https://dl.k6.io/deb stable main" \
46+
| sudo tee /etc/apt/sources.list.d/k6.list
47+
sudo apt-get update -qq
48+
sudo apt-get install -y k6
49+
50+
- name: Set target URL
51+
id: url
52+
run: |
53+
URL="${{ github.event.inputs.target_url || secrets.STAGING_BASE_URL }}"
54+
if [ -z "$URL" ]; then
55+
echo "::error::STAGING_BASE_URL secret is not set. Aborting."
56+
exit 1
57+
fi
58+
echo "base_url=$URL" >> "$GITHUB_OUTPUT"
59+
60+
- name: Smoke test (sanity check before load)
61+
env:
62+
BASE_URL: ${{ steps.url.outputs.base_url }}
63+
run: k6 run --vus 2 --duration 30s backend/loadtests/smoke.js
64+
65+
- name: Load test — claims list (read-heavy)
66+
env:
67+
BASE_URL: ${{ steps.url.outputs.base_url }}
68+
run: |
69+
k6 run \
70+
--out json=backend/docs/perf/$(date +%Y-%m-%d)-claims-list.json \
71+
backend/loadtests/claims-list.js
72+
73+
- name: Load test — health and quotes
74+
env:
75+
BASE_URL: ${{ steps.url.outputs.base_url }}
76+
run: |
77+
k6 run \
78+
--out json=backend/docs/perf/$(date +%Y-%m-%d)-health-quotes.json \
79+
backend/loadtests/health-and-quotes.js
80+
81+
- name: Load test — authenticated write flow
82+
env:
83+
BASE_URL: ${{ steps.url.outputs.base_url }}
84+
TEST_JWT: ${{ secrets.STAGING_TEST_JWT }}
85+
# Only run if the secret is available (skip on forks)
86+
if: env.TEST_JWT != ''
87+
run: |
88+
k6 run \
89+
--out json=backend/docs/perf/$(date +%Y-%m-%d)-claim-submit.json \
90+
backend/loadtests/claim-submit.js
91+
92+
- name: Upload k6 reports
93+
if: always()
94+
uses: actions/upload-artifact@v4
95+
with:
96+
name: k6-reports-${{ github.run_id }}
97+
path: backend/docs/perf/*.json
98+
retention-days: 90
99+
100+
# Optional: post a Slack notification on threshold breach
101+
# Uncomment and configure SLACK_WEBHOOK_URL secret to enable.
102+
# - name: Notify Slack on failure
103+
# if: failure()
104+
# uses: slackapi/slack-github-action@v1
105+
# with:
106+
# payload: |
107+
# {"text": "⚠️ Load test thresholds breached on staging. Check the run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"}
108+
# env:
109+
# SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
# Performance Baseline Report
2+
3+
## Metadata
4+
5+
| Field | Value |
6+
|---|---|
7+
| Date | YYYY-MM-DD |
8+
| Environment | staging |
9+
| Backend version / git SHA | |
10+
| DB instance | e.g. RDS db.t3.medium, PostgreSQL 15 |
11+
| Redis | e.g. ElastiCache cache.t3.micro |
12+
| k6 version | `k6 version` output |
13+
| Soroban RPC | https://soroban-testnet.stellar.org |
14+
15+
## Methodology
16+
17+
- Scripts: `loadtests/claims-list.js`, `loadtests/claim-submit.js`, `loadtests/health-and-quotes.js`
18+
- Each script run independently; no concurrent cross-script load
19+
- Think-time: 0.5–3 s between requests (see script comments)
20+
- Test credentials: short-lived JWT, staging wallet addresses only
21+
- RPC coordination: notified Soroban RPC provider before burst tests
22+
23+
## Results
24+
25+
### claims-list.js (10 VUs, 4.5 min total)
26+
27+
```
28+
scenarios: (100.00%) 1 scenario, 10 max VUs
29+
...paste k6 summary output here...
30+
```
31+
32+
| Metric | Value |
33+
|---|---|
34+
| p(50) | |
35+
| p(95) | |
36+
| p(99) | |
37+
| error rate | |
38+
| requests/s | |
39+
40+
### claim-submit.js (3 VUs, 3 min total)
41+
42+
```
43+
...paste k6 summary output here...
44+
```
45+
46+
| Metric | Value |
47+
|---|---|
48+
| p(95) build-tx | |
49+
| p(99) build-tx | |
50+
| error rate | |
51+
52+
### health-and-quotes.js (5 VUs, 3 min total)
53+
54+
```
55+
...paste k6 summary output here...
56+
```
57+
58+
| Metric | Value |
59+
|---|---|
60+
| p(95) health | |
61+
| p(95) quote | |
62+
| error rate | |
63+
64+
## Regression thresholds
65+
66+
| Metric | Threshold | Status |
67+
|---|---|---|
68+
| claims-list p(95) | < 500 ms | PASS / FAIL |
69+
| claims-list p(99) | < 2000 ms | PASS / FAIL |
70+
| build-tx p(95) | < 3000 ms | PASS / FAIL |
71+
| health p(95) | < 100 ms | PASS / FAIL |
72+
| error rate | < 1% | PASS / FAIL |
73+
74+
## Observations & action items
75+
76+
- [ ] Any hotspots identified?
77+
- [ ] Index changes needed?
78+
- [ ] Cache TTL adjustments?
79+
- [ ] Connection pool tuning?
80+
81+
## Capacity decisions
82+
83+
Document any instance sizing or pool limit decisions made based on these results.

backend/docs/tenant-isolation.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Tenant Isolation — Architecture & Guarantees
2+
3+
## Overview
4+
5+
NiffyInsure supports multi-tenant (white-label) deployments via **logical row-level
6+
isolation**. All tenants share the same PostgreSQL database and Soroban contract.
7+
Physical isolation (separate DB or contract per tenant) requires a separate deployment.
8+
9+
## Isolation level
10+
11+
| Layer | Isolation type | Notes |
12+
|---|---|---|
13+
| Database | Logical (row-level filter) | `tenantId` column on `claims` and `policies` |
14+
| Cache (Redis) | Logical (key namespace) | Cache keys prefixed with `tenantId` |
15+
| Soroban contract | None (shared) | Contract is tenant-unaware; isolation is off-chain only |
16+
| Auth (JWT) | None (shared) | JWTs are not tenant-scoped in the current implementation |
17+
18+
**Operators must understand**: this is logical separation only. A bug in the
19+
application layer could theoretically expose cross-tenant data. For strict
20+
physical isolation, deploy separate instances per tenant.
21+
22+
## How it works
23+
24+
### 1. Tenant resolution (per request)
25+
26+
`TenantMiddleware` runs on every request and populates the REQUEST-scoped
27+
`TenantContextService` with the resolved `tenantId`:
28+
29+
1. `x-tenant-id` header (explicit — used by API integrations)
30+
2. Subdomain: `<tenantId>.niffyinsur.com` → extracted from `Host` header
31+
32+
Tenant IDs must match `/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$|^[a-z0-9]{3}$/`.
33+
Invalid values are silently ignored (tenantId stays null).
34+
35+
### 2. Query scoping
36+
37+
Every repository query on a tenant-scoped model calls `claimTenantWhere()` or
38+
`policyTenantWhere()` which merges `{ tenantId }` into the Prisma `where` clause.
39+
40+
```typescript
41+
// Example — claims list
42+
const where = claimTenantWhere(tenantId, { status: 'PENDING' });
43+
// → { tenantId: 'acme', status: 'PENDING' } (multi-tenant)
44+
// → { status: 'PENDING' } (single-tenant, tenantId=null)
45+
```
46+
47+
### 3. Ownership assertion after findUnique
48+
49+
After fetching a record by primary key, `assertTenantOwnership()` verifies the
50+
record's `tenantId` matches the request tenant. Returns 404 (not 403) to avoid
51+
leaking resource existence to other tenants.
52+
53+
```typescript
54+
const claim = await prisma.claim.findUnique({ where: { id } });
55+
assertTenantOwnership(claim, tenantId, `Claim ${id}`);
56+
```
57+
58+
### 4. Cache namespacing
59+
60+
Cache keys include the tenantId to prevent cross-tenant cache poisoning:
61+
62+
```
63+
claims:list:acme:start:20:all
64+
claims:detail:acme:42
65+
```
66+
67+
## Single-tenant mode (default)
68+
69+
When `TENANT_RESOLUTION_ENABLED=false` (the default):
70+
71+
- `TenantMiddleware` is a no-op
72+
- `tenantId` is always `null`
73+
- `tenantFilter(null)` returns `{}`
74+
- All queries behave identically to pre-tenant code paths
75+
- No performance overhead
76+
77+
## Enabling multi-tenant mode
78+
79+
```env
80+
TENANT_RESOLUTION_ENABLED=true
81+
TENANT_BASE_DOMAIN=niffyinsur.com
82+
```
83+
84+
Run the Prisma migration to add `tenantId` columns and indexes:
85+
86+
```bash
87+
npx prisma migrate dev --name add-tenant-id
88+
```
89+
90+
## Database indexes
91+
92+
The following composite indexes are added to support tenant-scoped queries
93+
without full table scans:
94+
95+
```
96+
claims: (tenantId), (tenantId, status), (tenantId, createdAt, id)
97+
policies: (tenantId), (tenantId, isActive), (tenantId, createdAt, id)
98+
```
99+
100+
The `(tenantId, createdAt, id)` index matches the keyset pagination query shape:
101+
`WHERE tenantId = ? AND (createdAt, id) < (?, ?) ORDER BY createdAt DESC, id DESC`.
102+
103+
## Legal considerations
104+
105+
If handling user data on behalf of a tenant (white-label partner), a Data
106+
Processing Agreement (DPA) is required under GDPR and similar regulations.
107+
Consult qualified legal counsel before onboarding tenants who process EU/UK
108+
personal data. Each tenant's users should be informed of the sub-processor
109+
relationship in the tenant's own privacy policy.
110+
111+
## Limitations
112+
113+
- Soroban contract events are not tenant-scoped. The indexer assigns `tenantId`
114+
based on configuration, not on-chain data.
115+
- Votes are not tenant-scoped (the `votes` table has no `tenantId`). Votes are
116+
linked to claims which are tenant-scoped, so cross-tenant vote reads are
117+
prevented transitively.
118+
- Admin endpoints currently bypass tenant scoping. Admin operators can see all
119+
tenants' data. Restrict admin access accordingly.

0 commit comments

Comments
 (0)