diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..a42e503 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Re-scan main weekly so newly-disclosed CodeQL rules catch dormant issues. + - cron: '23 5 * * 1' + +permissions: + contents: read + security-events: write + actions: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + language: [javascript-typescript] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-and-quality + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@v3 + with: + category: '/language:${{ matrix.language }}' diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..c2937e6 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,85 @@ + +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hosting@hdnet.de. All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of actions. + +**Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/README.md b/README.md index cd544d9..7f055b3 100644 --- a/README.md +++ b/README.md @@ -129,9 +129,10 @@ lives in **[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)**. ```bash npm test # run all tests npm run test:watch # watch mode +npm run coverage # run tests with v8 coverage report (HTML + lcov under ./coverage) ``` -More than 210 tests across 25 test files including property-based tests (fast-check), unit tests covering crypto, webhooks, BunnyCDN API, auth, validation, scope, membership, security headers, health checks, i18n, and E2E smoke tests. +More than 220 tests across 25 test files including property-based tests (fast-check), unit tests covering crypto, webhooks, BunnyCDN API, auth, validation, scope, membership, security headers, health checks, i18n, and E2E smoke tests. ## Architecture diff --git a/package.json b/package.json index 8bed862..e4a16c4 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "start": "node .output/server/index.mjs", "test": "vitest run", "test:watch": "vitest", + "coverage": "vitest run --coverage", "dev:iframe": "npm run build && node --env-file=.env .output/server/index.mjs", "routes:generate": "tsr generate", "typecheck": "npm run routes:generate && tsc --noEmit", diff --git a/src/server/scope.ts b/src/server/scope.ts index 6c1103c..05e9b07 100644 --- a/src/server/scope.ts +++ b/src/server/scope.ts @@ -61,6 +61,25 @@ export function requireScope(db: AppDatabase, instanceId: string, scope: string) } } +/** + * Confirms the instance row exists; for read-only server functions whose + * downstream code doesn't follow up with `requireScope` / `requireEnabled`. + * Unlike `requireEnabled`, this deliberately does *not* check the paused + * flag — reads stay available while the extension is paused so the UI can + * still render the paused banner. Returns the row so callers that already + * needed to fetch it can skip a second query. + */ +export function requireInstanceExists(db: AppDatabase, instanceId: string) { + const instance = db.select().from(extensionInstances).where(eq(extensionInstances.id, instanceId)).get() + if (!instance) { + throw createAppError(ErrorType.AUTH_ERROR, 'Extension-Instanz nicht gefunden.', { + retryable: false, + code: 'INSTANCE_NOT_FOUND', + }) + } + return instance +} + /** * Blocks mutations when the extension instance is paused in mStudio. * mittwald sets `state.enabled = false` via webhook when the user deactivates diff --git a/src/serverFunctions/api-key.ts b/src/serverFunctions/api-key.ts index 28e4da0..cae7597 100644 --- a/src/serverFunctions/api-key.ts +++ b/src/serverFunctions/api-key.ts @@ -6,7 +6,7 @@ import { decrypt, encrypt } from '~/server/crypto' import { getDb } from '~/server/db/index' import { extensionInstances } from '~/server/db/schema' import { createLogger } from '~/server/logger.js' -import { requireEnabled } from '~/server/scope' +import { requireEnabled, requireInstanceExists } from '~/server/scope' import { createAppError, ErrorType } from '~/shared/errors' import { validateNonEmpty } from '~/shared/validation' @@ -54,12 +54,8 @@ export const deleteApiKeyFn = createServerFn({ method: 'POST' }) export const getApiKeyStatusFn = createServerFn({ method: 'GET' }) .middleware([authMiddleware]) .handler(async ({ context }: { context: { extensionInstanceId: string } }) => { - const instance = getDb() - .select() - .from(extensionInstances) - .where(eq(extensionInstances.id, context.extensionInstanceId)) - .get() - if (!instance?.encryptedApiKey) return { hasApiKey: false, last4: null } + const instance = requireInstanceExists(getDb(), context.extensionInstanceId) + if (!instance.encryptedApiKey) return { hasApiKey: false, last4: null } try { const key = decrypt(instance.encryptedApiKey) return { hasApiKey: true, last4: key.slice(-4) } diff --git a/src/serverFunctions/permissions.ts b/src/serverFunctions/permissions.ts index 15488c0..677abaa 100644 --- a/src/serverFunctions/permissions.ts +++ b/src/serverFunctions/permissions.ts @@ -1,7 +1,9 @@ import type { MittwaldAPIV2Client } from '@mittwald/api-client' import { createServerFn } from '@tanstack/react-start' import { authMiddlewareWithAccessToken } from '~/middleware/auth' +import { getDb } from '~/server/db/index' import { getProjectRole } from '~/server/membership' +import { requireInstanceExists } from '~/server/scope' export const checkPermissionsFn = createServerFn({ method: 'GET' }) .middleware([authMiddlewareWithAccessToken]) @@ -11,6 +13,7 @@ export const checkPermissionsFn = createServerFn({ method: 'GET' }) }: { context: { extensionInstanceId: string; contextId: string; mittwaldClient: MittwaldAPIV2Client } }) => { + requireInstanceExists(getDb(), context.extensionInstanceId) const { role, allowed } = await getProjectRole(context.mittwaldClient, context.contextId) return { role, allowed } }, diff --git a/tests/unit/bunnycdn-service.test.ts b/tests/unit/bunnycdn-service.test.ts index 01af0b1..823ce0f 100644 --- a/tests/unit/bunnycdn-service.test.ts +++ b/tests/unit/bunnycdn-service.test.ts @@ -248,3 +248,108 @@ describe('setupFullSiteCdn', () => { expect(urls[2]).toContain('loadFreeCertificate') }) }) + +describe('createPullZone — adoption flow', () => { + // bunny.net pull-zone names are globally unique. When POST returns the + // `pullzone.name_taken` error, createPullZone calls findPullZoneByName to + // see whether the existing zone lives in *our* account, and adopts it if so. + + it('adopts an existing pull zone when name is taken in our account and origins match', async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ ErrorKey: 'pullzone.name_taken' }), { status: 400 })) + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + Id: 9001, + Name: 'testzone', + Hostnames: [{ Value: 'testzone.b-cdn.net', HasCertificate: true }], + OriginUrl: 'https://example.com', + Enabled: true, + EnableGeoZoneEU: true, + EnableGeoZoneUS: true, + EnableGeoZoneASIA: true, + EnableGeoZoneSA: true, + EnableGeoZoneAF: true, + }, + ]), + { status: 200 }, + ), + ) + + const result = await createPullZone({ name: 'testzone', originUrl: 'https://example.com', apiKey: 'key' }) + + expect(result).toEqual({ + id: 9001, + name: 'testzone', + cdnDomain: 'testzone.b-cdn.net', + adopted: true, + }) + }) + + it('throws PULL_ZONE_ORIGIN_MISMATCH when the existing zone has a different origin', async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ ErrorKey: 'pullzone.name_taken' }), { status: 400 })) + mockFetch.mockResolvedValueOnce( + new Response( + JSON.stringify([ + { + Id: 9002, + Name: 'testzone', + Hostnames: [{ Value: 'testzone.b-cdn.net' }], + OriginUrl: 'https://different.com', + Enabled: true, + EnableGeoZoneEU: true, + EnableGeoZoneUS: true, + EnableGeoZoneASIA: true, + EnableGeoZoneSA: true, + EnableGeoZoneAF: true, + }, + ]), + { status: 200 }, + ), + ) + + await expect( + createPullZone({ name: 'testzone', originUrl: 'https://example.com', apiKey: 'key' }), + ).rejects.toMatchObject({ code: 'PULL_ZONE_ORIGIN_MISMATCH', type: ErrorType.BUNNY_API_ERROR }) + }) + + it('throws PULL_ZONE_NAME_GLOBAL_TAKEN when the name is taken outside our account', async () => { + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify({ ErrorKey: 'pullzone.name_taken' }), { status: 400 })) + // Search returns empty — name is taken globally but not by us. + mockFetch.mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + + await expect( + createPullZone({ name: 'globaltaken', originUrl: 'https://example.com', apiKey: 'key' }), + ).rejects.toMatchObject({ code: 'PULL_ZONE_NAME_GLOBAL_TAKEN', type: ErrorType.BUNNY_API_ERROR }) + }) + + it('rethrows non-name-taken errors unchanged', async () => { + mockFetch.mockResolvedValueOnce(new Response('Server Error', { status: 500 })) + + await expect( + createPullZone({ name: 'whatever', originUrl: 'https://example.com', apiKey: 'key' }), + ).rejects.toMatchObject({ code: 'BUNNY_API_ERROR', type: ErrorType.BUNNY_API_ERROR }) + }) +}) + +describe('bunnyFetch / bunnyRequest edge cases', () => { + it('createPullZone surfaces BUNNY_TIMEOUT when fetch aborts', async () => { + // Simulate AbortController firing — global fetch rejects with an + // AbortError instance. + const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' }) + mockFetch.mockRejectedValue(abortError) + + await expect(createPullZone({ name: 'x', originUrl: 'https://example.com', apiKey: 'key' })).rejects.toMatchObject({ + code: 'BUNNY_TIMEOUT', + type: ErrorType.NETWORK_ERROR, + }) + }) + + it('purgeCache succeeds even when the response body is empty (200 + no body)', async () => { + mockFetch.mockResolvedValue(new Response('', { status: 200 })) + + // No throw, undefined return — exercises the `if (!text) return undefined` + // branch in bunnyRequest. + await expect(purgeCache(42, 'key')).resolves.toBeUndefined() + }) +}) diff --git a/tests/unit/logger.test.ts b/tests/unit/logger.test.ts new file mode 100644 index 0000000..a942d2e --- /dev/null +++ b/tests/unit/logger.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// `createLogger` reads NODE_ENV at module-load time, so we re-import via +// dynamic import after toggling the env to test the production JSON path. +async function loadLoggerWithEnv(env: 'development' | 'production') { + vi.resetModules() + const original = process.env.NODE_ENV + process.env.NODE_ENV = env + const mod = await import('~/server/logger.js') + process.env.NODE_ENV = original + return mod +} + +describe('createLogger — dev mode', () => { + let infoSpy: ReturnType + let warnSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('emits human-readable lines with module prefix', async () => { + const { createLogger } = await loadLoggerWithEnv('development') + const log = createLogger('test') + log.info('hello') + expect(infoSpy).toHaveBeenCalledWith('[test]', 'hello') + }) + + it('routes warn to console.warn and error to console.error', async () => { + const { createLogger } = await loadLoggerWithEnv('development') + const log = createLogger('test') + log.warn('caution') + log.error('boom') + expect(warnSpy).toHaveBeenCalledWith('[test]', 'caution') + expect(errorSpy).toHaveBeenCalledWith('[test]', 'boom') + }) + + it('appends extra payload as JSON when provided', async () => { + const { createLogger } = await loadLoggerWithEnv('development') + const log = createLogger('test') + log.info('msg', { foo: 'bar' }) + expect(infoSpy).toHaveBeenCalledWith('[test]', 'msg {"foo":"bar"}') + }) +}) + +describe('createLogger — production mode (JSON lines)', () => { + let infoSpy: ReturnType + let warnSpy: ReturnType + let errorSpy: ReturnType + + beforeEach(() => { + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('emits a single JSON line with level, msg, module, ts', async () => { + const { createLogger } = await loadLoggerWithEnv('production') + const log = createLogger('mod') + log.info('hi') + + expect(infoSpy).toHaveBeenCalledTimes(1) + const line = infoSpy.mock.calls[0][0] as string + const parsed = JSON.parse(line) + expect(parsed).toMatchObject({ level: 'info', msg: 'hi', module: 'mod' }) + expect(parsed.ts).toMatch(/^\d{4}-\d{2}-\d{2}T/) + }) + + it('routes warn/error through console.warn/console.error', async () => { + const { createLogger } = await loadLoggerWithEnv('production') + const log = createLogger('mod') + log.warn('w') + log.error('e') + expect(warnSpy).toHaveBeenCalledTimes(1) + expect(errorSpy).toHaveBeenCalledTimes(1) + expect(JSON.parse(warnSpy.mock.calls[0][0] as string).level).toBe('warn') + expect(JSON.parse(errorSpy.mock.calls[0][0] as string).level).toBe('error') + }) +}) + +describe('logger redaction (dev mode is enough — same redact path is shared)', () => { + let infoSpy: ReturnType + + beforeEach(() => { + infoSpy = vi.spyOn(console, 'info').mockImplementation(() => {}) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('redacts top-level secret-named fields', async () => { + const { createLogger } = await loadLoggerWithEnv('development') + createLogger('m').info('msg', { apiKey: 'a-real-key', other: 'fine' }) + const out = infoSpy.mock.calls[0][1] as string + expect(out).toContain('[REDACTED]') + expect(out).not.toContain('a-real-key') + expect(out).toContain('fine') + }) + + it('redacts nested secret-named fields', async () => { + const { createLogger } = await loadLoggerWithEnv('development') + createLogger('m').info('msg', { headers: { authorization: 'Bearer xyz' } }) + const out = infoSpy.mock.calls[0][1] as string + expect(out).toContain('[REDACTED]') + expect(out).not.toContain('xyz') + }) + + it('redacts fields inside arrays of objects', async () => { + const { createLogger } = await loadLoggerWithEnv('development') + createLogger('m').info('msg', { entries: [{ token: 'leak-me' }, { ok: 1 }] }) + const out = infoSpy.mock.calls[0][1] as string + expect(out).not.toContain('leak-me') + }) + + it('matches case-insensitively (Authorization, API_KEY, Password)', async () => { + const { createLogger } = await loadLoggerWithEnv('development') + createLogger('m').info('msg', { API_KEY: 'x', Password: 'y', Authorization: 'z' }) + const out = infoSpy.mock.calls[0][1] as string + expect(out).not.toContain('"x"') + expect(out).not.toContain('"y"') + expect(out).not.toContain('"z"') + }) + + it('wraps non-object extras under a `value` key (still redacted recursively)', async () => { + const { createLogger } = await loadLoggerWithEnv('development') + createLogger('m').info('msg', 'just a string') + const out = infoSpy.mock.calls[0][1] as string + expect(out).toContain('"value":"just a string"') + }) + + it('omits extras when undefined or null', async () => { + const { createLogger } = await loadLoggerWithEnv('development') + createLogger('m').info('plain') + expect(infoSpy.mock.calls[0][1]).toBe('plain') + }) +}) diff --git a/tests/unit/scope.test.ts b/tests/unit/scope.test.ts index 2267432..03663a0 100644 --- a/tests/unit/scope.test.ts +++ b/tests/unit/scope.test.ts @@ -1,7 +1,7 @@ import { eq } from 'drizzle-orm' import { beforeEach, describe, expect, it } from 'vitest' import { extensionInstances } from '~/server/db/schema.js' -import { requireEnabled, requireScope } from '~/server/scope.js' +import { requireEnabled, requireInstanceExists, requireScope } from '~/server/scope.js' import { isAppError } from '~/shared/errors.js' import { createTestDb, seedInstance } from '../helpers/db.js' @@ -34,6 +34,42 @@ describe('requireScope', () => { expect(isAppError(e) && e.type).toBe('AUTH_ERROR') } }) + + it('treats invalid consentedScopes JSON as an empty scope set (fails closed)', () => { + db.update(extensionInstances).set({ consentedScopes: 'not-json' }).where(eq(extensionInstances.id, 'inst-1')).run() + + try { + requireScope(db, 'inst-1', 'domain:read') + throw new Error('expected throw') + } catch (e) { + expect(isAppError(e) && e.code).toBe('MISSING_SCOPE') + } + }) + + it('ignores non-string entries inside consentedScopes', () => { + db.update(extensionInstances) + .set({ consentedScopes: JSON.stringify(['domain:read', 42, null, 'domain:write']) }) + .where(eq(extensionInstances.id, 'inst-1')) + .run() + + // Strings pass through, non-strings are dropped — domain:write is still granted. + expect(() => requireScope(db, 'inst-1', 'domain:read')).not.toThrow() + expect(() => requireScope(db, 'inst-1', 'domain:write')).not.toThrow() + }) + + it('treats a non-array consentedScopes payload as empty', () => { + db.update(extensionInstances) + .set({ consentedScopes: '{"not":"an-array"}' }) + .where(eq(extensionInstances.id, 'inst-1')) + .run() + + try { + requireScope(db, 'inst-1', 'domain:read') + throw new Error('expected throw') + } catch (e) { + expect(isAppError(e) && e.code).toBe('MISSING_SCOPE') + } + }) }) describe('requireEnabled', () => { @@ -69,3 +105,34 @@ describe('requireEnabled', () => { } }) }) + +describe('requireInstanceExists', () => { + let db: ReturnType + + beforeEach(() => { + db = createTestDb() + seedInstance(db, 'inst-r') + }) + + it('returns the instance row when present', () => { + const row = requireInstanceExists(db, 'inst-r') + expect(row.id).toBe('inst-r') + expect(row.enabled).toBe(true) + }) + + it('returns the row even when the instance is paused (reads must still work)', () => { + db.update(extensionInstances).set({ enabled: false }).where(eq(extensionInstances.id, 'inst-r')).run() + const row = requireInstanceExists(db, 'inst-r') + expect(row.enabled).toBe(false) + }) + + it('throws AUTH_ERROR with INSTANCE_NOT_FOUND when the instance does not exist', () => { + try { + requireInstanceExists(db, 'ghost') + throw new Error('expected throw') + } catch (e) { + expect(isAppError(e) && e.type).toBe('AUTH_ERROR') + expect(isAppError(e) && e.code).toBe('INSTANCE_NOT_FOUND') + } + }) +}) diff --git a/tests/unit/webhook-dedup.test.ts b/tests/unit/webhook-dedup.test.ts index edf29e5..9e3991d 100644 --- a/tests/unit/webhook-dedup.test.ts +++ b/tests/unit/webhook-dedup.test.ts @@ -1,5 +1,5 @@ import { eq } from 'drizzle-orm' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { processedWebhookRequests } from '~/server/db/schema.js' import { markProcessed, pruneOlderThan, startWebhookDedupSweeper, wasProcessed } from '~/server/webhooks/dedup.js' import { createTestDb } from '../helpers/db.js' @@ -89,3 +89,77 @@ describe('startWebhookDedupSweeper', () => { process.env.NODE_ENV = before }) }) + +describe('startWebhookDedupSweeper — active mode (NODE_ENV!=test)', () => { + let envBefore: string | undefined + + beforeEach(() => { + envBefore = process.env.NODE_ENV + process.env.NODE_ENV = 'development' + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + process.env.NODE_ENV = envBefore + // The sweeper stores its interval handle in a module-scope `sweepHandle`. + // We cannot reset it from outside, so each test uses a fresh DB and the + // post-test cleanup just relies on `vi.useRealTimers()` to neutralise + // the leaked interval. + }) + + it('schedules an interval that prunes stale entries on each tick', async () => { + // Use a separate isolated module load so each test gets a fresh + // `sweepHandle = null` and won't be no-op'd by a prior test's handle. + vi.resetModules() + const { + markProcessed: m, + startWebhookDedupSweeper: start, + wasProcessed: w, + } = await import('~/server/webhooks/dedup.js') + const { processedWebhookRequests: pwr } = await import('~/server/db/schema.js') + + const db = createTestDb() + m(db, 'old-entry') + // Force-age the row beyond the 14d retention window. + const overRetention = new Date(Date.now() - 15 * 24 * 60 * 60 * 1000) + db.update(pwr).set({ processedAt: overRetention }).where(eq(pwr.id, 'old-entry')).run() + expect(w(db, 'old-entry')).toBe(true) + + start(db) + // SWEEP_INTERVAL_MS = 6h — advance past one tick. + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000) + + expect(w(db, 'old-entry')).toBe(false) + }) + + it('swallows db errors inside the sweep callback (does not crash the interval)', async () => { + vi.resetModules() + const { startWebhookDedupSweeper: start } = await import('~/server/webhooks/dedup.js') + + // Stub a db whose .delete().where().run() throws. + const throwingDb = { + delete: () => ({ + where: () => ({ + run: () => { + throw new Error('disk full') + }, + }), + }), + } as unknown as Parameters[0] + + start(throwingDb) + // No throw bubbling out across the tick — caught and logged inside. + await vi.advanceTimersByTimeAsync(6 * 60 * 60 * 1000) + }) + + it('is idempotent on repeated calls — second call leaves the existing interval alone', async () => { + vi.resetModules() + const { startWebhookDedupSweeper: start } = await import('~/server/webhooks/dedup.js') + + const db = createTestDb() + expect(() => start(db)).not.toThrow() + expect(() => start(db)).not.toThrow() + expect(() => start(db)).not.toThrow() + }) +}) diff --git a/tests/unit/webhook-handler.test.ts b/tests/unit/webhook-handler.test.ts index cc5ef7a..b820494 100644 --- a/tests/unit/webhook-handler.test.ts +++ b/tests/unit/webhook-handler.test.ts @@ -1,5 +1,5 @@ import { eq } from 'drizzle-orm' -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { extensionInstances, pullZones } from '~/server/db/schema.js' import { handleExtensionAdded, @@ -9,6 +9,14 @@ import { } from '~/server/webhooks/handler.js' import { createTestDb } from '../helpers/db.js' +vi.mock('~/server/bunnycdn.js', () => ({ + deletePullZone: vi.fn().mockResolvedValue(undefined), +})) + +// `decrypt` needs the encryption env vars set when imported by handler.ts. +process.env.ENCRYPTION_MASTER_PASSWORD = process.env.ENCRYPTION_MASTER_PASSWORD || 'test-password' +process.env.ENCRYPTION_SALT = process.env.ENCRYPTION_SALT || 'test-salt' + describe('handleExtensionAdded', () => { let db: ReturnType @@ -342,4 +350,105 @@ describe('handleInstanceRemoved', () => { const rows = db.select().from(extensionInstances).all() expect(rows).toHaveLength(1) }) + + it('best-effort deletes the bunny zone when an API key is present', async () => { + const { encrypt } = await import('~/server/crypto.js') + db.update(extensionInstances) + .set({ encryptedApiKey: encrypt('test-key') }) + .where(eq(extensionInstances.id, 'inst-del')) + .run() + db.insert(pullZones) + .values({ + id: 555, + instanceId: 'inst-del', + cdnDomain: 'xyz.b-cdn.net', + originUrl: 'https://example.com', + cdnMode: 'asset', + createdAt: new Date(), + }) + .run() + + const bunny = await import('~/server/bunnycdn.js') + vi.mocked(bunny.deletePullZone).mockClear() + + const result = await handleInstanceRemoved(db, { + id: 'inst-del', + kind: 'InstanceRemovedFromContext', + apiVersion: 'v1', + request: { id: 'req-1', createdAt: '2026-01-01T00:00:00Z', target: { method: 'POST', url: 'http://test' } }, + context: { id: 'ctx', kind: 'project' }, + meta: { extensionId: 'ext-1', contributorId: 'contrib-1' }, + consentedScopes: [], + state: { enabled: true }, + }) + + expect(bunny.deletePullZone).toHaveBeenCalledWith(555, 'test-key') + expect(result).toEqual({ hadPullZone: true, bunnyDeleted: true }) + }) + + it('swallows bunny.deletePullZone failures (does not block instance removal)', async () => { + const { encrypt } = await import('~/server/crypto.js') + db.update(extensionInstances) + .set({ encryptedApiKey: encrypt('test-key') }) + .where(eq(extensionInstances.id, 'inst-del')) + .run() + db.insert(pullZones) + .values({ + id: 666, + instanceId: 'inst-del', + cdnDomain: 'xyz.b-cdn.net', + originUrl: 'https://example.com', + cdnMode: 'asset', + createdAt: new Date(), + }) + .run() + + const bunny = await import('~/server/bunnycdn.js') + vi.mocked(bunny.deletePullZone).mockRejectedValueOnce(new Error('bunny 503')) + + const result = await handleInstanceRemoved(db, { + id: 'inst-del', + kind: 'InstanceRemovedFromContext', + apiVersion: 'v1', + request: { id: 'req-1', createdAt: '2026-01-01T00:00:00Z', target: { method: 'POST', url: 'http://test' } }, + context: { id: 'ctx', kind: 'project' }, + meta: { extensionId: 'ext-1', contributorId: 'contrib-1' }, + consentedScopes: [], + state: { enabled: true }, + }) + + expect(result).toEqual({ hadPullZone: true, bunnyDeleted: false }) + // Instance row still removed despite bunny failure + expect(db.select().from(extensionInstances).all()).toHaveLength(0) + }) + + it('logs and skips bunny call when pull zone exists but no API key is stored', async () => { + db.insert(pullZones) + .values({ + id: 777, + instanceId: 'inst-del', + cdnDomain: 'xyz.b-cdn.net', + originUrl: 'https://example.com', + cdnMode: 'asset', + createdAt: new Date(), + }) + .run() + + const bunny = await import('~/server/bunnycdn.js') + vi.mocked(bunny.deletePullZone).mockClear() + + const result = await handleInstanceRemoved(db, { + id: 'inst-del', + kind: 'InstanceRemovedFromContext', + apiVersion: 'v1', + request: { id: 'req-1', createdAt: '2026-01-01T00:00:00Z', target: { method: 'POST', url: 'http://test' } }, + context: { id: 'ctx', kind: 'project' }, + meta: { extensionId: 'ext-1', contributorId: 'contrib-1' }, + consentedScopes: [], + state: { enabled: true }, + }) + + expect(bunny.deletePullZone).not.toHaveBeenCalled() + expect(result).toEqual({ hadPullZone: true, bunnyDeleted: false }) + }) })