Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/lib/server/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ if (!existsSync(GIT_REPOS_DIR)) {
/**
* Mask sensitive values in environment variables for safe logging.
*/
function maskSecrets(vars: Record<string, string>): Record<string, string> {
export function maskSecrets(vars: Record<string, string>): Record<string, string> {
const masked: Record<string, string> = {};
const secretPatterns = /password|secret|token|key|api_key|apikey|auth|credential|private/i;
for (const [key, value] of Object.entries(vars)) {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/server/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from './db';

// Escape special characters for Telegram Markdown
function escapeTelegramMarkdown(text: string): string {
export function escapeTelegramMarkdown(text: string): string {
// Escape characters that have special meaning in Telegram Markdown
return text
.replace(/\\/g, '\\\\') // Escape backslashes first
Expand Down
83 changes: 83 additions & 0 deletions tests/api-smoke.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* API Smoke Tests
*
* Basic smoke tests that verify API endpoints are reachable and return
* expected status codes. Requires a running Dockhand instance.
*
* Set DOCKHAND_URL environment variable to override (default: http://localhost:3000).
*/
import { describe, test, expect } from 'bun:test';

const BASE_URL = process.env.DOCKHAND_URL || 'http://localhost:3000';

async function api(path: string, options: RequestInit = {}) {
const url = `${BASE_URL}${path}`;
const res = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
});
return { status: res.status, data: await res.json().catch(() => null) };
}

describe('API Smoke Tests', () => {
test('GET /api/health returns 200', async () => {
const { status } = await api('/api/health');
expect(status).toBe(200);
});

test('GET /api/system returns 200 with system info', async () => {
const { status, data } = await api('/api/system');
expect(status).toBe(200);
expect(data).toBeDefined();
});

test('GET /api/environments returns 200 with array', async () => {
const { status, data } = await api('/api/environments');
expect(status).toBe(200);
expect(Array.isArray(data)).toBe(true);
});

test('GET /api/stacks returns 200', async () => {
const { status, data } = await api('/api/stacks');
expect(status).toBe(200);
expect(data).toBeDefined();
});

test('GET /api/registries returns 200 with array', async () => {
const { status, data } = await api('/api/registries');
expect(status).toBe(200);
expect(Array.isArray(data)).toBe(true);
});

test('GET /api/git/repositories returns 200 with array', async () => {
const { status, data } = await api('/api/git/repositories');
expect(status).toBe(200);
expect(Array.isArray(data)).toBe(true);
});

test('GET /api/git/stacks returns 200 with array', async () => {
const { status, data } = await api('/api/git/stacks');
expect(status).toBe(200);
expect(Array.isArray(data)).toBe(true);
});

test('GET /api/notifications returns 200 with array', async () => {
const { status, data } = await api('/api/notifications');
expect(status).toBe(200);
expect(Array.isArray(data)).toBe(true);
});

test('GET /api/auth/session returns session status', async () => {
const { status } = await api('/api/auth/session');
// 200 if auth disabled or valid session, 401 if auth enabled without session
expect([200, 401]).toContain(status);
});

test('GET non-existent API endpoint returns 404', async () => {
const { status } = await api('/api/this-endpoint-does-not-exist');
expect(status).toBe(404);
});
});
126 changes: 126 additions & 0 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Tests for Authentication Logic
*
* Tests rate limiting (in-memory state) and password hashing/verification.
*/
import { describe, test, expect, beforeEach } from 'bun:test';
import {
isRateLimited,
recordFailedAttempt,
clearRateLimit,
hashPassword,
verifyPassword
} from '../src/lib/server/auth';

// =============================================================================
// Rate Limiting
// =============================================================================

describe('Rate Limiting', () => {
const testId = () => `test-${Date.now()}-${Math.random()}`;

test('unknown identifier is not rate limited', () => {
const result = isRateLimited(testId());
expect(result.limited).toBe(false);
expect(result.retryAfter).toBeUndefined();
});

test('1-4 failed attempts are not rate limited', () => {
const id = testId();
for (let i = 0; i < 4; i++) {
recordFailedAttempt(id);
}
const result = isRateLimited(id);
expect(result.limited).toBe(false);
});

test('5 failed attempts triggers rate limiting', () => {
const id = testId();
for (let i = 0; i < 5; i++) {
recordFailedAttempt(id);
}
const result = isRateLimited(id);
expect(result.limited).toBe(true);
expect(result.retryAfter).toBeGreaterThan(0);
});

test('clearRateLimit removes the limit', () => {
const id = testId();
for (let i = 0; i < 5; i++) {
recordFailedAttempt(id);
}
expect(isRateLimited(id).limited).toBe(true);

clearRateLimit(id);
expect(isRateLimited(id).limited).toBe(false);
});

test('clearing non-existent identifier does not throw', () => {
expect(() => clearRateLimit(testId())).not.toThrow();
});

test('different identifiers are tracked independently', () => {
const id1 = testId();
const id2 = testId();

for (let i = 0; i < 5; i++) {
recordFailedAttempt(id1);
}

expect(isRateLimited(id1).limited).toBe(true);
expect(isRateLimited(id2).limited).toBe(false);
});
});

// =============================================================================
// Password Hashing
// =============================================================================

describe('Password Hashing', () => {
test('hashPassword returns a hash string', async () => {
const hash = await hashPassword('test-password');
expect(typeof hash).toBe('string');
expect(hash.length).toBeGreaterThan(0);
expect(hash).not.toBe('test-password');
});

test('verifyPassword returns true for correct password', async () => {
const password = 'correct-password-123!';
const hash = await hashPassword(password);
const result = await verifyPassword(password, hash);
expect(result).toBe(true);
});

test('verifyPassword returns false for wrong password', async () => {
const hash = await hashPassword('correct-password');
const result = await verifyPassword('wrong-password', hash);
expect(result).toBe(false);
});

test('different passwords produce different hashes', async () => {
const hash1 = await hashPassword('password-one');
const hash2 = await hashPassword('password-two');
expect(hash1).not.toBe(hash2);
});

test('same password produces different hashes (salt)', async () => {
const hash1 = await hashPassword('same-password');
const hash2 = await hashPassword('same-password');
// Due to random salt, hashes should differ
expect(hash1).not.toBe(hash2);
});

test('handles special characters in passwords', async () => {
const password = 'P@$$w0rd!#%^&*()_+{}|:<>?äöü€';
const hash = await hashPassword(password);
const result = await verifyPassword(password, hash);
expect(result).toBe(true);
});

test('handles long passwords', async () => {
const password = 'x'.repeat(1000);
const hash = await hashPassword(password);
const result = await verifyPassword(password, hash);
expect(result).toBe(true);
});
});
47 changes: 47 additions & 0 deletions tests/authorize-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Unit Tests for Authorization Helper Functions
*
* Tests the response helper functions from the authorize module.
*/
import { describe, test, expect } from 'bun:test';
import { unauthorized, forbidden, enterpriseRequired } from '../src/lib/server/authorize';

describe('Authorization Helpers', () => {
describe('unauthorized', () => {
test('returns correct error object', () => {
const result = unauthorized();
expect(result).toEqual({
error: 'Authentication required',
status: 401
});
});
});

describe('forbidden', () => {
test('returns default message', () => {
const result = forbidden();
expect(result).toEqual({
error: 'Permission denied',
status: 403
});
});

test('returns custom message', () => {
const result = forbidden('Custom reason');
expect(result).toEqual({
error: 'Custom reason',
status: 403
});
});
});

describe('enterpriseRequired', () => {
test('returns enterprise required error', () => {
const result = enterpriseRequired();
expect(result).toEqual({
error: 'Enterprise license required',
status: 403
});
});
});
});
Loading