Skip to content

Commit d909fb8

Browse files
Copilotpatrickrb
andauthored
Add API health endpoint /api/ping with authentication validation (#162)
* Initial plan * Initial exploration and planning for /api/ping health endpoint Co-authored-by: patrickrb <6586559+patrickrb@users.noreply.github.com> * Implement /api/ping health endpoint with API key validation Co-authored-by: patrickrb <6586559+patrickrb@users.noreply.github.com> * Add detailed documentation to /api/ping endpoint Co-authored-by: patrickrb <6586559+patrickrb@users.noreply.github.com> * Fix API key validation in /api/ping endpoint Co-authored-by: patrickrb <6586559+patrickrb@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: patrickrb <6586559+patrickrb@users.noreply.github.com>
1 parent 9be3885 commit d909fb8

3 files changed

Lines changed: 269 additions & 1 deletion

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/api/ping/route.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// API health check endpoint
2+
// GET: Health check with API key validation
3+
// This endpoint provides a simple health check that validates API keys for client applications
4+
//
5+
// Usage:
6+
// GET /api/ping
7+
// Authorization: Bearer <api_key>
8+
// OR X-API-Key: <api_key>
9+
// OR ?api_key=<api_key>
10+
//
11+
// Response (success):
12+
// {
13+
// "success": true,
14+
// "message": "API is healthy and API key is valid",
15+
// "api_name": "Nextlog API",
16+
// "api_version": "1.0.0",
17+
// "timestamp": "2025-09-19T22:45:28.588Z",
18+
// "authenticated": true,
19+
// "api_key_info": {
20+
// "key_name": "My API Key",
21+
// "is_read_only": false,
22+
// "rate_limit_per_hour": 1000,
23+
// "station_id": 1
24+
// }
25+
// }
26+
//
27+
// Response (error):
28+
// {
29+
// "success": false,
30+
// "error": "API key is required",
31+
// "timestamp": "2025-09-19T22:45:28.588Z"
32+
// }
33+
34+
import { NextRequest, NextResponse } from 'next/server';
35+
import { verifyApiKey } from '@/lib/api-auth';
36+
import { addCorsHeaders, createCorsPreflightResponse } from '@/lib/cors';
37+
38+
// OPTIONS /api/ping - Handle CORS preflight requests
39+
export async function OPTIONS() {
40+
return createCorsPreflightResponse();
41+
}
42+
43+
// GET /api/ping - Health check with API key validation
44+
export async function GET(request: NextRequest) {
45+
try {
46+
// First, extract the API key manually to provide better error messages
47+
let apiKey: string | null = null;
48+
49+
// 1. Check Authorization header (Bearer token format)
50+
const authHeader = request.headers.get('authorization');
51+
if (authHeader && authHeader.startsWith('Bearer ')) {
52+
apiKey = authHeader.substring(7);
53+
}
54+
55+
// 2. Check X-API-Key header (Cloudlog style)
56+
if (!apiKey) {
57+
apiKey = request.headers.get('x-api-key');
58+
}
59+
60+
// 3. Check query parameters (for simple integrations)
61+
if (!apiKey) {
62+
const url = new URL(request.url);
63+
apiKey = url.searchParams.get('api_key');
64+
}
65+
66+
// Return appropriate error if no API key provided
67+
if (!apiKey) {
68+
const response = NextResponse.json({
69+
success: false,
70+
error: 'API key is required',
71+
timestamp: new Date().toISOString()
72+
}, {
73+
status: 401
74+
});
75+
return addCorsHeaders(response);
76+
}
77+
78+
// Check API key format
79+
const isValidFormat = /^nextlog_[A-Za-z0-9]{32}$/.test(apiKey);
80+
if (!isValidFormat) {
81+
const response = NextResponse.json({
82+
success: false,
83+
error: 'Invalid API key format',
84+
timestamp: new Date().toISOString()
85+
}, {
86+
status: 401
87+
});
88+
return addCorsHeaders(response);
89+
}
90+
91+
// Now verify API key authentication with database
92+
const authResult = await verifyApiKey(request);
93+
94+
if (!authResult.success) {
95+
// If we have a valid format key but database validation fails,
96+
// return the actual error from the auth system
97+
const response = NextResponse.json({
98+
success: false,
99+
error: authResult.error || 'Authentication failed',
100+
timestamp: new Date().toISOString()
101+
}, {
102+
status: authResult.statusCode || 401
103+
});
104+
return addCorsHeaders(response);
105+
}
106+
107+
// API key is valid - return success response
108+
const response = NextResponse.json({
109+
success: true,
110+
message: 'API is healthy and API key is valid',
111+
api_name: 'Nextlog API',
112+
api_version: '1.0.0',
113+
timestamp: new Date().toISOString(),
114+
authenticated: true,
115+
api_key_info: {
116+
key_name: authResult.auth!.keyName,
117+
is_read_only: authResult.auth!.isReadOnly,
118+
rate_limit_per_hour: authResult.auth!.rateLimitPerHour,
119+
station_id: authResult.auth!.stationId
120+
}
121+
});
122+
123+
return addCorsHeaders(response);
124+
125+
} catch (error) {
126+
console.error('Ping endpoint error:', error);
127+
const response = NextResponse.json({
128+
success: false,
129+
error: 'Internal server error',
130+
timestamp: new Date().toISOString()
131+
}, {
132+
status: 500
133+
});
134+
return addCorsHeaders(response);
135+
}
136+
}

tests/api-ping.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
test.describe('API Ping Health Endpoint', () => {
4+
test('should reject requests without API key', async ({ request }) => {
5+
const response = await request.get('/api/ping');
6+
7+
expect(response.status()).toBe(401);
8+
9+
const data = await response.json();
10+
expect(data.success).toBe(false);
11+
expect(data.error).toContain('API key is required');
12+
expect(data.timestamp).toBeTruthy();
13+
});
14+
15+
test('should reject requests with invalid API key format', async ({ request }) => {
16+
// Test with invalid format
17+
const response = await request.get('/api/ping', {
18+
headers: {
19+
'X-API-Key': 'invalid-key-format'
20+
}
21+
});
22+
23+
expect(response.status()).toBe(401);
24+
25+
const data = await response.json();
26+
expect(data.success).toBe(false);
27+
expect(data.error).toContain('Invalid API key format');
28+
expect(data.timestamp).toBeTruthy();
29+
});
30+
31+
test('should reject requests with non-existent API key', async ({ request }) => {
32+
// Test with valid format but non-existent key
33+
const response = await request.get('/api/ping', {
34+
headers: {
35+
'X-API-Key': 'nextlog_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
36+
}
37+
});
38+
39+
// In test environment without database, expect either 401 (auth error) or 500 (db error)
40+
expect([401, 500]).toContain(response.status());
41+
42+
const data = await response.json();
43+
expect(data.success).toBe(false);
44+
expect(data.error).toBeTruthy();
45+
expect(data.timestamp).toBeTruthy();
46+
});
47+
48+
test('should support different authentication methods', async ({ request }) => {
49+
const testKey = 'nextlog_test1234567890123456789012345';
50+
51+
// Test X-API-Key header
52+
const headerResponse = await request.get('/api/ping', {
53+
headers: {
54+
'X-API-Key': testKey
55+
}
56+
});
57+
expect([401, 500]).toContain(headerResponse.status()); // Expected since key doesn't exist or no DB
58+
59+
// Test Authorization Bearer header
60+
const bearerResponse = await request.get('/api/ping', {
61+
headers: {
62+
'Authorization': `Bearer ${testKey}`
63+
}
64+
});
65+
expect([401, 500]).toContain(bearerResponse.status()); // Expected since key doesn't exist or no DB
66+
67+
// Test query parameter
68+
const queryResponse = await request.get(`/api/ping?api_key=${testKey}`);
69+
expect([401, 500]).toContain(queryResponse.status()); // Expected since key doesn't exist or no DB
70+
});
71+
72+
test('should handle CORS preflight requests', async ({ request }) => {
73+
const response = await request.fetch('/api/ping', {
74+
method: 'OPTIONS'
75+
});
76+
77+
expect(response.status()).toBe(200);
78+
expect(response.headers()['access-control-allow-origin']).toBe('*');
79+
expect(response.headers()['access-control-allow-methods']).toContain('GET');
80+
expect(response.headers()['access-control-allow-headers']).toContain('X-API-Key');
81+
expect(response.headers()['access-control-allow-headers']).toContain('Authorization');
82+
});
83+
84+
test('should include CORS headers in response', async ({ request }) => {
85+
const response = await request.get('/api/ping');
86+
87+
// Check CORS headers are present
88+
expect(response.headers()['access-control-allow-origin']).toBe('*');
89+
expect(response.headers()['access-control-allow-methods']).toContain('GET');
90+
expect(response.headers()['access-control-allow-headers']).toContain('X-API-Key');
91+
});
92+
93+
test('should return proper response structure on authentication failure', async ({ request }) => {
94+
const response = await request.get('/api/ping');
95+
96+
const data = await response.json();
97+
98+
// Validate response structure
99+
expect(data).toHaveProperty('success');
100+
expect(data).toHaveProperty('error');
101+
expect(data).toHaveProperty('timestamp');
102+
expect(data.success).toBe(false);
103+
expect(typeof data.timestamp).toBe('string');
104+
105+
// Validate timestamp format (ISO 8601)
106+
expect(new Date(data.timestamp).toISOString()).toBe(data.timestamp);
107+
});
108+
109+
test('should handle server errors gracefully', async ({ request }) => {
110+
// This test ensures the endpoint handles unexpected errors
111+
// In a real scenario with a valid API key, it would test the success case
112+
const response = await request.get('/api/ping', {
113+
headers: {
114+
'X-API-Key': 'nextlog_test1234567890123456789012345'
115+
}
116+
});
117+
118+
// Should handle database connection errors gracefully
119+
expect([401, 500]).toContain(response.status());
120+
121+
const data = await response.json();
122+
expect(data.success).toBe(false);
123+
expect(data.timestamp).toBeTruthy();
124+
expect(data.error).toBeTruthy();
125+
});
126+
127+
test('should validate content type', async ({ request }) => {
128+
const response = await request.get('/api/ping');
129+
130+
expect(response.headers()['content-type']).toContain('application/json');
131+
});
132+
});

0 commit comments

Comments
 (0)