From 0b75a08d7ef8f5ee21729bb6a7e5353ee35568cc Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 14:59:52 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20resolve=20+=20cache=20auth=20at=20start?= =?UTF-8?q?up=20via=20getAuth()=20=E2=80=94=20fail-fast=20on=20invalid=20t?= =?UTF-8?q?oken?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- __tests__/auth.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++++ src/auth.ts | 4 +++ src/index.ts | 4 +++ 3 files changed, 81 insertions(+) create mode 100644 __tests__/auth.test.ts diff --git a/__tests__/auth.test.ts b/__tests__/auth.test.ts new file mode 100644 index 0000000..bfeb7c5 --- /dev/null +++ b/__tests__/auth.test.ts @@ -0,0 +1,73 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('../src/prisma.js', () => ({ + prisma: { + apiToken: { + findUnique: vi.fn(), + }, + }, +})) + +import { prisma } from '../src/prisma.js' +import { getAuth, resetAuthCache } from '../src/auth.js' + +const mockPrisma = prisma as unknown as { + apiToken: { findUnique: ReturnType } +} + +const VALID_TOKEN_ROW = { + id: 'token-id-1', + user_id: 'user-1', + token_hash: 'irrelevant-checked-by-crypto', + revoked_at: null, + user: { username: 'alice', is_demo: false }, +} + +beforeEach(() => { + vi.clearAllMocks() + resetAuthCache() + process.env.SCRUM4ME_TOKEN = 'test-token-value' +}) + +describe('getAuth', () => { + it('returns AuthContext for a valid token', async () => { + mockPrisma.apiToken.findUnique.mockResolvedValue(VALID_TOKEN_ROW) + + const auth = await getAuth() + + expect(auth.tokenId).toBe('token-id-1') + expect(auth.userId).toBe('user-1') + expect(auth.username).toBe('alice') + expect(auth.isDemo).toBe(false) + }) + + it('caches the result — prisma is only called once on repeated calls', async () => { + mockPrisma.apiToken.findUnique.mockResolvedValue(VALID_TOKEN_ROW) + + await getAuth() + await getAuth() + + expect(mockPrisma.apiToken.findUnique).toHaveBeenCalledTimes(1) + }) + + it('throws when SCRUM4ME_TOKEN is not set', async () => { + delete process.env.SCRUM4ME_TOKEN + + await expect(getAuth()).rejects.toThrow('SCRUM4ME_TOKEN is not set') + }) + + it('throws when token is not found in the database', async () => { + mockPrisma.apiToken.findUnique.mockResolvedValue(null) + + await expect(getAuth()).rejects.toThrow('invalid or revoked') + }) + + it('throws when token is revoked', async () => { + mockPrisma.apiToken.findUnique.mockResolvedValue({ + ...VALID_TOKEN_ROW, + revoked_at: new Date(), + }) + + await expect(getAuth()).rejects.toThrow('invalid or revoked') + }) +}) diff --git a/src/auth.ts b/src/auth.ts index c7dd2a6..dd2c18f 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -10,6 +10,10 @@ export type AuthContext = { let cached: AuthContext | null = null +export function resetAuthCache(): void { + cached = null +} + export async function getAuth(): Promise { if (cached) return cached diff --git a/src/index.ts b/src/index.ts index 15479e3..a0a5730 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,10 +22,14 @@ import { registerUpdateJobStatusTool } from './tools/update-job-status.js' import { registerVerifyTaskAgainstPlanTool } from './tools/verify-task-against-plan.js' import { registerCleanupMyWorktreesTool } from './tools/cleanup-my-worktrees.js' import { registerImplementNextStoryPrompt } from './prompts/implement-next-story.js' +import { getAuth } from './auth.js' const VERSION = '0.1.0' async function main() { + const auth = await getAuth() + console.error(`scrum4me-mcp: authenticated as ${auth.username} (token ${auth.tokenId})`) + const server = new McpServer( { name: 'scrum4me-mcp', version: VERSION }, {