A self-hosted test management platform for manual functional QA. Organise projects, run test suites, track executions, link defects to Jira, and store evidence in MinIO — a lightweight, Docker-Compose alternative to TestRail / Zephyr / qTest.
- Multi-tenant — every object is scoped to a Company; cross-tenant access is blocked at the API layer.
- Jira integration — Jira credentials are configured once per company; each project picks which Jira project to file bugs into.
Create Jira bugon a failed execution only enables when the execution's project has a Jira binding and the company has Jira credentials saved. - Custom fields — per-project configurable fields (text, long text, number, select, checkbox) that attach to every test case in the project.
- Shared / reusable steps — a per-project library of named steps; insert one into any case from the step editor.
- Rich-text editor — WYSIWYG (TipTap) with a toolbar (bold, italic, inline code, bullet/numbered lists, links, undo/redo) on the fields where formatting actually helps forensic detail: step action/expected, preconditions, shared-step library entries, and — on execution detail — Actual result and Why it failed. Stored and rendered as Markdown, so plain-text content round-trips and existing rows stay readable. Link URLs are restricted to
http(s)/mailto/tel—javascript:and other schemes are blocked at both insert and render time. - Webhooks — configure outbound HTTP webhooks per project on events (
run.created,run.completed,run.archived,execution.failed,execution.passed,execution_result.failed,execution_result.passed,jira.bug_created). Optional HMAC-SHA256 signing, delivery log, test-fire button. - Kanban run view — toggle between list and kanban on the Runs page; managers drag cards between columns to change a run's status.
- Archive runs — managers can archive a test run to remove it from the active list. An Archived tab on the Runs page shows all archived runs; managers can restore them back to active.
- Evidence storage — upload screenshots, videos and logs against cases or executions.
- Full activity log — per-project event stream with filters.
- CSV export of run results.
- API tokens — personal access tokens (
ts_…prefix, SHA-256 hashed at rest). Send asAuthorization: Bearer <token>to call the API from CI. Token auth is read-only on the token management endpoints — tokens can't mint more tokens. - Email verification — new signups must verify their email address before signing in. A tokenized verification link (24 h TTL) is emailed on signup; the UI shows the link in dev mode. Invited, SAML, and SCIM-provisioned users are auto-verified.
- Two-factor authentication (TOTP) — optional per-user 2FA. Users enable it from their Profile page (scan QR code with any TOTP app). When enabled, login requires a 6-digit code after the password step. A Trust this device checkbox lets users skip 2FA on the same browser for 30 days. Disabling 2FA requires the current password and revokes all trusted devices.
- Remember me — unchecked (default) sessions expire in 24 hours; checked sessions last 30 days. The choice carries through the 2FA step so the full flow honours it.
- Invite flow & password reset — managers invite teammates via one-time signed links; users reset their own password from the sign-in page. Links go through SMTP (Mailpit in dev, any SMTP provider in prod), and the UI also surfaces a copyable dev link for local testing.
- Requirements & traceability — first-class
Requirementobjects per project, with many-to-many links to test cases. The Coverage Matrix has a Requirement dimension that pivots the latest execution status per case under each linked requirement; unlinked cells are blank and linked-but-never-executed cells showUNTESTED. - User locking — managers can lock any user from the Team page. Locked users cannot sign in, use the app, or call the API (JWT and API-token requests return
423). Unlock to restore access. The Team list shows each member's avatar (or initials fallback) and last login timestamp. - Roles —
ADMIN(company settings, SSO/SCIM tokens, audit log),MANAGER(all project work),TESTER(runs/executions they own, created, or are assigned to; can execute and assign),VIEWER(read-only across the company). - SCIM 2.0 user provisioning — working. Issue a per-tenant SCIM token from Company Settings → SSO, point your IdP (Okta, Azure AD, JumpCloud) at
/api/scim/v2. Groups and role-to-group mapping are not yet supported. - SAML SSO — scaffolding only, not production-ready. The per-company config UI and routes are in place, but
/saml/:slug/loginreturns501and/saml/:slug/acsdeliberately rejects requests until a real assertion parser is wired up (see comments inbackend/src/routes/saml.tsfor the exact steps — install@node-saml/node-samland verify againstcfg.x509Cert). Do not expose these routes to the public internet as-is. - Audit log —
/api/auditsurfaces the tenant-wide activity stream with user/action/date filters and CSV export for compliance reviews. - Test case versioning — every
PATCH /cases/:idsnapshots the prior state toTestCaseRevisionandGET /cases/:id/revisionsreturns the full audit trail. The case detail page exposes a History panel that lists revisions with author/date/changed-field summary; a per-version field-level diff view is on the roadmap. - Executive dashboard — stat tiles, 30-day execution & pass-rate trend, release-readiness per milestone, defect aging buckets, top failing cases.
- i18n — English and French built in with a language switcher; sidebar collapses for more canvas. All API error, warning, and info messages are returned as stable
UPPER_SNAKE_CASEmachine keys (e.g.INVALID_CREDENTIALS,PROJECT_NOT_FOUND); the frontend translates them viat('errors.' + key). Validation messages, placeholders, and aria-labels are also served from the language files — no hardcoded English in.tsxfiles or API responses. - Dark mode — light by default. Toggle in the sidebar; the choice is saved in
localStorageand applied pre-hydration so there's no flash on refresh. Toasts and the TipTap editor follow the theme automatically.
| Layer | Tech |
|---|---|
| Frontend | React 18, Vite, TypeScript, Tailwind (with darkMode: "class"), TanStack Query, Zustand |
| Forms | react-hook-form + Zod (shared schemas with the backend) |
| Feedback | sonner toasts for async / server errors; inline ARIA-wired errors for field validation |
| Theming | class-strategy dark mode; state in lib/theme.tsx; pre-hydration set in index.html |
| Backend | Node.js 20, Express, TypeScript, Prisma, Zod, JWT auth |
| Database | PostgreSQL 16 |
| Object storage | MinIO (S3-compatible) |
| Drag-and-drop | dnd-kit (for reorderable test steps) |
| Orchestration | Docker Compose |
- Docker 24+ with Docker Compose v2
- That's it — everything else runs inside containers.
git clone https://github.com/<your-org>/testsuits.git
cd testsuits
cp .env.example .env
docker compose up --buildOn first boot the API container runs prisma db push to create the schema and prisma db seed to insert the generic demo data. Open:
| Service | URL |
|---|---|
| Web UI | http://localhost:5173 |
| API | http://localhost:4000/api |
| MinIO console | http://localhost:9005 (minioadmin/minioadmin) |
| Mailpit (mail) | http://localhost:8025 |
| Postgres | localhost:5434 (user/pass: testsuits) |
The seed creates two isolated tenants so you can verify cross-company isolation immediately:
| Tenant | Password | Role | |
|---|---|---|---|
| Acme QA (primary demo) | manager@acme.local |
acme123 |
MANAGER |
| Acme QA | tester@acme.local |
acme123 |
TESTER |
| Globex QA (isolation test) | manager@globex.local |
globex123 |
MANAGER |
| Globex QA | tester@globex.local |
globex123 |
TESTER |
The Acme tenant ships with two projects:
- Acme Checkout — 3 suites (Cart, Payment, Shipping), 10 cases, 2 milestones, 4 runs covering smoke / full regression / offline / localisation, a failing execution linked to a sample Jira key, comments, activity-log entries, API token, and attachment metadata.
- Acme Customer Portal — 2 suites (Account, Orders), 5 cases, 1 milestone, 1 smoke run with a failing profile-update execution.
The Globex tenant has a single project (BILLING) with a small run so the isolation test is still useful: log in as a Globex user and confirm you can't see Acme's data, then check that Globex's own project still renders on every page.
All runtime configuration is controlled by .env. Copy .env.example and tweak as needed.
| Variable | Purpose |
|---|---|
POSTGRES_USER |
Postgres superuser (created on first boot) |
POSTGRES_PASSWORD |
Postgres password |
POSTGRES_DB |
Database name |
DATABASE_URL |
Prisma connection string (uses the three vars above) |
JWT_SECRET |
Signing secret for JWTs — required in production (API refuses to start with the default/empty value when NODE_ENV=production). Generate with openssl rand -hex 48. |
API_PORT |
Port the Express API listens on (inside the container) |
WEB_PORT |
Port the Vite dev server listens on |
VITE_API_URL |
URL the browser uses to reach the API |
CORS_ALLOWED_ORIGINS |
Comma-separated list of origins allowed to call the API. Leave empty in dev for auto-reflect. Required in production — an empty list denies all cross-origin requests. |
TRUST_PROXY |
Number of reverse-proxy hops to trust for X-Forwarded-For (e.g. 1 behind a single LB). Without this, per-IP rate limits collapse every request onto the proxy IP. |
Change these if the defaults collide with something else on your host:
| Variable | Default | Maps to |
|---|---|---|
POSTGRES_HOST_PORT |
5434 |
Postgres 5432 inside the container |
MINIO_HOST_PORT |
9004 |
MinIO S3 API 9000 |
MINIO_CONSOLE_HOST_PORT |
9005 |
MinIO web console 9001 |
| Variable | Purpose |
|---|---|
MINIO_ROOT_USER |
MinIO admin user |
MINIO_ROOT_PASSWORD |
MinIO admin password |
MINIO_BUCKET |
Bucket name (created automatically by the minio-init service) |
S3_PUBLIC_ENDPOINT |
URL the browser uses for signed downloads (host + MINIO_HOST_PORT) |
Set these to have the seed attach a JiraConfig to the Acme tenant on first boot. JIRA_PROJECT_KEY, if present, is bound to the CHECKOUT project so the per-project targeting is ready-to-go too. Omit these to configure Jira through the UI instead (Company Settings → Jira, then Project Settings → Jira binding).
JIRA_BASE_URL=https://your-org.atlassian.net
JIRA_USER=you@example.com
JIRA_TOKEN=your-atlassian-api-token
JIRA_PROJECT_KEY=PROJAPI tokens: id.atlassian.com/manage-profile/security/api-tokens.
Outgoing email (password reset, team invites) is sent via SMTP. In dev the compose stack ships a Mailpit container that catches every message — no real mailbox required.
| Variable | Default | Purpose |
|---|---|---|
SMTP_URL |
smtp://mailpit:1025 |
SMTP connection string. Unset = log the body, don't send |
MAIL_FROM |
TestSuits <no-reply@testsuits.local> |
From header on every message |
APP_URL |
http://localhost:5173 |
Base URL used to build links (reset, invite) in email bodies |
MAILPIT_SMTP_PORT |
1025 |
Host-side SMTP port for Mailpit |
MAILPIT_UI_PORT |
8025 |
Host-side web UI for Mailpit |
Open http://localhost:8025 to read every email the app sends. For production, set SMTP_URL to your provider's URL (e.g. smtps://postmaster@mg.example.com:KEY@smtp.mailgun.org:465) — no code changes needed.
VITE_API_URL and S3_PUBLIC_ENDPOINT are URLs the browser resolves. When you open the UI from another machine, replace localhost with your host's LAN address, e.g.:
VITE_API_URL=http://192.168.1.20:4000/api
S3_PUBLIC_ENDPOINT=http://192.168.1.20:9004Then restart: docker compose up -d --build web.
Wipe everything and start from scratch:
docker compose down -v # -v removes the postgres-data and minio-data volumes
docker compose up --buildRun just the seed against an already-running stack:
docker compose exec api npx tsx prisma/seed.tsThe seed is idempotent — rows that already exist are skipped.
Log in as a manager, delete the project from the UI, then re-run the seed; it will re-create just the missing pieces.
Jira is split across two screens: credentials + templates live at the company level (they're reused by every project in the tenant), and the target Jira project lives on each project.
- Log in as a manager.
- Open Company Settings → Jira.
- Fill in:
- Base URL (e.g.
https://acme.atlassian.net) - Email (the owner of the API token)
- API token — create at id.atlassian.com/manage-profile/security/api-tokens
- Default issue type (default:
Bug) - Optional: summary / description templates used when filing bugs
- Base URL (e.g.
- Test connection → Save.
- Open the project → Settings → Jira.
- Pick the Jira project to file bugs into (the dropdown lists projects discovered via the company's saved credentials).
- Optionally override the issue type and pick a parent epic.
- Save.
When a run is created with multi-select platforms (Web, Android, …), multi-select connectivities (Online, Offline), and a comma-separated locale field (en, fr, ar), each test case in the run is split into one TestExecutionResult row per combination. The Run Detail page renders a Result per combination matrix for the selected case:
- Each row shows a combo label (e.g.
Android · Offline · fr), a status pill, and pass/fail/blocked/skipped buttons. - FAILED requires both a failure reason and an actual result — the API rejects the save with
RESULT_FAILED_REQUIRES_REASON_AND_DETAILSotherwise, and the cell stays red with a "needs reason & details" hint until both are filled. - A Mark all passed shortcut sets every remaining row to
PASSEDviaPOST /execution-results/bulk— the happy path for cases that work on every platform. - The case isn't considered done until every cell has a non-
PENDINGstatus; the parentTestExecution.statusaggregates the children (FAILEDif any failed,BLOCKEDif any blocked,PENDINGif any pending, elsePASSED/SKIPPED). - The aggregate badge calls out the exact failing combos — so "passed on Windows, failed on Android" stays visible as
Failed on: Android · Offline.
Runs created before this feature have no result rows and fall back to the legacy single-status editor — they keep working unchanged.
On a per-combo matrix, every failing cell has its own Create Jira bug button that opens one ticket per failing combination — Android · Offline · en files a separate ticket from iOS · Online · fr, so each bug has precise reproduction context. The ticket description pulls the combo-specific failureReason and actualResult (both are required before the save is accepted, so the ticket is never blank on arrival). Legacy single-status executions still use the old Create Jira bug button on the parent row.
The button is only enabled when the company has Jira credentials saved and the execution's project has a Jira binding.
Outbound mail goes through SMTP. In development, the mailpit container in docker-compose.yml provides a local SMTP sink + web UI — open http://localhost:8025 to read mail sent by the app. In production, point SMTP_URL at your real provider (Mailgun, SES, Postmark, etc).
- Fill in the signup form and submit.
- The server creates the company and user but does not sign you in. Instead, it emails a verification link valid for 24 hours. In dev mode the link is also shown in the UI.
- Click the verification link. The server marks the email as verified, issues a JWT, and logs you in.
- Until verified, login is blocked with a "please verify your email" message and a Resend button.
Invited users, SAML-authenticated users, and SCIM-provisioned users skip verification — their email ownership is already established.
- Log in as a manager.
- Open Team → Invite teammate.
- Fill in name, email, and role, and click Invite.
- The server issues a one-time link valid for 7 days and emails it to the invitee. In dev, the UI also shows the link for easy copy-paste.
- The invitee opens the link, sets their password, and is signed in directly — no second step, no shared passwords. The account is automatically marked as email-verified.
The sign-in page has a Forgot your password? link. Enter your email; if it matches an account, the server issues a one-time link valid for 1 hour and emails it. The response is identical for known and unknown emails (no user enumeration).
2FA is opt-in per user and disabled by default.
- Open Profile from the sidebar.
- In the Two-factor authentication section, click Enable 2FA.
- Scan the QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), or enter the manual key.
- Type the 6-digit code from the app and click Verify.
- 2FA is now active for your account.
After entering your email and password, a second screen asks for the 6-digit code from your authenticator app. The challenge token is valid for 5 minutes. Check Trust this device for 30 days to skip the code step on future logins from the same browser.
When you check "Trust this device" during 2FA, the server issues an opaque token (td_ prefix, SHA-256 hashed at rest, 30-day expiry). On subsequent logins the token is sent via X-Trust-Token header; if it's valid for the authenticated user, the 2FA step is bypassed. Trusted device tokens are revoked automatically when:
- The user changes their password
- The user resets their password
- 2FA is disabled
Logout does not revoke trusted devices — the trust represents the machine, not the session. A forced session revocation (401) clears the trust token from the browser.
In Profile → Two-factor authentication, enter your current password and click Disable 2FA. All trusted devices are revoked.
The login page has a Remember me for 30 days checkbox. When unchecked (default), the JWT session expires in 24 hours. When checked, it lasts 30 days. The flag is carried through the 2FA challenge flow so the session duration is honoured regardless of whether 2FA is enabled.
Open API tokens from the sidebar. Create a token, give it a name, and copy the plaintext — the server stores only a SHA-256 hash and the plaintext is shown exactly once. Use it as:
curl -H "Authorization: Bearer ts_<your-token>" https://testsuits.example.com/api/runsTokens inherit the creator's role and company. Token-authenticated callers can hit every endpoint except the token management endpoints (GET/POST/DELETE /api/tokens) — those require a JWT session so a compromised token can't mint fresh credentials.
Revoke a token anytime from the same page. Revocation is immediate.
A CI seed token is created automatically by the seed for the primary manager; the plaintext is printed to the seed log so you can demo the flow end-to-end.
Requirements are first-class per-project objects. Open a project → Requirements to create them; each one has an external reference (a URL or ID in your requirements system — Jira story, Confluence page, etc) and a title.
Link requirements to cases from Case → Edit → Linked requirements (multi-select). The legacy free-text requirements[] on a case still works and is shown in a separate panel; use the first-class links for anything that should surface in traceability reporting.
The Coverage Matrix picks up a new dimension: select Requirement on the Matrix page and the columns become the project's requirements. Each cell shows the latest non-PENDING execution status for the case, limited to the requirements the case is linked to. Cells for unlinked requirements are blank; cells for linked requirements that have never been executed show UNTESTED.
Company
├── Users (MANAGER / TESTER)
│ ├── emailVerifiedAt, totpSecret, totpEnabledAt, isLocked, lastLoginAt
│ ├── owned TestRuns
│ ├── assigned TestExecutions
│ ├── Comments
│ ├── ActivityLog entries
│ ├── ApiTokens
│ ├── EmailVerificationTokens
│ └── TrustedDevices (td_ tokens, SHA-256, 30d TTL)
├── JiraConfig (optional, 1 per company — credentials + templates)
└── Projects
├── jiraProjectKey / jiraIssueType / jiraParentEpicKey (per-project target)
├── customFields (JSON array — text/textarea/number/select/checkbox)
├── SharedSteps (reusable step library)
├── Webhooks (outbound HTTP on events; HMAC signing + delivery log)
├── Milestones
├── TestSuites (tree — suites can have sub-suites)
│ └── TestCases
│ ├── steps (ordered JSON array, drag-to-reorder, Markdown)
│ ├── customFieldValues (keyed by project's customFields)
│ ├── tags, priority, testLevel
│ ├── Attachments
│ └── Comments
└── TestRuns
├── Milestone (optional)
├── status (PENDING/IN_PROGRESS/COMPLETED/ARCHIVED)
├── environment, platforms[], connectivities[], locale, dueDate
└── TestExecutions
├── status (aggregate — rolls up child TestExecutionResults)
├── failureReason / actualResult (legacy runs only; per-combo runs use results[])
├── assignee, durationMinutes
├── jiraIssueKey / jiraIssueUrl (legacy runs only)
├── Attachments, Comments
└── TestExecutionResults (one per platform × connectivity × locale)
├── platform, connectivity, locale
├── status (PENDING/PASSED/FAILED/BLOCKED/SKIPPED)
├── failureReason, actualResult, notes (required when FAILED)
└── jiraIssueKey / jiraIssueUrl (one bug per failing combo)
Enums: Role (MANAGER, TESTER), RunStatus (PENDING/IN_PROGRESS/COMPLETED/ARCHIVED), Priority (LOW/MEDIUM/HIGH/CRITICAL), TestLevel (SMOKE/SANITY/REGRESSION/ADVANCED/EXPLORATORY), Platform (WEB/WINDOWS/MACOS/ANDROID/IOS), Connectivity (ONLINE/OFFLINE).
| Action | ADMIN | MANAGER | TESTER | VIEWER |
|---|---|---|---|---|
| View projects, suites, cases, milestones | ✔ | ✔ | ✔ | ✔ |
| View runs & executions | ✔ | ✔ | only those they created or are assigned to | ✔ |
| Create/edit projects, suites, cases, milestones | ✔ | ✔ | — | — |
| Create test runs | ✔ | ✔ | ✔ | — |
| Archive / restore test runs | ✔ | ✔ | — | — |
| Execute tests (per-combo status, reason, actual result, notes, duration) | ✔ | ✔ | ✔ | — |
| Assign / reassign executions (single & bulk) | ✔ | ✔ | ✔ | — |
| Add / remove / change roles of teammates | ✔ | ✔ | — | — |
| Lock / unlock users | ✔ | ✔ | — | — |
| Company Jira credentials + templates | ✔ | ✔ | view only | view only |
| Per-project Jira binding (target project, epic) | ✔ | ✔ | view only | view only |
| Create / link / unlink Jira issues on executions | ✔ | ✔ | ✔ (within their visible work) | — |
| Configure SSO / SAML, manage SCIM tokens | ✔ | — | — | — |
| Export audit log | ✔ | ✔ | — | — |
backend/
prisma/
schema.prisma Data model
seed.ts Generic demo seed (shipped publicly)
seed.hapster.ts Local-only seed extension (gitignored — optional)
migrations/ Generated Prisma migrations
src/
app.ts Express setup + router mounting
db.ts Prisma client
middleware/ auth, scope, error
lib/ s3, jira, logger, activity
routes/ auth, projects, suites, cases, runs, executions,
attachments, dashboard, jira, milestones,
comments, users, activity
src/
app.ts Express setup + router mounting
db.ts Prisma client
middleware/ auth, scope (tenant + tester visibility), logging, error
lib/ s3, jira (discover + md→ADF), logger, activity, webhooks
routes/ auth, twoFactor, users, companies, projects, suites,
cases, milestones, runs, executions, attachments,
comments, activity, dashboard, matrix, jira,
sharedSteps, webhooks
frontend/
src/
App.tsx Router
i18n/ en.json, fr.json — kept in sync (see CLAUDE.md)
lib/ api, auth, status, logger, enums, markdown (safe renderer)
components/ Layout (collapsible, company header), Comments,
ActivityFeed, LanguageSwitcher, PasswordInput,
CustomFieldsEditor, SharedStepsEditor, WebhooksEditor
pages/ Login, VerifyEmail, Dashboard, Projects, ProjectDetail,
CompanySettings (company Jira creds + templates),
ProjectSettings (tabbed: Jira / custom fields /
shared steps / webhooks),
Team, Milestones, Matrix (coverage by platform /
connectivity / locale),
SuiteDetail, CaseDetail (Markdown + shared-step
library + custom fields), Runs (list / kanban toggle),
RunDetail, Profile (password, 2FA, avatar),
Tokens, Audit, SsoSettings
docker-compose.yml postgres + minio + minio-init + mailpit + api + web
Docker is the recommended path. If you want to run the services natively (e.g. for faster hot-reload on the backend), point your local Node.js 20 at the Postgres and MinIO containers:
# One-time
cd backend && npm install
cd ../frontend && npm install
# Backend (reads DATABASE_URL from .env)
cd backend
npx prisma generate
npm run dev
# Frontend
cd ../frontend
npm run devYou can still run just Postgres + MinIO in Docker:
docker compose up -d db minio minio-initprisma/seed.ts automatically loads prisma/seed.hapster.ts if the file is present. That filename is gitignored, as is anything matching seed.*.local.ts. Export a default async function:
// backend/prisma/seed.hapster.ts
import { PrismaClient } from "@prisma/client";
export default async function (prisma: PrismaClient) {
// create your internal tenant, users, projects, etc.
}Run it:
docker compose exec api npx tsx prisma/seed.tsAll routes live under /api. Authentication is required except the public endpoints called out below. Every authenticated route is scoped to the caller's company; cross-tenant reads return 404 (not 403) so existence is never leaked.
Authentication: send Authorization: Bearer <token> where <token> is either a JWT (interactive login) or an API token (ts_… prefix, SHA-256 hashed). API-token callers can't call the token management endpoints themselves — those require a JWT session.
Error responses use stable machine keys, not English prose. Every error is returned as { "error": "UPPER_SNAKE_CASE_KEY" } (e.g. INVALID_CREDENTIALS, PROJECT_NOT_FOUND, RUN_REQUIRES_CASES). Validation errors add a details object: { "error": "VALIDATION_FAILED", "details": { "formErrors": [], "fieldErrors": { ... } } }. Clients should match on these keys, not on human-readable text.
| Area | Routes |
|---|---|
| Auth (public) | POST /auth/login, POST /auth/signup, POST /auth/forgot, POST /auth/reset, GET /auth/invite/:token, POST /auth/accept-invite, POST /auth/verify-email, POST /auth/resend-verification |
| Auth (mgr) | POST /auth/invite |
| 2FA (public) | POST /2fa/authenticate |
| 2FA (auth) | GET /2fa/status, POST /2fa/setup, POST /2fa/confirm-setup, POST /2fa/disable |
| Tokens | GET /tokens, POST /tokens, DELETE /tokens/:id — all require an interactive (JWT) session |
| Users | GET /users, GET /users/me, POST /users (mgr), PATCH/DELETE /users/:id (mgr), PATCH /users/:id/lock (mgr) |
| Companies | GET /companies/current, PATCH /companies/current (mgr) |
| Projects | GET /projects, POST /projects (mgr), GET/PATCH/DELETE /projects/:id, GET/PUT /projects/:id/custom-fields (PUT mgr) |
| Requirements | GET /requirements?projectId=…, GET /requirements/:id, POST /requirements (mgr), PATCH/DELETE /requirements/:id (mgr), POST/DELETE /requirements/:id/cases[/:caseId] (mgr) |
| Shared steps | GET /shared-steps?projectId=…, POST /shared-steps (mgr), PATCH/DELETE /shared-steps/:id (mgr) |
| Webhooks | GET /webhooks/events, GET /webhooks?projectId=…, POST/PATCH/DELETE /webhooks/:id (mgr), POST /webhooks/:id/test (mgr) |
| Suites | POST /suites (mgr), GET/PATCH/DELETE /suites/:id |
| Cases | POST /cases (mgr), GET /cases/:id, PATCH/DELETE /cases/:id (mgr), POST /cases/:id/clone (mgr) |
| Milestones | GET /milestones, POST /milestones (mgr), PATCH/DELETE /milestones/:id (mgr) |
| Runs | GET /runs, POST /runs (mgr), GET/PATCH/DELETE /runs/:id, GET /runs/:id/export.csv |
| Executions | GET /executions/:id, PATCH /executions/:id (legacy runs only — per-combo runs must use /execution-results/*), POST /executions/bulk-assign |
| Execution results | PATCH /execution-results/:id, POST /execution-results/bulk — per-combo status, failureReason, actualResult. FAILED requires both a reason and actual result. |
| Attachments | POST /attachments (multipart), GET /attachments/:id/download, DELETE /attachments/:id |
| Comments | GET /comments?caseId|executionId|runId=…, POST /comments, DELETE /comments/:id |
| Jira (company) | GET/PUT/DELETE /jira/config (PUT/DELETE mgr), POST /jira/test, GET /jira/defaults/templates |
| Jira (discover) | GET /jira/discover/projects, /jira/discover/issue-types, /jira/discover/epics |
| Jira (project) | GET /jira/projects/:id/binding, PUT /jira/projects/:id/binding (mgr) |
| Jira (bugs) | POST /jira/executions/:id/{create-bug,link,unlink} (legacy) · POST /jira/results/:id/{create-bug,link,unlink} (per-combo — one ticket per failing platform × connectivity × locale) |
| Matrix | GET /matrix/projects/:id?dimension=platform|connectivity|locale|requirement |
| Activity | GET /activity?projectId=…&entityType=…&entityId=… |
| Audit | GET /audit?userId=…&action=…&from=…&to=…&format=csv (mgr) |
| Case history | GET /cases/:id/revisions |
| Dashboard | GET /dashboard — includes trend, releaseReadiness, defectAging |
| SAML (admin) | GET/PUT /saml/config (admin); GET /saml/:slug/login, POST /saml/:slug/acs (public, IdP-facing) |
| SCIM tokens | GET/POST/DELETE /scim-tokens (admin) — issue/revoke provisioning tokens |
| SCIM v2 | GET/POST /scim/v2/Users, GET/PATCH/DELETE /scim/v2/Users/:id (Bearer SCIM token) |
| Client errors | POST /_client-log (no auth) — frontend posts window.onerror / unhandledrejection via sendBeacon; per-IP rate-limited, routed through the server logger with source:"client" and session/user-correlation fields. |
(mgr) = manager-level (MANAGER or ADMIN). (admin) = ADMIN-only.
- JWTs are signed with
JWT_SECRET. The API refuses to start whenNODE_ENV=productionandJWT_SECRETis unset or equal to the default placeholder — useopenssl rand -hex 48to generate a strong value. - API tokens (
ts_…prefix) are stored as SHA-256 hashes — a compromised database row can't be used to impersonate the caller. Plaintext is shown exactly once at creation. - Token management endpoints require a JWT session — API-token callers can't mint, list, or revoke tokens, so a leaked token can't be used to create fresh credentials.
- Email verification — new signups cannot log in until they verify their email via a tokenized link (24 h TTL, SHA-256 hashed at rest, single-use). Invited, SAML, and SCIM users are auto-verified. Resend is rate-limited and the response is identical for known/unknown emails.
- Two-factor authentication — optional TOTP (RFC 6238). The secret is stored per-user;
totpEnabledAtisnulluntil setup is confirmed with a valid code. Login returns a short-lived challenge JWT (5 min) instead of a session when 2FA is active; the full session JWT is only issued after the TOTP code is verified. Disabling 2FA requires the current password. - Trusted devices — when a user checks "Trust this device" during 2FA, an opaque token (
td_prefix, 256-bit, SHA-256 hashed at rest, 30-day server-side expiry) is issued. On subsequent logins the token is sent viaX-Trust-Tokenand validated againstTrustedDevice.tokenHash+userId+expiresAt. The token only bypasses the 2FA step — the user's password is still verified. Tokens are revoked on password change, password reset, or 2FA disable; logout does not revoke them. - Remember me — controls JWT lifespan: unchecked = 24 h, checked = 30 d. The flag is forwarded through the 2FA challenge so the session duration is honoured end-to-end.
- Password reset wipes the door on both sides: every outstanding JWT for that user is revoked (via
passwordUpdatedAtcompared against the JWT'siat), every API token is deleted, and every trusted device token is revoked. If a password leaks, a single reset kicks everyone off the account. - Login is timing-neutral — bcrypt runs even when the email is unknown so response latency can't be used to enumerate accounts.
- Client error pipeline (
POST /_client-log) is unauthenticated by design — browsers can't send JWTs onnavigator.sendBeacon. It's rate-limited per IP, body-capped, and zod-validated. Identity fields (userId,userEmail,sessionId) are client-asserted and never used for authorization — only for correlating client errors to backend traces. Authz still lives on the JWT-protected/api/*routes.
All credential-adjacent endpoints are rate-limited per IP:
| Endpoint | Window | Max requests |
|---|---|---|
POST /auth/login |
1 min | 10 |
POST /auth/signup |
1 hour | 5 |
POST /auth/forgot |
1 hour | 5 |
POST /auth/reset |
1 min | 10 |
POST /auth/invite |
1 hour | 30 |
GET /auth/invite/:t |
1 min | 30 |
POST /auth/accept-invite |
1 min | 10 |
POST /auth/verify-email |
1 min | 10 |
POST /auth/resend-verification |
1 hour | 5 |
POST /2fa/setup |
1 min | 10 |
POST /2fa/confirm-setup |
1 min | 10 |
POST /2fa/disable |
1 min | 10 |
POST /2fa/authenticate |
1 min | 10 |
When deployed behind a reverse proxy, set TRUST_PROXY (e.g. 1 for a single hop) so limits key off the real client IP instead of the proxy.
Applied on signup, password reset, and invite-accept:
- Minimum 10 characters, maximum 128.
- Rejects a small list of common passwords (
password,12345678,qwerty,admin123, etc). - Login does not re-validate strength, so pre-policy accounts keep working until their next reset.
Set CORS_ALLOWED_ORIGINS to a comma-separated list of origins the browser may call from (scheme + host + port). In development, an empty list auto-reflects the request's Origin; in production, an empty list denies all cross-origin requests.
- Reset / invite tokens are cryptographically random 192-bit values, SHA-256 hashed at rest, and single-use. Raw tokens are never logged — only the token row ID is.
devTokenfield in/auth/forgot,/auth/invite,/auth/signup, and/auth/resend-verificationresponses is only populated whenNODE_ENV !== "production", so the plaintext never leaks in prod even if the UI would still render it.- Expired
PasswordResetTokenandInviteTokenrows are swept every 6 hours by a background task (backend/src/lib/cleanup.ts), so the tables don't grow unbounded. - Attachments are stored privately in MinIO; downloads go through signed URLs served by
S3_PUBLIC_ENDPOINT. .envis gitignored by default. Never commit a populated.envorseed.hapster.ts.- All multi-tenant reads are scoped by
companyIdat the query level (seebackend/src/middleware/scope.ts). Cross-tenant lookups return 404 (not 403) so existence isn't leaked.
- "Failed to resolve import @dnd-kit/core" — run
docker compose up -d --build web. The web image bakesnode_modulesat build time; adding a dependency requires rebuilding. - Browser can't reach the API from another device — set
VITE_API_URLto your host's LAN IP (notlocalhost) and rebuild the web container. - Signed download URLs fail —
S3_PUBLIC_ENDPOINTmust be the URL the browser can hit (host IP +MINIO_HOST_PORT), not the in-Docker MinIO address. - Port already in use — override the corresponding
*_HOST_PORTin.env. - Seed did nothing on a fresh DB — check
docker compose logs api | grep Seed. Every seed step logs either "created" or "skipping".
Each project can register outbound webhooks under Project Settings → Webhooks. Supported events:
| Event | Fires when |
|---|---|
run.created |
a test run is created |
run.completed |
a test run's status is set to COMPLETED |
run.archived |
a test run is archived |
execution.passed |
a legacy (single-status) execution transitions to PASSED |
execution.failed |
a legacy (single-status) execution transitions to FAILED |
execution_result.passed |
a per-combo result row transitions to PASSED |
execution_result.failed |
a per-combo result row transitions to FAILED |
jira.bug_created |
a Jira bug is auto-filed (payload includes resultId when the bug was filed per combo) |
Request body:
{
"event": "execution.failed",
"projectId": "…",
"deliveredAt": "2026-04-15T09:00:00.000Z",
"data": { "executionId": "…", "runId": "…", "caseId": "…", "...": "..." }
}If a signing secret is configured, every request carries
x-testsuits-signature: <hex> where <hex> is HMAC-SHA256(secret, rawBody).
Every delivery is recorded (status + error) and shown on the webhook row. Use
the test button to fire a sample payload at any time.
- SSO end-to-end against a real IdP — schema, admin UI, and the SP-initiated
/login+/acsroutes are shipped; the remaining gap is wiring@node-saml/node-samlinto backend/src/routes/saml.ts to verify SAML assertions against the stored x509 cert - SCIM groups / role-to-group mapping (Users work today)
- Playwright / Cypress reporter packages
- Traceability matrix report
- Test case versioning / history diff
Copyright © 2026 Salah AWAD. All rights reserved.
TestSuits is distributed under the Business Source License 1.1.
- Free for personal use, evaluation, non-commercial projects, and your own internal business operations (using TestSuits to manage your own company's QA).
- Requires a commercial agreement for offering TestSuits (or a derivative) to third parties as a hosted or managed service — for example, a multi-tenant SaaS.
On the Change Date (2030-04-15) this version of the code becomes available under the Apache License 2.0.
To request a commercial licence, see COMMERCIAL.md or email
salah.awad@outlook.com. Contributions are accepted under DCO sign-off — see
CONTRIBUTING.md.