From 3253c8960cdb0a2e41c4fe2c6f8a0f49ae3c2e20 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Sat, 27 Jun 2026 08:10:17 +0100 Subject: [PATCH 1/3] ci: enforce per-directory coverage floor for src/webhooks --- .github/workflows/ci.yml | 124 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..63d4ea9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,124 @@ +name: CI + +on: + push: + branches: [ main, develop, feature/* ] + pull_request: + branches: [ main, develop ] + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: indexer_test + POSTGRES_USER: test_user + POSTGRES_PASSWORD: test_password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Get pnpm store directory + id: pnpm-cache + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT + + - name: Setup pnpm cache + uses: actions/cache@v3 + with: + path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run linter + run: pnpm run lint || echo "Linter not configured" + continue-on-error: true + + - name: Run tests + env: + DATABASE_URL: postgresql://test_user:test_password@localhost:5432/indexer_test + REPLAY_BATCH_SIZE: 100 + run: pnpm test:coverage + + - name: Check webhooks coverage gate + if: always() + run: node scripts/check-webhooks-coverage.mjs + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + files: ./coverage/lcov.info + flags: unittests + name: codecov-umbrella + + - name: Build + run: pnpm run build + + security: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install pnpm + uses: pnpm/action-setup@v2 + with: + version: 8 + + - name: Run security audit + run: pnpm audit --audit-level=moderate + + docker: + name: Docker Build + runs-on: ubuntu-latest + needs: [test] + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Build Docker image + uses: docker/build-push-action@v4 + with: + context: . + push: false + tags: indexer:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max From 95638980a0086fb4f467115dc6db9e196cbd7118 Mon Sep 17 00:00:00 2001 From: MerlinTheWhiz Date: Sat, 27 Jun 2026 10:04:56 +0100 Subject: [PATCH 2/3] feat(streams): honor If-None-Match and return 304 on GET /api/streams/:id --- src/openapi/spec.ts | 37 ++++++++++++++- src/routes/streams.ts | 41 ++++++++++++++++ tests/routes/streams.test.ts | 91 ++++++++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/openapi/spec.ts b/src/openapi/spec.ts index b1e2a3f..45f317c 100644 --- a/src/openapi/spec.ts +++ b/src/openapi/spec.ts @@ -703,8 +703,26 @@ registry.registerPath({ method: 'get', path: '/api/streams/{id}', summary: 'Get stream by ID', + description: + 'Returns a single stream record. Supports conditional GET via the ' + + '`If-None-Match` request header (RFC 7232 §3.2). ' + + 'When the ETag matches, a 304 Not Modified response is returned ' + + 'with no body, indicating the client\'s cached representation is still fresh. ' + + 'The server emits a weak ETag (`W/"…"`) derived from the stream id and ' + + '`updated_at` timestamp.', tags: ['streams'], - request: { params: z.object({ id: z.string().openapi({ example: 'stream-abc123' }) }) }, + request: { + params: z.object({ id: z.string().openapi({ example: 'stream-abc123' }) }), + headers: z.object({ + 'If-None-Match': z.string().optional().openapi({ + description: + 'Conditional request header (RFC 7232 §3.2). ' + + 'Accepts `*`, a single entity-tag, or a comma-separated list of ' + + 'entity-tags. Weak comparison is used per the spec.', + example: 'W/"abc123..."', + }), + }), + }, responses: { '200': { description: 'Stream record', @@ -712,6 +730,23 @@ registry.registerPath({ 'application/json': { schema: successSchema(z.object({ stream: StreamObject })) }, }, }, + '304': { + description: + 'Not Modified — the ETag matches the `If-None-Match` value. ' + + 'Body is empty. The response includes the same `ETag` and `Last-Modified` ' + + 'headers that would accompany a 200 response.', + headers: { + ...commonResponseHeaders, + 'ETag': { + description: 'Weak entity-tag of the unchanged representation.', + schema: { type: 'string', example: 'W/"abc123..."' }, + }, + 'Last-Modified': { + description: 'Last modification timestamp of the stream.', + schema: { type: 'string', format: 'http-date', example: 'Thu, 01 Jan 2026 00:00:00 GMT' }, + }, + }, + }, '404': errorResponses['404'], '503': errorResponses['503'], }, diff --git a/src/routes/streams.ts b/src/routes/streams.ts index 4a5657a..62dfe38 100644 --- a/src/routes/streams.ts +++ b/src/routes/streams.ts @@ -232,6 +232,31 @@ function setStreamResourceHeaders( res.set('Last-Modified', new Date(metadata.updated_at).toUTCString()); } +/** + * RFC 7232 §3.2 weak comparison for If-None-Match. + * + * The `*` wildcard matches any current representation. + * Otherwise the field value is a comma-separated list of entity-tags and the + * recipient uses the weak comparison function (strip `W/` prefix before + * character-for-character comparison of the opaque-tag, including DQUOTES). + * + * @param ifNoneMatch - raw value of the If-None-Match request header + * @param etag - the server-computed ETag for the current representation + * @returns `true` when any entry in the list matches + */ +function matchesIfNoneMatch(ifNoneMatch: string, etag: string): boolean { + const trimmed = ifNoneMatch.trim(); + if (trimmed === '*') return true; + + const normalize = (tag: string): string => tag.replace(/^W\//i, ''); + const normalizedEtag = normalize(etag); + + return trimmed + .split(',') + .map((t) => normalize(t.trim())) + .some((t) => t === normalizedEtag); +} + // ── Cursor helpers ──────────────────────────────────────────────────────────── function encodeCursor(lastId: string): string { @@ -547,6 +572,22 @@ streamsRouter.get( } if (!record) throw notFound('Stream', id); + + // Conditional GET (RFC 7232 §3.2) + const etag = streamEntityTag(record!); + const rawIfNoneMatch = req.headers['if-none-match']; + if (rawIfNoneMatch !== undefined) { + const header = Array.isArray(rawIfNoneMatch) + ? rawIfNoneMatch.join(', ') + : rawIfNoneMatch; + if (matchesIfNoneMatch(header, etag)) { + res.set('ETag', etag); + res.set('Last-Modified', new Date(record!.updated_at).toUTCString()); + res.status(304).end(); + return; + } + } + const stream = toApiStream(record!); setStreamResourceHeaders(res, record!); res.set( diff --git a/tests/routes/streams.test.ts b/tests/routes/streams.test.ts index ee28e9a..e8d4f0f 100644 --- a/tests/routes/streams.test.ts +++ b/tests/routes/streams.test.ts @@ -248,6 +248,97 @@ describe('streams routes', () => { mockGetById.mockRejectedValue(new PoolExhaustedError()); expect((await request(app).get('/api/streams/stream-x')).status).toBe(503); }); + + // ── Conditional GET (RFC 7232) ─────────────────────────────────────── + + it('returns 304 when If-None-Match matches the ETag', async () => { + mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' })); + const first = await request(app).get('/api/streams/stream-abc-0'); + const etag = first.headers['etag'] as string; + + const res = await request(app) + .get('/api/streams/stream-abc-0') + .set('If-None-Match', etag); + expect(res.status).toBe(304); + expect(res.text).toBe(''); + expect(res.headers['etag']).toBe(etag); + }); + + it('returns 304 for If-None-Match: *', async () => { + mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' })); + const res = await request(app) + .get('/api/streams/stream-abc-0') + .set('If-None-Match', '*'); + expect(res.status).toBe(304); + expect(res.text).toBe(''); + }); + + it('returns 304 when If-None-Match is a comma-separated list containing the ETag', async () => { + mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' })); + const first = await request(app).get('/api/streams/stream-abc-0'); + const etag = first.headers['etag'] as string; + + const res = await request(app) + .get('/api/streams/stream-abc-0') + .set('If-None-Match', `W/"other", ${etag}, W/"another"`); + expect(res.status).toBe(304); + }); + + it('returns 200 when If-None-Match does not match', async () => { + mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' })); + const res = await request(app) + .get('/api/streams/stream-abc-0') + .set('If-None-Match', 'W/"some-other-tag"'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.stream.id).toBe('stream-abc-0'); + }); + + it('returns 200 when If-None-Match is absent', async () => { + mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' })); + const res = await request(app).get('/api/streams/stream-abc-0'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + }); + + it('sets ETag and Last-Modified headers on 304', async () => { + mockGetById.mockResolvedValue(makeDbRecord({ + id: 'stream-abc-0', updated_at: '2024-01-01T00:00:00.000Z', + })); + const first = await request(app).get('/api/streams/stream-abc-0'); + const etag = first.headers['etag'] as string; + + const res = await request(app) + .get('/api/streams/stream-abc-0') + .set('If-None-Match', etag); + expect(res.status).toBe(304); + expect(res.headers['etag']).toBeDefined(); + expect(res.headers['last-modified']).toBeDefined(); + }); + + it('304 has no body', async () => { + mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' })); + const first = await request(app).get('/api/streams/stream-abc-0'); + const etag = first.headers['etag'] as string; + + const res = await request(app) + .get('/api/streams/stream-abc-0') + .set('If-None-Match', etag); + expect(res.status).toBe(304); + expect(res.text).toBe(''); + }); + + it('does not break the success envelope shape for 200', async () => { + mockGetById.mockResolvedValue(makeDbRecord({ id: 'stream-abc-0' })); + const res = await request(app) + .get('/api/streams/stream-abc-0') + .set('If-None-Match', 'W/"non-matching"'); + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toBeDefined(); + expect(res.body.data.stream).toBeDefined(); + expect(res.body.meta.timestamp).toBeDefined(); + }); }); From 2682ccea19e19dcc66edaeaf566d11df4f5998fb Mon Sep 17 00:00:00 2001 From: Jagadeeshftw Date: Mon, 29 Jun 2026 07:53:12 +0530 Subject: [PATCH 3/3] remove workflow files for merge --- .github/workflows/ci.yml | 124 --------------------------------------- 1 file changed, 124 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 63d4ea9..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,124 +0,0 @@ -name: CI - -on: - push: - branches: [ main, develop, feature/* ] - pull_request: - branches: [ main, develop ] - -jobs: - test: - name: Test - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:15-alpine - env: - POSTGRES_DB: indexer_test - POSTGRES_USER: test_user - POSTGRES_PASSWORD: test_password - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 8 - - - name: Get pnpm store directory - id: pnpm-cache - shell: bash - run: | - echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT - - - name: Setup pnpm cache - uses: actions/cache@v3 - with: - path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} - key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} - restore-keys: | - ${{ runner.os }}-pnpm-store- - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run linter - run: pnpm run lint || echo "Linter not configured" - continue-on-error: true - - - name: Run tests - env: - DATABASE_URL: postgresql://test_user:test_password@localhost:5432/indexer_test - REPLAY_BATCH_SIZE: 100 - run: pnpm test:coverage - - - name: Check webhooks coverage gate - if: always() - run: node scripts/check-webhooks-coverage.mjs - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - files: ./coverage/lcov.info - flags: unittests - name: codecov-umbrella - - - name: Build - run: pnpm run build - - security: - name: Security Audit - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: '18' - - - name: Install pnpm - uses: pnpm/action-setup@v2 - with: - version: 8 - - - name: Run security audit - run: pnpm audit --audit-level=moderate - - docker: - name: Docker Build - runs-on: ubuntu-latest - needs: [test] - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Build Docker image - uses: docker/build-push-action@v4 - with: - context: . - push: false - tags: indexer:${{ github.sha }} - cache-from: type=gha - cache-to: type=gha,mode=max