mirror of
https://github.com/formbricks/formbricks.git
synced 2026-04-24 03:21:20 -05:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eadb9d009 | |||
| b35cabcbcc | |||
| 4f435f1a1f | |||
| 99c1e434df | |||
| b13699801b | |||
| ceb2e85d96 | |||
| c5f8b5ec32 | |||
| bdbd57c2fc | |||
| d44aa17814 | |||
| 23d38b4c5b | |||
| 58213969e8 | |||
| ef973c8995 | |||
| bea02ba3b5 | |||
| 1c1e2ee09c | |||
| 2bf7fe6c54 | |||
| 9639402c39 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description:
|
||||
description: Migrate deprecated UI components to a unified component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
description: Create a story in Storybook for a given component
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Formbricks Storybook Stories
|
||||
|
||||
## When generating Storybook stories for Formbricks components:
|
||||
|
||||
### 1. **File Structure**
|
||||
- Create `stories.tsx` (not `.stories.tsx`) in component directory
|
||||
- Use exact import: `import { Meta, StoryObj } from "@storybook/react-vite";`
|
||||
- Import component from `"./index"`
|
||||
|
||||
### 2. **Story Structure Template**
|
||||
```tsx
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { ComponentName } from "./index";
|
||||
|
||||
// For complex components with configurable options
|
||||
// consider this as an example the options need to reflect the props types
|
||||
interface StoryOptions {
|
||||
showIcon: boolean;
|
||||
numberOfElements: number;
|
||||
customLabels: string[];
|
||||
}
|
||||
|
||||
type StoryProps = React.ComponentProps<typeof ComponentName> & StoryOptions;
|
||||
|
||||
const meta: Meta<StoryProps> = {
|
||||
title: "UI/ComponentName",
|
||||
component: ComponentName,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component: "The **ComponentName** component provides [description].",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
// Organize in exactly these categories: Behavior, Appearance, Content
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ComponentName> & { args: StoryOptions };
|
||||
```
|
||||
|
||||
### 3. **ArgTypes Organization**
|
||||
Organize ALL argTypes into exactly three categories:
|
||||
- **Behavior**: disabled, variant, onChange, etc.
|
||||
- **Appearance**: size, color, layout, styling, etc.
|
||||
- **Content**: text, icons, numberOfElements, etc.
|
||||
|
||||
Format:
|
||||
```tsx
|
||||
argTypes: {
|
||||
propName: {
|
||||
control: "select" | "boolean" | "text" | "number",
|
||||
options: ["option1", "option2"], // for select
|
||||
description: "Clear description",
|
||||
table: {
|
||||
category: "Behavior" | "Appearance" | "Content",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 4. **Required Stories**
|
||||
Every component must include:
|
||||
- `Default`: Most common use case
|
||||
- `Disabled`: If component supports disabled state
|
||||
- `WithIcon`: If component supports icons
|
||||
- Variant stories for each variant (Primary, Secondary, Error, etc.)
|
||||
- Edge case stories (ManyElements, LongText, CustomStyling)
|
||||
|
||||
### 5. **Story Format**
|
||||
```tsx
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
// Props with realistic values
|
||||
},
|
||||
};
|
||||
|
||||
export const EdgeCase: Story = {
|
||||
args: { /* ... */ },
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this when [specific scenario].",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 6. **Dynamic Content Pattern**
|
||||
For components with dynamic content, create render function:
|
||||
```tsx
|
||||
const renderComponent = (args: StoryProps) => {
|
||||
const { numberOfElements, showIcon, customLabels } = args;
|
||||
|
||||
// Generate dynamic content
|
||||
const elements = Array.from({ length: numberOfElements }, (_, i) => ({
|
||||
id: `element-${i}`,
|
||||
label: customLabels[i] || `Element ${i + 1}`,
|
||||
icon: showIcon ? <IconComponent /> : undefined,
|
||||
}));
|
||||
|
||||
return <ComponentName {...args} elements={elements} />;
|
||||
};
|
||||
|
||||
export const Dynamic: Story = {
|
||||
render: renderComponent,
|
||||
args: {
|
||||
numberOfElements: 3,
|
||||
showIcon: true,
|
||||
customLabels: ["First", "Second", "Third"],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 7. **State Management**
|
||||
For interactive components:
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
|
||||
const ComponentWithState = (args: any) => {
|
||||
const [value, setValue] = useState(args.defaultValue);
|
||||
|
||||
return (
|
||||
<ComponentName
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(newValue) => {
|
||||
setValue(newValue);
|
||||
args.onChange?.(newValue);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: ComponentWithState,
|
||||
args: { defaultValue: "initial" },
|
||||
};
|
||||
```
|
||||
|
||||
### 8. **Quality Requirements**
|
||||
- Include component description in parameters.docs
|
||||
- Add story documentation for non-obvious use cases
|
||||
- Test edge cases (overflow, empty states, many elements)
|
||||
- Ensure no TypeScript errors
|
||||
- Use realistic prop values
|
||||
- Include at least 3-5 story variants
|
||||
- Example values need to be in the context of survey application
|
||||
|
||||
### 9. **Naming Conventions**
|
||||
- **Story titles**: "UI/ComponentName"
|
||||
- **Story exports**: PascalCase (Default, WithIcon, ManyElements)
|
||||
- **Categories**: "Behavior", "Appearance", "Content" (exact spelling)
|
||||
- **Props**: camelCase matching component props
|
||||
|
||||
### 10. **Special Cases**
|
||||
- **Generic components**: Remove `component` from meta if type conflicts
|
||||
- **Form components**: Include Invalid, WithValue stories
|
||||
- **Navigation**: Include ManyItems stories
|
||||
- **Modals, Dropdowns and Popups **: Include trigger and content structure
|
||||
|
||||
## Generate stories that are comprehensive, well-documented, and reflect all component states and edge cases.
|
||||
@@ -189,7 +189,6 @@ ENTERPRISE_LICENSE_KEY=
|
||||
UNSPLASH_ACCESS_KEY=
|
||||
|
||||
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
|
||||
# You can also add more configuration to Redis using the redis.conf file in the root directory
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)
|
||||
|
||||
@@ -11,6 +11,10 @@ inputs:
|
||||
sentry_auth_token:
|
||||
description: 'Sentry authentication token'
|
||||
required: true
|
||||
environment:
|
||||
description: 'Sentry environment (e.g., production, staging)'
|
||||
required: false
|
||||
default: 'staging'
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
@@ -107,7 +111,7 @@ runs:
|
||||
SENTRY_ORG: formbricks
|
||||
SENTRY_PROJECT: formbricks-cloud
|
||||
with:
|
||||
environment: production
|
||||
environment: ${{ inputs.environment }}
|
||||
version: ${{ inputs.release_version }}
|
||||
sourcemaps: './extracted-next/'
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ on:
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- stage
|
||||
- prod
|
||||
- staging
|
||||
- production
|
||||
workflow_call:
|
||||
inputs:
|
||||
VERSION:
|
||||
@@ -52,6 +52,7 @@ jobs:
|
||||
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
|
||||
oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
|
||||
tags: tag:github
|
||||
args: --accept-routes
|
||||
|
||||
- name: Configure AWS Credentials
|
||||
uses: aws-actions/configure-aws-credentials@f24d7193d98baebaeacc7e2227925dd47cc267f5 # v4.2.0
|
||||
@@ -66,8 +67,8 @@ jobs:
|
||||
AWS_REGION: eu-central-1
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Prod
|
||||
if: inputs.ENVIRONMENT == 'prod'
|
||||
name: Deploy Formbricks Cloud Production
|
||||
if: inputs.ENVIRONMENT == 'production'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -84,8 +85,8 @@ jobs:
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- uses: helmfile/helmfile-action@v2
|
||||
name: Deploy Formbricks Cloud Stage
|
||||
if: inputs.ENVIRONMENT == 'stage'
|
||||
name: Deploy Formbricks Cloud Staging
|
||||
if: inputs.ENVIRONMENT == 'staging'
|
||||
env:
|
||||
VERSION: ${{ inputs.VERSION }}
|
||||
REPOSITORY: ${{ inputs.REPOSITORY }}
|
||||
@@ -101,13 +102,13 @@ jobs:
|
||||
helmfile-workdirectory: infra/formbricks-cloud-helm
|
||||
|
||||
- name: Purge Cloudflare Cache
|
||||
if: ${{ inputs.ENVIRONMENT == 'prod' || inputs.ENVIRONMENT == 'stage' }}
|
||||
if: ${{ inputs.ENVIRONMENT == 'production' || inputs.ENVIRONMENT == 'staging' }}
|
||||
env:
|
||||
CF_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CF_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: |
|
||||
# Set hostname based on environment
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "prod" ]]; then
|
||||
if [[ "${{ inputs.ENVIRONMENT }}" == "production" ]]; then
|
||||
PURGE_HOST="app.formbricks.com"
|
||||
else
|
||||
PURGE_HOST="stage.app.formbricks.com"
|
||||
|
||||
@@ -89,6 +89,7 @@ jobs:
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
|
||||
echo "" >> .env
|
||||
echo "E2E_TESTING=1" >> .env
|
||||
shell: bash
|
||||
@@ -102,6 +103,12 @@ jobs:
|
||||
# pnpm prisma migrate deploy
|
||||
pnpm db:migrate:dev
|
||||
|
||||
- name: Run Rate Limiter Load Tests
|
||||
run: |
|
||||
echo "Running rate limiter load tests with Redis/Valkey..."
|
||||
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||
shell: bash
|
||||
|
||||
- name: Check for Enterprise License
|
||||
run: |
|
||||
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
name: Build, release & deploy Formbricks images
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
ENVIRONMENT: ${{ github.event.release.prerelease && 'staging' || 'production' }}
|
||||
|
||||
jobs:
|
||||
docker-build:
|
||||
name: Build & release stable docker image
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
name: Build & release docker image
|
||||
uses: ./.github/workflows/release-docker-github.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
IS_PRERELEASE: ${{ github.event.release.prerelease }}
|
||||
|
||||
helm-chart-release:
|
||||
name: Release Helm Chart
|
||||
@@ -31,7 +36,7 @@ jobs:
|
||||
- helm-chart-release
|
||||
with:
|
||||
VERSION: v${{ needs.docker-build.outputs.VERSION }}
|
||||
ENVIRONMENT: "prod"
|
||||
ENVIRONMENT: ${{ env.ENVIRONMENT }}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
@@ -54,3 +59,4 @@ jobs:
|
||||
docker_image: ghcr.io/formbricks/formbricks:v${{ needs.docker-build.outputs.VERSION }}
|
||||
release_version: v${{ needs.docker-build.outputs.VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: ${{ env.ENVIRONMENT }}
|
||||
|
||||
@@ -29,6 +29,10 @@ jobs:
|
||||
# with sigstore/fulcio when running outside of PRs.
|
||||
id-token: write
|
||||
|
||||
outputs:
|
||||
DOCKER_IMAGE: ${{ steps.extract_image_info.outputs.DOCKER_IMAGE }}
|
||||
RELEASE_VERSION: ${{ steps.extract_image_info.outputs.RELEASE_VERSION }}
|
||||
|
||||
steps:
|
||||
- name: Harden the runner (Audit all outbound calls)
|
||||
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
|
||||
@@ -38,6 +42,56 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Generate SemVer version from branch or tag
|
||||
id: generate_version
|
||||
run: |
|
||||
# Get reference name and type
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
REF_TYPE="${{ github.ref_type }}"
|
||||
|
||||
echo "Reference type: $REF_TYPE"
|
||||
echo "Reference name: $REF_NAME"
|
||||
|
||||
if [[ "$REF_TYPE" == "tag" ]]; then
|
||||
# If running from a tag, use the tag name
|
||||
if [[ "$REF_NAME" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+.*$ ]]; then
|
||||
# Tag looks like a SemVer, use it directly (remove 'v' prefix if present)
|
||||
VERSION=$(echo "$REF_NAME" | sed 's/^v//')
|
||||
echo "Using SemVer tag: $VERSION"
|
||||
else
|
||||
# Tag is not SemVer, treat as prerelease
|
||||
SANITIZED_TAG=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_TAG"
|
||||
echo "Using tag as prerelease: $VERSION"
|
||||
fi
|
||||
else
|
||||
# Running from branch, use branch name as prerelease
|
||||
SANITIZED_BRANCH=$(echo "$REF_NAME" | sed 's/[^a-zA-Z0-9.-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
||||
VERSION="0.0.0-$SANITIZED_BRANCH"
|
||||
echo "Using branch as prerelease: $VERSION"
|
||||
fi
|
||||
|
||||
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Generated SemVer version: $VERSION"
|
||||
|
||||
- name: Update package.json version
|
||||
env:
|
||||
VERSION: ${{ env.VERSION }}
|
||||
run: |
|
||||
cd ./apps/web
|
||||
npm version $VERSION --no-git-tag-version
|
||||
echo "Updated version to: $(npm pkg get version)"
|
||||
|
||||
- name: Set Sentry environment in .env
|
||||
run: |
|
||||
if ! grep -q "^SENTRY_ENVIRONMENT=staging$" .env 2>/dev/null; then
|
||||
echo "SENTRY_ENVIRONMENT=staging" >> .env
|
||||
echo "Added SENTRY_ENVIRONMENT=staging to .env file"
|
||||
else
|
||||
echo "SENTRY_ENVIRONMENT=staging already exists in .env file"
|
||||
fi
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@b0b1ea4f69e92ebf5dea3f8713a1b0c37b2126a5 # v1.6.0
|
||||
|
||||
@@ -83,6 +137,21 @@ jobs:
|
||||
database_url=${{ secrets.DUMMY_DATABASE_URL }}
|
||||
encryption_key=${{ secrets.DUMMY_ENCRYPTION_KEY }}
|
||||
|
||||
- name: Extract image info for sourcemap upload
|
||||
id: extract_image_info
|
||||
run: |
|
||||
# Use the first readable tag from metadata action output
|
||||
DOCKER_IMAGE=$(echo "${{ steps.meta.outputs.tags }}" | head -n1 | xargs)
|
||||
echo "DOCKER_IMAGE=$DOCKER_IMAGE" >> $GITHUB_OUTPUT
|
||||
|
||||
# Use the generated version for Sentry release
|
||||
RELEASE_VERSION="$VERSION"
|
||||
echo "RELEASE_VERSION=$RELEASE_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "Docker image: $DOCKER_IMAGE"
|
||||
echo "Release version: $RELEASE_VERSION"
|
||||
echo "Available tags: ${{ steps.meta.outputs.tags }}"
|
||||
|
||||
# Sign the resulting Docker image digest except on PRs.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
@@ -97,3 +166,25 @@ jobs:
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
||||
upload-sentry-sourcemaps:
|
||||
name: Upload Sentry Sourcemaps
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- build
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Upload Sentry Sourcemaps
|
||||
uses: ./.github/actions/upload-sentry-sourcemaps
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker_image: ${{ needs.build.outputs.DOCKER_IMAGE }}
|
||||
release_version: ${{ needs.build.outputs.RELEASE_VERSION }}
|
||||
sentry_auth_token: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
environment: staging
|
||||
|
||||
@@ -7,6 +7,12 @@ name: Docker Release to Github
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
IS_PRERELEASE:
|
||||
description: 'Whether this is a prerelease (affects latest tag)'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
VERSION:
|
||||
description: release version
|
||||
@@ -45,10 +51,12 @@ jobs:
|
||||
- name: Get Release Tag
|
||||
id: extract_release_tag
|
||||
run: |
|
||||
# Extract version from tag (e.g., refs/tags/v1.2.3 -> 1.2.3)
|
||||
TAG=${{ github.ref }}
|
||||
TAG=${TAG#refs/tags/v}
|
||||
echo "RELEASE_TAG=$TAG" >> $GITHUB_ENV
|
||||
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Using tag-based version: $TAG"
|
||||
|
||||
- name: Update package.json version
|
||||
run: |
|
||||
@@ -81,6 +89,13 @@ jobs:
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# Default semver tags (version, major.minor, major)
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
# Only tag as 'latest' for stable releases (not prereleases)
|
||||
type=raw,value=latest,enable=${{ inputs.IS_PRERELEASE != 'true' }}
|
||||
|
||||
# Build and push Docker image with Buildx (don't push on PR)
|
||||
# https://github.com/docker/build-push-action
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
|
||||
@@ -41,6 +41,7 @@ jobs:
|
||||
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
|
||||
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
|
||||
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
|
||||
|
||||
- name: Test
|
||||
run: pnpm test
|
||||
|
||||
@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_ISSUER: "https://mock-oidc-issuer.com",
|
||||
OIDC_SIGNING_ALGORITHM: "RS256",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
IS_PRODUCTION: false,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
|
||||
@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
|
||||
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "mock-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: false,
|
||||
SENTRY_DSN: "mock-sentry-dsn",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
}));
|
||||
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_USER: "mock-smtp-user",
|
||||
SMTP_PASSWORD: "mock-smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
+333
-1
@@ -40,7 +40,7 @@ vi.mock("@tolgee/react", () => ({
|
||||
|
||||
// Mock Next.js hooks
|
||||
const mockPush = vi.fn();
|
||||
const mockPathname = "/environments/env-id/surveys/survey-id/summary";
|
||||
const mockPathname = "/environments/test-env-id/surveys/test-survey-id/summary";
|
||||
const mockSearchParams = new URLSearchParams();
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
@@ -69,6 +69,14 @@ vi.mock("@/modules/survey/list/actions", () => ({
|
||||
copySurveyToOtherEnvironmentAction: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the useSingleUseId hook
|
||||
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
useSingleUseId: vi.fn(() => ({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock child components
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage",
|
||||
@@ -434,4 +442,328 @@ describe("SurveyAnalysisCTA", () => {
|
||||
expect(screen.getByTestId("success-message")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("duplicates survey when primary button is clicked in edit dialog", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
...mockSurvey,
|
||||
id: "new-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
expect(mockCopySurveyAction).toHaveBeenCalledWith({
|
||||
environmentId: "test-env-id",
|
||||
surveyId: "test-survey-id",
|
||||
targetEnvironmentId: "test-env-id",
|
||||
});
|
||||
expect(toast.default.success).toHaveBeenCalledWith("Survey duplicated successfully");
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/new-survey-id/edit");
|
||||
});
|
||||
|
||||
test("handles error when duplicating survey fails", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: undefined,
|
||||
serverError: "Duplication failed",
|
||||
validationErrors: undefined,
|
||||
bindArgsValidationErrors: [],
|
||||
});
|
||||
|
||||
const toast = await import("react-hot-toast");
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
expect(toast.default.error).toHaveBeenCalledWith("Error message");
|
||||
});
|
||||
|
||||
test("navigates to edit when secondary button is clicked in edit dialog", async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click secondary button (edit)
|
||||
await user.click(screen.getByTestId("secondary-button"));
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith("/environments/test-env-id/surveys/test-survey-id/edit");
|
||||
});
|
||||
|
||||
test("shows loading state during duplication", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
|
||||
// Mock a delayed response
|
||||
mockCopySurveyAction.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
data: {
|
||||
...mockSurvey,
|
||||
id: "new-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
}),
|
||||
100
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
// Check loading state
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
|
||||
test("closes dialog after successful duplication", async () => {
|
||||
const mockCopySurveyAction = vi.mocked(
|
||||
await import("@/modules/survey/list/actions")
|
||||
).copySurveyToOtherEnvironmentAction;
|
||||
mockCopySurveyAction.mockResolvedValue({
|
||||
data: {
|
||||
...mockSurvey,
|
||||
id: "new-survey-id",
|
||||
environmentId: "test-env-id",
|
||||
triggers: [],
|
||||
segment: null,
|
||||
resultShareKey: null,
|
||||
languages: [],
|
||||
},
|
||||
});
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={5} />);
|
||||
|
||||
// Click edit button to open dialog
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Click primary button (duplicate & edit)
|
||||
await user.click(screen.getByTestId("primary-button"));
|
||||
|
||||
// Dialog should be closed
|
||||
expect(screen.getByTestId("edit-public-survey-alert-dialog")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("opens preview with single use ID when enabled", async () => {
|
||||
const mockUseSingleUseId = vi.mocked(
|
||||
await import("@/modules/survey/hooks/useSingleUseId")
|
||||
).useSingleUseId;
|
||||
mockUseSingleUseId.mockReturnValue({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue("new-single-use-id"),
|
||||
});
|
||||
|
||||
const surveyWithSingleUse = {
|
||||
...mockSurvey,
|
||||
type: "link" as const,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
};
|
||||
|
||||
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith(
|
||||
"https://example.com/s/test-survey-id?suId=new-single-use-id&preview=true",
|
||||
"_blank"
|
||||
);
|
||||
windowOpenSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("handles single use ID generation failure", async () => {
|
||||
const mockUseSingleUseId = vi.mocked(
|
||||
await import("@/modules/survey/hooks/useSingleUseId")
|
||||
).useSingleUseId;
|
||||
mockUseSingleUseId.mockReturnValue({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
const surveyWithSingleUse = {
|
||||
...mockSurvey,
|
||||
type: "link" as const,
|
||||
singleUse: { enabled: true, isEncrypted: false },
|
||||
};
|
||||
|
||||
const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
await user.click(screen.getByTestId("icon-bar-action-1"));
|
||||
|
||||
expect(windowOpenSpy).toHaveBeenCalledWith("https://example.com/s/test-survey-id?preview=true", "_blank");
|
||||
windowOpenSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("opens share modal with correct modal view when share button clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share");
|
||||
});
|
||||
|
||||
test("handles different survey statuses correctly", () => {
|
||||
const completedSurvey = { ...mockSurvey, status: "completed" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={completedSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles paused survey status", () => {
|
||||
const pausedSurvey = { ...mockSurvey, status: "paused" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={pausedSurvey} />);
|
||||
|
||||
expect(screen.getByTestId("survey-status-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not render share modal when user is null", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} user={null as any} />);
|
||||
|
||||
expect(screen.queryByTestId("share-survey-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isFormbricksCloud values", () => {
|
||||
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={true} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} isFormbricksCloud={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders with different isContactsEnabled values", () => {
|
||||
const { rerender } = render(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={true} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
|
||||
rerender(<SurveyAnalysisCTA {...defaultProps} isContactsEnabled={false} />);
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles app survey type", () => {
|
||||
const appSurvey = { ...mockSurvey, type: "app" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={appSurvey} />);
|
||||
|
||||
// Should not show preview icon for app surveys
|
||||
expect(screen.queryByTestId("icon-bar-action-1")).toBeInTheDocument(); // This should be edit button
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
|
||||
test("handles modal state changes correctly", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Open modal via share button
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
// Close modal
|
||||
await user.click(screen.getByText("Close Modal"));
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("opens share modal via share button", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("Share survey"));
|
||||
|
||||
// Should open the modal with share view
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-modal-view", "share");
|
||||
});
|
||||
|
||||
test("closes share modal and updates modal state", async () => {
|
||||
mockSearchParams.set("share", "true");
|
||||
const user = userEvent.setup();
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Modal should be open initially due to share param
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "true");
|
||||
|
||||
await user.click(screen.getByText("Close Modal"));
|
||||
|
||||
// Should close the modal
|
||||
expect(screen.getByTestId("share-survey-modal")).toHaveAttribute("data-open", "false");
|
||||
});
|
||||
|
||||
test("handles empty segments array", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} segments={[]} />);
|
||||
|
||||
expect(screen.getByTestId("share-survey-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles zero response count", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} responseCount={0} />);
|
||||
|
||||
expect(screen.queryByTestId("edit-public-survey-alert-dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows all icon actions for non-readonly app survey", () => {
|
||||
render(<SurveyAnalysisCTA {...defaultProps} />);
|
||||
|
||||
// Should show bell (notifications) and edit actions
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
|
||||
test("shows all icon actions for non-readonly link survey", () => {
|
||||
const linkSurvey = { ...mockSurvey, type: "link" as const };
|
||||
render(<SurveyAnalysisCTA {...defaultProps} survey={linkSurvey} />);
|
||||
|
||||
// Should show bell (notifications), preview, and edit actions
|
||||
expect(screen.getByTestId("icon-bar-action-0")).toHaveAttribute("title", "Configure alerts");
|
||||
expect(screen.getByTestId("icon-bar-action-1")).toHaveAttribute("title", "Preview");
|
||||
expect(screen.getByTestId("icon-bar-action-2")).toHaveAttribute("title", "Edit");
|
||||
});
|
||||
});
|
||||
|
||||
+21
-9
@@ -5,6 +5,7 @@ import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surve
|
||||
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
@@ -12,7 +13,7 @@ import { IconBar } from "@/modules/ui/components/iconbar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
@@ -48,17 +49,16 @@ export const SurveyAnalysisCTA = ({
|
||||
isFormbricksCloud,
|
||||
}: SurveyAnalysisCTAProps) => {
|
||||
const { t } = useTranslate();
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [modalState, setModalState] = useState<ModalState>({
|
||||
start: searchParams.get("share") === "true",
|
||||
share: false,
|
||||
});
|
||||
|
||||
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
|
||||
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
|
||||
|
||||
@@ -102,9 +102,18 @@ export const SurveyAnalysisCTA = ({
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const getPreviewUrl = () => {
|
||||
const separator = surveyUrl.includes("?") ? "&" : "?";
|
||||
return `${surveyUrl}${separator}preview=true`;
|
||||
const getPreviewUrl = async () => {
|
||||
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
surveyUrl.searchParams.set("suId", newId);
|
||||
}
|
||||
}
|
||||
|
||||
surveyUrl.searchParams.set("preview", "true");
|
||||
return surveyUrl.toString();
|
||||
};
|
||||
|
||||
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
|
||||
@@ -119,7 +128,10 @@ export const SurveyAnalysisCTA = ({
|
||||
{
|
||||
icon: Eye,
|
||||
tooltip: t("common.preview"),
|
||||
onClick: () => window.open(getPreviewUrl(), "_blank"),
|
||||
onClick: async () => {
|
||||
const previewUrl = await getPreviewUrl();
|
||||
window.open(previewUrl, "_blank");
|
||||
},
|
||||
isVisible: survey.type === "link",
|
||||
},
|
||||
{
|
||||
|
||||
+5
-19
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
|
||||
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
|
||||
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
|
||||
@@ -8,21 +7,14 @@ import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surv
|
||||
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
|
||||
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
|
||||
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
|
||||
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
|
||||
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
|
||||
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import {
|
||||
Code2Icon,
|
||||
LinkIcon,
|
||||
MailIcon,
|
||||
QrCodeIcon,
|
||||
Share2Icon,
|
||||
SquareStack,
|
||||
UserIcon
|
||||
} from "lucide-react";
|
||||
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -205,17 +197,11 @@ export const ShareSurveyModal = ({
|
||||
}
|
||||
|
||||
if (survey.type === "link") {
|
||||
return (
|
||||
<ShareView
|
||||
tabs={linkTabs}
|
||||
activeId={activeId}
|
||||
setActiveId={setActiveId}
|
||||
/>
|
||||
);
|
||||
return <ShareView tabs={linkTabs} activeId={activeId} setActiveId={setActiveId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`h-full w-full bg-slate-50 p-6 rounded-lg`}>
|
||||
<div className={`h-full w-full rounded-lg bg-slate-50 p-6`}>
|
||||
<TabContainer
|
||||
title={t("environments.surveys.summary.in_app.title")}
|
||||
description={t("environments.surveys.summary.in_app.description")}>
|
||||
@@ -235,7 +221,7 @@ export const ShareSurveyModal = ({
|
||||
width={survey.type === "link" ? "wide" : "default"}
|
||||
aria-describedby={undefined}
|
||||
unconstrained>
|
||||
{renderContent()}
|
||||
{renderContent()}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
+52
@@ -378,4 +378,56 @@ describe("AnonymousLinksTab", () => {
|
||||
screen.getByText("environments.surveys.share.anonymous_links.data_prefilling")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows read-only input with copy button when encryption is disabled", async () => {
|
||||
// surveyWithSingleUse has encryption disabled
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithSingleUse} />);
|
||||
|
||||
// Check if single-use link is enabled
|
||||
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check if encryption is disabled
|
||||
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute(
|
||||
"data-checked",
|
||||
"false"
|
||||
);
|
||||
|
||||
// Check for the custom URL display
|
||||
const surveyUrlWithCustomSuid = `${defaultProps.surveyUrl}?suId=CUSTOM-ID`;
|
||||
expect(screen.getByText(surveyUrlWithCustomSuid)).toBeInTheDocument();
|
||||
|
||||
// Check for the copy button and try to click it
|
||||
const copyButton = screen.getByText("common.copy");
|
||||
expect(copyButton).toBeInTheDocument();
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
// check if toast is called
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
|
||||
// Check for the alert
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.custom_single_use_id_title")
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Ensure the number of links input is not visible
|
||||
expect(
|
||||
screen.queryByText("environments.surveys.share.anonymous_links.number_of_links_label")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("hides read-only input with copy button when encryption is enabled", async () => {
|
||||
// surveyWithEncryption has encryption enabled
|
||||
render(<AnonymousLinksTab {...defaultProps} survey={surveyWithEncryption} />);
|
||||
|
||||
// Check if single-use link is enabled
|
||||
expect(screen.getByTestId("toggle-single-use-link-switch")).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Check if encryption is enabled
|
||||
expect(screen.getByTestId("toggle-single-use-encryption-switch")).toHaveAttribute("data-checked", "true");
|
||||
|
||||
// Ensure the number of links input is visible
|
||||
expect(
|
||||
screen.getByText("environments.surveys.share.anonymous_links.number_of_links_label")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+30
-13
@@ -4,14 +4,13 @@ import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmen
|
||||
import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal";
|
||||
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
|
||||
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
|
||||
import { getSurveyUrl } from "@/modules/analysis/utils";
|
||||
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CirclePlayIcon } from "lucide-react";
|
||||
import { CirclePlayIcon, CopyIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -33,6 +32,7 @@ export const AnonymousLinksTab = ({
|
||||
setSurveyUrl,
|
||||
locale,
|
||||
}: AnonymousLinksTabProps) => {
|
||||
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
|
||||
const router = useRouter();
|
||||
const { t } = useTranslate();
|
||||
|
||||
@@ -173,11 +173,9 @@ export const AnonymousLinksTab = ({
|
||||
count,
|
||||
});
|
||||
|
||||
const baseSurveyUrl = getSurveyUrl(survey, publicDomain, "default");
|
||||
|
||||
if (!!response?.data?.length) {
|
||||
const singleUseIds = response.data;
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => `${baseSurveyUrl}?suId=${singleUseId}`);
|
||||
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
|
||||
|
||||
// Create content with just the links
|
||||
const csvContent = surveyLinks.join("\n");
|
||||
@@ -258,14 +256,33 @@ export const AnonymousLinksTab = ({
|
||||
/>
|
||||
|
||||
{!singleUseEncryption ? (
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<Alert variant="info" size="default">
|
||||
<AlertTitle>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="grid w-full grid-cols-6 items-center gap-2">
|
||||
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
|
||||
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
|
||||
toast.success(t("common.copied_to_clipboard"));
|
||||
}}
|
||||
className="col-span-1 gap-1 text-sm">
|
||||
{t("common.copy")}
|
||||
<CopyIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{singleUseEncryption && (
|
||||
|
||||
@@ -39,7 +39,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
|
||||
IS_FORMBRICKS_ENABLED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
|
||||
|
||||
export const GET = async () => {
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
@@ -44,7 +46,7 @@ export const GET = async () => {
|
||||
|
||||
if (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
apiKeyData.apiKeyEnvironments[0].permission === "manage"
|
||||
ALLOWED_PERMISSIONS.includes(apiKeyData.apiKeyEnvironments[0].permission)
|
||||
) {
|
||||
return Response.json({
|
||||
id: apiKeyData.apiKeyEnvironments[0].environment.id,
|
||||
|
||||
+122
-8
@@ -9,16 +9,46 @@ vi.mock("@/modules/ui/components/button", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/error-component", () => ({
|
||||
ErrorComponent: () => <div data-testid="ErrorComponent">ErrorComponent</div>,
|
||||
ErrorComponent: ({ title, description }: { title: string; description: string }) => (
|
||||
<div data-testid="ErrorComponent">
|
||||
<div data-testid="error-title">{title}</div>
|
||||
<div data-testid="error-description">{description}</div>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => {
|
||||
const translations: Record<string, string> = {
|
||||
"common.error_rate_limit_title": "Too Many Requests",
|
||||
"common.error_rate_limit_description": "You're making too many requests. Please slow down.",
|
||||
"common.error_component_title": "Something went wrong",
|
||||
"common.error_component_description": "An unexpected error occurred. Please try again.",
|
||||
"common.try_again": "Try Again",
|
||||
"common.go_to_dashboard": "Go to Dashboard",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/types/errors", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@formbricks/types/errors")>();
|
||||
return {
|
||||
...actual,
|
||||
getClientErrorData: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("ErrorBoundary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const dummyError = new Error("Test error");
|
||||
@@ -29,6 +59,12 @@ describe("ErrorBoundary", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
|
||||
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -42,7 +78,13 @@ describe("ErrorBoundary", () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
|
||||
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Sentry.captureException).toHaveBeenCalled();
|
||||
@@ -50,23 +92,95 @@ describe("ErrorBoundary", () => {
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls reset when try again button is clicked", async () => {
|
||||
test("calls reset when try again button is clicked for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
const tryAgainBtn = screen.getByRole("button", { name: "common.try_again" });
|
||||
const tryAgainBtn = screen.getByRole("button", { name: "Try Again" });
|
||||
userEvent.click(tryAgainBtn);
|
||||
await waitFor(() => expect(resetMock).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
test("sets window.location.href to '/' when dashboard button is clicked", async () => {
|
||||
test("sets window.location.href to '/' when dashboard button is clicked for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
const originalLocation = window.location;
|
||||
delete (window as any).location;
|
||||
(window as any).location = undefined;
|
||||
(window as any).location = { href: "" };
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
const dashBtn = screen.getByRole("button", { name: "common.go_to_dashboard" });
|
||||
const dashBtn = screen.getByRole("button", { name: "Go to Dashboard" });
|
||||
userEvent.click(dashBtn);
|
||||
await waitFor(() => {
|
||||
expect(window.location.href).toBe("/");
|
||||
});
|
||||
window.location = originalLocation;
|
||||
(window as any).location = originalLocation;
|
||||
});
|
||||
|
||||
test("does not show buttons for rate limit errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "rate_limit",
|
||||
showButtons: false,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Try Again" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Go to Dashboard" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows error component with rate limit messages for rate limit errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "rate_limit",
|
||||
showButtons: false,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Too Many Requests");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"You're making too many requests. Please slow down."
|
||||
);
|
||||
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
|
||||
});
|
||||
|
||||
test("shows error component with general messages for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("error-title")).toHaveTextContent("Something went wrong");
|
||||
expect(screen.getByTestId("error-description")).toHaveTextContent(
|
||||
"An unexpected error occurred. Please try again."
|
||||
);
|
||||
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
|
||||
});
|
||||
|
||||
test("shows buttons for general errors", async () => {
|
||||
const { getClientErrorData } = await import("@formbricks/types/errors");
|
||||
vi.mocked(getClientErrorData).mockReturnValue({
|
||||
type: "general",
|
||||
showButtons: true,
|
||||
});
|
||||
|
||||
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Try Again" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Go to Dashboard" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
+31
-7
@@ -5,9 +5,31 @@ import { Button } from "@/modules/ui/components/button";
|
||||
import { ErrorComponent } from "@/modules/ui/components/error-component";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { type ClientErrorType, getClientErrorData } from "@formbricks/types/errors";
|
||||
|
||||
/**
|
||||
* Get translated error messages based on error type
|
||||
* All translation keys are directly visible to Tolgee's static analysis
|
||||
*/
|
||||
const getErrorMessages = (type: ClientErrorType, t: (key: string) => string) => {
|
||||
if (type === "rate_limit") {
|
||||
return {
|
||||
title: t("common.error_rate_limit_title"),
|
||||
description: t("common.error_rate_limit_description"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: t("common.error_component_title"),
|
||||
description: t("common.error_component_description"),
|
||||
};
|
||||
};
|
||||
|
||||
const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) => {
|
||||
const { t } = useTranslate();
|
||||
const errorData = getClientErrorData(error);
|
||||
const { title, description } = getErrorMessages(errorData.type, t);
|
||||
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.error(error.message);
|
||||
} else {
|
||||
@@ -16,13 +38,15 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center">
|
||||
<ErrorComponent />
|
||||
<div className="mt-2">
|
||||
<Button variant="secondary" onClick={() => reset()} className="mr-2">
|
||||
{t("common.try_again")}
|
||||
</Button>
|
||||
<Button onClick={() => (window.location.href = "/")}>{t("common.go_to_dashboard")}</Button>
|
||||
</div>
|
||||
<ErrorComponent title={title} description={description} />
|
||||
{errorData.showButtons && (
|
||||
<div className="mt-2">
|
||||
<Button variant="secondary" onClick={() => reset()} className="mr-2">
|
||||
{t("common.try_again")}
|
||||
</Button>
|
||||
<Button onClick={() => (window.location.href = "/")}>{t("common.go_to_dashboard")}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,54 +13,6 @@ describe("bucket middleware rate limiters", () => {
|
||||
mockedRateLimit.mockImplementation((config) => config);
|
||||
});
|
||||
|
||||
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
|
||||
const { loginLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(loginLimiter).toEqual({
|
||||
interval: constants.LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
|
||||
const { signupLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(signupLimiter).toEqual({
|
||||
interval: constants.SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
|
||||
const { verifyEmailLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(verifyEmailLimiter).toEqual({
|
||||
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
|
||||
const { forgotPasswordLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(forgotPasswordLimiter).toEqual({
|
||||
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
|
||||
const { clientSideApiEndpointsLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
@@ -73,18 +25,6 @@ describe("bucket middleware rate limiters", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => {
|
||||
const { shareUrlLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
interval: constants.SHARE_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
expect(shareUrlLimiter).toEqual({
|
||||
interval: constants.SHARE_RATE_LIMIT.interval,
|
||||
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
});
|
||||
|
||||
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
|
||||
const { syncUserIdentificationLimiter } = await import("./bucket");
|
||||
expect(rateLimit).toHaveBeenCalledWith({
|
||||
|
||||
@@ -1,40 +1,11 @@
|
||||
import {
|
||||
CLIENT_SIDE_API_RATE_LIMIT,
|
||||
FORGET_PASSWORD_RATE_LIMIT,
|
||||
LOGIN_RATE_LIMIT,
|
||||
SHARE_RATE_LIMIT,
|
||||
SIGNUP_RATE_LIMIT,
|
||||
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
|
||||
VERIFY_EMAIL_RATE_LIMIT,
|
||||
} from "@/lib/constants";
|
||||
import { CLIENT_SIDE_API_RATE_LIMIT, SYNC_USER_IDENTIFICATION_RATE_LIMIT } from "@/lib/constants";
|
||||
import { rateLimit } from "@/lib/utils/rate-limit";
|
||||
|
||||
export const loginLimiter = rateLimit({
|
||||
interval: LOGIN_RATE_LIMIT.interval,
|
||||
allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const signupLimiter = rateLimit({
|
||||
interval: SIGNUP_RATE_LIMIT.interval,
|
||||
allowedPerInterval: SIGNUP_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const verifyEmailLimiter = rateLimit({
|
||||
interval: VERIFY_EMAIL_RATE_LIMIT.interval,
|
||||
allowedPerInterval: VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const forgotPasswordLimiter = rateLimit({
|
||||
interval: FORGET_PASSWORD_RATE_LIMIT.interval,
|
||||
allowedPerInterval: FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
export const clientSideApiEndpointsLimiter = rateLimit({
|
||||
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
|
||||
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
|
||||
export const shareUrlLimiter = rateLimit({
|
||||
interval: SHARE_RATE_LIMIT.interval,
|
||||
allowedPerInterval: SHARE_RATE_LIMIT.allowedPerInterval,
|
||||
});
|
||||
|
||||
export const syncUserIdentificationLimiter = rateLimit({
|
||||
interval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
|
||||
allowedPerInterval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,
|
||||
|
||||
@@ -3,63 +3,13 @@ import {
|
||||
isAdminDomainRoute,
|
||||
isAuthProtectedRoute,
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isManagementApiRoute,
|
||||
isPublicDomainRoute,
|
||||
isRouteAllowedForDomain,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "./endpoint-validator";
|
||||
|
||||
describe("endpoint-validator", () => {
|
||||
describe("isLoginRoute", () => {
|
||||
test("should return true for login routes", () => {
|
||||
expect(isLoginRoute("/api/auth/callback/credentials")).toBe(true);
|
||||
expect(isLoginRoute("/auth/login")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-login routes", () => {
|
||||
expect(isLoginRoute("/auth/signup")).toBe(false);
|
||||
expect(isLoginRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSignupRoute", () => {
|
||||
test("should return true for signup route", () => {
|
||||
expect(isSignupRoute("/auth/signup")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-signup routes", () => {
|
||||
expect(isSignupRoute("/auth/login")).toBe(false);
|
||||
expect(isSignupRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isVerifyEmailRoute", () => {
|
||||
test("should return true for verify email route", () => {
|
||||
expect(isVerifyEmailRoute("/auth/verify-email")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-verify email routes", () => {
|
||||
expect(isVerifyEmailRoute("/auth/login")).toBe(false);
|
||||
expect(isVerifyEmailRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isForgotPasswordRoute", () => {
|
||||
test("should return true for forgot password route", () => {
|
||||
expect(isForgotPasswordRoute("/auth/forgot-password")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-forgot password routes", () => {
|
||||
expect(isForgotPasswordRoute("/auth/login")).toBe(false);
|
||||
expect(isForgotPasswordRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isClientSideApiRoute", () => {
|
||||
test("should return true for client-side API routes", () => {
|
||||
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
|
||||
@@ -91,20 +41,6 @@ describe("endpoint-validator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isShareUrlRoute", () => {
|
||||
test("should return true for share URL routes", () => {
|
||||
expect(isShareUrlRoute("/share/abc123/summary")).toBe(true);
|
||||
expect(isShareUrlRoute("/share/abc123/responses")).toBe(true);
|
||||
expect(isShareUrlRoute("/share/abc123def456/summary")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for non-share URL routes", () => {
|
||||
expect(isShareUrlRoute("/share/abc123")).toBe(false);
|
||||
expect(isShareUrlRoute("/share/abc123/other")).toBe(false);
|
||||
expect(isShareUrlRoute("/api/something")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthProtectedRoute", () => {
|
||||
test("should return true for protected routes", () => {
|
||||
expect(isAuthProtectedRoute("/environments")).toBe(true);
|
||||
|
||||
@@ -4,15 +4,6 @@ import {
|
||||
matchesAnyPattern,
|
||||
} from "./route-config";
|
||||
|
||||
export const isLoginRoute = (url: string) =>
|
||||
url === "/api/auth/callback/credentials" || url === "/auth/login";
|
||||
|
||||
export const isSignupRoute = (url: string) => url === "/auth/signup";
|
||||
|
||||
export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email";
|
||||
|
||||
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
|
||||
|
||||
export const isClientSideApiRoute = (url: string): boolean => {
|
||||
// Open Graph image generation route is a client side API route but it should not be rate limited
|
||||
if (url.includes("/api/v1/client/og")) return false;
|
||||
@@ -28,11 +19,6 @@ export const isManagementApiRoute = (url: string): boolean => {
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
export const isShareUrlRoute = (url: string): boolean => {
|
||||
const regex = /\/share\/[A-Za-z0-9]+\/(?:summary|responses)/;
|
||||
return regex.test(url);
|
||||
};
|
||||
|
||||
export const isAuthProtectedRoute = (url: string): boolean => {
|
||||
// List of routes that require authentication
|
||||
const protectedRoutes = ["/environments", "/setup/organization", "/organizations"];
|
||||
|
||||
@@ -7,6 +7,8 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { getTagsByEnvironmentId } from "@/lib/tag/service";
|
||||
import { findMatchingLocale } from "@/lib/utils/locale";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -21,6 +23,8 @@ interface ResponsesPageProps {
|
||||
}
|
||||
|
||||
const Page = async (props: ResponsesPageProps) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
// Import mocked functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock all dependencies to avoid server-side environment issues
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
IS_FORMBRICKS_CLOUD: false,
|
||||
IS_PRODUCTION: false,
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SHORT_URL_BASE: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
IS_FORMBRICKS_CLOUD: "0",
|
||||
NODE_ENV: "test",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
SHORT_URL_BASE: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
RATE_LIMITING_DISABLED: "false",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock rate limiting dependencies
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
share: {
|
||||
url: { interval: 60, allowedPerInterval: 30, namespace: "share:url" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurveyIdByResultShareKey: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Share Summary Page Rate Limiting", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("Rate Limiting Configuration", () => {
|
||||
test("should have correct rate limit config for share URLs", () => {
|
||||
expect(rateLimitConfigs.share.url).toEqual({
|
||||
interval: 60,
|
||||
allowedPerInterval: 30,
|
||||
namespace: "share:url",
|
||||
});
|
||||
});
|
||||
|
||||
test("should apply rate limiting function correctly", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 60,
|
||||
allowedPerInterval: 30,
|
||||
namespace: "share:url",
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw rate limit error when limit exceeded", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Share Key Validation Flow", () => {
|
||||
test("should validate sharing key after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
|
||||
|
||||
// Simulate the flow: rate limit first, then validate sharing key
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
const surveyId = await getSurveyIdByResultShareKey("test-sharing-key-123");
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
|
||||
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("test-sharing-key-123");
|
||||
expect(surveyId).toBe("survey123");
|
||||
});
|
||||
|
||||
test("should handle invalid sharing keys after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
|
||||
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
const surveyId = await getSurveyIdByResultShareKey("invalid-key");
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
|
||||
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("invalid-key");
|
||||
expect(surveyId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Considerations", () => {
|
||||
test("should rate limit all requests regardless of sharing key validity", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
|
||||
// Test with valid sharing key
|
||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
await getSurveyIdByResultShareKey("valid-key");
|
||||
|
||||
// Test with invalid sharing key
|
||||
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
await getSurveyIdByResultShareKey("invalid-key");
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("should not expose internal errors when rate limited", async () => {
|
||||
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
// Ensure no other operations are performed
|
||||
expect(getSurveyIdByResultShareKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,8 @@ import { getEnvironment } from "@/lib/environment/service";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
@@ -20,6 +22,8 @@ interface SummaryPageProps {
|
||||
}
|
||||
|
||||
const Page = async (props: SummaryPageProps) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.share.url);
|
||||
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock the redirect function
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import the page component
|
||||
const PageComponent = (await import("./page")).default;
|
||||
|
||||
describe("Share Redirect Page", () => {
|
||||
test("should redirect to summary page without rate limiting", async () => {
|
||||
const mockParams = Promise.resolve({ sharingKey: "test-sharing-key-123" });
|
||||
|
||||
await PageComponent({ params: mockParams });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith("/share/test-sharing-key-123/summary");
|
||||
});
|
||||
|
||||
test("should handle different sharing keys", async () => {
|
||||
const testCases = ["abc123", "survey-key-456", "long-sharing-key-with-dashes-789"];
|
||||
|
||||
for (const sharingKey of testCases) {
|
||||
vi.clearAllMocks();
|
||||
const mockParams = Promise.resolve({ sharingKey });
|
||||
|
||||
await PageComponent({ params: mockParams });
|
||||
|
||||
expect(redirect).toHaveBeenCalledWith(`/share/${sharingKey}/summary`);
|
||||
}
|
||||
});
|
||||
|
||||
test("should be lightweight and not perform any rate limiting", async () => {
|
||||
// This test ensures the page doesn't import or use rate limiting
|
||||
const mockParams = Promise.resolve({ sharingKey: "test-key" });
|
||||
|
||||
// Measure execution time to ensure it's very fast (< 10ms)
|
||||
const startTime = performance.now();
|
||||
await PageComponent({ params: mockParams });
|
||||
const endTime = performance.now();
|
||||
|
||||
const executionTime = endTime - startTime;
|
||||
expect(executionTime).toBeLessThan(10); // Should be very fast since it's just a redirect
|
||||
expect(redirect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -154,15 +154,6 @@ export const SURVEY_BG_COLORS = [
|
||||
];
|
||||
|
||||
// Rate Limiting
|
||||
export const SIGNUP_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
allowedPerInterval: 30,
|
||||
};
|
||||
export const LOGIN_RATE_LIMIT = {
|
||||
interval: 15 * 60, // 15 minutes
|
||||
allowedPerInterval: 30,
|
||||
};
|
||||
|
||||
export const CLIENT_SIDE_API_RATE_LIMIT = {
|
||||
interval: 60, // 1 minute
|
||||
allowedPerInterval: 100,
|
||||
@@ -171,23 +162,6 @@ export const MANAGEMENT_API_RATE_LIMIT = {
|
||||
interval: 60, // 1 minute
|
||||
allowedPerInterval: 100,
|
||||
};
|
||||
|
||||
export const SHARE_RATE_LIMIT = {
|
||||
interval: 60 * 1, // 1 minutes
|
||||
allowedPerInterval: 30,
|
||||
};
|
||||
export const FORGET_PASSWORD_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
allowedPerInterval: 5, // Limit to 5 requests per hour
|
||||
};
|
||||
export const RESET_PASSWORD_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
allowedPerInterval: 5, // Limit to 5 requests per hour
|
||||
};
|
||||
export const VERIFY_EMAIL_RATE_LIMIT = {
|
||||
interval: 60 * 60, // 60 minutes
|
||||
allowedPerInterval: 10, // Limit to 10 requests per hour
|
||||
};
|
||||
export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
|
||||
interval: 60, // 1 minute
|
||||
allowedPerInterval: 5,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { hashString } from "./hashString";
|
||||
import { hashString } from "./hash-string";
|
||||
|
||||
describe("hashString", () => {
|
||||
test("should return a string", () => {
|
||||
@@ -1,6 +1,7 @@
|
||||
import "server-only";
|
||||
import { isValidImageFile } from "@/lib/fileValidation";
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
@@ -133,6 +134,7 @@ export const deleteUser = async (id: string): Promise<TUser> => {
|
||||
}
|
||||
|
||||
const deletedUser = await deleteUserById(id);
|
||||
await deleteBrevoCustomerByEmail({ email: deletedUser.email });
|
||||
|
||||
return deletedUser;
|
||||
} catch (error) {
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Umgebung nicht gefunden",
|
||||
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
|
||||
"error": "Fehler",
|
||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
||||
"error_component_title": "Fehler beim Laden der Ressourcen",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"finish": "Fertigstellen",
|
||||
"follow_these": "Folge diesen",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Umfrage automatisch zu Beginn des Tages (UTC) freigeben.",
|
||||
"back_button_label": "Zurück\"- Button ",
|
||||
"background_styling": "Hintergründe",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blockiert die Umfrage, wenn bereits eine Antwort mit der Single Use Id (suId) existiert.",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blockiert Umfrage, wenn die Umfrage-URL keine Single-Use-ID (suId) hat.",
|
||||
"brand_color": "Markenfarbe",
|
||||
"brightness": "Helligkeit",
|
||||
"button_label": "Beschriftung",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Fängt nicht an mit",
|
||||
"edit_recall": "Erinnerung bearbeiten",
|
||||
"edit_translations": "{lang} -Übersetzungen bearbeiten",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Single Use Id (suId) in der Umfrage-URL verschlüsseln.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Teilnehmer können die Umfragesprache jederzeit während der Umfrage ändern.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Spamschutz verwendet reCAPTCHA v3, um Spam-Antworten herauszufiltern.",
|
||||
"enable_spam_protection": "Spamschutz",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Logo in dieser speziellen Umfrage verstecken",
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Wie funky sollen deine Karten in {surveyTypeDerived} Umfragen sein",
|
||||
"how_it_works": "Wie es funktioniert",
|
||||
"if_you_need_more_please": "Wenn Du mehr brauchst, bitte",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Wenn Du diese Antwort brauchst, frag so lange, bis Du sie bekommst.",
|
||||
"ignore_waiting_time_between_surveys": "Wartezeit zwischen Umfragen ignorieren",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Maximale Dateigröße begrenzen",
|
||||
"limit_upload_file_size_to": "Maximale Dateigröße für Uploads",
|
||||
"link_survey_description": "Teile einen Link zu einer Umfrageseite oder bette ihn in eine Webseite oder E-Mail ein.",
|
||||
"link_used_message": "Link verwendet",
|
||||
"load_segment": "Segment laden",
|
||||
"logic_error_warning": "Änderungen werden zu Logikfehlern führen",
|
||||
"logic_error_warning_text": "Das Ändern des Fragetypen entfernt die Logikbedingungen von dieser Frage",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Umfrage % der Nutzer anzeigen",
|
||||
"show_to_x_percentage_of_targeted_users": "Zeige {percentage}% der Zielbenutzer",
|
||||
"simple": "Einfach",
|
||||
"single_use_survey_links": "Einmalige Umfragelinks",
|
||||
"single_use_survey_links_description": "Erlaube nur eine Antwort pro Umfragelink.",
|
||||
"six_points": "6 Punkte",
|
||||
"skip_button_label": "Überspringen-Button-Beschriftung",
|
||||
"smiley": "Smiley",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Zwischenüberschrift",
|
||||
"subtract": "Subtrahieren -",
|
||||
"suggest_colors": "Farben vorschlagen",
|
||||
"survey_already_answered_heading": "Die Umfrage wurde bereits beantwortet.",
|
||||
"survey_already_answered_subheading": "Du kannst diesen Link nur einmal verwenden.",
|
||||
"survey_completed_heading": "Umfrage abgeschlossen",
|
||||
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
|
||||
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Hochladen",
|
||||
"upload_at_least_2_images": "Lade mindestens 2 Bilder hoch",
|
||||
"upper_label": "Oberes Label",
|
||||
"url_encryption": "URL-Verschlüsselung",
|
||||
"url_filters": "URL-Filter",
|
||||
"url_not_supported": "URL nicht unterstützt",
|
||||
"use_with_caution": "Mit Vorsicht verwenden",
|
||||
@@ -1733,7 +1721,6 @@
|
||||
"embed_on_website": {
|
||||
"description": "Formbricks-Umfragen können als statisches Element eingebettet werden.",
|
||||
"embed_code_copied_to_clipboard": "Einbettungscode in die Zwischenablage kopiert!",
|
||||
"embed_in_app": "In App einbetten",
|
||||
"embed_mode": "Einbettungsmodus",
|
||||
"embed_mode_description": "Bette deine Umfrage mit einem minimalistischen Design ein, ohne Karten und Hintergrund.",
|
||||
"nav_title": "Auf Website einbetten"
|
||||
@@ -1844,7 +1831,6 @@
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"link_to_public_results_copied": "Link zu öffentlichen Ergebnissen kopiert",
|
||||
"mobile_app": "Mobile App",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"only_completed": "Nur vollständige Antworten",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
@@ -1857,10 +1843,6 @@
|
||||
"qr_code_download_failed": "QR-Code-Download fehlgeschlagen",
|
||||
"qr_code_download_with_start_soon": "QR Code-Download startet bald",
|
||||
"qr_code_generation_failed": "Es gab ein Problem beim Laden des QR-Codes für die Umfrage. Bitte versuchen Sie es erneut.",
|
||||
"quickstart_mobile_apps": "Schnellstart: Mobile-Apps",
|
||||
"quickstart_mobile_apps_description": "Um mit Umfragen in mobilen Apps zu beginnen, folge bitte der Schnellstartanleitung:",
|
||||
"quickstart_web_apps": "Schnellstart: Web-Apps",
|
||||
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
|
||||
"results_are_public": "Ergebnisse sind öffentlich",
|
||||
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
|
||||
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
|
||||
@@ -1883,7 +1865,6 @@
|
||||
"use_personal_links": "Nutze persönliche Links",
|
||||
"view_site": "Seite ansehen",
|
||||
"waiting_for_response": "Warte auf eine Antwort \uD83E\uDDD8♂️",
|
||||
"web_app": "Web-App",
|
||||
"whats_next": "Was kommt als Nächstes?",
|
||||
"your_survey_is_public": "Deine Umfrage ist öffentlich",
|
||||
"youre_not_plugged_in_yet": "Du bist noch nicht verbunden!"
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Environment not found",
|
||||
"environment_notice": "You're currently in the {environment} environment.",
|
||||
"error": "Error",
|
||||
"error_component_description": "This resource doesn't exist or you don't have the necessary rights to access it.",
|
||||
"error_component_title": "Error loading resources",
|
||||
"expand_rows": "Expand rows",
|
||||
"finish": "Finish",
|
||||
"follow_these": "Follow these",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Automatically release the survey at the beginning of the day (UTC).",
|
||||
"back_button_label": "\"Back\" Button Label",
|
||||
"background_styling": "Background Styling",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Blocks survey if a submission with the Single Use Id (suId) exists already.",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Blocks survey if the survey URL has no Single Use Id (suId).",
|
||||
"brand_color": "Brand color",
|
||||
"brightness": "Brightness",
|
||||
"button_label": "Button Label",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Does not start with",
|
||||
"edit_recall": "Edit Recall",
|
||||
"edit_translations": "Edit {lang} translations",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Enable encryption of Single Use Id (suId) in survey URL.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Enable participants to switch the survey language at any point during the survey.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "Spam protection uses reCAPTCHA v3 to filter out the spam responses.",
|
||||
"enable_spam_protection": "Spam protection",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Hide the logo in this specific survey",
|
||||
"hostname": "Hostname",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "How funky do you want your cards in {surveyTypeDerived} Surveys",
|
||||
"how_it_works": "How it works",
|
||||
"if_you_need_more_please": "If you need more, please",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "If you really want that answer, ask until you get it.",
|
||||
"ignore_waiting_time_between_surveys": "Ignore waiting time between surveys",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Limit the maximum file size",
|
||||
"limit_upload_file_size_to": "Limit upload file size to",
|
||||
"link_survey_description": "Share a link to a survey page or embed it in a web page or email.",
|
||||
"link_used_message": "Link Used",
|
||||
"load_segment": "Load segment",
|
||||
"logic_error_warning": "Changing will cause logic errors",
|
||||
"logic_error_warning_text": "Changing the question type will remove the logic conditions from this question",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Show survey to % of users",
|
||||
"show_to_x_percentage_of_targeted_users": "Show to {percentage}% of targeted users",
|
||||
"simple": "Simple",
|
||||
"single_use_survey_links": "Single-use survey links",
|
||||
"single_use_survey_links_description": "Allow only 1 response per survey link.",
|
||||
"six_points": "6 points",
|
||||
"skip_button_label": "Skip Button Label",
|
||||
"smiley": "Smiley",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Subheading",
|
||||
"subtract": "Subtract -",
|
||||
"suggest_colors": "Suggest colors",
|
||||
"survey_already_answered_heading": "The survey has already been answered.",
|
||||
"survey_already_answered_subheading": "You can only use this link once.",
|
||||
"survey_completed_heading": "Survey Completed",
|
||||
"survey_completed_subheading": "This free & open-source survey has been closed",
|
||||
"survey_display_settings": "Survey Display Settings",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Upload",
|
||||
"upload_at_least_2_images": "Upload at least 2 images",
|
||||
"upper_label": "Upper Label",
|
||||
"url_encryption": "URL Encryption",
|
||||
"url_filters": "URL Filters",
|
||||
"url_not_supported": "URL not supported",
|
||||
"use_with_caution": "Use with caution",
|
||||
@@ -1733,7 +1721,6 @@
|
||||
"embed_on_website": {
|
||||
"description": "Formbricks surveys can be embedded as a static element.",
|
||||
"embed_code_copied_to_clipboard": "Embed code copied to clipboard!",
|
||||
"embed_in_app": "Embed in app",
|
||||
"embed_mode": "Embed Mode",
|
||||
"embed_mode_description": "Embed your survey with a minimalist design, discarding padding and background.",
|
||||
"nav_title": "Website embed"
|
||||
@@ -1844,7 +1831,6 @@
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"link_to_public_results_copied": "Link to public results copied",
|
||||
"mobile_app": "Mobile app",
|
||||
"no_responses_found": "No responses found",
|
||||
"only_completed": "Only completed",
|
||||
"other_values_found": "Other values found",
|
||||
@@ -1857,10 +1843,6 @@
|
||||
"qr_code_download_failed": "QR code download failed",
|
||||
"qr_code_download_with_start_soon": "QR code download will start soon",
|
||||
"qr_code_generation_failed": "There was a problem, loading the survey QR Code. Please try again.",
|
||||
"quickstart_mobile_apps": "Quickstart: Mobile apps",
|
||||
"quickstart_mobile_apps_description": "To get started with surveys in mobile apps, please follow the Quickstart guide:",
|
||||
"quickstart_web_apps": "Quickstart: Web apps",
|
||||
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
|
||||
"results_are_public": "Results are public",
|
||||
"selected_responses_csv": "Selected responses (CSV)",
|
||||
"selected_responses_excel": "Selected responses (Excel)",
|
||||
@@ -1883,7 +1865,6 @@
|
||||
"use_personal_links": "Use personal links",
|
||||
"view_site": "View site",
|
||||
"waiting_for_response": "Waiting for a response \uD83E\uDDD8♂️",
|
||||
"web_app": "Web app",
|
||||
"whats_next": "What's next?",
|
||||
"your_survey_is_public": "Your survey is public",
|
||||
"youre_not_plugged_in_yet": "You're not plugged in yet!"
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Environnement non trouvé",
|
||||
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
|
||||
"error": "Erreur",
|
||||
"error_component_description": "Cette ressource n'existe pas ou vous n'avez pas les droits nécessaires pour y accéder.",
|
||||
"error_component_title": "Erreur de chargement des ressources",
|
||||
"expand_rows": "Développer les lignes",
|
||||
"finish": "Terminer",
|
||||
"follow_these": "Suivez ceci",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Libérer automatiquement l'enquête au début de la journée (UTC).",
|
||||
"back_button_label": "Label du bouton \"Retour''",
|
||||
"background_styling": "Style de fond",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloque les enquêtes si une soumission avec l'Identifiant à Usage Unique (suId) existe déjà.",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloque les enquêtes si l'URL de l'enquête n'a pas d'Identifiant d'Utilisation Unique (suId).",
|
||||
"brand_color": "Couleur de marque",
|
||||
"brightness": "Luminosité",
|
||||
"button_label": "Label du bouton",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Ne commence pas par",
|
||||
"edit_recall": "Modifier le rappel",
|
||||
"edit_translations": "Modifier les traductions {lang}",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Activer le chiffrement de l'identifiant à usage unique (suId) dans l'URL de l'enquête.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permettre aux participants de changer la langue de l'enquête à tout moment pendant celle-ci.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "La protection contre le spam utilise reCAPTCHA v3 pour filtrer les réponses indésirables.",
|
||||
"enable_spam_protection": "Protection contre le spam",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Cacher le logo dans cette enquête spécifique",
|
||||
"hostname": "Nom d'hôte",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "À quel point voulez-vous que vos cartes soient funky dans les enquêtes {surveyTypeDerived}",
|
||||
"how_it_works": "Comment ça fonctionne",
|
||||
"if_you_need_more_please": "Si vous en avez besoin de plus, s'il vous plaît",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Si tu veux vraiment cette réponse, demande jusqu'à ce que tu l'obtiennes.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorer le temps d'attente entre les enquêtes",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Limiter la taille maximale du fichier",
|
||||
"limit_upload_file_size_to": "Limiter la taille des fichiers téléchargés à",
|
||||
"link_survey_description": "Partagez un lien vers une page d'enquête ou intégrez-le dans une page web ou un e-mail.",
|
||||
"link_used_message": "Lien utilisé",
|
||||
"load_segment": "Segment de chargement",
|
||||
"logic_error_warning": "Changer causera des erreurs logiques",
|
||||
"logic_error_warning_text": "Changer le type de question supprimera les conditions logiques de cette question.",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Afficher l'enquête à % des utilisateurs",
|
||||
"show_to_x_percentage_of_targeted_users": "Afficher à {percentage}% des utilisateurs ciblés",
|
||||
"simple": "Simple",
|
||||
"single_use_survey_links": "Liens d'enquête à usage unique",
|
||||
"single_use_survey_links_description": "Autoriser uniquement 1 réponse par lien d'enquête.",
|
||||
"six_points": "6 points",
|
||||
"skip_button_label": "Étiquette du bouton Ignorer",
|
||||
"smiley": "Sourire",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Sous-titre",
|
||||
"subtract": "Soustraire -",
|
||||
"suggest_colors": "Suggérer des couleurs",
|
||||
"survey_already_answered_heading": "L'enquête a déjà été répondue.",
|
||||
"survey_already_answered_subheading": "Vous ne pouvez utiliser ce lien qu'une seule fois.",
|
||||
"survey_completed_heading": "Enquête terminée",
|
||||
"survey_completed_subheading": "Cette enquête gratuite et open-source a été fermée",
|
||||
"survey_display_settings": "Paramètres d'affichage de l'enquête",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Télécharger",
|
||||
"upload_at_least_2_images": "Téléchargez au moins 2 images",
|
||||
"upper_label": "Étiquette supérieure",
|
||||
"url_encryption": "Chiffrement d'URL",
|
||||
"url_filters": "Filtres d'URL",
|
||||
"url_not_supported": "URL non supportée",
|
||||
"use_with_caution": "À utiliser avec précaution",
|
||||
@@ -1733,7 +1721,6 @@
|
||||
"embed_on_website": {
|
||||
"description": "Les enquêtes Formbricks peuvent être intégrées comme élément statique.",
|
||||
"embed_code_copied_to_clipboard": "Code d'intégration copié dans le presse-papiers !",
|
||||
"embed_in_app": "Intégrer dans l'application",
|
||||
"embed_mode": "Mode d'intégration",
|
||||
"embed_mode_description": "Intégrez votre enquête avec un design minimaliste, en supprimant les marges et l'arrière-plan.",
|
||||
"nav_title": "Incorporer sur le site web"
|
||||
@@ -1844,7 +1831,6 @@
|
||||
"last_quarter": "dernier trimestre",
|
||||
"last_year": "l'année dernière",
|
||||
"link_to_public_results_copied": "Lien vers les résultats publics copié",
|
||||
"mobile_app": "Application mobile",
|
||||
"no_responses_found": "Aucune réponse trouvée",
|
||||
"only_completed": "Uniquement terminé",
|
||||
"other_values_found": "D'autres valeurs trouvées",
|
||||
@@ -1857,10 +1843,6 @@
|
||||
"qr_code_download_failed": "Échec du téléchargement du code QR",
|
||||
"qr_code_download_with_start_soon": "Le téléchargement du code QR débutera bientôt",
|
||||
"qr_code_generation_failed": "\"Un problème est survenu lors du chargement du code QR du sondage. Veuillez réessayer.\"",
|
||||
"quickstart_mobile_apps": "Démarrage rapide : Applications mobiles",
|
||||
"quickstart_mobile_apps_description": "Pour commencer avec les enquêtes dans les applications mobiles, veuillez suivre le guide de démarrage rapide :",
|
||||
"quickstart_web_apps": "Démarrage rapide : Applications web",
|
||||
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
|
||||
"results_are_public": "Les résultats sont publics.",
|
||||
"selected_responses_csv": "Réponses sélectionnées (CSV)",
|
||||
"selected_responses_excel": "Réponses sélectionnées (Excel)",
|
||||
@@ -1883,7 +1865,6 @@
|
||||
"use_personal_links": "Utilisez des liens personnels",
|
||||
"view_site": "Voir le site",
|
||||
"waiting_for_response": "En attente d'une réponse \uD83E\uDDD8♂️",
|
||||
"web_app": "application web",
|
||||
"whats_next": "Qu'est-ce qui vient ensuite ?",
|
||||
"your_survey_is_public": "Votre enquête est publique.",
|
||||
"youre_not_plugged_in_yet": "Vous n'êtes pas encore branché !"
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Você está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Esse recurso não existe ou você não tem permissão para acessá-lo.",
|
||||
"error_component_title": "Erro ao carregar recursos",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"finish": "Terminar",
|
||||
"follow_these": "Siga esses",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Liberar automaticamente a pesquisa no começo do dia (UTC).",
|
||||
"back_button_label": "Voltar",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia a pesquisa se já existir uma submissão com o Id de Uso Único (suId).",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia a pesquisa se a URL da pesquisa não tiver um Id de Uso Único (suId).",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "brilho",
|
||||
"button_label": "Rótulo do Botão",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções de {lang}",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Habilitar criptografia do Id de Uso Único (suId) na URL da pesquisa.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir que os participantes mudem o idioma da pesquisa a qualquer momento durante a pesquisa.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
|
||||
"enable_spam_protection": "Proteção contra spam",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Esconder o logo nessa pesquisa específica",
|
||||
"hostname": "nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão descoladas você quer suas cartas em Pesquisas {surveyTypeDerived}",
|
||||
"how_it_works": "Como funciona",
|
||||
"if_you_need_more_please": "Se você precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Se você realmente quer essa resposta, pergunte até conseguir.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre pesquisas",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do arquivo",
|
||||
"limit_upload_file_size_to": "Limitar tamanho do arquivo de upload para",
|
||||
"link_survey_description": "Compartilhe um link para a página da pesquisa ou incorpore-a em uma página da web ou e-mail.",
|
||||
"link_used_message": "Link Usado",
|
||||
"load_segment": "segmento de carga",
|
||||
"logic_error_warning": "Mudar vai causar erros de lógica",
|
||||
"logic_error_warning_text": "Mudar o tipo de pergunta vai remover as condições lógicas dessa pergunta",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Mostrar pesquisa para % dos usuários",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar para {percentage}% dos usuários segmentados",
|
||||
"simple": "Simples",
|
||||
"single_use_survey_links": "Links de pesquisa de uso único",
|
||||
"single_use_survey_links_description": "Permitir apenas 1 resposta por link da pesquisa.",
|
||||
"six_points": "6 pontos",
|
||||
"skip_button_label": "Botão de Pular",
|
||||
"smiley": "Sorridente",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"suggest_colors": "Sugerir cores",
|
||||
"survey_already_answered_heading": "A pesquisa já foi respondida.",
|
||||
"survey_already_answered_subheading": "Você só pode usar esse link uma vez.",
|
||||
"survey_completed_heading": "Pesquisa Concluída",
|
||||
"survey_completed_subheading": "Essa pesquisa gratuita e de código aberto foi encerrada",
|
||||
"survey_display_settings": "Configurações de Exibição da Pesquisa",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Enviar",
|
||||
"upload_at_least_2_images": "Faz o upload de pelo menos 2 imagens",
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_encryption": "Criptografia de URL",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportada",
|
||||
"use_with_caution": "Use com cuidado",
|
||||
@@ -1733,7 +1721,6 @@
|
||||
"embed_on_website": {
|
||||
"description": "Os formulários Formbricks podem ser incorporados como um elemento estático.",
|
||||
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
|
||||
"embed_in_app": "Integrar no app",
|
||||
"embed_mode": "Modo Embutido",
|
||||
"embed_mode_description": "Incorpore sua pesquisa com um design minimalista, sem preenchimento e fundo.",
|
||||
"nav_title": "Incorporar no site"
|
||||
@@ -1844,7 +1831,6 @@
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Último ano",
|
||||
"link_to_public_results_copied": "Link pros resultados públicos copiado",
|
||||
"mobile_app": "app de celular",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Somente concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
@@ -1857,10 +1843,6 @@
|
||||
"qr_code_download_failed": "falha no download do código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Houve um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"quickstart_mobile_apps": "Início rápido: Aplicativos móveis",
|
||||
"quickstart_mobile_apps_description": "Para começar com pesquisas em aplicativos móveis, por favor, siga o guia de início rápido:",
|
||||
"quickstart_web_apps": "Início rápido: Aplicativos web",
|
||||
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
@@ -1883,7 +1865,6 @@
|
||||
"use_personal_links": "Use links pessoais",
|
||||
"view_site": "Ver site",
|
||||
"waiting_for_response": "Aguardando uma resposta \uD83E\uDDD8♂️",
|
||||
"web_app": "aplicativo web",
|
||||
"whats_next": "E agora?",
|
||||
"your_survey_is_public": "Sua pesquisa é pública",
|
||||
"youre_not_plugged_in_yet": "Você ainda não tá conectado!"
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "Ambiente não encontrado",
|
||||
"environment_notice": "Está atualmente no ambiente {environment}.",
|
||||
"error": "Erro",
|
||||
"error_component_description": "Este recurso não existe ou não tem os direitos necessários para aceder a ele.",
|
||||
"error_component_title": "Erro ao carregar recursos",
|
||||
"expand_rows": "Expandir linhas",
|
||||
"finish": "Concluir",
|
||||
"follow_these": "Siga estes",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "Lançar automaticamente o inquérito no início do dia (UTC).",
|
||||
"back_button_label": "Rótulo do botão \"Voltar\"",
|
||||
"background_styling": "Estilo de Fundo",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "Bloqueia o inquérito se já existir uma submissão com o Id de Uso Único (suId).",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "Bloqueia o inquérito se o URL do inquérito não tiver um Id de Uso Único (suId).",
|
||||
"brand_color": "Cor da marca",
|
||||
"brightness": "Brilho",
|
||||
"button_label": "Rótulo do botão",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "Não começa com",
|
||||
"edit_recall": "Editar Lembrete",
|
||||
"edit_translations": "Editar traduções {lang}",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "Ativar encriptação do Id de Uso Único (suId) no URL do inquérito.",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "Permitir aos participantes mudar a língua do inquérito a qualquer momento durante o inquérito.",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "A proteção contra spam usa o reCAPTCHA v3 para filtrar as respostas de spam.",
|
||||
"enable_spam_protection": "Proteção contra spam",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "Ocultar o logótipo neste inquérito específico",
|
||||
"hostname": "Nome do host",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "Quão extravagantes quer os seus cartões em Inquéritos {surveyTypeDerived}",
|
||||
"how_it_works": "Como funciona",
|
||||
"if_you_need_more_please": "Se precisar de mais, por favor",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "Se realmente quiser essa resposta, pergunte até obtê-la.",
|
||||
"ignore_waiting_time_between_surveys": "Ignorar tempo de espera entre inquéritos",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "Limitar o tamanho máximo do ficheiro",
|
||||
"limit_upload_file_size_to": "Limitar tamanho do ficheiro carregado a",
|
||||
"link_survey_description": "Partilhe um link para uma página de inquérito ou incorpore-o numa página web ou email.",
|
||||
"link_used_message": "Link Utilizado",
|
||||
"load_segment": "Carregar segmento",
|
||||
"logic_error_warning": "A alteração causará erros de lógica",
|
||||
"logic_error_warning_text": "Alterar o tipo de pergunta irá remover as condições lógicas desta pergunta",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "Mostrar inquérito a % dos utilizadores",
|
||||
"show_to_x_percentage_of_targeted_users": "Mostrar a {percentage}% dos utilizadores alvo",
|
||||
"simple": "Simples",
|
||||
"single_use_survey_links": "Links de inquérito de uso único",
|
||||
"single_use_survey_links_description": "Permitir apenas 1 resposta por link de inquérito.",
|
||||
"six_points": "6 pontos",
|
||||
"skip_button_label": "Rótulo do botão Ignorar",
|
||||
"smiley": "Sorridente",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "Subtítulo",
|
||||
"subtract": "Subtrair -",
|
||||
"suggest_colors": "Sugerir cores",
|
||||
"survey_already_answered_heading": "O inquérito já foi respondido.",
|
||||
"survey_already_answered_subheading": "Só pode usar este link uma vez.",
|
||||
"survey_completed_heading": "Inquérito Concluído",
|
||||
"survey_completed_subheading": "Este inquérito gratuito e de código aberto foi encerrado",
|
||||
"survey_display_settings": "Configurações de Exibição do Inquérito",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "Carregar",
|
||||
"upload_at_least_2_images": "Carregue pelo menos 2 imagens",
|
||||
"upper_label": "Etiqueta Superior",
|
||||
"url_encryption": "Encriptação de URL",
|
||||
"url_filters": "Filtros de URL",
|
||||
"url_not_supported": "URL não suportado",
|
||||
"use_with_caution": "Usar com cautela",
|
||||
@@ -1733,7 +1721,6 @@
|
||||
"embed_on_website": {
|
||||
"description": "Os inquéritos Formbricks podem ser incorporados como um elemento estático.",
|
||||
"embed_code_copied_to_clipboard": "Código incorporado copiado para a área de transferência!",
|
||||
"embed_in_app": "Incorporar na aplicação",
|
||||
"embed_mode": "Modo de Incorporação",
|
||||
"embed_mode_description": "Incorpore o seu inquérito com um design minimalista, descartando o preenchimento e o fundo.",
|
||||
"nav_title": "Incorporar no site"
|
||||
@@ -1844,7 +1831,6 @@
|
||||
"last_quarter": "Último trimestre",
|
||||
"last_year": "Ano passado",
|
||||
"link_to_public_results_copied": "Link para resultados públicos copiado",
|
||||
"mobile_app": "Aplicação móvel",
|
||||
"no_responses_found": "Nenhuma resposta encontrada",
|
||||
"only_completed": "Apenas concluído",
|
||||
"other_values_found": "Outros valores encontrados",
|
||||
@@ -1857,10 +1843,6 @@
|
||||
"qr_code_download_failed": "Falha ao transferir o código QR",
|
||||
"qr_code_download_with_start_soon": "O download do código QR começará em breve",
|
||||
"qr_code_generation_failed": "Ocorreu um problema ao carregar o Código QR do questionário. Por favor, tente novamente.",
|
||||
"quickstart_mobile_apps": "Início rápido: Aplicações móveis",
|
||||
"quickstart_mobile_apps_description": "Para começar com inquéritos em aplicações móveis, por favor, siga o guia de início rápido:",
|
||||
"quickstart_web_apps": "Início rápido: Aplicações web",
|
||||
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
|
||||
"results_are_public": "Os resultados são públicos",
|
||||
"selected_responses_csv": "Respostas selecionadas (CSV)",
|
||||
"selected_responses_excel": "Respostas selecionadas (Excel)",
|
||||
@@ -1883,7 +1865,6 @@
|
||||
"use_personal_links": "Utilize links pessoais",
|
||||
"view_site": "Ver site",
|
||||
"waiting_for_response": "A aguardar uma resposta \uD83E\uDDD8♂️",
|
||||
"web_app": "Aplicação web",
|
||||
"whats_next": "O que se segue?",
|
||||
"your_survey_is_public": "O seu inquérito é público",
|
||||
"youre_not_plugged_in_yet": "Ainda não está ligado!"
|
||||
|
||||
@@ -199,8 +199,6 @@
|
||||
"environment_not_found": "找不到環境",
|
||||
"environment_notice": "您目前在 '{'environment'}' 環境中。",
|
||||
"error": "錯誤",
|
||||
"error_component_description": "此資源不存在或您沒有存取權限。",
|
||||
"error_component_title": "載入資源錯誤",
|
||||
"expand_rows": "展開列",
|
||||
"finish": "完成",
|
||||
"follow_these": "按照這些步驟",
|
||||
@@ -1282,8 +1280,6 @@
|
||||
"automatically_release_the_survey_at_the_beginning_of_the_day_utc": "在指定日期(UTC時間)自動發佈問卷。",
|
||||
"back_button_label": "「返回」按鈕標籤",
|
||||
"background_styling": "背景樣式設定",
|
||||
"blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already": "如果已存在具有單次使用 ID (suId) 的提交,則封鎖問卷。",
|
||||
"blocks_survey_if_the_survey_url_has_no_single_use_id_suid": "如果問卷網址沒有單次使用 ID (suId),則封鎖問卷。",
|
||||
"brand_color": "品牌顏色",
|
||||
"brightness": "亮度",
|
||||
"button_label": "按鈕標籤",
|
||||
@@ -1368,7 +1364,6 @@
|
||||
"does_not_start_with": "不以...開頭",
|
||||
"edit_recall": "編輯回憶",
|
||||
"edit_translations": "編輯 '{'language'}' 翻譯",
|
||||
"enable_encryption_of_single_use_id_suid_in_survey_url": "啟用問卷網址中單次使用 ID (suId) 的加密。",
|
||||
"enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey": "允許參與者在問卷中的任何時間點切換問卷語言。",
|
||||
"enable_recaptcha_to_protect_your_survey_from_spam": "垃圾郵件保護使用 reCAPTCHA v3 過濾垃圾回應。",
|
||||
"enable_spam_protection": "垃圾郵件保護",
|
||||
@@ -1444,7 +1439,6 @@
|
||||
"hide_the_logo_in_this_specific_survey": "在此特定問卷中隱藏標誌",
|
||||
"hostname": "主機名稱",
|
||||
"how_funky_do_you_want_your_cards_in_survey_type_derived_surveys": "您希望 '{'surveyTypeDerived'}' 問卷中的卡片有多酷炫",
|
||||
"how_it_works": "運作方式",
|
||||
"if_you_need_more_please": "如果您需要更多,請",
|
||||
"if_you_really_want_that_answer_ask_until_you_get_it": "如果您真的想要該答案,請詢問直到您獲得它。",
|
||||
"ignore_waiting_time_between_surveys": "忽略問卷之間的等待時間",
|
||||
@@ -1482,7 +1476,6 @@
|
||||
"limit_the_maximum_file_size": "限制最大檔案大小",
|
||||
"limit_upload_file_size_to": "限制上傳檔案大小為",
|
||||
"link_survey_description": "分享問卷頁面的連結或將其嵌入網頁或電子郵件中。",
|
||||
"link_used_message": "已使用連結",
|
||||
"load_segment": "載入區隔",
|
||||
"logic_error_warning": "變更將導致邏輯錯誤",
|
||||
"logic_error_warning_text": "變更問題類型將會從此問題中移除邏輯條件",
|
||||
@@ -1574,8 +1567,6 @@
|
||||
"show_survey_to_users": "將問卷顯示給 % 的使用者",
|
||||
"show_to_x_percentage_of_targeted_users": "顯示給 '{'percentage'}'% 的目標使用者",
|
||||
"simple": "簡單",
|
||||
"single_use_survey_links": "單次使用問卷連結",
|
||||
"single_use_survey_links_description": "每個問卷連結只允許 1 個回應。",
|
||||
"six_points": "6 分",
|
||||
"skip_button_label": "「跳過」按鈕標籤",
|
||||
"smiley": "表情符號",
|
||||
@@ -1592,8 +1583,6 @@
|
||||
"subheading": "副標題",
|
||||
"subtract": "減 -",
|
||||
"suggest_colors": "建議顏色",
|
||||
"survey_already_answered_heading": "問卷已回答。",
|
||||
"survey_already_answered_subheading": "您只能使用此連結一次。",
|
||||
"survey_completed_heading": "問卷已完成",
|
||||
"survey_completed_subheading": "此免費且開源的問卷已關閉",
|
||||
"survey_display_settings": "問卷顯示設定",
|
||||
@@ -1624,7 +1613,6 @@
|
||||
"upload": "上傳",
|
||||
"upload_at_least_2_images": "上傳至少 2 張圖片",
|
||||
"upper_label": "上標籤",
|
||||
"url_encryption": "網址加密",
|
||||
"url_filters": "網址篩選器",
|
||||
"url_not_supported": "不支援網址",
|
||||
"use_with_caution": "謹慎使用",
|
||||
@@ -1733,7 +1721,6 @@
|
||||
"embed_on_website": {
|
||||
"description": "Formbricks 調查可以 作為 靜態 元素 嵌入。",
|
||||
"embed_code_copied_to_clipboard": "嵌入程式碼已複製到剪貼簿!",
|
||||
"embed_in_app": "嵌入應用程式",
|
||||
"embed_mode": "嵌入模式",
|
||||
"embed_mode_description": "以簡約設計嵌入您的問卷,捨棄邊距和背景。",
|
||||
"nav_title": "嵌入網站"
|
||||
@@ -1844,7 +1831,6 @@
|
||||
"last_quarter": "上一季",
|
||||
"last_year": "去年",
|
||||
"link_to_public_results_copied": "已複製公開結果的連結",
|
||||
"mobile_app": "行動應用程式",
|
||||
"no_responses_found": "找不到回應",
|
||||
"only_completed": "僅已完成",
|
||||
"other_values_found": "找到其他值",
|
||||
@@ -1857,10 +1843,6 @@
|
||||
"qr_code_download_failed": "QR code 下載失敗",
|
||||
"qr_code_download_with_start_soon": "QR code 下載即將開始",
|
||||
"qr_code_generation_failed": "載入調查 QR Code 時發生問題。請再試一次。",
|
||||
"quickstart_mobile_apps": "快速入門:Mobile apps",
|
||||
"quickstart_mobile_apps_description": "要開始使用行動應用程式中的調查,請按照 Quickstart 指南:",
|
||||
"quickstart_web_apps": "快速入門:Web apps",
|
||||
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
|
||||
"results_are_public": "結果是公開的",
|
||||
"selected_responses_csv": "選擇的回應 (CSV)",
|
||||
"selected_responses_excel": "選擇的回應 (Excel)",
|
||||
@@ -1883,7 +1865,6 @@
|
||||
"use_personal_links": "使用 個人 連結",
|
||||
"view_site": "檢視網站",
|
||||
"waiting_for_response": "正在等待回應 \uD83E\uDDD8♂️",
|
||||
"web_app": "Web 應用程式",
|
||||
"whats_next": "下一步是什麼?",
|
||||
"your_survey_is_public": "您的問卷是公開的",
|
||||
"youre_not_plugged_in_yet": "您尚未插入任何內容!"
|
||||
|
||||
+3
-26
@@ -1,23 +1,10 @@
|
||||
import {
|
||||
clientSideApiEndpointsLimiter,
|
||||
forgotPasswordLimiter,
|
||||
loginLimiter,
|
||||
shareUrlLimiter,
|
||||
signupLimiter,
|
||||
syncUserIdentificationLimiter,
|
||||
verifyEmailLimiter,
|
||||
} from "@/app/middleware/bucket";
|
||||
import { clientSideApiEndpointsLimiter, syncUserIdentificationLimiter } from "@/app/middleware/bucket";
|
||||
import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middleware/domain-utils";
|
||||
import {
|
||||
isAuthProtectedRoute,
|
||||
isClientSideApiRoute,
|
||||
isForgotPasswordRoute,
|
||||
isLoginRoute,
|
||||
isRouteAllowedForDomain,
|
||||
isShareUrlRoute,
|
||||
isSignupRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
isVerifyEmailRoute,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@/lib/constants";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
@@ -51,23 +38,13 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
|
||||
};
|
||||
|
||||
const applyRateLimiting = async (request: NextRequest, ip: string) => {
|
||||
if (isLoginRoute(request.nextUrl.pathname)) {
|
||||
await loginLimiter(`login-${ip}`);
|
||||
} else if (isSignupRoute(request.nextUrl.pathname)) {
|
||||
await signupLimiter(`signup-${ip}`);
|
||||
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
|
||||
await verifyEmailLimiter(`verify-email-${ip}`);
|
||||
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
|
||||
await forgotPasswordLimiter(`forgot-password-${ip}`);
|
||||
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
|
||||
if (isClientSideApiRoute(request.nextUrl.pathname)) {
|
||||
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
|
||||
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
|
||||
if (envIdAndUserId) {
|
||||
const { environmentId, userId } = envIdAndUserId;
|
||||
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
|
||||
}
|
||||
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
|
||||
await shareUrlLimiter(`share-${ip}`);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -134,7 +111,7 @@ export const middleware = async (originalRequest: NextRequest) => {
|
||||
await applyRateLimiting(request, ip);
|
||||
return nextResponseWithCustomHeader;
|
||||
} catch (e) {
|
||||
// NOSONAR - This is a catch all for rate limiting errors
|
||||
logger.error(e, "Error applying rate limiting");
|
||||
const apiError: ApiErrorResponseV2 = {
|
||||
type: "too_many_requests",
|
||||
details: [{ field: "", issue: "Too many requests. Please try again later." }],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { toast } from "react-hot-toast";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getSurveyUrl } from "../../utils";
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
toast: {
|
||||
@@ -11,6 +12,24 @@ vi.mock("react-hot-toast", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the useSingleUseId hook
|
||||
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
|
||||
useSingleUseId: vi.fn(() => ({
|
||||
singleUseId: "test-single-use-id",
|
||||
refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock the survey utils
|
||||
vi.mock("../../utils", () => ({
|
||||
getSurveyUrl: vi.fn((survey, publicDomain, language) => {
|
||||
if (language && language !== "en") {
|
||||
return `${publicDomain}/s/${survey.id}?lang=${language}`;
|
||||
}
|
||||
return `${publicDomain}/s/${survey.id}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
const survey: TSurvey = {
|
||||
id: "survey-id",
|
||||
name: "Test Survey",
|
||||
@@ -161,7 +180,7 @@ describe("ShareSurveyLink", () => {
|
||||
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
|
||||
});
|
||||
|
||||
test("opens the preview link in a new tab when preview button is clicked (no query params)", () => {
|
||||
test("opens the preview link in a new tab when preview button is clicked (no query params)", async () => {
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
@@ -175,10 +194,13 @@ describe("ShareSurveyLink", () => {
|
||||
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
// Wait for the async function to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(global.open).toHaveBeenCalledWith(`${surveyUrl}?preview=true`, "_blank");
|
||||
});
|
||||
|
||||
test("opens the preview link in a new tab when preview button is clicked (with query params)", () => {
|
||||
test("opens the preview link in a new tab when preview button is clicked (with query params)", async () => {
|
||||
const surveyWithParamsUrl = `${publicDomain}/s/survey-id?foo=bar`;
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
@@ -193,6 +215,9 @@ describe("ShareSurveyLink", () => {
|
||||
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
|
||||
fireEvent.click(previewButton);
|
||||
|
||||
// Wait for the async function to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(global.open).toHaveBeenCalledWith(`${surveyWithParamsUrl}&preview=true`, "_blank");
|
||||
});
|
||||
|
||||
@@ -215,7 +240,9 @@ describe("ShareSurveyLink", () => {
|
||||
});
|
||||
|
||||
test("updates the survey URL when the language is changed", () => {
|
||||
const { rerender } = render(
|
||||
const mockGetSurveyUrl = vi.mocked(getSurveyUrl);
|
||||
|
||||
render(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
@@ -231,16 +258,7 @@ describe("ShareSurveyLink", () => {
|
||||
const germanOption = screen.getByText("German");
|
||||
fireEvent.click(germanOption);
|
||||
|
||||
rerender(
|
||||
<ShareSurveyLink
|
||||
survey={survey}
|
||||
publicDomain={publicDomain}
|
||||
surveyUrl={surveyUrl}
|
||||
setSurveyUrl={setSurveyUrl}
|
||||
locale={locale}
|
||||
/>
|
||||
);
|
||||
expect(setSurveyUrl).toHaveBeenCalled();
|
||||
expect(surveyUrl).toContain("lang=de");
|
||||
expect(mockGetSurveyUrl).toHaveBeenCalledWith(survey, publicDomain, "de");
|
||||
expect(setSurveyUrl).toHaveBeenCalledWith(`${publicDomain}/s/${survey.id}?lang=de`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Copy, SquareArrowOutUpRight } from "lucide-react";
|
||||
@@ -32,6 +33,22 @@ export const ShareSurveyLink = ({
|
||||
setSurveyUrl(url);
|
||||
};
|
||||
|
||||
const { refreshSingleUseId } = useSingleUseId(survey);
|
||||
|
||||
const getPreviewUrl = async () => {
|
||||
const previewUrl = new URL(surveyUrl);
|
||||
|
||||
if (survey.singleUse?.enabled) {
|
||||
const newId = await refreshSingleUseId();
|
||||
if (newId) {
|
||||
previewUrl.searchParams.set("suId", newId);
|
||||
}
|
||||
}
|
||||
|
||||
previewUrl.searchParams.set("preview", "true");
|
||||
return previewUrl.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex max-w-full flex-col items-center justify-center gap-2 md:flex-row"}>
|
||||
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
|
||||
@@ -53,14 +70,9 @@ export const ShareSurveyLink = ({
|
||||
title={t("environments.surveys.preview_survey_in_a_new_tab")}
|
||||
aria-label={t("environments.surveys.preview_survey_in_a_new_tab")}
|
||||
disabled={!surveyUrl}
|
||||
onClick={() => {
|
||||
let previewUrl = surveyUrl;
|
||||
if (previewUrl.includes("?")) {
|
||||
previewUrl += "&preview=true";
|
||||
} else {
|
||||
previewUrl += "?preview=true";
|
||||
}
|
||||
window.open(previewUrl, "_blank");
|
||||
onClick={async () => {
|
||||
const url = await getPreviewUrl();
|
||||
window.open(url, "_blank");
|
||||
}}>
|
||||
{t("common.preview")}
|
||||
<SquareArrowOutUpRight />
|
||||
|
||||
@@ -20,7 +20,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
IS_PRODUCTION: true,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
ENCRYPTION_KEY: "mocked-encryption-key",
|
||||
REDIS_URL: "mock-url",
|
||||
REDIS_URL: undefined,
|
||||
}));
|
||||
|
||||
describe("utils", () => {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
// Import mocked functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { sendForgotPasswordEmail } from "@/modules/email";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { forgotPasswordAction } from "./actions";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
PASSWORD_RESET_DISABLED: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
auth: {
|
||||
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/user", () => ({
|
||||
getUserByEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendForgotPasswordEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
actionClient: {
|
||||
schema: vi.fn().mockReturnThis(),
|
||||
action: vi.fn((fn) => fn),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("forgotPasswordAction", () => {
|
||||
const validInput = {
|
||||
email: "test@example.com",
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
identityProvider: "email",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before processing forgot password request", async () => {
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.forgotPassword);
|
||||
expect(applyIPRateLimit).toHaveBeenCalledBefore(getUserByEmail as any);
|
||||
});
|
||||
|
||||
test("should throw rate limit error when limit exceeded", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
expect(getUserByEmail).not.toHaveBeenCalled();
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 3600,
|
||||
allowedPerInterval: 5,
|
||||
namespace: "auth:forgot",
|
||||
});
|
||||
});
|
||||
|
||||
test("should apply rate limiting even when user doesn't exist", async () => {
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.forgotPassword);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Reset Flow", () => {
|
||||
test("should send password reset email when user exists with email identity provider", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendForgotPasswordEmail).toHaveBeenCalledWith(mockUser);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should not send email when user doesn't exist", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should not send email when user has non-email identity provider", async () => {
|
||||
const ssoUser = { ...mockUser, identityProvider: "google" };
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Password Reset Disabled", () => {
|
||||
test("should check password reset is enabled in our implementation", async () => {
|
||||
// This test verifies that password reset is enabled by default
|
||||
// The actual PASSWORD_RESET_DISABLED check is part of the implementation
|
||||
// and we've mocked it as false, so rate limiting should work normally
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
test("should propagate rate limiting errors", async () => {
|
||||
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
});
|
||||
|
||||
test("should handle user lookup errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
|
||||
"Database error"
|
||||
);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle email sending errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(sendForgotPasswordEmail).mockRejectedValue(new Error("Email service error"));
|
||||
|
||||
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
|
||||
"Email service error"
|
||||
);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Considerations", () => {
|
||||
test("should always return success even for non-existent users to prevent email enumeration", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should always return success even for SSO users to prevent identity provider enumeration", async () => {
|
||||
const ssoUser = { ...mockUser, identityProvider: "github" };
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
|
||||
|
||||
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should rate limit all requests regardless of user existence", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
|
||||
// Test with existing user
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
await forgotPasswordAction({ parsedInput: validInput } as any);
|
||||
|
||||
// Test with non-existing user
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
await forgotPasswordAction({ parsedInput: { email: "nonexistent@example.com" } } as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { sendForgotPasswordEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
import { OperationNotAllowedError } from "@formbricks/types/errors";
|
||||
@@ -15,6 +17,8 @@ const ZForgotPasswordAction = z.object({
|
||||
export const forgotPasswordAction = actionClient
|
||||
.schema(ZForgotPasswordAction)
|
||||
.action(async ({ parsedInput }) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.auth.forgotPassword);
|
||||
|
||||
if (PASSWORD_RESET_DISABLED) {
|
||||
throw new OperationNotAllowedError("Password reset is disabled");
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_HOST: "smtp.example.com",
|
||||
SMTP_PORT: "587",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
|
||||
import { createToken } from "@/lib/jwt";
|
||||
// Import mocked rate limiting functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -8,6 +11,20 @@ import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
|
||||
// Mock rate limiting dependencies
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
auth: {
|
||||
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" },
|
||||
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock constants that this test needs
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
EMAIL_VERIFICATION_DISABLED: false,
|
||||
@@ -21,6 +38,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
ENTERPRISE_LICENSE_KEY: undefined,
|
||||
SENTRY_DSN: undefined,
|
||||
BREVO_API_KEY: undefined,
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
}));
|
||||
|
||||
// Mock next/headers
|
||||
@@ -69,6 +87,7 @@ function getProviderById(id: string): Provider {
|
||||
|
||||
describe("authOptions", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -82,6 +101,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if user not found", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
@@ -92,6 +112,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if user has no password stored", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
@@ -106,6 +127,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if password verification fails", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -120,6 +142,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should successfully login when credentials are valid", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const fakeUser = {
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
@@ -142,8 +165,64 @@ describe("authOptions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before credential validation", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
password: mockHashedPassword,
|
||||
emailVerified: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
} as any);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
await credentialsProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login);
|
||||
expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
|
||||
});
|
||||
|
||||
test("should block login when rate limit exceeded", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUserId,
|
||||
email: mockUser.email,
|
||||
password: mockHashedPassword,
|
||||
emailVerified: new Date(),
|
||||
twoFactorEnabled: false,
|
||||
} as any);
|
||||
|
||||
const credentials = { email: mockUser.email, password: mockPassword };
|
||||
|
||||
await credentialsProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 900,
|
||||
allowedPerInterval: 30,
|
||||
namespace: "auth:login",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Two-Factor Backup Code login", () => {
|
||||
test("should throw error if backup codes are missing", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "2fa@example.com",
|
||||
@@ -172,6 +251,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if token is invalid or user not found", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const credentials = { token: "badtoken" };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
@@ -180,6 +260,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if email is already verified", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { token: createToken(mockUser.id, mockUser.email) };
|
||||
@@ -190,6 +271,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should update user and verify email when token is valid", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
@@ -206,6 +288,70 @@ describe("authOptions", () => {
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
expect(result.emailVerified).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before token verification", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
emailVerified: null,
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
password: mockHashedPassword,
|
||||
backupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
|
||||
});
|
||||
|
||||
test("should block verification when rate limit exceeded", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
emailVerified: null,
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
password: mockHashedPassword,
|
||||
backupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 3600,
|
||||
allowedPerInterval: 10,
|
||||
namespace: "auth:verify",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Callbacks", () => {
|
||||
@@ -275,6 +421,7 @@ describe("authOptions", () => {
|
||||
const credentialsProvider = getProviderById("credentials");
|
||||
|
||||
test("should throw error if TOTP code is missing when 2FA is enabled", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "2fa@example.com",
|
||||
@@ -292,6 +439,7 @@ describe("authOptions", () => {
|
||||
});
|
||||
|
||||
test("should throw error if two factor secret is missing", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
email: "2fa@example.com",
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
shouldLogAuthFailure,
|
||||
verifyPassword,
|
||||
} from "@/modules/auth/lib/utils";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
|
||||
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
|
||||
@@ -52,6 +54,8 @@ export const authOptions: NextAuthOptions = {
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
await applyIPRateLimit(rateLimitConfigs.auth.login);
|
||||
|
||||
// Use email for rate limiting when available, fall back to "unknown_user" for credential validation
|
||||
const identifier = credentials?.email || "unknown_user"; // NOSONAR // We want to check for empty strings
|
||||
|
||||
@@ -221,6 +225,8 @@ export const authOptions: NextAuthOptions = {
|
||||
},
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
|
||||
|
||||
// For token verification, we can't rate limit effectively by token (single-use)
|
||||
// So we use a generic identifier for token abuse attempts
|
||||
const identifier = "email_verification_attempts";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { createBrevoCustomer, updateBrevoCustomer } from "./brevo";
|
||||
import { createBrevoCustomer, deleteBrevoCustomerByEmail, updateBrevoCustomer } from "./brevo";
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
BREVO_API_KEY: "mock_api_key",
|
||||
@@ -125,3 +125,63 @@ describe("updateBrevoCustomer", () => {
|
||||
expect(validateInputs).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteBrevoCustomerByEmail", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should return early if BREVO_API_KEY is not defined", async () => {
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
BREVO_API_KEY: undefined,
|
||||
BREVO_LIST_ID: "123",
|
||||
}));
|
||||
|
||||
const { deleteBrevoCustomerByEmail } = await import("./brevo"); // Re-import to get the mocked version
|
||||
|
||||
const result = await deleteBrevoCustomerByEmail({ email: "test@example.com" });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
expect(validateInputs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should log an error if fetch fails", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
|
||||
|
||||
await deleteBrevoCustomerByEmail({ email: "test@example.com" });
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error deleting user from Brevo");
|
||||
});
|
||||
|
||||
test("should log the error response if fetch status is not 204", async () => {
|
||||
const loggerSpy = vi.spyOn(logger, "error");
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(
|
||||
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
|
||||
);
|
||||
|
||||
await deleteBrevoCustomerByEmail({ email: "test@example.com" });
|
||||
|
||||
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error deleting user from Brevo");
|
||||
});
|
||||
|
||||
test("should successfully delete a Brevo customer", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 }));
|
||||
|
||||
await deleteBrevoCustomerByEmail({ email: "test@example.com" });
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.brevo.com/v3/contacts/test%40example.com?identifierType=email_id",
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"api-key": "mock_api_key",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,3 +95,28 @@ export const updateBrevoCustomer = async ({ id, email }: { id: string; email: TU
|
||||
logger.error(error, "Error updating user in Brevo");
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteBrevoCustomerByEmail = async ({ email }: { email: TUserEmail }) => {
|
||||
if (!BREVO_API_KEY) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encodedEmail = encodeURIComponent(email.toLowerCase());
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.brevo.com/v3/contacts/${encodedEmail}?identifierType=email_id`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"api-key": BREVO_API_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status !== 204) {
|
||||
const errorText = await res.text();
|
||||
logger.error({ errorText }, "Error deleting user from Brevo");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Error deleting user from Brevo");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("crypto", () => ({
|
||||
digest: vi.fn(() => "a".repeat(32)), // Mock 64-char hex string
|
||||
})),
|
||||
})),
|
||||
randomUUID: vi.fn(() => "test-uuid-123"),
|
||||
}));
|
||||
|
||||
// Mock Sentry
|
||||
@@ -42,8 +43,12 @@ vi.mock("@/lib/constants", () => ({
|
||||
}));
|
||||
|
||||
// Mock Redis client
|
||||
const { mockGetRedisClient } = vi.hoisted(() => ({
|
||||
mockGetRedisClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/cache/redis", () => ({
|
||||
default: null, // Intentionally simulate Redis unavailability to test fail-closed security behavior
|
||||
getRedisClient: mockGetRedisClient,
|
||||
}));
|
||||
|
||||
describe("Auth Utils", () => {
|
||||
@@ -109,11 +114,17 @@ describe("Auth Utils", () => {
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should always allow successful authentication logging", async () => {
|
||||
// This test doesn't need Redis to be available as it short-circuits for success
|
||||
mockGetRedisClient.mockResolvedValue(null);
|
||||
|
||||
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
|
||||
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
|
||||
});
|
||||
|
||||
test("should implement fail-closed behavior when Redis is unavailable", async () => {
|
||||
// Set Redis unavailable for this test
|
||||
mockGetRedisClient.mockResolvedValue(null);
|
||||
|
||||
const email = "rate-limit-test@example.com";
|
||||
|
||||
// When Redis is unavailable (mocked as null), the system fails closed for security.
|
||||
@@ -131,6 +142,254 @@ describe("Auth Utils", () => {
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 9th failure - blocked
|
||||
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 10th failure - blocked
|
||||
});
|
||||
|
||||
describe("Redis Available - All Branch Coverage", () => {
|
||||
let mockRedis: any;
|
||||
let mockMulti: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clear mocks first
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create comprehensive Redis mock
|
||||
mockMulti = {
|
||||
zRemRangeByScore: vi.fn().mockReturnThis(),
|
||||
zCard: vi.fn().mockReturnThis(),
|
||||
zAdd: vi.fn().mockReturnThis(),
|
||||
expire: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn(),
|
||||
};
|
||||
|
||||
mockRedis = {
|
||||
multi: vi.fn().mockReturnValue(mockMulti),
|
||||
zRange: vi.fn(),
|
||||
isReady: true, // Add isReady property
|
||||
};
|
||||
|
||||
// Reset the Redis mock for these specific tests
|
||||
mockGetRedisClient.mockReset();
|
||||
mockGetRedisClient.mockReturnValue(mockRedis); // Use mockReturnValue instead of mockResolvedValue
|
||||
});
|
||||
|
||||
test("should handle Redis transaction failure - !results branch", async () => {
|
||||
// Create fresh mock objects for this test
|
||||
const testMockMulti = {
|
||||
zRemRangeByScore: vi.fn().mockReturnThis(),
|
||||
zCard: vi.fn().mockReturnThis(),
|
||||
zAdd: vi.fn().mockReturnThis(),
|
||||
expire: vi.fn().mockReturnThis(),
|
||||
exec: vi.fn().mockResolvedValue(null), // Mock transaction returning null
|
||||
};
|
||||
|
||||
const testMockRedis = {
|
||||
multi: vi.fn().mockReturnValue(testMockMulti),
|
||||
zRange: vi.fn(),
|
||||
isReady: true,
|
||||
};
|
||||
|
||||
// Reset and setup mock for this specific test
|
||||
mockGetRedisClient.mockReset();
|
||||
mockGetRedisClient.mockReturnValue(testMockRedis);
|
||||
|
||||
const email = "transaction-failure@example.com";
|
||||
const result = await shouldLogAuthFailure(email, false);
|
||||
|
||||
// Function should return false when Redis transaction fails (fail-closed behavior)
|
||||
expect(result).toBe(false);
|
||||
expect(mockGetRedisClient).toHaveBeenCalled();
|
||||
expect(testMockRedis.multi).toHaveBeenCalled();
|
||||
expect(testMockMulti.zRemRangeByScore).toHaveBeenCalled();
|
||||
expect(testMockMulti.zCard).toHaveBeenCalled();
|
||||
expect(testMockMulti.zAdd).toHaveBeenCalled();
|
||||
expect(testMockMulti.expire).toHaveBeenCalled();
|
||||
expect(testMockMulti.exec).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should allow logging when currentCount <= AGGREGATION_THRESHOLD", async () => {
|
||||
// Mock Redis transaction returning count <= threshold (assuming threshold is 3)
|
||||
mockMulti.exec.mockResolvedValue([
|
||||
null, // zRemRangeByScore result
|
||||
2, // zCard result - below threshold
|
||||
null, // zAdd result
|
||||
null, // expire result
|
||||
]);
|
||||
|
||||
const email = "below-threshold@example.com";
|
||||
const result = await shouldLogAuthFailure(email, false);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockMulti.exec).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should allow logging when recentEntries.length === 0", async () => {
|
||||
// Mock Redis transaction returning count above threshold
|
||||
mockMulti.exec.mockResolvedValue([
|
||||
null, // zRemRangeByScore result
|
||||
5, // zCard result - above threshold
|
||||
null, // zAdd result
|
||||
null, // expire result
|
||||
]);
|
||||
|
||||
// Mock zRange returning empty array
|
||||
mockRedis.zRange.mockResolvedValue([]);
|
||||
|
||||
const email = "no-recent-entries@example.com";
|
||||
const result = await shouldLogAuthFailure(email, false);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockRedis.zRange).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:"), -10, -1);
|
||||
});
|
||||
|
||||
test("should allow logging on every 10th attempt - currentCount % 10 === 0", async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Mock Redis transaction returning count that is divisible by 10
|
||||
mockMulti.exec.mockResolvedValue([
|
||||
null, // zRemRangeByScore result
|
||||
10, // zCard result - 10th attempt
|
||||
null, // zAdd result
|
||||
null, // expire result
|
||||
]);
|
||||
|
||||
// Mock zRange returning recent entries
|
||||
mockRedis.zRange.mockResolvedValue([
|
||||
`${now - 30000}:uuid1`, // 30 seconds ago
|
||||
]);
|
||||
|
||||
const email = "tenth-attempt@example.com";
|
||||
const result = await shouldLogAuthFailure(email, false);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockRedis.zRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should allow logging after 1 minute gap - timeSinceLastLog > 60000", async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Mock Redis transaction returning count not divisible by 10
|
||||
mockMulti.exec.mockResolvedValue([
|
||||
null, // zRemRangeByScore result
|
||||
7, // zCard result - 7th attempt (not divisible by 10)
|
||||
null, // zAdd result
|
||||
null, // expire result
|
||||
]);
|
||||
|
||||
// Mock zRange returning entry older than 1 minute
|
||||
mockRedis.zRange.mockResolvedValue([
|
||||
`${now - 120000}:uuid1`, // 2 minutes ago
|
||||
]);
|
||||
|
||||
const email = "one-minute-gap@example.com";
|
||||
const result = await shouldLogAuthFailure(email, false);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(mockRedis.zRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should block logging when neither condition is met", async () => {
|
||||
const now = Date.now();
|
||||
|
||||
// Mock Redis transaction returning count not divisible by 10
|
||||
mockMulti.exec.mockResolvedValue([
|
||||
null, // zRemRangeByScore result
|
||||
7, // zCard result - 7th attempt (not divisible by 10)
|
||||
null, // zAdd result
|
||||
null, // expire result
|
||||
]);
|
||||
|
||||
// Mock zRange returning recent entry (less than 1 minute)
|
||||
mockRedis.zRange.mockResolvedValue([
|
||||
`${now - 30000}:uuid1`, // 30 seconds ago
|
||||
]);
|
||||
|
||||
const email = "blocked-logging@example.com";
|
||||
const result = await shouldLogAuthFailure(email, false);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockRedis.zRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle Redis operation errors gracefully", async () => {
|
||||
// Mock Redis multi throwing an error
|
||||
mockMulti.exec.mockRejectedValue(new Error("Redis operation failed"));
|
||||
|
||||
const email = "redis-error@example.com";
|
||||
const result = await shouldLogAuthFailure(email, false);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockMulti.exec).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle zRange errors gracefully", async () => {
|
||||
// Mock successful transaction but zRange failing
|
||||
mockMulti.exec.mockResolvedValue([
|
||||
null, // zRemRangeByScore result
|
||||
5, // zCard result - above threshold
|
||||
null, // zAdd result
|
||||
null, // expire result
|
||||
]);
|
||||
|
||||
mockRedis.zRange.mockRejectedValue(new Error("zRange failed"));
|
||||
|
||||
const email = "zrange-error@example.com";
|
||||
const result = await shouldLogAuthFailure(email, false);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockRedis.zRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle malformed timestamp in recent entries", async () => {
|
||||
// Mock Redis transaction returning count not divisible by 10
|
||||
mockMulti.exec.mockResolvedValue([
|
||||
null, // zRemRangeByScore result
|
||||
7, // zCard result - 7th attempt
|
||||
null, // zAdd result
|
||||
null, // expire result
|
||||
]);
|
||||
|
||||
// Mock zRange returning entry with malformed timestamp
|
||||
mockRedis.zRange.mockResolvedValue(["invalid-timestamp:uuid1"]);
|
||||
|
||||
const email = "malformed-timestamp@example.com";
|
||||
const result = await shouldLogAuthFailure(email, false);
|
||||
|
||||
// Should handle parseInt(NaN) gracefully and still make a decision
|
||||
expect(typeof result).toBe("boolean");
|
||||
expect(mockRedis.zRange).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should verify correct Redis key generation and operations", async () => {
|
||||
mockMulti.exec.mockResolvedValue([
|
||||
null, // zRemRangeByScore result
|
||||
2, // zCard result - below threshold
|
||||
null, // zAdd result
|
||||
null, // expire result
|
||||
]);
|
||||
|
||||
const email = "key-generation@example.com";
|
||||
await shouldLogAuthFailure(email, false);
|
||||
|
||||
// Verify correct Redis operations were called
|
||||
expect(mockRedis.multi).toHaveBeenCalled();
|
||||
expect(mockMulti.zRemRangeByScore).toHaveBeenCalledWith(
|
||||
expect.stringContaining("rate_limit:auth:"),
|
||||
0,
|
||||
expect.any(Number)
|
||||
);
|
||||
expect(mockMulti.zCard).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:"));
|
||||
expect(mockMulti.zAdd).toHaveBeenCalledWith(
|
||||
expect.stringContaining("rate_limit:auth:"),
|
||||
expect.objectContaining({
|
||||
score: expect.any(Number),
|
||||
value: expect.stringMatching(/^\d+:.+$/),
|
||||
})
|
||||
);
|
||||
expect(mockMulti.expire).toHaveBeenCalledWith(
|
||||
expect.stringContaining("rate_limit:auth:"),
|
||||
expect.any(Number)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Audit Logging Functions", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import redis from "@/modules/cache/redis";
|
||||
import { getRedisClient } from "@/modules/cache/redis";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
@@ -228,46 +228,47 @@ export const shouldLogAuthFailure = async (
|
||||
const rateLimitKey = `rate_limit:auth:${createAuditIdentifier(identifier, "ratelimit")}`;
|
||||
const now = Date.now();
|
||||
|
||||
if (redis) {
|
||||
try {
|
||||
// Use Redis for distributed rate limiting
|
||||
const multi = redis.multi();
|
||||
const windowStart = now - RATE_LIMIT_WINDOW;
|
||||
|
||||
// Remove expired entries and count recent failures
|
||||
multi.zremrangebyscore(rateLimitKey, 0, windowStart);
|
||||
multi.zcard(rateLimitKey);
|
||||
multi.zadd(rateLimitKey, now, `${now}:${randomUUID()}`);
|
||||
multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000));
|
||||
|
||||
const results = await multi.exec();
|
||||
if (!results) {
|
||||
throw new Error("Redis transaction failed");
|
||||
}
|
||||
|
||||
const currentCount = results[1][1] as number;
|
||||
|
||||
// Apply throttling logic
|
||||
if (currentCount <= AGGREGATION_THRESHOLD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we should log (every 10th or after 1 minute gap)
|
||||
const recentEntries = await redis.zrange(rateLimitKey, -10, -1);
|
||||
if (recentEntries.length === 0) return true;
|
||||
|
||||
const lastLogTime = parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
|
||||
const timeSinceLastLog = now - lastLogTime;
|
||||
|
||||
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
|
||||
} catch (error) {
|
||||
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
|
||||
// If Redis fails, do not log as Redis is required for audit logs
|
||||
try {
|
||||
// Get Redis client
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
|
||||
// If Redis not configured, do not log as Redis is required for audit logs
|
||||
|
||||
// Use Redis for distributed rate limiting
|
||||
const multi = redis.multi();
|
||||
const windowStart = now - RATE_LIMIT_WINDOW;
|
||||
|
||||
// Remove expired entries and count recent failures
|
||||
multi.zRemRangeByScore(rateLimitKey, 0, windowStart);
|
||||
multi.zCard(rateLimitKey);
|
||||
multi.zAdd(rateLimitKey, { score: now, value: `${now}:${randomUUID()}` });
|
||||
multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000));
|
||||
|
||||
const results = await multi.exec();
|
||||
if (!results) {
|
||||
throw new Error("Redis transaction failed");
|
||||
}
|
||||
|
||||
const currentCount = results[1] as number;
|
||||
|
||||
// Apply throttling logic
|
||||
if (currentCount <= AGGREGATION_THRESHOLD) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we should log (every 10th or after 1 minute gap)
|
||||
const recentEntries = await redis.zRange(rateLimitKey, -10, -1);
|
||||
if (recentEntries.length === 0) return true;
|
||||
|
||||
const lastLogTime = Number.parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
|
||||
const timeSinceLastLog = now - lastLogTime;
|
||||
|
||||
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
|
||||
} catch (error) {
|
||||
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
|
||||
// If Redis fails, do not log as Redis is required for audit logs
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -11,6 +11,8 @@ import { createUser, updateUser } from "@/modules/auth/lib/user";
|
||||
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
|
||||
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
|
||||
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
|
||||
@@ -177,6 +179,7 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
|
||||
"created",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.auth.signup);
|
||||
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
|
||||
|
||||
const hashedPassword = await hashPassword(parsedInput.password);
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
// Import mocked functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendVerificationEmail } from "@/modules/email";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { resendVerificationEmailAction } from "./actions";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
auth: {
|
||||
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/user", () => ({
|
||||
getUserByEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/email", () => ({
|
||||
sendVerificationEmail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
withAuditLogging: vi.fn((type, object, fn) => fn),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/action-client", () => ({
|
||||
actionClient: {
|
||||
schema: vi.fn().mockReturnThis(),
|
||||
action: vi.fn((fn) => fn),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("resendVerificationEmailAction", () => {
|
||||
const validInput = {
|
||||
email: "test@example.com",
|
||||
};
|
||||
|
||||
const mockUser = {
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
emailVerified: null, // Not verified
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
const mockVerifiedUser = {
|
||||
id: "user123",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date(),
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
const mockCtx = {
|
||||
auditLoggingCtx: {
|
||||
organizationId: "",
|
||||
userId: "",
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before processing verification email resend", async () => {
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
await resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
|
||||
expect(applyIPRateLimit).toHaveBeenCalledBefore(getUserByEmail as any);
|
||||
});
|
||||
|
||||
test("should throw rate limit error when limit exceeded", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any)
|
||||
).rejects.toThrow("Maximum number of requests reached. Please try again later.");
|
||||
|
||||
expect(getUserByEmail).not.toHaveBeenCalled();
|
||||
expect(sendVerificationEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
await resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 3600,
|
||||
allowedPerInterval: 10,
|
||||
namespace: "auth:verify",
|
||||
});
|
||||
});
|
||||
|
||||
test("should apply rate limiting even when user doesn't exist", async () => {
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any)
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Verification Email Resend Flow", () => {
|
||||
test("should send verification email when user exists and email is not verified", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
const result = await resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendVerificationEmail).toHaveBeenCalledWith(mockUser);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should return success without sending email when user email is already verified", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any);
|
||||
|
||||
const result = await resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendVerificationEmail).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError when user doesn't exist", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any)
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
|
||||
expect(sendVerificationEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Audit Logging", () => {
|
||||
test("should be wrapped with audit logging decorator", () => {
|
||||
// withAuditLogging is called at module load time to wrap the action
|
||||
// We just verify the mock was set up correctly
|
||||
expect(withAuditLogging).toBeDefined();
|
||||
});
|
||||
|
||||
test("should set audit context userId when sending verification email", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
|
||||
const testCtx = {
|
||||
auditLoggingCtx: {
|
||||
organizationId: "",
|
||||
userId: "",
|
||||
},
|
||||
};
|
||||
|
||||
await resendVerificationEmailAction({
|
||||
ctx: testCtx,
|
||||
parsedInput: validInput,
|
||||
} as any);
|
||||
|
||||
// The userId should be set in the audit context
|
||||
expect(testCtx.auditLoggingCtx.userId).toBe(mockUser.id);
|
||||
});
|
||||
|
||||
test("should not set audit context userId when email is already verified", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any);
|
||||
|
||||
const testCtx = {
|
||||
auditLoggingCtx: {
|
||||
organizationId: "",
|
||||
userId: "",
|
||||
},
|
||||
};
|
||||
|
||||
await resendVerificationEmailAction({
|
||||
ctx: testCtx,
|
||||
parsedInput: validInput,
|
||||
} as any);
|
||||
|
||||
// The userId should not be set since no email was sent
|
||||
expect(testCtx.auditLoggingCtx.userId).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling", () => {
|
||||
test("should propagate rate limiting errors", async () => {
|
||||
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
|
||||
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any)
|
||||
).rejects.toThrow("Maximum number of requests reached. Please try again later.");
|
||||
});
|
||||
|
||||
test("should handle user lookup errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
|
||||
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any)
|
||||
).rejects.toThrow("Database error");
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle email sending errors after rate limiting", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(sendVerificationEmail).mockRejectedValue(new Error("Email service error"));
|
||||
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any)
|
||||
).rejects.toThrow("Email service error");
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
expect(getUserByEmail).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Input Validation", () => {
|
||||
test("should handle empty email input", async () => {
|
||||
const invalidInput = { email: "" };
|
||||
|
||||
// This would be caught by the Zod schema validation in the actual action
|
||||
// but we test the behavior if it somehow gets through
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: invalidInput,
|
||||
} as any)
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should handle malformed email input", async () => {
|
||||
const invalidInput = { email: "invalid-email" };
|
||||
|
||||
// This would be caught by the Zod schema validation in the actual action
|
||||
// but we test the behavior if it somehow gets through
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: invalidInput,
|
||||
} as any)
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Considerations", () => {
|
||||
test("should always apply rate limiting regardless of user existence", async () => {
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any)
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not leak information about user existence through different error messages", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.mocked(getUserByEmail).mockResolvedValue(null);
|
||||
|
||||
// Both non-existent users should throw the same ResourceNotFoundError
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: validInput,
|
||||
} as any)
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
|
||||
const anotherEmail = { email: "another@example.com" };
|
||||
await expect(
|
||||
resendVerificationEmailAction({
|
||||
ctx: mockCtx,
|
||||
parsedInput: anotherEmail,
|
||||
} as any)
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getUserByEmail } from "@/modules/auth/lib/user";
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { sendVerificationEmail } from "@/modules/email";
|
||||
import { z } from "zod";
|
||||
@@ -18,6 +20,8 @@ export const resendVerificationEmailAction = actionClient.schema(ZResendVerifica
|
||||
"verificationEmailSent",
|
||||
"user",
|
||||
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
|
||||
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
|
||||
|
||||
const user = await getUserByEmail(parsedInput.email);
|
||||
if (!user) {
|
||||
throw new ResourceNotFoundError("user", parsedInput.email);
|
||||
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createCacheKey, parseCacheKey, validateCacheKey } from "./cacheKeys";
|
||||
|
||||
describe("cacheKeys", () => {
|
||||
describe("createCacheKey", () => {
|
||||
describe("environment keys", () => {
|
||||
test("should create environment state key", () => {
|
||||
const key = createCacheKey.environment.state("env123");
|
||||
expect(key).toBe("fb:env:env123:state");
|
||||
});
|
||||
|
||||
test("should create environment surveys key", () => {
|
||||
const key = createCacheKey.environment.surveys("env456");
|
||||
expect(key).toBe("fb:env:env456:surveys");
|
||||
});
|
||||
|
||||
test("should create environment actionClasses key", () => {
|
||||
const key = createCacheKey.environment.actionClasses("env789");
|
||||
expect(key).toBe("fb:env:env789:action_classes");
|
||||
});
|
||||
|
||||
test("should create environment config key", () => {
|
||||
const key = createCacheKey.environment.config("env101");
|
||||
expect(key).toBe("fb:env:env101:config");
|
||||
});
|
||||
|
||||
test("should create environment segments key", () => {
|
||||
const key = createCacheKey.environment.segments("env202");
|
||||
expect(key).toBe("fb:env:env202:segments");
|
||||
});
|
||||
});
|
||||
|
||||
describe("organization keys", () => {
|
||||
test("should create organization billing key", () => {
|
||||
const key = createCacheKey.organization.billing("org123");
|
||||
expect(key).toBe("fb:org:org123:billing");
|
||||
});
|
||||
|
||||
test("should create organization environments key", () => {
|
||||
const key = createCacheKey.organization.environments("org456");
|
||||
expect(key).toBe("fb:org:org456:environments");
|
||||
});
|
||||
|
||||
test("should create organization config key", () => {
|
||||
const key = createCacheKey.organization.config("org789");
|
||||
expect(key).toBe("fb:org:org789:config");
|
||||
});
|
||||
|
||||
test("should create organization limits key", () => {
|
||||
const key = createCacheKey.organization.limits("org101");
|
||||
expect(key).toBe("fb:org:org101:limits");
|
||||
});
|
||||
});
|
||||
|
||||
describe("license keys", () => {
|
||||
test("should create license status key", () => {
|
||||
const key = createCacheKey.license.status("org123");
|
||||
expect(key).toBe("fb:license:org123:status");
|
||||
});
|
||||
|
||||
test("should create license features key", () => {
|
||||
const key = createCacheKey.license.features("org456");
|
||||
expect(key).toBe("fb:license:org456:features");
|
||||
});
|
||||
|
||||
test("should create license usage key", () => {
|
||||
const key = createCacheKey.license.usage("org789");
|
||||
expect(key).toBe("fb:license:org789:usage");
|
||||
});
|
||||
|
||||
test("should create license check key", () => {
|
||||
const key = createCacheKey.license.check("org123", "feature-x");
|
||||
expect(key).toBe("fb:license:org123:check:feature-x");
|
||||
});
|
||||
|
||||
test("should create license previous_result key", () => {
|
||||
const key = createCacheKey.license.previous_result("org456");
|
||||
expect(key).toBe("fb:license:org456:previous_result");
|
||||
});
|
||||
});
|
||||
|
||||
describe("user keys", () => {
|
||||
test("should create user profile key", () => {
|
||||
const key = createCacheKey.user.profile("user123");
|
||||
expect(key).toBe("fb:user:user123:profile");
|
||||
});
|
||||
|
||||
test("should create user preferences key", () => {
|
||||
const key = createCacheKey.user.preferences("user456");
|
||||
expect(key).toBe("fb:user:user456:preferences");
|
||||
});
|
||||
|
||||
test("should create user organizations key", () => {
|
||||
const key = createCacheKey.user.organizations("user789");
|
||||
expect(key).toBe("fb:user:user789:organizations");
|
||||
});
|
||||
|
||||
test("should create user permissions key", () => {
|
||||
const key = createCacheKey.user.permissions("user123", "org456");
|
||||
expect(key).toBe("fb:user:user123:org:org456:permissions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("project keys", () => {
|
||||
test("should create project config key", () => {
|
||||
const key = createCacheKey.project.config("proj123");
|
||||
expect(key).toBe("fb:project:proj123:config");
|
||||
});
|
||||
|
||||
test("should create project environments key", () => {
|
||||
const key = createCacheKey.project.environments("proj456");
|
||||
expect(key).toBe("fb:project:proj456:environments");
|
||||
});
|
||||
|
||||
test("should create project surveys key", () => {
|
||||
const key = createCacheKey.project.surveys("proj789");
|
||||
expect(key).toBe("fb:project:proj789:surveys");
|
||||
});
|
||||
});
|
||||
|
||||
describe("survey keys", () => {
|
||||
test("should create survey metadata key", () => {
|
||||
const key = createCacheKey.survey.metadata("survey123");
|
||||
expect(key).toBe("fb:survey:survey123:metadata");
|
||||
});
|
||||
|
||||
test("should create survey responses key", () => {
|
||||
const key = createCacheKey.survey.responses("survey456");
|
||||
expect(key).toBe("fb:survey:survey456:responses");
|
||||
});
|
||||
|
||||
test("should create survey stats key", () => {
|
||||
const key = createCacheKey.survey.stats("survey789");
|
||||
expect(key).toBe("fb:survey:survey789:stats");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session keys", () => {
|
||||
test("should create session data key", () => {
|
||||
const key = createCacheKey.session.data("session123");
|
||||
expect(key).toBe("fb:session:session123:data");
|
||||
});
|
||||
|
||||
test("should create session permissions key", () => {
|
||||
const key = createCacheKey.session.permissions("session456");
|
||||
expect(key).toBe("fb:session:session456:permissions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rate limit keys", () => {
|
||||
test("should create rate limit api key", () => {
|
||||
const key = createCacheKey.rateLimit.api("api-key-123", "endpoint-v1");
|
||||
expect(key).toBe("fb:rate_limit:api:api-key-123:endpoint-v1");
|
||||
});
|
||||
|
||||
test("should create rate limit login key", () => {
|
||||
const key = createCacheKey.rateLimit.login("user-ip-hash");
|
||||
expect(key).toBe("fb:rate_limit:login:user-ip-hash");
|
||||
});
|
||||
|
||||
test("should create rate limit core key", () => {
|
||||
const key = createCacheKey.rateLimit.core("auth:login", "user123", 1703174400);
|
||||
expect(key).toBe("fb:rate_limit:auth:login:user123:1703174400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom keys", () => {
|
||||
test("should create custom key without subResource", () => {
|
||||
const key = createCacheKey.custom("temp", "identifier123");
|
||||
expect(key).toBe("fb:temp:identifier123");
|
||||
});
|
||||
|
||||
test("should create custom key with subResource", () => {
|
||||
const key = createCacheKey.custom("analytics", "user456", "daily-stats");
|
||||
expect(key).toBe("fb:analytics:user456:daily-stats");
|
||||
});
|
||||
|
||||
test("should work with all valid namespaces", () => {
|
||||
const validNamespaces = ["temp", "analytics", "webhook", "integration", "backup"];
|
||||
|
||||
validNamespaces.forEach((namespace) => {
|
||||
const key = createCacheKey.custom(namespace, "test-id");
|
||||
expect(key).toBe(`fb:${namespace}:test-id`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid namespace", () => {
|
||||
expect(() => createCacheKey.custom("invalid", "identifier")).toThrow(
|
||||
"Invalid cache namespace: invalid. Use: temp, analytics, webhook, integration, backup"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error for empty namespace", () => {
|
||||
expect(() => createCacheKey.custom("", "identifier")).toThrow(
|
||||
"Invalid cache namespace: . Use: temp, analytics, webhook, integration, backup"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateCacheKey", () => {
|
||||
test("should validate correct cache keys", () => {
|
||||
const validKeys = [
|
||||
"fb:env:env123:state",
|
||||
"fb:user:user456:profile",
|
||||
"fb:org:org789:billing",
|
||||
"fb:rate_limit:api:key123:endpoint",
|
||||
"fb:custom:namespace:identifier:sub:resource",
|
||||
];
|
||||
|
||||
validKeys.forEach((key) => {
|
||||
expect(validateCacheKey(key)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject keys without fb prefix", () => {
|
||||
const invalidKeys = ["env:env123:state", "user:user456:profile", "redis:key:value", "cache:item:data"];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(validateCacheKey(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject keys with insufficient parts", () => {
|
||||
const invalidKeys = ["fb:", "fb:env", "fb:env:", "fb:user:user123:"];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(validateCacheKey(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject keys with empty parts", () => {
|
||||
const invalidKeys = ["fb::env123:state", "fb:env::state", "fb:env:env123:", "fb:user::profile"];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(validateCacheKey(key)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("should validate minimum valid key", () => {
|
||||
expect(validateCacheKey("fb:a:b")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCacheKey", () => {
|
||||
test("should parse basic cache key", () => {
|
||||
const result = parseCacheKey("fb:env:env123:state");
|
||||
|
||||
expect(result).toEqual({
|
||||
prefix: "fb",
|
||||
resource: "env",
|
||||
identifier: "env123",
|
||||
subResource: "state",
|
||||
full: "fb:env:env123:state",
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse key without subResource", () => {
|
||||
const result = parseCacheKey("fb:user:user123");
|
||||
|
||||
expect(result).toEqual({
|
||||
prefix: "fb",
|
||||
resource: "user",
|
||||
identifier: "user123",
|
||||
subResource: undefined,
|
||||
full: "fb:user:user123",
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse key with multiple subResource parts", () => {
|
||||
const result = parseCacheKey("fb:user:user123:org:org456:permissions");
|
||||
|
||||
expect(result).toEqual({
|
||||
prefix: "fb",
|
||||
resource: "user",
|
||||
identifier: "user123",
|
||||
subResource: "org:org456:permissions",
|
||||
full: "fb:user:user123:org:org456:permissions",
|
||||
});
|
||||
});
|
||||
|
||||
test("should parse rate limit key with timestamp", () => {
|
||||
const result = parseCacheKey("fb:rate_limit:auth:login:user123:1703174400");
|
||||
|
||||
expect(result).toEqual({
|
||||
prefix: "fb",
|
||||
resource: "rate_limit",
|
||||
identifier: "auth",
|
||||
subResource: "login:user123:1703174400",
|
||||
full: "fb:rate_limit:auth:login:user123:1703174400",
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid cache key", () => {
|
||||
const invalidKeys = ["invalid:key:format", "fb:env", "fb::env123:state", "redis:user:profile"];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(() => parseCacheKey(key)).toThrow(`Invalid cache key format: ${key}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cache key patterns and consistency", () => {
|
||||
test("all environment keys should follow same pattern", () => {
|
||||
const envId = "test-env-123";
|
||||
const envKeys = [
|
||||
createCacheKey.environment.state(envId),
|
||||
createCacheKey.environment.surveys(envId),
|
||||
createCacheKey.environment.actionClasses(envId),
|
||||
createCacheKey.environment.config(envId),
|
||||
createCacheKey.environment.segments(envId),
|
||||
];
|
||||
|
||||
envKeys.forEach((key) => {
|
||||
expect(key).toMatch(/^fb:env:test-env-123:.+$/);
|
||||
expect(validateCacheKey(key)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("all organization keys should follow same pattern", () => {
|
||||
const orgId = "test-org-456";
|
||||
const orgKeys = [
|
||||
createCacheKey.organization.billing(orgId),
|
||||
createCacheKey.organization.environments(orgId),
|
||||
createCacheKey.organization.config(orgId),
|
||||
createCacheKey.organization.limits(orgId),
|
||||
];
|
||||
|
||||
orgKeys.forEach((key) => {
|
||||
expect(key).toMatch(/^fb:org:test-org-456:.+$/);
|
||||
expect(validateCacheKey(key)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("all generated keys should be parseable", () => {
|
||||
const testKeys = [
|
||||
createCacheKey.environment.state("env123"),
|
||||
createCacheKey.user.profile("user456"),
|
||||
createCacheKey.organization.billing("org789"),
|
||||
createCacheKey.survey.metadata("survey101"),
|
||||
createCacheKey.session.data("session202"),
|
||||
createCacheKey.rateLimit.core("auth:login", "user303", 1703174400),
|
||||
createCacheKey.custom("temp", "temp404", "cleanup"),
|
||||
];
|
||||
|
||||
testKeys.forEach((key) => {
|
||||
expect(() => parseCacheKey(key)).not.toThrow();
|
||||
|
||||
const parsed = parseCacheKey(key);
|
||||
expect(parsed.prefix).toBe("fb");
|
||||
expect(parsed.full).toBe(key);
|
||||
expect(parsed.resource).toBeTruthy();
|
||||
expect(parsed.identifier).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test("keys should be unique across different resources", () => {
|
||||
const keys = [
|
||||
createCacheKey.environment.state("same-id"),
|
||||
createCacheKey.user.profile("same-id"),
|
||||
createCacheKey.organization.billing("same-id"),
|
||||
createCacheKey.project.config("same-id"),
|
||||
createCacheKey.survey.metadata("same-id"),
|
||||
];
|
||||
|
||||
const uniqueKeys = new Set(keys);
|
||||
expect(uniqueKeys.size).toBe(keys.length);
|
||||
});
|
||||
|
||||
test("namespace validation should prevent collisions", () => {
|
||||
// These should not throw (valid namespaces)
|
||||
expect(() => createCacheKey.custom("temp", "id")).not.toThrow();
|
||||
expect(() => createCacheKey.custom("analytics", "id")).not.toThrow();
|
||||
|
||||
// These should throw (reserved/invalid namespaces)
|
||||
expect(() => createCacheKey.custom("env", "id")).toThrow();
|
||||
expect(() => createCacheKey.custom("user", "id")).toThrow();
|
||||
expect(() => createCacheKey.custom("org", "id")).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
+3
@@ -11,6 +11,7 @@ import "server-only";
|
||||
* - Predictable invalidation patterns
|
||||
* - Multi-tenant safe
|
||||
*/
|
||||
|
||||
export const createCacheKey = {
|
||||
// Environment-related keys
|
||||
environment: {
|
||||
@@ -71,6 +72,8 @@ export const createCacheKey = {
|
||||
rateLimit: {
|
||||
api: (identifier: string, endpoint: string) => `fb:rate_limit:api:${identifier}:${endpoint}`,
|
||||
login: (identifier: string) => `fb:rate_limit:login:${identifier}`,
|
||||
core: (namespace: string, identifier: string, windowStart: number) =>
|
||||
`fb:rate_limit:${namespace}:${identifier}:${windowStart}`,
|
||||
},
|
||||
|
||||
// Custom keys with validation
|
||||
|
||||
Vendored
+261
@@ -0,0 +1,261 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the redis client
|
||||
const mockRedisClient = {
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
on: vi.fn(),
|
||||
isReady: true,
|
||||
get: vi.fn(),
|
||||
set: vi.fn(),
|
||||
del: vi.fn(),
|
||||
exists: vi.fn(),
|
||||
expire: vi.fn(),
|
||||
ttl: vi.fn(),
|
||||
keys: vi.fn(),
|
||||
flushall: vi.fn(),
|
||||
};
|
||||
|
||||
vi.mock("redis", () => ({
|
||||
createClient: vi.fn(() => mockRedisClient),
|
||||
}));
|
||||
|
||||
// Mock crypto for UUID generation
|
||||
vi.mock("crypto", () => ({
|
||||
randomUUID: vi.fn(() => "test-uuid-123"),
|
||||
}));
|
||||
|
||||
describe("Redis module", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset environment variable
|
||||
process.env.REDIS_URL = "redis://localhost:6379";
|
||||
|
||||
// Reset isReady state
|
||||
mockRedisClient.isReady = true;
|
||||
|
||||
// Make connect resolve successfully
|
||||
mockRedisClient.connect.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules();
|
||||
process.env.REDIS_URL = undefined;
|
||||
});
|
||||
|
||||
describe("Module initialization", () => {
|
||||
test("should create Redis client when REDIS_URL is set", async () => {
|
||||
const { createClient } = await import("redis");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
expect(createClient).toHaveBeenCalledWith({
|
||||
url: "redis://localhost:6379",
|
||||
socket: {
|
||||
reconnectStrategy: expect.any(Function),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should not create Redis client when REDIS_URL is not set", async () => {
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
const { createClient } = await import("redis");
|
||||
|
||||
// Clear the module cache and re-import
|
||||
vi.resetModules();
|
||||
await import("./redis");
|
||||
|
||||
expect(createClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should set up event listeners", async () => {
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
expect(mockRedisClient.on).toHaveBeenCalledWith("error", expect.any(Function));
|
||||
expect(mockRedisClient.on).toHaveBeenCalledWith("connect", expect.any(Function));
|
||||
expect(mockRedisClient.on).toHaveBeenCalledWith("reconnecting", expect.any(Function));
|
||||
expect(mockRedisClient.on).toHaveBeenCalledWith("ready", expect.any(Function));
|
||||
});
|
||||
|
||||
test("should attempt initial connection", async () => {
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
expect(mockRedisClient.connect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRedisClient", () => {
|
||||
test("should return client when ready", async () => {
|
||||
mockRedisClient.isReady = true;
|
||||
|
||||
const { getRedisClient } = await import("./redis");
|
||||
const client = getRedisClient();
|
||||
|
||||
expect(client).toBe(mockRedisClient);
|
||||
});
|
||||
|
||||
test("should return null when client is not ready", async () => {
|
||||
mockRedisClient.isReady = false;
|
||||
|
||||
const { getRedisClient } = await import("./redis");
|
||||
const client = getRedisClient();
|
||||
|
||||
expect(client).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null when no REDIS_URL is set", async () => {
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
vi.resetModules();
|
||||
const { getRedisClient } = await import("./redis");
|
||||
const client = getRedisClient();
|
||||
|
||||
expect(client).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("disconnectRedis", () => {
|
||||
test("should disconnect the client", async () => {
|
||||
const { disconnectRedis } = await import("./redis");
|
||||
|
||||
await disconnectRedis();
|
||||
|
||||
expect(mockRedisClient.disconnect).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle case when client is null", async () => {
|
||||
delete process.env.REDIS_URL;
|
||||
|
||||
vi.resetModules();
|
||||
const { disconnectRedis } = await import("./redis");
|
||||
|
||||
await expect(disconnectRedis()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Reconnection strategy", () => {
|
||||
test("should configure reconnection strategy properly", async () => {
|
||||
const { createClient } = await import("redis");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
const createClientCall = vi.mocked(createClient).mock.calls[0];
|
||||
const config = createClientCall[0] as any;
|
||||
|
||||
expect(config.socket.reconnectStrategy).toBeDefined();
|
||||
expect(typeof config.socket.reconnectStrategy).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Event handlers", () => {
|
||||
test("should log error events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the error event handler
|
||||
const errorCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "error");
|
||||
const errorHandler = errorCall?.[1];
|
||||
|
||||
const testError = new Error("Test error");
|
||||
errorHandler?.(testError);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith("Redis client error:", testError);
|
||||
});
|
||||
|
||||
test("should log connect events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the connect event handler
|
||||
const connectCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "connect");
|
||||
const connectHandler = connectCall?.[1];
|
||||
|
||||
connectHandler?.();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("Redis client connected");
|
||||
});
|
||||
|
||||
test("should log reconnecting events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the reconnecting event handler
|
||||
const reconnectingCall = vi
|
||||
.mocked(mockRedisClient.on)
|
||||
.mock.calls.find((call) => call[0] === "reconnecting");
|
||||
const reconnectingHandler = reconnectingCall?.[1];
|
||||
|
||||
reconnectingHandler?.();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("Redis client reconnecting");
|
||||
});
|
||||
|
||||
test("should log ready events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the ready event handler
|
||||
const readyCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "ready");
|
||||
const readyHandler = readyCall?.[1];
|
||||
|
||||
readyHandler?.();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("Redis client ready");
|
||||
});
|
||||
|
||||
test("should log end events", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
// Re-import the module to trigger initialization
|
||||
await import("./redis");
|
||||
|
||||
// Find the end event handler
|
||||
const endCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "end");
|
||||
const endHandler = endCall?.[1];
|
||||
|
||||
endHandler?.();
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith("Redis client disconnected");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Connection failure handling", () => {
|
||||
test("should handle initial connection failure", async () => {
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
|
||||
const connectionError = new Error("Connection failed");
|
||||
mockRedisClient.connect.mockRejectedValue(connectionError);
|
||||
|
||||
vi.resetModules();
|
||||
await import("./redis");
|
||||
|
||||
// Wait for the connection promise to resolve
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith("Initial Redis connection failed:", connectionError);
|
||||
});
|
||||
});
|
||||
});
|
||||
Vendored
+64
-6
@@ -1,11 +1,69 @@
|
||||
import { REDIS_URL } from "@/lib/constants";
|
||||
import Redis from "ioredis";
|
||||
import { createClient } from "redis";
|
||||
import { logger } from "@formbricks/logger";
|
||||
|
||||
const redis = REDIS_URL ? new Redis(REDIS_URL) : null;
|
||||
type RedisClient = ReturnType<typeof createClient>;
|
||||
|
||||
if (!redis) {
|
||||
logger.info("REDIS_URL is not set");
|
||||
const REDIS_URL = process.env.REDIS_URL;
|
||||
|
||||
let client: RedisClient | null = null;
|
||||
|
||||
if (REDIS_URL) {
|
||||
client = createClient({
|
||||
url: REDIS_URL,
|
||||
socket: {
|
||||
reconnectStrategy: (retries) => {
|
||||
logger.info(`Redis reconnection attempt ${retries}`);
|
||||
|
||||
// For the first 5 attempts, use exponential backoff with max 5 second delay
|
||||
if (retries <= 5) {
|
||||
return Math.min(retries * 1000, 5000);
|
||||
}
|
||||
|
||||
// After 5 attempts, use a longer delay but never give up
|
||||
// This ensures the client keeps trying to reconnect when Redis comes back online
|
||||
logger.info("Redis reconnection using extended delay (30 seconds)");
|
||||
return 30000; // 30 second delay for persistent reconnection attempts
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
client.on("error", (err) => {
|
||||
logger.error("Redis client error:", err);
|
||||
});
|
||||
|
||||
client.on("connect", () => {
|
||||
logger.info("Redis client connected");
|
||||
});
|
||||
|
||||
client.on("reconnecting", () => {
|
||||
logger.info("Redis client reconnecting");
|
||||
});
|
||||
|
||||
client.on("ready", () => {
|
||||
logger.info("Redis client ready");
|
||||
});
|
||||
|
||||
client.on("end", () => {
|
||||
logger.info("Redis client disconnected");
|
||||
});
|
||||
|
||||
// Connect immediately
|
||||
client.connect().catch((err) => {
|
||||
logger.error("Initial Redis connection failed:", err);
|
||||
});
|
||||
}
|
||||
|
||||
export default redis;
|
||||
export const getRedisClient = (): RedisClient | null => {
|
||||
if (!client?.isReady) {
|
||||
logger.warn("Redis client not ready, operations will be skipped");
|
||||
return null;
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
export const disconnectRedis = async (): Promise<void> => {
|
||||
if (client) {
|
||||
await client.disconnect();
|
||||
client = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
// Import modules after mocking
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { applyIPRateLimit, applyRateLimit, getClientIdentifier } from "./helpers";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
|
||||
// Mock all dependencies
|
||||
vi.mock("@/lib/utils/client-ip", () => ({
|
||||
getClientIpFromHeaders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/hash-string", () => ({
|
||||
hashString: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("helpers", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getClientIdentifier", () => {
|
||||
test("should get client IP and return hashed identifier", async () => {
|
||||
const mockIp = "192.168.1.1";
|
||||
const mockHashedIp = "abc123hashedip";
|
||||
|
||||
(getClientIpFromHeaders as any).mockResolvedValue(mockIp);
|
||||
(hashString as any).mockReturnValue(mockHashedIp);
|
||||
|
||||
const result = await getClientIdentifier();
|
||||
|
||||
expect(getClientIpFromHeaders).toHaveBeenCalledOnce();
|
||||
expect(hashString).toHaveBeenCalledWith(mockIp);
|
||||
expect(result).toBe(mockHashedIp);
|
||||
|
||||
// Verify no error was logged on successful hashing
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle IP retrieval errors", async () => {
|
||||
const mockError = new Error("Failed to get IP");
|
||||
(getClientIpFromHeaders as any).mockRejectedValue(mockError);
|
||||
|
||||
await expect(getClientIdentifier()).rejects.toThrow("Failed to get IP");
|
||||
});
|
||||
|
||||
test("should handle hashing errors with proper logging", async () => {
|
||||
const mockIp = "192.168.1.1";
|
||||
const originalError = new Error("Hashing failed");
|
||||
|
||||
(getClientIpFromHeaders as any).mockResolvedValue(mockIp);
|
||||
(hashString as any).mockImplementation(() => {
|
||||
throw originalError;
|
||||
});
|
||||
|
||||
await expect(getClientIdentifier()).rejects.toThrow("Failed to hash IP");
|
||||
|
||||
// Verify that the error was logged with proper context
|
||||
expect(logger.error).toHaveBeenCalledWith("Failed to hash IP", { error: originalError });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyRateLimit", () => {
|
||||
const mockConfig = {
|
||||
interval: 300,
|
||||
allowedPerInterval: 5,
|
||||
namespace: "test",
|
||||
};
|
||||
|
||||
const mockIdentifier = "test-identifier";
|
||||
|
||||
test("should allow request when rate limit check passes", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toBeUndefined();
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
|
||||
test("should throw error when rate limit is exceeded", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: false }));
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
|
||||
test("should throw error when rate limit check fails", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(err("Redis connection failed"));
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
|
||||
test("should throw error when rate limit check throws exception", async () => {
|
||||
const mockError = new Error("Unexpected error");
|
||||
(checkRateLimit as any).mockRejectedValue(mockError);
|
||||
|
||||
await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow("Unexpected error");
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
|
||||
});
|
||||
|
||||
test("should work with different configurations", async () => {
|
||||
const customConfig = {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "api:v1",
|
||||
};
|
||||
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toBeUndefined();
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier");
|
||||
});
|
||||
|
||||
test("should work with different identifiers", async () => {
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"];
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
await expect(applyRateLimit(mockConfig, identifier)).resolves.toBeUndefined();
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier);
|
||||
}
|
||||
|
||||
expect(checkRateLimit).toHaveBeenCalledTimes(identifiers.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyIPRateLimit", () => {
|
||||
test("should be a convenience function that gets IP and applies rate limit", async () => {
|
||||
// This is an integration test - the function calls getClientIdentifier internally
|
||||
// and then calls applyRateLimit, which we've already tested extensively
|
||||
const mockConfig = {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "test:page",
|
||||
};
|
||||
|
||||
// Mock the IP getting functions
|
||||
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
|
||||
(hashString as any).mockReturnValue("hashed-ip-123");
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
|
||||
|
||||
await expect(applyIPRateLimit(mockConfig)).resolves.toBeUndefined();
|
||||
|
||||
expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1);
|
||||
expect(hashString).toHaveBeenCalledWith("192.168.1.1");
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, "hashed-ip-123");
|
||||
});
|
||||
|
||||
test("should propagate errors from getClientIdentifier", async () => {
|
||||
const mockConfig = {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "test:page",
|
||||
};
|
||||
|
||||
(getClientIpFromHeaders as any).mockRejectedValue(new Error("IP fetch failed"));
|
||||
|
||||
await expect(applyIPRateLimit(mockConfig)).rejects.toThrow("IP fetch failed");
|
||||
});
|
||||
|
||||
test("should propagate rate limit exceeded errors", async () => {
|
||||
const mockConfig = {
|
||||
interval: 3600,
|
||||
allowedPerInterval: 100,
|
||||
namespace: "test:page",
|
||||
};
|
||||
|
||||
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
|
||||
(hashString as any).mockReturnValue("hashed-ip-123");
|
||||
(checkRateLimit as any).mockResolvedValue(ok({ allowed: false }));
|
||||
|
||||
await expect(applyIPRateLimit(mockConfig)).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
import { type TRateLimitConfig } from "./types/rate-limit";
|
||||
|
||||
/**
|
||||
* Get client identifier for rate limiting with IP hashing
|
||||
* Used when the user is not authenticated or the api is called from the client
|
||||
*
|
||||
* @returns {Promise<string>} Hashed IP address for rate limiting
|
||||
* @throws {Error} When IP hashing fails due to invalid IP format or hashing algorithm issues
|
||||
*/
|
||||
export const getClientIdentifier = async (): Promise<string> => {
|
||||
const ip = await getClientIpFromHeaders();
|
||||
|
||||
try {
|
||||
return hashString(ip);
|
||||
} catch (error) {
|
||||
const errorMessage = "Failed to hash IP";
|
||||
logger.error(errorMessage, { error });
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic rate limit application function
|
||||
*
|
||||
* @param config - Rate limit configuration
|
||||
* @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.)
|
||||
* @throws {Error} When rate limit is exceeded or rate limiting system fails
|
||||
*/
|
||||
export const applyRateLimit = async (config: TRateLimitConfig, identifier: string): Promise<void> => {
|
||||
const result = await checkRateLimit(config, identifier);
|
||||
|
||||
if (!result.ok || !result.data.allowed) {
|
||||
throw new TooManyRequestsError("Maximum number of requests reached. Please try again later.");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply IP-based rate limiting for unauthenticated requests
|
||||
* Generic function for IP-based rate limiting in authentication flows and public pages
|
||||
*
|
||||
* @param config - Rate limit configuration to apply
|
||||
* @throws {Error} When rate limit is exceeded or IP hashing fails
|
||||
*/
|
||||
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<void> => {
|
||||
const identifier = await getClientIdentifier();
|
||||
await applyRateLimit(config, identifier);
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { ZRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { applyRateLimit } from "./helpers";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
import { rateLimitConfigs } from "./rate-limit-configs";
|
||||
|
||||
const { mockEval, mockRedisClient, mockGetRedisClient } = vi.hoisted(() => {
|
||||
const _mockEval = vi.fn();
|
||||
const _mockRedisClient = { eval: _mockEval } as any;
|
||||
const _mockGetRedisClient = vi.fn().mockReturnValue(_mockRedisClient);
|
||||
return { mockEval: _mockEval, mockRedisClient: _mockRedisClient, mockGetRedisClient: _mockGetRedisClient };
|
||||
});
|
||||
|
||||
// Mock dependencies for integration tests
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
SENTRY_DSN: "https://test@sentry.io/test",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/cache/redis", () => ({
|
||||
getRedisClient: mockGetRedisClient,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
addBreadcrumb: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/cache/lib/cacheKeys", () => ({
|
||||
createCacheKey: {
|
||||
rateLimit: {
|
||||
core: vi.fn(
|
||||
(namespace, identifier, windowStart) => `fb:rate_limit:${namespace}:${identifier}:${windowStart}`
|
||||
),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("rateLimitConfigs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the mock to return our mock client
|
||||
mockGetRedisClient.mockReturnValue(mockRedisClient);
|
||||
});
|
||||
|
||||
describe("Configuration Structure", () => {
|
||||
test("should have all required config groups", () => {
|
||||
expect(rateLimitConfigs).toHaveProperty("auth");
|
||||
expect(rateLimitConfigs).toHaveProperty("api");
|
||||
expect(rateLimitConfigs).toHaveProperty("actions");
|
||||
expect(rateLimitConfigs).toHaveProperty("share");
|
||||
});
|
||||
|
||||
test("should have all auth configurations", () => {
|
||||
const authConfigs = Object.keys(rateLimitConfigs.auth);
|
||||
expect(authConfigs).toEqual(["login", "signup", "forgotPassword", "verifyEmail"]);
|
||||
});
|
||||
|
||||
test("should have all API configurations", () => {
|
||||
const apiConfigs = Object.keys(rateLimitConfigs.api);
|
||||
expect(apiConfigs).toEqual(["v1", "v2", "client"]);
|
||||
});
|
||||
|
||||
test("should have all action configurations", () => {
|
||||
const actionConfigs = Object.keys(rateLimitConfigs.actions);
|
||||
expect(actionConfigs).toEqual(["profileUpdate", "surveyFollowUp"]);
|
||||
});
|
||||
|
||||
test("should have all share configurations", () => {
|
||||
const shareConfigs = Object.keys(rateLimitConfigs.share);
|
||||
expect(shareConfigs).toEqual(["url"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Zod Validation", () => {
|
||||
test("all configurations should pass Zod validation", () => {
|
||||
const allConfigs = [
|
||||
...Object.values(rateLimitConfigs.auth),
|
||||
...Object.values(rateLimitConfigs.api),
|
||||
...Object.values(rateLimitConfigs.actions),
|
||||
...Object.values(rateLimitConfigs.share),
|
||||
];
|
||||
|
||||
allConfigs.forEach((config) => {
|
||||
expect(() => ZRateLimitConfig.parse(config)).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Configuration Logic", () => {
|
||||
test("all namespaces should be unique", () => {
|
||||
const allNamespaces: string[] = [];
|
||||
|
||||
// Collect all namespaces
|
||||
Object.values(rateLimitConfigs.auth).forEach((config) => allNamespaces.push(config.namespace));
|
||||
Object.values(rateLimitConfigs.api).forEach((config) => allNamespaces.push(config.namespace));
|
||||
Object.values(rateLimitConfigs.actions).forEach((config) => allNamespaces.push(config.namespace));
|
||||
Object.values(rateLimitConfigs.share).forEach((config) => allNamespaces.push(config.namespace));
|
||||
|
||||
const uniqueNamespaces = new Set(allNamespaces);
|
||||
expect(uniqueNamespaces.size).toBe(allNamespaces.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Integration with Rate Limiting", () => {
|
||||
test("should work with checkRateLimit function", async () => {
|
||||
mockEval.mockResolvedValue([1, 1]);
|
||||
|
||||
const config = rateLimitConfigs.auth.login;
|
||||
const result = await checkRateLimit(config, "test-identifier");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("should work with applyRateLimit helper", async () => {
|
||||
mockEval.mockResolvedValue([1, 1]);
|
||||
|
||||
const config = rateLimitConfigs.api.v1;
|
||||
await expect(applyRateLimit(config, "api-key-123")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("should enforce limits correctly for each config type", async () => {
|
||||
const testCases = [
|
||||
{ config: rateLimitConfigs.auth.login, identifier: "user-login" },
|
||||
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
|
||||
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
|
||||
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
|
||||
{ config: rateLimitConfigs.actions.profileUpdate, identifier: "user-profile" },
|
||||
{ config: rateLimitConfigs.share.url, identifier: "share-url" },
|
||||
];
|
||||
|
||||
const testAllowedRequest = async (config: any, identifier: string) => {
|
||||
mockEval.mockClear();
|
||||
mockEval.mockResolvedValue([1, 1]);
|
||||
const result = await checkRateLimit(config, identifier);
|
||||
expect(result.ok).toBe(true);
|
||||
expect((result as any).data.allowed).toBe(true);
|
||||
};
|
||||
|
||||
const testExceededLimit = async (config: any, identifier: string) => {
|
||||
// When limit is exceeded, remaining should be 0
|
||||
mockEval.mockClear();
|
||||
mockEval.mockResolvedValue([config.allowedPerInterval + 1, 0]);
|
||||
const result = await checkRateLimit(config, identifier);
|
||||
expect(result.ok).toBe(true);
|
||||
expect((result as any).data.allowed).toBe(false);
|
||||
};
|
||||
|
||||
for (const { config, identifier } of testCases) {
|
||||
await testAllowedRequest(config, identifier);
|
||||
await testExceededLimit(config, identifier);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export const rateLimitConfigs = {
|
||||
// Authentication endpoints - stricter limits for security
|
||||
auth: {
|
||||
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, // 30 per 15 minutes
|
||||
signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour
|
||||
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour
|
||||
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour
|
||||
},
|
||||
|
||||
// API endpoints - higher limits for legitimate usage
|
||||
api: {
|
||||
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute
|
||||
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
|
||||
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute
|
||||
},
|
||||
|
||||
// Server actions - varies by action type
|
||||
actions: {
|
||||
profileUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:profile" }, // 3 per hour
|
||||
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
|
||||
},
|
||||
|
||||
// Share pages - moderate limits for public access
|
||||
share: {
|
||||
url: { interval: 60, allowedPerInterval: 30, namespace: "share:url" }, // 30 per minute
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,543 @@
|
||||
import { getRedisClient } from "@/modules/cache/redis";
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { applyRateLimit } from "./helpers";
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
import { TRateLimitConfig } from "./types/rate-limit";
|
||||
|
||||
// Check if Redis is available (basic requirements)
|
||||
let isRedisAvailable = false;
|
||||
|
||||
// Test Redis availability
|
||||
async function checkRedisAvailability() {
|
||||
try {
|
||||
const redis = getRedisClient();
|
||||
if (redis === null) {
|
||||
console.log("Redis client is null - Redis not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Test basic Redis operation
|
||||
await redis.ping();
|
||||
console.log("Redis ping successful - Redis is available");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error checking Redis availability:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate Limiter Load Tests - Race Condition Detection
|
||||
*
|
||||
* This test suite verifies that the rate limiter implementation is free from race conditions
|
||||
* and handles high concurrency correctly. The rate limiter uses Redis with Lua scripts for
|
||||
* atomic operations to prevent race conditions in multi-pod Kubernetes environments.
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Redis server must be running and accessible
|
||||
* - REDIS_URL environment variable must be set to a valid Redis connection string
|
||||
* - Tests will be automatically skipped if REDIS_URL is empty or Redis client is not available
|
||||
*
|
||||
* Running the tests:
|
||||
* Local development: cd apps/web && npx vitest run modules/core/rate-limit/rate-limit-load.test.ts
|
||||
* CI Environment: Tests run automatically in E2E workflow with Redis/Valkey service
|
||||
*
|
||||
* Test Scenarios:
|
||||
*
|
||||
* 1. Basic Race Condition Test
|
||||
* - Purpose: Verify atomic operations under high concurrency
|
||||
* - Method: Send 20 concurrent requests to the same identifier (limit: 3)
|
||||
* - Expected: Exactly 3 requests allowed, 17 denied
|
||||
* - Failure Indicates: Race conditions in the Redis Lua script
|
||||
*
|
||||
* 2. Multiple Waves Test
|
||||
* - Purpose: Test consistency across multiple request waves
|
||||
* - Method: Send 3 waves of 15 concurrent requests each (limit: 10)
|
||||
* - Expected: Exactly 10 requests allowed total across all waves
|
||||
* - Failure Indicates: Window boundary issues or counter corruption
|
||||
*
|
||||
* 3. Different Identifiers Test
|
||||
* - Purpose: Ensure identifiers don't interfere with each other
|
||||
* - Method: 5 different identifiers, 10 requests each (limit: 3 per identifier)
|
||||
* - Expected: Each identifier gets exactly 3 allowed requests
|
||||
* - Failure Indicates: Key collision or identifier mixing
|
||||
*
|
||||
* 4. Window Boundary Test
|
||||
* - Purpose: Verify correct window expiration and reset
|
||||
* - Method: Send requests, wait for window expiry, send more requests
|
||||
* - Expected: Fresh limits after window expiry
|
||||
* - Failure Indicates: TTL or window calculation issues
|
||||
*
|
||||
* 5. High Throughput Stress Test
|
||||
* - Purpose: Test performance under sustained load
|
||||
* - Method: 200 requests in batches (limit: 50)
|
||||
* - Expected: Exactly 50 requests allowed, consistent performance
|
||||
* - Failure Indicates: Performance degradation or counter corruption
|
||||
*
|
||||
* 6. applyRateLimit Function Test
|
||||
* - Purpose: Test the higher-level wrapper function
|
||||
* - Method: Concurrent requests using applyRateLimit instead of checkRateLimit
|
||||
* - Expected: Exact limit compliance with proper error handling
|
||||
* - Failure Indicates: Issues in the wrapper function logic
|
||||
*
|
||||
* 7. Mixed Identifier Patterns Test
|
||||
* - Purpose: Test real-world identifier patterns under load
|
||||
* - Method: Different identifier formats running concurrently
|
||||
* - Expected: Each pattern respects its individual limits
|
||||
* - Failure Indicates: Pattern-specific issues
|
||||
*
|
||||
* 8. TTL Expiration Test
|
||||
* - Purpose: Verify that rate limit keys expire correctly and unblock requests
|
||||
* - Method: Hit rate limit, wait for TTL expiration, verify unblocking
|
||||
* - Expected: Keys expire automatically, fresh limits after expiration
|
||||
* - Failure Indicates: TTL not working, keys not expiring, memory leaks
|
||||
*
|
||||
* Success Indicators:
|
||||
* ✅ Exact limit compliance (no more, no less than configured limit)
|
||||
* ✅ Consistent behavior across multiple runs
|
||||
* ✅ No interference between different identifiers
|
||||
* ✅ Proper window reset behavior
|
||||
*
|
||||
* Failure Indicators:
|
||||
* ❌ More requests allowed than limit: Race condition in increment
|
||||
* ❌ Fewer requests allowed than limit: Lock contention or failed operations
|
||||
* ❌ Identifier interference: Key collision or namespace issues
|
||||
* ❌ Window boundary failures: TTL or timestamp calculation errors
|
||||
*/
|
||||
|
||||
// The availability check and logging is now handled in the beforeAll hook
|
||||
|
||||
// Test configurations
|
||||
const TEST_CONFIGS = {
|
||||
// Very restrictive for race condition testing
|
||||
strict: {
|
||||
interval: 5, // 5 seconds
|
||||
allowedPerInterval: 3,
|
||||
namespace: "test:strict",
|
||||
} as TRateLimitConfig,
|
||||
|
||||
// Medium restrictive
|
||||
medium: {
|
||||
interval: 10,
|
||||
allowedPerInterval: 10,
|
||||
namespace: "test:medium",
|
||||
} as TRateLimitConfig,
|
||||
|
||||
// High throughput
|
||||
high: {
|
||||
interval: 5,
|
||||
allowedPerInterval: 50,
|
||||
namespace: "test:high",
|
||||
} as TRateLimitConfig,
|
||||
} as const;
|
||||
|
||||
describe("Rate Limiter Load Tests - Race Conditions", () => {
|
||||
beforeAll(async () => {
|
||||
// Check Redis availability first
|
||||
isRedisAvailable = await checkRedisAvailability();
|
||||
|
||||
if (!isRedisAvailable) {
|
||||
console.log("🟡 Rate Limiter Load Tests: Redis not available - tests will be skipped");
|
||||
console.log(" To run these tests locally, ensure Redis is running and REDIS_URL is set");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🟢 Rate Limiter Load Tests: Redis available - tests will run");
|
||||
|
||||
// Clear any existing test keys
|
||||
const redis = getRedisClient();
|
||||
if (redis) {
|
||||
const testKeys = await redis.keys("fb:rate_limit:test:*");
|
||||
if (testKeys.length > 0) {
|
||||
await redis.del(testKeys);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up test keys
|
||||
const redis = getRedisClient();
|
||||
if (redis) {
|
||||
const testKeys = await redis.keys("fb:rate_limit:test:*");
|
||||
if (testKeys.length > 0) {
|
||||
await redis.del(testKeys);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("Race condition test: concurrent requests to same identifier", async () => {
|
||||
if (!isRedisAvailable) {
|
||||
console.log("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = TEST_CONFIGS.strict;
|
||||
const identifier = "race-test-same-id";
|
||||
const concurrentRequests = 20; // More than allowed (3)
|
||||
|
||||
// Create array of concurrent promises
|
||||
const promises = Array.from({ length: concurrentRequests }, () => checkRateLimit(config, identifier));
|
||||
|
||||
// Execute all requests concurrently
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Count allowed vs denied requests
|
||||
const allowed = results.filter((r) => r.ok && r.data.allowed).length;
|
||||
const denied = results.filter((r) => r.ok && !r.data.allowed).length;
|
||||
|
||||
console.log(`Race condition test results: ${allowed} allowed, ${denied} denied`);
|
||||
|
||||
// Should allow exactly the configured limit
|
||||
expect(allowed).toBe(config.allowedPerInterval);
|
||||
expect(denied).toBe(concurrentRequests - config.allowedPerInterval);
|
||||
expect(allowed + denied).toBe(concurrentRequests);
|
||||
}, 15000);
|
||||
|
||||
test("Race condition test: multiple waves of concurrent requests", async () => {
|
||||
if (!isRedisAvailable) {
|
||||
console.log("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = TEST_CONFIGS.medium;
|
||||
const identifier = "race-test-waves";
|
||||
const wavesCount = 3;
|
||||
const requestsPerWave = 15; // More than allowed (10)
|
||||
|
||||
const allResults: Array<Awaited<ReturnType<typeof checkRateLimit>>> = [];
|
||||
|
||||
// Send waves of concurrent requests (no delay to ensure same window)
|
||||
for (let wave = 0; wave < wavesCount; wave++) {
|
||||
const promises = Array.from({ length: requestsPerWave }, () => checkRateLimit(config, identifier));
|
||||
|
||||
const waveResults = await Promise.all(promises);
|
||||
allResults.push(...waveResults);
|
||||
|
||||
// No delay - we want all waves in the same window for this test
|
||||
}
|
||||
|
||||
const totalAllowed = allResults.filter((r) => r.ok && r.data.allowed).length;
|
||||
const totalDenied = allResults.filter((r) => r.ok && !r.data.allowed).length;
|
||||
|
||||
console.log(`Multi-wave test: ${totalAllowed} allowed, ${totalDenied} denied`);
|
||||
|
||||
// Should still only allow the configured limit across all waves
|
||||
expect(totalAllowed).toBe(config.allowedPerInterval);
|
||||
expect(totalDenied).toBe(wavesCount * requestsPerWave - config.allowedPerInterval);
|
||||
}, 20000);
|
||||
|
||||
test("Race condition test: different identifiers should not interfere", async () => {
|
||||
if (!isRedisAvailable) {
|
||||
console.log("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = TEST_CONFIGS.strict;
|
||||
const identifiersCount = 5;
|
||||
const requestsPerIdentifier = 10;
|
||||
|
||||
// Create promises for multiple identifiers concurrently
|
||||
const allPromises: Promise<{ identifier: string; result: Awaited<ReturnType<typeof checkRateLimit>> }>[] =
|
||||
[];
|
||||
for (let i = 0; i < identifiersCount; i++) {
|
||||
const identifier = `race-test-different-${i}`;
|
||||
for (let j = 0; j < requestsPerIdentifier; j++) {
|
||||
allPromises.push(checkRateLimit(config, identifier).then((result) => ({ identifier, result })));
|
||||
}
|
||||
}
|
||||
|
||||
// Execute all requests concurrently
|
||||
const results = await Promise.all(allPromises);
|
||||
|
||||
// Group results by identifier
|
||||
const resultsByIdentifier = results.reduce(
|
||||
(acc, { identifier, result }) => {
|
||||
if (!acc[identifier]) acc[identifier] = [];
|
||||
acc[identifier].push(result);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any[]>
|
||||
);
|
||||
|
||||
// Each identifier should have exactly the allowed limit
|
||||
Object.entries(resultsByIdentifier).forEach(([identifier, identifierResults]) => {
|
||||
const allowed = identifierResults.filter((r) => r.ok && r.data.allowed).length;
|
||||
const denied = identifierResults.filter((r) => r.ok && !r.data.allowed).length;
|
||||
|
||||
console.log(`Identifier ${identifier}: ${allowed} allowed, ${denied} denied`);
|
||||
|
||||
expect(allowed).toBe(config.allowedPerInterval);
|
||||
expect(denied).toBe(requestsPerIdentifier - config.allowedPerInterval);
|
||||
});
|
||||
}, 20000);
|
||||
|
||||
test("Window boundary race condition test", async () => {
|
||||
if (!isRedisAvailable) {
|
||||
console.log("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = {
|
||||
interval: 2, // Very short window for testing
|
||||
allowedPerInterval: 5,
|
||||
namespace: "test:boundary",
|
||||
} as TRateLimitConfig;
|
||||
|
||||
const identifier = "boundary-test";
|
||||
|
||||
// First batch of requests
|
||||
const firstBatch = Array.from({ length: 8 }, () => checkRateLimit(config, identifier));
|
||||
|
||||
const firstResults = await Promise.all(firstBatch);
|
||||
const firstAllowed = firstResults.filter((r) => r.ok && r.data.allowed).length;
|
||||
|
||||
console.log(`First batch: ${firstAllowed} allowed`);
|
||||
expect(firstAllowed).toBe(config.allowedPerInterval);
|
||||
|
||||
// Wait for window to expire
|
||||
await new Promise((resolve) => setTimeout(resolve, config.interval * 1000 + 100));
|
||||
|
||||
// Second batch should get fresh limits
|
||||
const secondBatch = Array.from({ length: 8 }, () => checkRateLimit(config, identifier));
|
||||
|
||||
const secondResults = await Promise.all(secondBatch);
|
||||
const secondAllowed = secondResults.filter((r) => r.ok && r.data.allowed).length;
|
||||
|
||||
console.log(`Second batch: ${secondAllowed} allowed`);
|
||||
expect(secondAllowed).toBe(config.allowedPerInterval);
|
||||
}, 15000);
|
||||
|
||||
test("High throughput stress test", async () => {
|
||||
if (!isRedisAvailable) {
|
||||
console.log("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = TEST_CONFIGS.high;
|
||||
const totalRequests = 200;
|
||||
const batchSize = 50;
|
||||
const identifier = "stress-test";
|
||||
|
||||
let totalAllowed = 0;
|
||||
let totalDenied = 0;
|
||||
|
||||
// Send requests in batches to simulate real load
|
||||
for (let i = 0; i < totalRequests; i += batchSize) {
|
||||
const batchEnd = Math.min(i + batchSize, totalRequests);
|
||||
const batchPromises = Array.from({ length: batchEnd - i }, () => checkRateLimit(config, identifier));
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
|
||||
const batchAllowed = batchResults.filter((r) => r.ok && r.data.allowed).length;
|
||||
const batchDenied = batchResults.filter((r) => r.ok && !r.data.allowed).length;
|
||||
|
||||
totalAllowed += batchAllowed;
|
||||
totalDenied += batchDenied;
|
||||
|
||||
// Small delay between batches
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
console.log(`Stress test: ${totalAllowed} allowed, ${totalDenied} denied`);
|
||||
|
||||
// Should respect the rate limit even under high load
|
||||
expect(totalAllowed).toBe(config.allowedPerInterval);
|
||||
expect(totalDenied).toBe(totalRequests - config.allowedPerInterval);
|
||||
expect(totalAllowed + totalDenied).toBe(totalRequests);
|
||||
}, 30000);
|
||||
|
||||
test("applyRateLimit function race condition test", async () => {
|
||||
if (!isRedisAvailable) {
|
||||
console.log("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = TEST_CONFIGS.strict;
|
||||
const identifier = "apply-rate-limit-test";
|
||||
const concurrentRequests = 15;
|
||||
|
||||
// Test the higher-level applyRateLimit function
|
||||
const promises = Array.from({ length: concurrentRequests }, async () => {
|
||||
try {
|
||||
await applyRateLimit(config, identifier);
|
||||
return { success: true, error: null };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const successes = results.filter((r) => r.success).length;
|
||||
const failures = results.filter((r) => !r.success).length;
|
||||
|
||||
console.log(`applyRateLimit test: ${successes} successes, ${failures} failures`);
|
||||
|
||||
// Should allow exactly the configured limit
|
||||
expect(successes).toBe(config.allowedPerInterval);
|
||||
expect(failures).toBe(concurrentRequests - config.allowedPerInterval);
|
||||
|
||||
// All failures should be "Maximum number of requests reached. Please try again later."
|
||||
const rateLimitErrors = results.filter(
|
||||
(r) => r.error === "Maximum number of requests reached. Please try again later."
|
||||
).length;
|
||||
expect(rateLimitErrors).toBe(failures);
|
||||
}, 15000);
|
||||
|
||||
test("Mixed identifier patterns under load", async () => {
|
||||
if (!isRedisAvailable) {
|
||||
console.log("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
const config = TEST_CONFIGS.medium;
|
||||
const patterns = ["user-123", "ip-192.168.1.1", "api-key-abc", "session-xyz"];
|
||||
|
||||
const requestsPerPattern = 15;
|
||||
|
||||
// Create mixed concurrent requests
|
||||
const allPromises: Promise<{ pattern: string; result: any }>[] = [];
|
||||
for (const pattern of patterns) {
|
||||
for (let i = 0; i < requestsPerPattern; i++) {
|
||||
allPromises.push(checkRateLimit(config, pattern).then((result) => ({ pattern, result })));
|
||||
}
|
||||
}
|
||||
|
||||
// Shuffle the array to simulate random request order
|
||||
for (let i = allPromises.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[allPromises[i], allPromises[j]] = [allPromises[j], allPromises[i]];
|
||||
}
|
||||
|
||||
const results = await Promise.all(allPromises);
|
||||
|
||||
// Group and verify results
|
||||
const resultsByPattern = results.reduce(
|
||||
(acc, { pattern, result }) => {
|
||||
if (!acc[pattern]) acc[pattern] = [];
|
||||
acc[pattern].push(result);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any[]>
|
||||
);
|
||||
|
||||
Object.entries(resultsByPattern).forEach(([pattern, patternResults]) => {
|
||||
const allowed = patternResults.filter((r) => r.ok && r.data.allowed).length;
|
||||
const denied = patternResults.filter((r) => r.ok && !r.data.allowed).length;
|
||||
|
||||
console.log(`Pattern ${pattern}: ${allowed} allowed, ${denied} denied`);
|
||||
|
||||
expect(allowed).toBe(config.allowedPerInterval);
|
||||
expect(denied).toBe(requestsPerPattern - config.allowedPerInterval);
|
||||
});
|
||||
}, 25000);
|
||||
|
||||
test("TTL expiration test: rate limit key should expire and unblock requests", async () => {
|
||||
if (!isRedisAvailable) {
|
||||
console.log("Skipping test: Redis not available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a very short interval for faster testing
|
||||
const config: TRateLimitConfig = {
|
||||
interval: 3, // 3 seconds
|
||||
allowedPerInterval: 2,
|
||||
namespace: "test:ttl",
|
||||
};
|
||||
|
||||
const identifier = "ttl-test-user";
|
||||
|
||||
// Clear any existing keys first
|
||||
const redis = getRedisClient();
|
||||
if (redis) {
|
||||
const existingKeys = await redis.keys(`fb:rate_limit:${config.namespace}:*`);
|
||||
if (existingKeys.length > 0) {
|
||||
await redis.del(existingKeys);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Phase 1: Hitting rate limit...");
|
||||
|
||||
// Phase 1: Make requests until rate limit is hit
|
||||
const phase1Promises = Array.from({ length: 5 }, () => checkRateLimit(config, identifier));
|
||||
|
||||
const phase1Results = await Promise.all(phase1Promises);
|
||||
const phase1Allowed = phase1Results.filter((r) => r.ok && r.data.allowed).length;
|
||||
const phase1Denied = phase1Results.filter((r) => r.ok && !r.data.allowed).length;
|
||||
|
||||
console.log(`Phase 1 results: ${phase1Allowed} allowed, ${phase1Denied} denied`);
|
||||
|
||||
// Verify rate limit is working
|
||||
expect(phase1Allowed).toBe(config.allowedPerInterval);
|
||||
expect(phase1Denied).toBe(5 - config.allowedPerInterval);
|
||||
|
||||
// Check that the key exists in Redis
|
||||
if (redis) {
|
||||
const now = Date.now();
|
||||
const windowStart = Math.floor(now / (config.interval * 1000)) * config.interval;
|
||||
const expectedKey = `fb:rate_limit:${config.namespace}:${identifier}:${windowStart}`;
|
||||
|
||||
const keyExists = await redis.exists(expectedKey);
|
||||
expect(keyExists).toBe(1);
|
||||
console.log(`Redis key exists: ${expectedKey}`);
|
||||
|
||||
// Check the TTL
|
||||
const ttl = await redis.ttl(expectedKey);
|
||||
expect(ttl).toBeGreaterThan(0);
|
||||
expect(ttl).toBeLessThanOrEqual(config.interval);
|
||||
console.log(`Key TTL: ${ttl} seconds`);
|
||||
|
||||
// Phase 2: Wait for TTL to expire
|
||||
console.log(`Phase 2: Waiting for TTL expiration (${config.interval + 1} seconds)...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, (config.interval + 1) * 1000));
|
||||
|
||||
// Verify key has been automatically deleted by Redis
|
||||
const keyExistsAfterTTL = await redis.exists(expectedKey);
|
||||
expect(keyExistsAfterTTL).toBe(0);
|
||||
console.log("Key automatically deleted by Redis TTL ✅");
|
||||
}
|
||||
|
||||
// Phase 3: Make new requests after TTL expiration
|
||||
console.log("Phase 3: Making requests after TTL expiration...");
|
||||
|
||||
const phase3Promises = Array.from({ length: 5 }, () => checkRateLimit(config, identifier));
|
||||
|
||||
const phase3Results = await Promise.all(phase3Promises);
|
||||
const phase3Allowed = phase3Results.filter((r) => r.ok && r.data.allowed).length;
|
||||
const phase3Denied = phase3Results.filter((r) => r.ok && !r.data.allowed).length;
|
||||
|
||||
console.log(`Phase 3 results: ${phase3Allowed} allowed, ${phase3Denied} denied`);
|
||||
|
||||
// Should get fresh limits after TTL expiration
|
||||
expect(phase3Allowed).toBe(config.allowedPerInterval);
|
||||
expect(phase3Denied).toBe(5 - config.allowedPerInterval);
|
||||
|
||||
// Verify new key was created for the new window
|
||||
if (redis) {
|
||||
const newNow = Date.now();
|
||||
const newWindowStart = Math.floor(newNow / (config.interval * 1000)) * config.interval;
|
||||
const newKey = `fb:rate_limit:${config.namespace}:${identifier}:${newWindowStart}`;
|
||||
|
||||
const newKeyExists = await redis.exists(newKey);
|
||||
expect(newKeyExists).toBe(1);
|
||||
console.log(`New Redis key created: ${newKey}`);
|
||||
}
|
||||
|
||||
// Phase 4: Test that we're blocked again within the new window
|
||||
console.log("Phase 4: Verifying rate limit is active in new window...");
|
||||
|
||||
const phase4Promises = Array.from({ length: 3 }, () => checkRateLimit(config, identifier));
|
||||
|
||||
const phase4Results = await Promise.all(phase4Promises);
|
||||
const phase4Allowed = phase4Results.filter((r) => r.ok && r.data.allowed).length;
|
||||
const phase4Denied = phase4Results.filter((r) => r.ok && !r.data.allowed).length;
|
||||
|
||||
console.log(`Phase 4 results: ${phase4Allowed} allowed, ${phase4Denied} denied`);
|
||||
|
||||
// Should be blocked since we already used up the limit in phase 3
|
||||
expect(phase4Allowed).toBe(0);
|
||||
expect(phase4Denied).toBe(3);
|
||||
|
||||
console.log("✅ TTL expiration working correctly - rate limits properly reset after expiration");
|
||||
}, 20000);
|
||||
});
|
||||
@@ -0,0 +1,344 @@
|
||||
// Import modules after mocking
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
// Import after mocking
|
||||
import { checkRateLimit } from "./rate-limit";
|
||||
import { TRateLimitConfig } from "./types/rate-limit";
|
||||
|
||||
const { mockEval, mockRedisClient, mockGetRedisClient } = vi.hoisted(() => {
|
||||
const _mockEval = vi.fn();
|
||||
const _mockRedisClient = {
|
||||
eval: _mockEval,
|
||||
} as any;
|
||||
|
||||
const _mockGetRedisClient = vi.fn().mockReturnValue(_mockRedisClient);
|
||||
|
||||
return {
|
||||
mockEval: _mockEval,
|
||||
mockRedisClient: _mockRedisClient,
|
||||
mockGetRedisClient: _mockGetRedisClient,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock all dependencies (will use the hoisted mocks above)
|
||||
vi.mock("@/modules/cache/redis", () => ({
|
||||
getRedisClient: mockGetRedisClient,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
SENTRY_DSN: "https://test@sentry.io/test",
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
addBreadcrumb: vi.fn(),
|
||||
captureException: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
const testConfig: TRateLimitConfig = {
|
||||
interval: 300, // 5 minutes
|
||||
allowedPerInterval: 5,
|
||||
namespace: "test",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset the mock to return our mock client
|
||||
mockGetRedisClient.mockReturnValue(mockRedisClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Ensure mocks don't leak to other test suites (e.g. load tests)
|
||||
afterAll(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should allow request when under limit", async () => {
|
||||
// Mock Redis returning count of 2, which is under limit of 5
|
||||
mockEval.mockResolvedValue([2, 1]);
|
||||
|
||||
const result = await checkRateLimit(testConfig, "test-user");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("should deny request when over limit", async () => {
|
||||
// Mock Redis returning count of 6, which is over limit of 5
|
||||
mockEval.mockResolvedValue([6, 0]);
|
||||
|
||||
const result = await checkRateLimit(testConfig, "test-user");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.allowed).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("should fail open when Redis is unavailable", async () => {
|
||||
// Mock Redis throwing an error
|
||||
mockEval.mockRejectedValue(new Error("Redis connection failed"));
|
||||
|
||||
const result = await checkRateLimit(testConfig, "test-user");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("should fail open when rate limiting is disabled", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
RATE_LIMITING_DISABLED: true,
|
||||
SENTRY_DSN: "https://test@sentry.io/test",
|
||||
}));
|
||||
|
||||
// Dynamic import after mocking
|
||||
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
|
||||
const result = await checkRateLimitMocked(testConfig, "test-user");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("should fail open when Redis is not configured", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/modules/cache/redis", () => ({
|
||||
getRedisClient: vi.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
// Dynamic import after mocking
|
||||
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
|
||||
const result = await checkRateLimitMocked(testConfig, "test-user");
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.allowed).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("should generate correct Redis key with window alignment", async () => {
|
||||
mockEval.mockResolvedValue([1, 1]);
|
||||
|
||||
await checkRateLimit(testConfig, "test-user");
|
||||
|
||||
expect(mockEval).toHaveBeenCalledWith(
|
||||
expect.stringContaining("redis.call('INCR', key)"),
|
||||
expect.objectContaining({
|
||||
keys: [expect.stringMatching(/^fb:rate_limit:test:test-user:\d+$/)],
|
||||
arguments: ["5", expect.any(String)],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should use provided namespace", async () => {
|
||||
const configWithCustomNamespace: TRateLimitConfig = {
|
||||
interval: 300,
|
||||
allowedPerInterval: 5,
|
||||
namespace: "custom",
|
||||
};
|
||||
|
||||
mockEval.mockResolvedValue([1, 1]);
|
||||
|
||||
await checkRateLimit(configWithCustomNamespace, "test-user");
|
||||
|
||||
expect(mockEval).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
keys: [expect.stringMatching(/^fb:rate_limit:custom:test-user:\d+$/)],
|
||||
arguments: ["5", expect.any(String)],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("should calculate correct TTL for window expiration", async () => {
|
||||
mockEval.mockResolvedValue([1, 1]);
|
||||
|
||||
await checkRateLimit(testConfig, "test-user");
|
||||
|
||||
// TTL should be between 0 and 300 seconds (window interval)
|
||||
const ttlUsed = Number.parseInt(mockEval.mock.calls[0][1].arguments[1]);
|
||||
expect(ttlUsed).toBeGreaterThan(0);
|
||||
expect(ttlUsed).toBeLessThanOrEqual(300);
|
||||
});
|
||||
|
||||
test("should set TTL only on first increment", async () => {
|
||||
mockEval.mockResolvedValue([1, 1]);
|
||||
|
||||
await checkRateLimit(testConfig, "test-user");
|
||||
|
||||
// Verify the Lua script contains the conditional TTL logic
|
||||
const luaScript = mockEval.mock.calls[0][0];
|
||||
expect(luaScript).toContain("if current == 1 then");
|
||||
expect(luaScript).toContain("redis.call('EXPIRE', key, ttl)");
|
||||
expect(luaScript).toContain("end");
|
||||
|
||||
// Verify script structure for atomic increment and conditional expire
|
||||
expect(luaScript).toContain("redis.call('INCR', key)");
|
||||
expect(luaScript).toContain("return {current, current <= limit and 1 or 0}");
|
||||
});
|
||||
|
||||
test("should not call Sentry when SENTRY_DSN is not configured", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
// Re-mock all dependencies after resetModules
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
SENTRY_DSN: undefined,
|
||||
}));
|
||||
|
||||
const mockAddBreadcrumb = vi.fn();
|
||||
const mockCaptureException = vi.fn();
|
||||
vi.doMock("@sentry/nextjs", () => ({
|
||||
addBreadcrumb: mockAddBreadcrumb,
|
||||
captureException: mockCaptureException,
|
||||
}));
|
||||
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock("@/modules/cache/redis", () => ({
|
||||
getRedisClient: vi.fn().mockReturnValue({
|
||||
eval: vi.fn().mockResolvedValue([6, 0]),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Dynamic import after mocking
|
||||
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
|
||||
|
||||
await checkRateLimitMocked(testConfig, "test-user");
|
||||
|
||||
// Verify Sentry functions were not called
|
||||
expect(mockAddBreadcrumb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should call Sentry when SENTRY_DSN is configured and rate limit exceeded", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
// Re-mock all dependencies after resetModules
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
SENTRY_DSN: "https://test@sentry.io/test",
|
||||
}));
|
||||
|
||||
const mockAddBreadcrumb = vi.fn();
|
||||
const mockCaptureException = vi.fn();
|
||||
vi.doMock("@sentry/nextjs", () => ({
|
||||
addBreadcrumb: mockAddBreadcrumb,
|
||||
captureException: mockCaptureException,
|
||||
}));
|
||||
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.doMock("@/modules/cache/redis", () => ({
|
||||
getRedisClient: vi.fn().mockReturnValue({
|
||||
eval: vi.fn().mockResolvedValue([6, 0]),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Dynamic import after mocking
|
||||
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
|
||||
|
||||
await checkRateLimitMocked(testConfig, "test-user");
|
||||
|
||||
// Verify Sentry breadcrumb was added
|
||||
expect(mockAddBreadcrumb).toHaveBeenCalledWith({
|
||||
message: "Rate limit exceeded",
|
||||
level: "warning",
|
||||
data: expect.objectContaining({
|
||||
identifier: "test-user",
|
||||
currentCount: 6,
|
||||
limit: 5,
|
||||
namespace: "test",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test("should call Sentry when SENTRY_DSN is configured and Redis error occurs", async () => {
|
||||
vi.resetModules();
|
||||
|
||||
// Re-mock all dependencies after resetModules
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
SENTRY_DSN: "https://test@sentry.io/test",
|
||||
}));
|
||||
|
||||
const mockAddBreadcrumb = vi.fn();
|
||||
const mockCaptureException = vi.fn();
|
||||
vi.doMock("@sentry/nextjs", () => ({
|
||||
addBreadcrumb: mockAddBreadcrumb,
|
||||
captureException: mockCaptureException,
|
||||
}));
|
||||
|
||||
vi.doMock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const redisError = new Error("Redis connection failed");
|
||||
vi.doMock("@/modules/cache/redis", () => ({
|
||||
getRedisClient: vi.fn().mockReturnValue({
|
||||
eval: vi.fn().mockRejectedValue(redisError),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Dynamic import after mocking
|
||||
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
|
||||
|
||||
await checkRateLimitMocked(testConfig, "test-user");
|
||||
|
||||
// Verify Sentry exception was captured
|
||||
expect(mockCaptureException).toHaveBeenCalledWith(
|
||||
redisError,
|
||||
expect.objectContaining({
|
||||
tags: {
|
||||
component: "rate-limiter",
|
||||
namespace: "test",
|
||||
},
|
||||
extra: expect.objectContaining({
|
||||
error: redisError,
|
||||
identifier: "test-user",
|
||||
namespace: "test",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { RATE_LIMITING_DISABLED, SENTRY_DSN } from "@/lib/constants";
|
||||
import { createCacheKey } from "@/modules/cache/lib/cacheKeys";
|
||||
import { getRedisClient } from "@/modules/cache/redis";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { Result, ok } from "@formbricks/types/error-handlers";
|
||||
import { TRateLimitConfig, type TRateLimitResponse } from "./types/rate-limit";
|
||||
|
||||
/**
|
||||
* Atomic Redis-based rate limiter using Lua scripts
|
||||
* Prevents race conditions in multi-pod Kubernetes environments
|
||||
*/
|
||||
export const checkRateLimit = async (
|
||||
config: TRateLimitConfig,
|
||||
identifier: string
|
||||
): Promise<Result<TRateLimitResponse, string>> => {
|
||||
// Skip rate limiting if disabled
|
||||
if (RATE_LIMITING_DISABLED) {
|
||||
logger.debug(`Rate limiting disabled`);
|
||||
return ok({
|
||||
allowed: true,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Get Redis client
|
||||
const redis = getRedisClient();
|
||||
if (!redis) {
|
||||
logger.debug(`Redis unavailable`);
|
||||
return ok({
|
||||
allowed: true,
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const windowStart = Math.floor(now / (config.interval * 1000)) * config.interval;
|
||||
const key = createCacheKey.rateLimit.core(config.namespace, identifier, windowStart);
|
||||
|
||||
// Calculate TTL to expire exactly at window end, value in seconds
|
||||
const windowEnd = windowStart + config.interval;
|
||||
// Convert window end from seconds to milliseconds, subtract current time, then convert back to seconds for Redis EXPIRE
|
||||
const ttlSeconds = Math.ceil((windowEnd * 1000 - now) / 1000);
|
||||
|
||||
// Lua script for atomic increment and conditional expire
|
||||
// This prevents race conditions between INCR and EXPIRE operations
|
||||
const luaScript = `
|
||||
local key = KEYS[1]
|
||||
local limit = tonumber(ARGV[1])
|
||||
local ttl = tonumber(ARGV[2])
|
||||
|
||||
-- Atomically increment and get current count
|
||||
local current = redis.call('INCR', key)
|
||||
|
||||
-- Set TTL only if this is the first increment (avoids extending windows)
|
||||
if current == 1 then
|
||||
redis.call('EXPIRE', key, ttl)
|
||||
end
|
||||
|
||||
-- Return current count and whether it's within limit
|
||||
return {current, current <= limit and 1 or 0}
|
||||
`;
|
||||
|
||||
const result = (await redis.eval(luaScript, {
|
||||
keys: [key],
|
||||
arguments: [config.allowedPerInterval.toString(), ttlSeconds.toString()],
|
||||
})) as [number, number];
|
||||
const [currentCount, isAllowed] = result;
|
||||
|
||||
// Log debug information for every Redis count increase
|
||||
logger.debug(`Rate limit check`, {
|
||||
identifier,
|
||||
currentCount,
|
||||
limit: config.allowedPerInterval,
|
||||
window: config.interval,
|
||||
key,
|
||||
allowed: isAllowed === 1,
|
||||
windowEnd,
|
||||
});
|
||||
|
||||
const response: TRateLimitResponse = {
|
||||
allowed: isAllowed === 1,
|
||||
};
|
||||
|
||||
// Log rate limit violations for security monitoring
|
||||
if (!response.allowed) {
|
||||
const violationContext = {
|
||||
identifier,
|
||||
currentCount,
|
||||
limit: config.allowedPerInterval,
|
||||
window: config.interval,
|
||||
key,
|
||||
namespace: config.namespace,
|
||||
};
|
||||
|
||||
logger.error(`Rate limit exceeded`, violationContext);
|
||||
|
||||
if (SENTRY_DSN) {
|
||||
// Breadcrumb because the exception will be captured in the error handler
|
||||
Sentry.addBreadcrumb({
|
||||
message: `Rate limit exceeded`,
|
||||
level: "warning",
|
||||
data: violationContext,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ok(response);
|
||||
} catch (error) {
|
||||
const errorMessage = `Rate limit check failed`;
|
||||
const errorContext = { error, identifier, namespace: config.namespace };
|
||||
|
||||
logger.error(errorMessage, errorContext);
|
||||
|
||||
if (SENTRY_DSN) {
|
||||
// Log error to Sentry
|
||||
Sentry.captureException(error, {
|
||||
tags: {
|
||||
component: "rate-limiter",
|
||||
namespace: config.namespace,
|
||||
},
|
||||
extra: errorContext,
|
||||
});
|
||||
}
|
||||
|
||||
// Fail open - allow request if rate limiting fails
|
||||
// This ensures system availability over perfect rate limiting
|
||||
return ok({
|
||||
allowed: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const ZRateLimitConfig = z.object({
|
||||
/** Rate limit window in seconds */
|
||||
interval: z.number().int().positive().describe("Rate limit window in seconds"),
|
||||
/** Maximum allowed requests per interval */
|
||||
allowedPerInterval: z.number().int().positive().describe("Maximum allowed requests per interval"),
|
||||
/** Namespace for grouping rate limit per feature */
|
||||
namespace: z.string().min(1).describe("Namespace for grouping rate limit per feature"),
|
||||
});
|
||||
|
||||
export type TRateLimitConfig = z.infer<typeof ZRateLimitConfig>;
|
||||
|
||||
const ZRateLimitResponse = z.object({
|
||||
allowed: z.boolean().describe("Whether the request is allowed"),
|
||||
});
|
||||
|
||||
export type TRateLimitResponse = z.infer<typeof ZRateLimitResponse>;
|
||||
@@ -364,7 +364,7 @@ describe("License Core Logic", () => {
|
||||
},
|
||||
}));
|
||||
// Import hashString to compute the expected cache key
|
||||
const { hashString } = await import("@/lib/hashString");
|
||||
const { hashString } = await import("@/lib/hash-string");
|
||||
const hashedKey = hashString("test-license-key");
|
||||
const detailsKey = `fb:license:${hashedKey}:status`;
|
||||
// Patch the cache mock to match the actual key logic
|
||||
@@ -476,7 +476,7 @@ describe("License Core Logic", () => {
|
||||
HTTP_PROXY: undefined,
|
||||
},
|
||||
}));
|
||||
const { hashString } = await import("@/lib/hashString");
|
||||
const { hashString } = await import("@/lib/hash-string");
|
||||
const expectedHash = hashString(testLicenseKey);
|
||||
const { getEnterpriseLicense } = await import("./license");
|
||||
await getEnterpriseLicense();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hashString";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { createCacheKey } from "@/modules/cache/lib/cacheKeys";
|
||||
import { getCache } from "@/modules/cache/lib/service";
|
||||
import {
|
||||
|
||||
@@ -192,7 +192,7 @@ export const handleSsoCallback = async ({
|
||||
});
|
||||
|
||||
// send new user to brevo
|
||||
createBrevoCustomer({ id: user.id, email: user.email });
|
||||
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
|
||||
|
||||
if (isMultiOrgEnabled) return true;
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
WEBAPP_URL: "test-webapp-url",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
}));
|
||||
|
||||
// Mock @/lib/env
|
||||
|
||||
@@ -55,7 +55,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SAML_OAUTH_ENABLED: true,
|
||||
SMTP_PASSWORD: "smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SAML_OAUTH_ENABLED: true,
|
||||
SMTP_PASSWORD: "smtp-password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_PORT: "mock-smtp-port",
|
||||
IS_POSTHOG_CONFIGURED: true,
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,12 +7,10 @@ import { DatePicker } from "@/modules/ui/components/date-picker";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { Slider } from "@/modules/ui/components/slider";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import * as Collapsible from "@radix-ui/react-collapsible";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { ArrowUpRight, CheckIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { KeyboardEventHandler, useEffect, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
@@ -47,12 +45,6 @@ export const ResponseOptionsCard = ({
|
||||
subheading: t("environments.surveys.edit.survey_completed_subheading"),
|
||||
});
|
||||
|
||||
const [singleUseMessage, setSingleUseMessage] = useState({
|
||||
heading: t("environments.surveys.edit.survey_already_answered_heading"),
|
||||
subheading: t("environments.surveys.edit.survey_already_answered_subheading"),
|
||||
});
|
||||
|
||||
const [singleUseEncryption, setSingleUseEncryption] = useState(true);
|
||||
const [runOnDate, setRunOnDate] = useState<Date | null>(null);
|
||||
const [closeOnDate, setCloseOnDate] = useState<Date | null>(null);
|
||||
const [recaptchaThreshold, setRecaptchaThreshold] = useState<number>(localSurvey.recaptcha?.threshold ?? 0);
|
||||
@@ -163,53 +155,6 @@ export const ResponseOptionsCard = ({
|
||||
setLocalSurvey({ ...localSurvey, surveyClosedMessage: message });
|
||||
};
|
||||
|
||||
const handleSingleUseSurveyToggle = () => {
|
||||
if (!localSurvey.singleUse?.enabled) {
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: singleUseEncryption },
|
||||
});
|
||||
} else {
|
||||
setLocalSurvey({ ...localSurvey, singleUse: { enabled: false, isEncrypted: false } });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSingleUseSurveyMessageChange = ({
|
||||
heading,
|
||||
subheading,
|
||||
}: {
|
||||
heading?: string;
|
||||
subheading?: string;
|
||||
}) => {
|
||||
const message = {
|
||||
heading: heading ?? singleUseMessage.heading,
|
||||
subheading: subheading ?? singleUseMessage.subheading,
|
||||
};
|
||||
|
||||
const localSurveySingleUseEnabled = localSurvey.singleUse?.enabled ?? false;
|
||||
setSingleUseMessage(message);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: localSurveySingleUseEnabled, ...message, isEncrypted: singleUseEncryption },
|
||||
});
|
||||
};
|
||||
|
||||
const hangleSingleUseEncryptionToggle = () => {
|
||||
if (!singleUseEncryption) {
|
||||
setSingleUseEncryption(true);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: true },
|
||||
});
|
||||
} else {
|
||||
setSingleUseEncryption(false);
|
||||
setLocalSurvey({
|
||||
...localSurvey,
|
||||
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: false },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleHideBackButtonToggle = () => {
|
||||
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
|
||||
};
|
||||
@@ -223,14 +168,6 @@ export const ResponseOptionsCard = ({
|
||||
setSurveyClosedMessageToggle(true);
|
||||
}
|
||||
|
||||
if (localSurvey.singleUse?.enabled) {
|
||||
setSingleUseMessage({
|
||||
heading: localSurvey.singleUse.heading ?? singleUseMessage.heading,
|
||||
subheading: localSurvey.singleUse.subheading ?? singleUseMessage.subheading,
|
||||
});
|
||||
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
|
||||
}
|
||||
|
||||
if (localSurvey.runOnDate) {
|
||||
setRunOnDate(localSurvey.runOnDate);
|
||||
setRunOnDateToggle(true);
|
||||
@@ -240,13 +177,7 @@ export const ResponseOptionsCard = ({
|
||||
setCloseOnDate(localSurvey.closeOnDate);
|
||||
setCloseOnDateToggle(true);
|
||||
}
|
||||
}, [
|
||||
localSurvey,
|
||||
singleUseMessage.heading,
|
||||
singleUseMessage.subheading,
|
||||
surveyClosedMessage.heading,
|
||||
surveyClosedMessage.subheading,
|
||||
]);
|
||||
}, [localSurvey, surveyClosedMessage.heading, surveyClosedMessage.subheading]);
|
||||
|
||||
const toggleAutocomplete = () => {
|
||||
if (autoComplete) {
|
||||
@@ -471,80 +402,6 @@ export const ResponseOptionsCard = ({
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Single User Survey Options */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="singleUserSurveyOptions"
|
||||
isChecked={!!localSurvey.singleUse?.enabled}
|
||||
onToggle={handleSingleUseSurveyToggle}
|
||||
title={t("environments.surveys.edit.single_use_survey_links")}
|
||||
description={t("environments.surveys.edit.single_use_survey_links_description")}
|
||||
childBorder={true}>
|
||||
<div className="flex w-full items-center space-x-1 p-4 pb-4">
|
||||
<div className="w-full cursor-pointer items-center bg-slate-50">
|
||||
<div className="row mb-2 flex cursor-default items-center space-x-2">
|
||||
<Label htmlFor="howItWorks">{t("environments.surveys.edit.how_it_works")}</Label>
|
||||
</div>
|
||||
<ul className="mb-3 ml-4 cursor-default list-inside list-disc space-y-1">
|
||||
<li className="text-sm text-slate-600">
|
||||
{t(
|
||||
"environments.surveys.edit.blocks_survey_if_the_survey_url_has_no_single_use_id_suid"
|
||||
)}
|
||||
</li>
|
||||
<li className="text-sm text-slate-600">
|
||||
{t(
|
||||
"environments.surveys.edit.blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already"
|
||||
)}
|
||||
</li>
|
||||
<li className="text-sm text-slate-600">
|
||||
<Link
|
||||
href="https://formbricks.com/docs/link-surveys/single-use-links"
|
||||
target="_blank"
|
||||
className="underline">
|
||||
{t("common.read_docs")} <ArrowUpRight className="inline" size={16} />
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
<Label htmlFor="headline">{t("environments.surveys.edit.link_used_message")}</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="heading"
|
||||
className="mb-4 mt-2 bg-white"
|
||||
name="heading"
|
||||
value={singleUseMessage.heading}
|
||||
onChange={(e) => handleSingleUseSurveyMessageChange({ heading: e.target.value })}
|
||||
/>
|
||||
|
||||
<Label htmlFor="headline">{t("environments.surveys.edit.subheading")}</Label>
|
||||
<Input
|
||||
className="mb-4 mt-2 bg-white"
|
||||
id="subheading"
|
||||
name="subheading"
|
||||
value={singleUseMessage.subheading}
|
||||
onChange={(e) => handleSingleUseSurveyMessageChange({ subheading: e.target.value })}
|
||||
/>
|
||||
<Label htmlFor="headline">{t("environments.surveys.edit.url_encryption")}</Label>
|
||||
<div>
|
||||
<div className="mt-2 flex items-center space-x-1">
|
||||
<Switch
|
||||
id="encryption-switch"
|
||||
checked={singleUseEncryption}
|
||||
onCheckedChange={hangleSingleUseEncryptionToggle}
|
||||
/>
|
||||
<Label htmlFor="encryption-label">
|
||||
<div className="ml-2">
|
||||
<p className="text-sm font-normal text-slate-600">
|
||||
{t(
|
||||
"environments.surveys.edit.enable_encryption_of_single_use_id_suid_in_survey_url"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedOptionToggle>
|
||||
|
||||
{/* Verify Email Section */}
|
||||
<AdvancedOptionToggle
|
||||
htmlId="verifyEmailBeforeSubmission"
|
||||
|
||||
@@ -14,7 +14,7 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList) => {
|
||||
if (survey.singleUse?.enabled) {
|
||||
const response = await generateSingleUseIdsAction({
|
||||
surveyId: survey.id,
|
||||
isEncrypted: !!survey.singleUse?.isEncrypted,
|
||||
isEncrypted: Boolean(survey.singleUse?.isEncrypted),
|
||||
count: 1,
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SMTP_USERNAME: "user@example.com",
|
||||
SMTP_PASSWORD: "password",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
REDIS_URL: "test-redis-url",
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: true,
|
||||
}));
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
ENTERPRISE_LICENSE_KEY: "mock-license-key",
|
||||
SESSION_MAX_AGE: 1000,
|
||||
AUDIT_LOG_ENABLED: 1,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
REDIS_URL: undefined,
|
||||
}));
|
||||
|
||||
// Track the callback for useDebounce to better control when it fires
|
||||
|
||||
@@ -19,9 +19,13 @@ const meta: Meta<StoryProps> = {
|
||||
component: Alert,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
controls: {
|
||||
sort: "requiredFirst",
|
||||
exclude: [],
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Alert** component displays important messages to users with various styles and optional actions. It supports different variants for different message types and can include icons and buttons.",
|
||||
},
|
||||
},
|
||||
},
|
||||
// These argTypes are for story controls, not component props
|
||||
@@ -61,10 +65,10 @@ const meta: Meta<StoryProps> = {
|
||||
control: "boolean",
|
||||
description: "Whether to show action buttons",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
},
|
||||
order: 4,
|
||||
order: 1,
|
||||
},
|
||||
title: {
|
||||
control: "text",
|
||||
@@ -91,7 +95,7 @@ const meta: Meta<StoryProps> = {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 2,
|
||||
order: 3,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -160,7 +164,7 @@ export const Small: Story = {
|
||||
};
|
||||
|
||||
// With custom icon
|
||||
export const withButtonAndIcon: Story = {
|
||||
export const WithButtonAndIcon: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "default",
|
||||
@@ -170,6 +174,13 @@ export const withButtonAndIcon: Story = {
|
||||
showButton: true,
|
||||
actionButtonText: "Learn more",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you need both visual emphasis and actionable content.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Error variant
|
||||
@@ -186,7 +197,7 @@ export const Destructive: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Only use if the user needs to take immediate action or there is a critical error.",
|
||||
story: "Use for critical errors that need immediate attention or action.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -206,7 +217,7 @@ export const Warning: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to make the user aware of potential issues.",
|
||||
story: "Use to make the user aware of potential issues or risks.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -226,7 +237,7 @@ export const Info: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to give contextual information and support the user.",
|
||||
story: "Use to give contextual information and support the user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -246,7 +257,46 @@ export const Success: Story = {
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use this to give positive feedback.",
|
||||
story: "Use to give positive feedback and confirm successful actions.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "info",
|
||||
title: "Information",
|
||||
description: "This alert has an icon for better visual hierarchy.",
|
||||
showIcon: true,
|
||||
showButton: false,
|
||||
actionButtonText: "",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use icons to improve visual hierarchy and message clarity.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongContent: Story = {
|
||||
render: renderAlert,
|
||||
args: {
|
||||
variant: "warning",
|
||||
title: "Long Alert Title That Might Wrap to Multiple Lines",
|
||||
description:
|
||||
"This is a very long alert description that demonstrates how the alert component handles longer content. It should wrap gracefully and maintain proper spacing and readability even with extensive text content.",
|
||||
showIcon: true,
|
||||
showButton: true,
|
||||
actionButtonText: "Acknowledge",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the alert handles longer content with proper text wrapping.",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Badge } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["warning", "success", "error", "gray"],
|
||||
},
|
||||
size: { control: "select", options: ["small", "normal", "large"] },
|
||||
className: { control: "text" },
|
||||
},
|
||||
} satisfies Meta<typeof Badge>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Gray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeWarning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeSuccess: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeError: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const LargeGray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "large",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyWarning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinySuccess: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyError: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
|
||||
export const TinyGray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "tiny",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Badge } from "./index";
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: "UI/Badge",
|
||||
component: Badge,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Badge** component displays small status indicators or labels with different colors and sizes. Use it to highlight important information or show status states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
type: {
|
||||
control: "select",
|
||||
options: ["warning", "success", "error", "gray"],
|
||||
description: "Color variant of the badge",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "gray" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["tiny", "normal", "large"],
|
||||
description: "Size of the badge",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "normal" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
role: {
|
||||
control: "text",
|
||||
description: "Accessibility role attribute",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
text: {
|
||||
control: "text",
|
||||
description: "Badge content text",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
text: "Badge",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
text: "Warning",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for warnings or actions that need attention.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
text: "Success",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use to indicate successful operations or positive states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
text: "Error",
|
||||
type: "error",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for errors or failed operations.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Gray: Story = {
|
||||
args: {
|
||||
text: "Gray",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for neutral information or inactive states.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Tiny: Story = {
|
||||
args: {
|
||||
text: "Tiny",
|
||||
type: "gray",
|
||||
size: "tiny",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when space is very limited or for subtle indicators.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
text: "Large",
|
||||
type: "gray",
|
||||
size: "large",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for prominent badges or when more visibility is needed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const LongText: Story = {
|
||||
args: {
|
||||
text: "Very Long Badge Text",
|
||||
type: "warning",
|
||||
size: "normal",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Badge handles longer text content gracefully.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
text: "Custom",
|
||||
type: "gray",
|
||||
size: "normal",
|
||||
className: "bg-purple-100 border-purple-200 text-purple-800",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can override the default styling with custom CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRole: Story = {
|
||||
args: {
|
||||
text: "Status",
|
||||
type: "success",
|
||||
size: "normal",
|
||||
role: "status",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use role attribute for better accessibility, especially for dynamic status updates.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "ui/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
// Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout
|
||||
layout: "centered",
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["outline", "default", "secondary", "ghost", "destructive", "link"],
|
||||
},
|
||||
size: { control: "select", options: ["sm", "lg", "fab", "icon"] },
|
||||
},
|
||||
// Use `fn` to spy on the onClick arg, which will appear in the actions panel once invoked: https://storybook.js.org/docs/essentials/actions#action-args
|
||||
args: { onClick: fn() },
|
||||
} satisfies Meta<typeof Button>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "secondary",
|
||||
},
|
||||
};
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "ghost",
|
||||
},
|
||||
};
|
||||
|
||||
export const Warn: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "destructive",
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "default",
|
||||
loading: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,253 @@
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { Button } from "./index";
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: "UI/Button",
|
||||
component: Button,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha", exclude: [] },
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"The **Button** component provides clickable actions with multiple variants and sizes. It supports loading states, different visual styles, and can be used as a child component wrapper.",
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
loading: {
|
||||
control: "boolean",
|
||||
description: "Shows loading spinner and disables interaction",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
disabled: {
|
||||
control: "boolean",
|
||||
description: "Disables the button interaction",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
asChild: {
|
||||
control: "boolean",
|
||||
description: "Render as a child component using Slot",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "boolean" },
|
||||
defaultValue: { summary: "false" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
onClick: {
|
||||
action: "clicked",
|
||||
description: "Click handler function",
|
||||
table: {
|
||||
category: "Behavior",
|
||||
type: { summary: "function" },
|
||||
},
|
||||
order: 4,
|
||||
},
|
||||
variant: {
|
||||
control: "select",
|
||||
options: ["default", "destructive", "outline", "secondary", "ghost", "link"],
|
||||
description: "Visual style variant of the button",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
size: {
|
||||
control: "select",
|
||||
options: ["default", "sm", "lg", "icon"],
|
||||
description: "Size of the button",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
defaultValue: { summary: "default" },
|
||||
},
|
||||
order: 2,
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: {
|
||||
category: "Appearance",
|
||||
type: { summary: "string" },
|
||||
},
|
||||
order: 3,
|
||||
},
|
||||
children: {
|
||||
control: "text",
|
||||
description: "Button content",
|
||||
table: {
|
||||
category: "Content",
|
||||
type: { summary: "React.ReactNode" },
|
||||
},
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
args: { onClick: fn() },
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "default",
|
||||
},
|
||||
};
|
||||
|
||||
export const Destructive: Story = {
|
||||
args: {
|
||||
children: "Delete",
|
||||
variant: "destructive",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for actions that are destructive or potentially harmful, like deleting data.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Outline: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "outline",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for secondary actions or when you need a button with less visual weight.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "secondary",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for secondary actions that are less important than the primary action.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Ghost: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "ghost",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for subtle actions or when you need minimal visual impact.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Link: Story = {
|
||||
args: {
|
||||
children: "Button",
|
||||
variant: "link",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when you want button functionality but link appearance.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
children: "Small Button",
|
||||
size: "sm",
|
||||
},
|
||||
};
|
||||
|
||||
export const Large: Story = {
|
||||
args: {
|
||||
children: "Large Button",
|
||||
size: "lg",
|
||||
},
|
||||
};
|
||||
|
||||
export const Icon: Story = {
|
||||
args: {
|
||||
children: "×",
|
||||
size: "icon",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use for icon-only buttons. The button will be square-shaped.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
children: "Loading...",
|
||||
variant: "default",
|
||||
loading: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story:
|
||||
"Use to show loading state during async operations. The button becomes disabled and shows a spinner.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
children: "Disabled Button",
|
||||
disabled: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Use when the button action is temporarily unavailable.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const CustomStyling: Story = {
|
||||
args: {
|
||||
children: "Custom Button",
|
||||
className: "bg-purple-500 hover:bg-purple-600 text-white",
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "You can override the default styling with custom CSS classes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,68 +1,132 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { BellRing } from "lucide-react";
|
||||
import { Card } from "./index";
|
||||
import { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Button } from "../button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./index";
|
||||
|
||||
const meta = {
|
||||
interface CardStoryProps {
|
||||
title: string;
|
||||
description: string;
|
||||
content: string;
|
||||
footerContent: string;
|
||||
showFooter: boolean;
|
||||
className?: string;
|
||||
footerButton?: boolean;
|
||||
}
|
||||
|
||||
const meta: Meta<CardStoryProps> = {
|
||||
title: "UI/Card",
|
||||
component: Card,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
layout: "centered",
|
||||
controls: { sort: "alpha" },
|
||||
docs: {
|
||||
description: {
|
||||
component: `The **card** component is used to display a card with a label, description, and optional icon. It can also display a status and buttons for connecting and viewing documentation.`,
|
||||
component:
|
||||
"The **Card** component is a flexible container for content with consistent styling. It includes subcomponents like CardHeader, CardTitle, CardDescription, CardContent, and CardFooter for structured layouts.",
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
icon: { control: "text" },
|
||||
},
|
||||
argTypes: {
|
||||
title: {
|
||||
control: "text",
|
||||
description: "Card title",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
description: {
|
||||
control: "text",
|
||||
description: "Card description",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
content: {
|
||||
control: "text",
|
||||
description: "Main content of the card",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
footerContent: {
|
||||
control: "text",
|
||||
description: "Content for the card footer",
|
||||
table: { category: "Content" },
|
||||
},
|
||||
showFooter: {
|
||||
control: "boolean",
|
||||
description: "Toggle footer visibility",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
footerButton: {
|
||||
control: "boolean",
|
||||
description: "Show a button in the footer",
|
||||
table: { category: "Behavior" },
|
||||
},
|
||||
className: {
|
||||
control: "text",
|
||||
description: "Additional CSS classes",
|
||||
table: { category: "Appearance" },
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Card>;
|
||||
render: ({ title, description, content, footerContent, showFooter, footerButton, className }) => (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{content}</p>
|
||||
</CardContent>
|
||||
{showFooter && (
|
||||
<CardFooter>{footerButton ? <Button>{footerContent}</Button> : <p>{footerContent}</p>}</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<CardStoryProps>;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Primary: Story = {
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: true,
|
||||
statusText: "Connected",
|
||||
title: "Default Card",
|
||||
description: "This is the default card description.",
|
||||
content: "This is the main content area of the card. You can put any React node here.",
|
||||
footerContent: "Footer content",
|
||||
showFooter: true,
|
||||
footerButton: false,
|
||||
className: "w-96",
|
||||
},
|
||||
};
|
||||
|
||||
export const Disconnected: Story = {
|
||||
export const HeaderOnly: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: false,
|
||||
statusText: "Disconnected",
|
||||
...Default.args,
|
||||
title: "Header Only",
|
||||
description: "This card only has a header.",
|
||||
content: "",
|
||||
showFooter: false,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "A card that only displays a header. Useful for short announcements or titles.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
export const LongContent: Story = {
|
||||
args: {
|
||||
label: "Card Label",
|
||||
description: "This is the description of the card.",
|
||||
connectText: "Connect",
|
||||
connectHref: "#",
|
||||
connectNewTab: false,
|
||||
docsText: "Docs",
|
||||
docsHref: "#",
|
||||
docsNewTab: false,
|
||||
connected: true,
|
||||
statusText: "Connected",
|
||||
icon: <BellRing />,
|
||||
...Default.args,
|
||||
title: "Card with Long Content",
|
||||
description: "This card demonstrates how longer content is handled.",
|
||||
content:
|
||||
"This is a card with a longer content section to demonstrate how the card component handles extensive text content. The card will expand to accommodate the content while maintaining proper spacing and readability. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.",
|
||||
footerContent: "Read More",
|
||||
showFooter: true,
|
||||
footerButton: true,
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows how the card handles longer content with proper spacing and layout.",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user