UNPKG

@accounter/scraper-app

Version:
435 lines (359 loc) 14.4 kB
import { randomBytes } from 'node:crypto'; import { access, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import Fastify, { type FastifyInstance } from 'fastify'; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; import { defaultVault, encryptVault, saveVaultFile } from '../vault.js'; import { isLocked, lockVault } from '../vault-store.js'; import { registerVaultRoutes } from '../vault-routes.js'; const PASSWORD = 'test-password-123'; function makeTmpPath() { return join(tmpdir(), `vault-test-${randomBytes(4).toString('hex')}.vault`); } async function buildApp(vaultPath: string): Promise<FastifyInstance> { process.env['VAULT_PATH'] = vaultPath; const app = Fastify(); await registerVaultRoutes(app); await app.ready(); return app; } // ── Tests where the vault file does NOT yet exist ───────────────────────────── describe('GET /api/vault/status', () => { let vaultPath: string; let app: FastifyInstance; beforeEach(async () => { vaultPath = makeTmpPath(); app = await buildApp(vaultPath); }); afterEach(async () => { lockVault(); await app.close(); await rm(vaultPath, { force: true }); delete process.env['VAULT_PATH']; }); it('returns locked=true and hasFile=false when no vault file exists', async () => { const res = await app.inject({ method: 'GET', url: '/api/vault/status' }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ locked: true, hasFile: false }); }); }); // ── Tests for POST /api/vault/create ───────────────────────────────────────── describe('POST /api/vault/create', () => { let vaultPath: string; let app: FastifyInstance; beforeEach(async () => { vaultPath = makeTmpPath(); app = await buildApp(vaultPath); }); afterEach(async () => { lockVault(); await app.close(); await rm(vaultPath, { force: true }); delete process.env['VAULT_PATH']; }); it('creates a vault and returns 201', async () => { const res = await app.inject({ method: 'POST', url: '/api/vault/create', payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' }, }); expect(res.statusCode).toBe(201); expect(res.json()).toEqual({ ok: true }); }); it('GET /api/vault/status after create returns locked=false and hasFile=true', async () => { await app.inject({ method: 'POST', url: '/api/vault/create', payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' }, }); const res = await app.inject({ method: 'GET', url: '/api/vault/status' }); expect(res.json()).toEqual({ locked: false, hasFile: true }); }); it('returns 409 when vault file already exists', async () => { await app.inject({ method: 'POST', url: '/api/vault/create', payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' }, }); const res = await app.inject({ method: 'POST', url: '/api/vault/create', payload: { password: PASSWORD, serverUrl: 'http://localhost:4000/graphql', apiKey: 'key1' }, }); expect(res.statusCode).toBe(409); }); }); // ── Tests where the vault file ALREADY exists ───────────────────────────────── describe('GET /api/vault/status', () => { let vaultPath: string; let app: FastifyInstance; beforeEach(async () => { vaultPath = makeTmpPath(); await saveVaultFile(vaultPath, defaultVault(), PASSWORD); app = await buildApp(vaultPath); }); afterEach(async () => { lockVault(); await app.close(); await rm(vaultPath, { force: true }); delete process.env['VAULT_PATH']; }); it('returns locked=true and hasFile=true before unlock', async () => { const res = await app.inject({ method: 'GET', url: '/api/vault/status' }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ locked: true, hasFile: true }); }); it('returns locked=false after successful unlock', async () => { await app.inject({ method: 'POST', url: '/api/vault/unlock', payload: { password: PASSWORD }, }); const res = await app.inject({ method: 'GET', url: '/api/vault/status' }); expect(res.json()).toMatchObject({ locked: false, hasFile: true }); }); }); describe('GET /api/vault/env-path', () => { let vaultPath: string; let app: FastifyInstance; beforeEach(async () => { vaultPath = makeTmpPath(); app = await buildApp(vaultPath); process.env['VAULT_PATH'] = '/tmp/custom.vault'; }); afterEach(async () => { lockVault(); await app.close(); await rm(vaultPath, { force: true }); delete process.env['VAULT_PATH']; }); it('returns the VAULT_PATH env var when set', async () => { const res = await app.inject({ method: 'GET', url: '/api/vault/env-path' }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ path: '/tmp/custom.vault' }); }); it('returns ".vault" when VAULT_PATH is not set', async () => { delete process.env['VAULT_PATH']; const res = await app.inject({ method: 'GET', url: '/api/vault/env-path' }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ path: '.vault' }); }); }); describe('GET /api/vault/download', () => { describe('when vault file exists', () => { let vaultPath: string; let app: FastifyInstance; beforeEach(async () => { vaultPath = makeTmpPath(); await saveVaultFile(vaultPath, defaultVault(), PASSWORD); app = await buildApp(vaultPath); }); afterEach(async () => { lockVault(); await app.close(); await rm(vaultPath, { force: true }); delete process.env['VAULT_PATH']; }); it('returns 200 with octet-stream content-type when vault file exists', async () => { const res = await app.inject({ method: 'GET', url: '/api/vault/download' }); expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toMatch(/application\/octet-stream/); expect(res.headers['content-disposition']).toBe('attachment; filename=".vault"'); expect(res.rawPayload.length).toBeGreaterThan(0); }); }); describe('when vault file does not exist', () => { let vaultPath: string; let app: FastifyInstance; beforeEach(async () => { vaultPath = makeTmpPath(); app = await buildApp(vaultPath); }); afterEach(async () => { lockVault(); await app.close(); await rm(vaultPath, { force: true }); delete process.env['VAULT_PATH']; }); it('returns 404 when no vault file exists', async () => { const res = await app.inject({ method: 'GET', url: '/api/vault/download' }); expect(res.statusCode).toBe(404); expect(res.json().error).toBe('no-vault-file'); }); }); }); describe('POST /api/vault/unlock', () => { let vaultPath: string; let app: FastifyInstance; beforeEach(async () => { vaultPath = makeTmpPath(); await saveVaultFile(vaultPath, defaultVault(), PASSWORD); app = await buildApp(vaultPath); }); afterEach(async () => { lockVault(); await app.close(); await rm(vaultPath, { force: true }); delete process.env['VAULT_PATH']; }); it('returns ok with correct password', async () => { const res = await app.inject({ method: 'POST', url: '/api/vault/unlock', payload: { password: PASSWORD }, }); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ ok: true }); }); it('returns 401 with wrong password', async () => { const res = await app.inject({ method: 'POST', url: '/api/vault/unlock', payload: { password: 'wrong-password' }, }); expect(res.statusCode).toBe(401); }); }); // ── Tests for POST /api/vault/upload ───────────────────────────────────────── async function uploadVault( app: FastifyInstance, opts: { force?: boolean } = {}, ): Promise<ReturnType<FastifyInstance['inject']>> { const blob = await encryptVault(defaultVault(), PASSWORD); return app.inject({ method: 'POST', url: '/api/vault/upload' + (opts.force ? '?force=true' : ''), payload: Buffer.from(blob, 'base64'), headers: { 'content-type': 'application/octet-stream' }, }); } describe('POST /api/vault/upload', () => { let vaultPath: string; let app: FastifyInstance; beforeEach(async () => { vaultPath = makeTmpPath(); app = await buildApp(vaultPath); }); afterEach(async () => { lockVault(); await app.close(); await rm(vaultPath, { force: true }); await rm(vaultPath + '.tmp', { force: true }); delete process.env['VAULT_PATH']; }); it('returns 200 and writes the vault file when no file exists', async () => { const res = await uploadVault(app); expect(res.statusCode).toBe(200); expect(res.json()).toEqual({ ok: true }); const exists = await access(vaultPath).then(() => true).catch(() => false); expect(exists).toBe(true); }); it('returns 409 when vault already exists and force is not set', async () => { await saveVaultFile(vaultPath, defaultVault(), PASSWORD); const res = await uploadVault(app); expect(res.statusCode).toBe(409); expect(res.json().error).toBe('vault-already-exists'); }); it('overwrites and returns 200 when force=true and vault exists', async () => { await saveVaultFile(vaultPath, defaultVault(), PASSWORD); const res = await uploadVault(app, { force: true }); expect(res.statusCode).toBe(200); }); it('locks the vault after successful upload even if it was unlocked', async () => { await saveVaultFile(vaultPath, defaultVault(), PASSWORD); await app.inject({ method: 'POST', url: '/api/vault/unlock', payload: { password: PASSWORD }, }); expect(isLocked()).toBe(false); await uploadVault(app, { force: true }); const res = await app.inject({ method: 'GET', url: '/api/vault/status' }); expect(res.json()).toMatchObject({ locked: true }); }); }); // ── Tests for GET /api/vault/test-connection ───────────────────────────────── describe('GET /api/vault/test-connection', () => { const SERVER_URL = 'http://localhost:4000/graphql'; const API_KEY = 'scraper-key-1'; let vaultPath: string; let app: FastifyInstance; let fetchSpy: MockInstance<typeof globalThis.fetch>; function mockReply(body: string, init?: { status?: number; contentType?: string }) { fetchSpy.mockResolvedValue( new Response(body, { status: init?.status ?? 200, headers: { 'content-type': init?.contentType ?? 'application/json' }, }), ); } beforeEach(async () => { vaultPath = makeTmpPath(); app = await buildApp(vaultPath); fetchSpy = vi.spyOn(globalThis, 'fetch'); await app.inject({ method: 'POST', url: '/api/vault/create', payload: { password: PASSWORD, serverUrl: SERVER_URL, apiKey: API_KEY }, }); }); afterEach(async () => { fetchSpy.mockRestore(); lockVault(); await app.close(); await rm(vaultPath, { force: true }); delete process.env['VAULT_PATH']; }); it('probes with the same X-API-Key header the upload client sends', async () => { mockReply(JSON.stringify({ data: { __typename: 'Query' } })); await app.inject({ method: 'GET', url: '/api/vault/test-connection' }); expect(fetchSpy).toHaveBeenCalledTimes(1); const [url, init] = fetchSpy.mock.calls[0]!; expect(url).toBe(SERVER_URL); const headers = (init as RequestInit).headers as Record<string, string>; expect(headers['X-API-Key']).toBe(API_KEY); expect(headers['Authorization']).toBeUndefined(); }); it('reports ok for a real GraphQL result', async () => { mockReply(JSON.stringify({ data: { __typename: 'Query' } })); const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' }); expect(res.statusCode).toBe(200); expect(res.json()).toMatchObject({ ok: true }); expect(res.json().latencyMs).toBeTypeOf('number'); }); it('fails on a 200 that is really a SPA index.html', async () => { // The regression this route missed: a serverUrl pointing at a SPA (this very app) // answers an unknown path with index.html and a 200, which used to read as healthy. mockReply('<!doctype html>\n<html lang="en"><head><title>Scraper App</title></head></html>', { contentType: 'text/html', }); const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' }); expect(res.json().ok).toBe(false); expect(res.json().error).toContain('did not return JSON'); expect(res.json().error).toContain('/graphql'); }); it('surfaces a GraphQL error message', async () => { mockReply(JSON.stringify({ errors: [{ message: 'Unauthorized' }] })); const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' }); expect(res.json()).toMatchObject({ ok: false, error: 'Unauthorized' }); }); it('fails on valid JSON that is not a GraphQL result', async () => { mockReply(JSON.stringify({ hello: 'world' })); const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' }); expect(res.json()).toMatchObject({ ok: false }); expect(res.json().error).toContain('did not return a GraphQL result'); }); it('reports the status and body of a non-2xx response', async () => { mockReply('nope', { status: 502, contentType: 'text/plain' }); const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' }); expect(res.json()).toMatchObject({ ok: false, error: 'HTTP 502: nope' }); }); it('returns 401 when the vault is locked', async () => { lockVault(); const res = await app.inject({ method: 'GET', url: '/api/vault/test-connection' }); expect(res.statusCode).toBe(401); expect(res.json()).toEqual({ error: 'vault-locked' }); expect(fetchSpy).not.toHaveBeenCalled(); }); });