Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/workflows/test-sharding.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Parallelized test sharding — splits the Vitest suite across 4 concurrent
# workers, reducing CI runtime proportionally to shard count.
#
# Sharding strategy
# -----------------
# Shard count: 4
# - Each shard executes roughly 25 % of test files.
# - Spin-up cost per worker is ~15 s; suite currently runs ~60 s total,
# giving an effective ~45 s wall-clock with 4 shards (3× speedup after
# overhead). Revisit shard count when any shard regularly exceeds 30 s.
#
# Shard safety requirements (enforced by vitest.config.ts `sequence.shuffle: false`)
# - Tests must not rely on shared mutable global state across describe blocks.
# - Each test file must be independently runnable in any order.
# - Top-level `vi.mock()` declarations are fine; singleton state must be
# reset in `beforeEach` so it does not bleed between tests in a shard.
#
# Result aggregation
# - Each shard uploads its JSON coverage report as an artifact.
# - The `merge-coverage` job downloads all shard artifacts and merges them
# with `vitest merge-reports`, producing a single combined coverage summary.

name: Parallelized Test Sharding

on:
pull_request:
push:
branches:
- main

jobs:
test:
name: Test shard ${{ matrix.shard }}/${{ strategy.job-total }}
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Run test shard ${{ matrix.shard }}/4
run: |
npm run --workspace @craft/backend test -- \
--reporter=json \
--outputFile=coverage/shard-${{ matrix.shard }}.json \
--shard=${{ matrix.shard }}/4
env:
ARTIFACT_SIGNING_SECRET: test-artifact-signing-secret-32b!!

- name: Upload shard coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-shard-${{ matrix.shard }}
path: coverage/shard-${{ matrix.shard }}.json
retention-days: 7

merge-coverage:
name: Merge coverage reports
runs-on: ubuntu-latest
needs: test
if: always()

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Download all shard coverage artifacts
uses: actions/download-artifact@v4
with:
pattern: coverage-shard-*
merge-multiple: true
path: coverage/shards

- name: Merge shard reports into combined summary
run: npx vitest merge-reports coverage/shards --reporter=text

frontend-test:
name: Frontend test shard ${{ matrix.shard }}/${{ strategy.job-total }}
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
shard: [1, 2]

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: Install dependencies
run: npm ci

- name: Run frontend test shard ${{ matrix.shard }}/2
run: |
npm run --workspace @craft/frontend test -- \
--shard=${{ matrix.shard }}/2
131 changes: 131 additions & 0 deletions apps/backend/src/app/api/deployments/[id]/dns/verify/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,135 @@ describe('POST /api/deployments/[id]/dns/verify', () => {
expect(body.requiredTier).toBe('pro');
expect(body.upgradeUrl).toBe('/pricing');
});

// DNS record validation — A record is not a supported method; only TXT and CNAME are valid
// WCAG SC 3.3.1: invalid input should produce a clear error response (400)
it('returns 400 when method is an unsupported DNS record type (A record)', async () => {
mockFrom.mockReturnValue(withProTier([]));
const { POST } = await import('./route');
const res = await POST(makeRequest({ method: 'a-record', token: '1.2.3.4' }), { params });
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBeTruthy();
});

it('returns 400 when request body is not valid JSON', async () => {
mockFrom.mockReturnValue(withProTier([]));
const { POST } = await import('./route');
const req = new NextRequest('http://localhost/api/deployments/dep-1/dns/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: 'not-json{{',
});
const res = await POST(req, { params });
expect(res.status).toBe(400);
});

it('returns 400 when token is not a string for txt method', async () => {
mockFrom.mockReturnValue(withProTier([]));
const { POST } = await import('./route');
const res = await POST(makeRequest({ method: 'txt', token: 12345 }), { params });
expect(res.status).toBe(400);
});

// DNS verification failure — TXT record present but value does not match
it('returns 200 with verified=false when TXT record value does not match', async () => {
mockFrom.mockReturnValue(
withProTier([{ data: { custom_domain: 'example.com' }, error: null }]),
);
mockVerifyViaTxt.mockResolvedValue({
verified: false,
method: 'txt',
domain: 'example.com',
errorCode: 'TOKEN_MISMATCH',
});

const { POST } = await import('./route');
const res = await POST(makeRequest({ method: 'txt', token: 'wrong-token' }), { params });

expect(res.status).toBe(200);
const body = await res.json();
expect(body.verified).toBe(false);
expect(body.errorCode).toBe('TOKEN_MISMATCH');
});

// DNS verification failure — CNAME points to wrong target
it('returns 200 with verified=false when CNAME does not point to expected target', async () => {
mockFrom.mockReturnValue(
withProTier([{ data: { custom_domain: 'www.example.com' }, error: null }]),
);
mockVerifyViaCname.mockResolvedValue({
verified: false,
method: 'cname',
domain: 'www.example.com',
errorCode: 'WRONG_TARGET',
});

const { POST } = await import('./route');
const res = await POST(makeRequest({ method: 'cname' }), { params });

expect(res.status).toBe(200);
const body = await res.json();
expect(body.verified).toBe(false);
expect(body.errorCode).toBe('WRONG_TARGET');
});

// DNS verification throws — upstream DNS resolver unavailable
it('returns 500 when DNS verification throws an unexpected error', async () => {
mockFrom.mockReturnValue(
withProTier([{ data: { custom_domain: 'example.com' }, error: null }]),
);
mockVerifyViaTxt.mockRejectedValue(new Error('DNS resolver unavailable'));

const { POST } = await import('./route');
const res = await POST(makeRequest({ method: 'txt', token: 'tok' }), { params });

expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toMatch(/DNS resolver unavailable/);
});

// CNAME verification throws — network timeout during DNS lookup
it('returns 500 when CNAME lookup times out', async () => {
mockFrom.mockReturnValue(
withProTier([{ data: { custom_domain: 'www.example.com' }, error: null }]),
);
mockVerifyViaCname.mockRejectedValue(new Error('DNS lookup timed out'));

const { POST } = await import('./route');
const res = await POST(makeRequest({ method: 'cname' }), { params });

expect(res.status).toBe(500);
const body = await res.json();
expect(body.error).toMatch(/timed out/);
});

// Deployment not found in DB
it('returns 404 when deployment row does not exist in database', async () => {
mockFrom.mockReturnValue(
withProTier([{ data: null, error: { message: 'No rows found' } }]),
);
const { POST } = await import('./route');
const res = await POST(makeRequest({ method: 'txt', token: 'tok' }), { params });
expect(res.status).toBe(404);
});

// Enterprise tier is also allowed (not just pro)
it('allows enterprise-tier users to verify DNS', async () => {
mockFrom.mockReturnValue(
makeSupabaseQuery([
{ data: { user_id: fakeUser.id }, error: null },
{ data: { subscription_tier: 'enterprise' }, error: null },
{ data: { custom_domain: 'corp.example.com' }, error: null },
]),
);
mockVerifyViaTxt.mockResolvedValue({ verified: true, method: 'txt', domain: 'corp.example.com' });

const { POST } = await import('./route');
const res = await POST(makeRequest({ method: 'txt', token: 'ent-token' }), { params });

expect(res.status).toBe(200);
const body = await res.json();
expect(body.verified).toBe(true);
});
});
Loading
Loading