diff --git a/integration/backend/auth/local.test.ts b/integration/backend/auth/local.test.ts new file mode 100644 index 0000000..69e3510 --- /dev/null +++ b/integration/backend/auth/local.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert' +import { cleanup, startTestServer } from '../../fixtures.js' + +describe('/api/auth/local', () => { + afterEach(async () => await cleanup()) + + it('returns 404 if local login not configured', async () => { + const { origin } = await startTestServer() + + const response = await fetch(`${origin}/api/auth/local`, { method: 'POST' }) + assert.strictEqual(response.status, 404) + assert.strictEqual(response.headers.get('Content-Type'), 'application/json; charset=utf-8') + assert.deepStrictEqual(await response.json(), { error: 'Not Found' }) + }) + + it('returns 400 for invalid body schema', async () => { + const { origin } = await startTestServer({ + config (input) { + return { + ...input, + auth: { + ...input.auth, + local: { + enabled: true, + username: 'test-user', + password: 'test-password' + } + } + } + } + }) + + const bodies = [ + '{}', + '{"username":"test-user"}', + '{"password":"test-password"}', + '{"username":"user-test","password":null}' + ] + + for (const body of bodies) { + const response = await fetch(`${origin}/api/auth/local`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }) + assert.strictEqual(response.status, 400) + // TODO respond with JSON instead of plain text + assert.strictEqual(response.headers.get('Content-Type'), 'text/plain; charset=utf-8') + assert.strictEqual(await response.text(), 'Bad Request') + } + }) + + it('returns 401 for invalid credentials', async () => { + const { origin } = await startTestServer({ + config (input) { + return { + ...input, + auth: { + ...input.auth, + local: { + enabled: true, + username: 'test-user', + password: 'test-password' + } + } + } + } + }) + + const bodies = [ + '{"username":"user-test","password":"password-test"}', + '{"username":"test-user","password":"password-test"}', + '{"username":"test-user ","password":"test-password"}', + '{"username":"test-user","password":"test-password "}' + ] + + for (const body of bodies) { + const response = await fetch(`${origin}/api/auth/local`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body + }) + assert.strictEqual(response.status, 401) + // TODO respond with JSON instead of plain text + assert.strictEqual(response.headers.get('Content-Type'), 'text/plain; charset=utf-8') + assert.deepStrictEqual(await response.text(), 'Unauthorized') + } + }) + + it('creates a session for valid credentials', async () => { + const { origin } = await startTestServer({ + config (input) { + return { + ...input, + auth: { + ...input.auth, + local: { + enabled: true, + username: 'test-user', + password: 'test-password' + } + } + } + } + }) + + const response = await fetch(`${origin}/api/auth/local`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"username":"test-user","password":"test-password"}' + }) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.headers.get('Content-Type'), 'application/json; charset=utf-8') + // TODO set SameSite=Strict + const setCookie = response.headers.getSetCookie() + assert.ok(setCookie.length === 1) + assert.match(setCookie[0], /^session=[^;]+; Max-Age=86400; Path=\/api; HttpOnly; SameSite=Lax$/) + assert.deepStrictEqual(await response.json(), {}) + }) +}) diff --git a/integration/backend/auth/logout.test.ts b/integration/backend/auth/logout.test.ts new file mode 100644 index 0000000..a6b0229 --- /dev/null +++ b/integration/backend/auth/logout.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert' +import { cleanup, startTestServer } from '../../fixtures.js' + +describe('/api/auth/logout', () => { + afterEach(async () => await cleanup()) + + it('returns 200 if called without a session', async () => { + const { origin } = await startTestServer() + + // Note: This sets a cookie, but its contents indicate a missing session. + const response = await fetch(`${origin}/api/auth/logout`, { method: 'POST' }) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.headers.get('Content-Type'), 'application/json; charset=utf-8') + // TODO set SameSite=Strict + const setCookie = response.headers.getSetCookie() + assert.ok(setCookie.length === 1) + assert.match(setCookie[0], /^session=[^;]+; Max-Age=86400; Path=\/api; HttpOnly; SameSite=Lax$/) + assert.deepStrictEqual(await response.json(), {}) + + // user should not have a session + const meResponse = await fetch(`${origin}/api/auth/me`, { method: 'GET' }) + assert.strictEqual(meResponse.status, 403) + }) + + it('invalidates an existing session', async () => { + const { origin } = await startTestServer({ + config (input) { + return { + ...input, + auth: { + ...input.auth, + local: { + enabled: true, + username: 'test-user', + password: 'test-password' + } + } + } + } + }) + + const loginResponse = await fetch(`${origin}/api/auth/local`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{"username":"test-user","password":"test-password"}' + }) + assert.strictEqual(loginResponse.status, 200) + + const sessionCookie = loginResponse.headers.getSetCookie().at(0) + assert.ok(sessionCookie?.startsWith('session=')) + const session = sessionCookie?.split(';').at(0)?.split('=').at(1) + assert.ok(session != null) + + // validate session + const meResponse = await fetch(`${origin}/api/auth/me`, { + headers: { Cookie: `session=${session}` } + }) + assert.strictEqual(meResponse.status, 200) + + // logout + const logoutResponse = await fetch(`${origin}/api/auth/logout`, { + method: 'POST', + headers: { Cookie: `session=${session}` } + }) + assert.strictEqual(logoutResponse.status, 200) + assert.strictEqual(logoutResponse.headers.get('Content-Type'), 'application/json; charset=utf-8') + // TODO set SameSite=Strict + const setCookie = logoutResponse.headers.getSetCookie() + assert.ok(setCookie.length === 1) + assert.match(setCookie[0], /^session=[^;]+; Max-Age=86400; Path=\/api; HttpOnly; SameSite=Lax$/) + assert.deepStrictEqual(await logoutResponse.json(), {}) + + const sessionCookie2 = setCookie.at(0)?.split(';').at(0)?.split('=').at(1) + assert.ok(sessionCookie2 != null) + assert.notStrictEqual(sessionCookie2, session) + + // session should be invalidated + const meResponse2 = await fetch(`${origin}/api/auth/me`, { + headers: { Cookie: `session=${sessionCookie2}` } + }) + assert.strictEqual(meResponse2.status, 403) + }) +}) diff --git a/integration/backend/auth/me.test.ts b/integration/backend/auth/me.test.ts new file mode 100644 index 0000000..ea6c289 --- /dev/null +++ b/integration/backend/auth/me.test.ts @@ -0,0 +1,15 @@ +import assert from 'node:assert' +import { cleanup, startTestServer } from '../../fixtures.js' + +describe('/api/auth/me', () => { + afterEach(async () => await cleanup()) + + it('requires authentication', async () => { + const { origin } = await startTestServer() + + const response = await fetch(`${origin}/api/auth/me`, { method: 'GET' }) + assert.strictEqual(response.status, 403) + assert.strictEqual(response.headers.get('Content-Type'), 'application/json; charset=utf-8') + assert.deepStrictEqual(await response.json(), { error: 'Forbidden' }) + }) +}) diff --git a/integration/backend/auth/strategies.test.ts b/integration/backend/auth/strategies.test.ts new file mode 100644 index 0000000..4caa54e --- /dev/null +++ b/integration/backend/auth/strategies.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert' +import { cleanup, startTestServer } from '../../fixtures.js' + +describe('/api/auth/strategies', () => { + afterEach(async () => await cleanup()) + + it('returns empty array if not strategies are enabled', async () => { + const { origin } = await startTestServer() + + const response = await fetch(`${origin}/api/auth/strategies`, { method: 'GET' }) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.headers.get('Content-Type'), 'application/json; charset=utf-8') + + const strategies = await response.json() + assert.deepStrictEqual(strategies, []) + }) + + it('includes local login if enabled', async () => { + const { origin } = await startTestServer({ + config (input) { + return { + ...input, + auth: { + ...input.auth, + local: { + enabled: true, + username: 'test', + password: 'test' + } + } + } + } + }) + + const response = await fetch(`${origin}/api/auth/strategies`, { method: 'GET' }) + assert.strictEqual(response.status, 200) + assert.strictEqual(response.headers.get('Content-Type'), 'application/json; charset=utf-8') + + const strategies = await response.json() + assert.deepStrictEqual(strategies, ['local']) + }) +}) diff --git a/integration/fixtures.ts b/integration/fixtures.ts index 3af0b3f..21a9aa1 100644 --- a/integration/fixtures.ts +++ b/integration/fixtures.ts @@ -1,7 +1,7 @@ import pino, { BaseLogger } from 'pino' import { KubeConfig } from '@kubernetes/client-node' import { startServer } from '../src/server.js' -import { createConfig } from '../src/config.js' +import { Config, createConfig } from '../src/config.js' const testServerPort = 3333 const testClusterPort = 56443 @@ -29,10 +29,16 @@ interface TestServerResult { * * @returns Information about the server. */ -export async function startTestServer (): Promise { +export async function startTestServer (options?: { + config?: (input: Config) => Config +}): Promise { + let config = createConfig() + if (options?.config != null) { + config = options.config(config) + } const closeFn = await startServer({ log: getTestLogger(), - config: createConfig(), + config, port: testServerPort, kubeConfig: getTestKubeConfig() })