diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 722be10..114361f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,7 +2,8 @@ "name": "CronMon App Dev Container", "build": { "dockerfile": "../Dockerfile", - "context": ".." + "context": "..", + "target": "builder" }, "runArgs": [ "--name", @@ -38,6 +39,7 @@ "ms-azuretools.vscode-docker", "tamasfe.even-better-toml", "Vue.volar", + "matthewpi.caddyfile-support", "ms-vscode.makefile-tools", "tamasfe.even-better-toml" ] diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh index 0e27d96..4709d59 100755 --- a/.devcontainer/on-create.sh +++ b/.devcontainer/on-create.sh @@ -1,7 +1,7 @@ #! /usr/bin/bash # Need bash-completion for bash completion, curl to download startship.rs, and ssh for Git. -apt-get update && apt-get install -y bash-completion curl ssh +apt-get update && apt-get install -y bash-completion caddy curl ssh # Setup bash completion echo "source /etc/bash_completion" >> ~/.bashrc diff --git a/Dockerfile b/Dockerfile index 87c8f00..deab198 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM public.ecr.aws/docker/library/node:20.12-slim +FROM public.ecr.aws/docker/library/node:20.12-slim as builder WORKDIR /usr/cron-mon/app @@ -6,3 +6,14 @@ RUN npm install -g npm@latest COPY ./app . ENV PATH /usr/cron-mon/app/node_modules/.bin:$PATH + +RUN npm install && npm run build + +FROM public.ecr.aws/docker/library/caddy:2.9 + +COPY ./entrypoint.sh /entrypoint.sh +COPY --from=builder /usr/cron-mon/app/dist /srv +COPY ./app/Caddyfile /etc/caddy/Caddyfile + +ENTRYPOINT ["/entrypoint.sh"] +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/Makefile b/Makefile index cb79327..4e4778b 100644 --- a/Makefile +++ b/Makefile @@ -1,21 +1,30 @@ -install: build-containers npm-install +install: build-containers build-containers: - docker compose build --no-cache + docker compose build npm-install: - docker compose run --rm app bash -c 'npm install' + docker compose run --rm dev bash -c 'npm install' build-app: - docker compose run --rm --no-deps app bash -c 'npm run build' + docker compose run --rm --no-deps dev bash -c 'npm run build' run: - docker compose up app + docker compose up caddy + +run-caddy-dev: + docker compose up caddy-dev + +run-vue-dev: + docker compose up vue-dev + +reload-caddy-dev: + docker compose exec caddy-dev caddy reload --config /etc/caddy/Caddyfile test: static-test unit-test unit-test: - docker compose run --rm --no-deps app bash -c 'npm run test:unit' + docker compose run --rm --no-deps vue-dev bash -c 'npm run test:unit' static-test: - docker compose run --rm --no-deps app bash -c 'npm run lint && npm run type-check' + docker compose run --rm --no-deps vue-dev bash -c 'npm run lint && npm run type-check' diff --git a/app/.env b/app/.env new file mode 100644 index 0000000..f049fee --- /dev/null +++ b/app/.env @@ -0,0 +1 @@ +VITE_API_HOST= diff --git a/app/.env.development b/app/.env.development new file mode 100644 index 0000000..7379fea --- /dev/null +++ b/app/.env.development @@ -0,0 +1,4 @@ +VITE_API_HOST=$API_HOST +VITE_KEYCLOAK_URL=$KEYCLOAK_URL +VITE_KEYCLOAK_REALM=$KEYCLOAK_REALM +VITE_KEYCLOAK_CLIENT_ID=$KEYCLOAK_CLIENT_ID diff --git a/app/.env.test b/app/.env.test new file mode 100644 index 0000000..52ee9c7 --- /dev/null +++ b/app/.env.test @@ -0,0 +1,3 @@ +VITE_KEYCLOAK_URL=http://keycloak +VITE_KEYCLOAK_REALM=keycloak-realm +VITE_KEYCLOAK_CLIENT_ID=keycloak-client-id diff --git a/app/.eslintrc.cjs b/app/.eslintrc.cjs index cf016f4..afb381f 100644 --- a/app/.eslintrc.cjs +++ b/app/.eslintrc.cjs @@ -25,5 +25,6 @@ module.exports = { "ignoreRestSiblings": true } ] - } + }, + ignorePatterns: ["dist", "node_modules"] } diff --git a/app/Caddyfile b/app/Caddyfile new file mode 100644 index 0000000..553251d --- /dev/null +++ b/app/Caddyfile @@ -0,0 +1,48 @@ +:3000 { + # Access logs + log { + format filter { + # No need for any headers to be in any of the logs + # (might be useful to be able to switch these on though?) + request>headers delete + resp_headers delete + + wrap json { + message_key message + time_key time + time_format rfc3339 + } + } + } + + encode gzip zstd + + # Provide dynamic Keycloak config. + handle /auth-config { + header { + Content-Type application/json + } + respond 200 { + # Caddy docs say we can use heredocs here, but it doesn't work, and the caddy formatter + # doesn't like the newlines in the string, so we have to do it like this. + body ` { + "url": "{$KEYCLOAK_URL}", + "realm": "{$KEYCLOAK_REALM}", + "client": "{$KEYCLOAK_CLIENT_ID}" + } + ` + } + } + + # Proxy the Cron Mon API. + handle /api/* { + reverse_proxy {$API_HOST} + } + + # Serve up the frontend application. + handle { + root * /srv + try_files {path}.html {path} /index.html + file_server + } +} diff --git a/app/src/composables/__test__/auth.spec.ts b/app/src/composables/__test__/auth.spec.ts index ba9815c..c76c55b 100644 --- a/app/src/composables/__test__/auth.spec.ts +++ b/app/src/composables/__test__/auth.spec.ts @@ -51,6 +51,23 @@ const TestComponent = defineComponent({ } }) +function mountTestComponent(protectedRoutes: string[]) { + return mount(TestComponent, { + props: { + protectedRoutes + }, + global: { + provide: { + $authConfig: { + url: 'http://keycloak', + realm: 'cron-mon-io', + client: 'cron-mon' + } + } + } + }) +} + describe('useAuth composable when user not previously authenticated', () => { interface MockKeycloak { init: () => Promise<{ authenticated: boolean }> @@ -99,11 +116,7 @@ describe('useAuth composable when user not previously authenticated', () => { it('does not log in user when on unprotected route', async () => { vi.useFakeTimers() - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/foo'] - } - }) + const wrapper = mountTestComponent(['/foo']) await flushPromises() expect(mockKeycloak.init).toHaveBeenCalled() @@ -119,11 +132,7 @@ describe('useAuth composable when user not previously authenticated', () => { }) it('logs in user when on protected route', async () => { - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/protected'] - } - }) + const wrapper = mountTestComponent(['/protected']) await flushPromises() expect(mockKeycloak.init).toHaveBeenCalled() @@ -136,11 +145,7 @@ describe('useAuth composable when user not previously authenticated', () => { }) it('logs in user when navigating to protected route', async () => { - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/foo'] - } - }) + const wrapper = mountTestComponent(['/foo']) await flushPromises() expect(mockKeycloak.init).toHaveBeenCalled() @@ -163,11 +168,7 @@ describe('useAuth composable when user not previously authenticated', () => { it('handles login failure', async () => { mockKeycloak.login = vi.fn().mockRejectedValue(new Error('Login failed')) - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/protected'] - } - }) + const wrapper = mountTestComponent(['/protected']) await flushPromises() expect(mockKeycloak.init).toHaveBeenCalled() @@ -176,11 +177,7 @@ describe('useAuth composable when user not previously authenticated', () => { }) it('isReady when immediately ready', async () => { - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/protected'] - } - }) + const wrapper = mountTestComponent(['/protected']) await flushPromises() await wrapper.vm.isReady() @@ -195,11 +192,7 @@ describe('useAuth composable when user not previously authenticated', () => { }, async () => { vi.useFakeTimers() - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/protected'] - } - }) + const wrapper = mountTestComponent(['/protected']) await flushPromises() const prom = wrapper.vm.isReady() @@ -211,6 +204,16 @@ describe('useAuth composable when user not previously authenticated', () => { } ) }) + + it('throws error when no auth config provided', async () => { + await expect(() => { + mount(TestComponent, { + props: { + protectedRoutes: ['/protected'] + } + }) + }).toThrowError('AuthConfig not provided') + }) }) describe('useAuth composable when user is previously authenticated', () => { @@ -253,11 +256,7 @@ describe('useAuth composable when user is previously authenticated', () => { }) it('authenticates user without making them login again', async () => { - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/protected'] - } - }) + const wrapper = mountTestComponent(['/protected']) expect(wrapper.vm.user).toBeNull() @@ -275,11 +274,7 @@ describe('useAuth composable when user is previously authenticated', () => { it('refreshes token', async () => { vi.useFakeTimers() - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/protected'] - } - }) + const wrapper = mountTestComponent(['/protected']) await flushPromises() const initialToken = wrapper.vm.getToken() @@ -304,11 +299,7 @@ describe('useAuth composable when user is previously authenticated', () => { async () => { vi.useFakeTimers() - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/protected'] - } - }) + const wrapper = mountTestComponent(['/protected']) await flushPromises() const initialToken = wrapper.vm.getToken() @@ -333,11 +324,7 @@ describe('useAuth composable when user is previously authenticated', () => { { protectedRoutes: ['/protected'], redirectUri: 'http://cron-mon.io' }, { protectedRoutes: ['/foo'], redirectUri: 'http://cron-mon.io/protected' } ])('logs out user', async ({ protectedRoutes, redirectUri }) => { - const wrapper = mount(TestComponent, { - props: { - protectedRoutes - } - }) + const wrapper = mountTestComponent(protectedRoutes) await flushPromises() await wrapper.vm.logout() @@ -348,11 +335,7 @@ describe('useAuth composable when user is previously authenticated', () => { }) it('opens account management', async () => { - const wrapper = mount(TestComponent, { - props: { - protectedRoutes: ['/protected'] - } - }) + const wrapper = mountTestComponent(['/protected']) await flushPromises() await wrapper.vm.openAccountManagement() diff --git a/app/src/composables/auth.ts b/app/src/composables/auth.ts index 24722bb..6d15b68 100644 --- a/app/src/composables/auth.ts +++ b/app/src/composables/auth.ts @@ -1,7 +1,9 @@ import Keycloak from 'keycloak-js' -import { ref, type Ref, type ComputedRef, onMounted, computed } from 'vue' +import { ref, type Ref, type ComputedRef, onMounted, computed, inject } from 'vue' import { useRouter, useRoute, type RouteLocationNormalizedGeneric } from 'vue-router' +import type { AuthConfig } from '@/utils/config' + export interface AuthenticatedUser { firstName: string lastName: string @@ -18,11 +20,7 @@ export interface Auth { } export function useAuth(protectedRoutes: string[]): Auth { - const keycloak = new Keycloak({ - url: 'http://127.0.0.1:8080', - realm: 'cron-mon-io', - clientId: 'cron-mon' - }) + const keycloak = setupKeycloak() let ready = false const router = useRouter() @@ -88,6 +86,19 @@ export function useAuth(protectedRoutes: string[]): Auth { : null } + function setupKeycloak(): Keycloak { + console.log('Getting authConfig...') + const authConfig = inject('$authConfig') + if (!authConfig) { + throw new Error('AuthConfig not provided') + } + return new Keycloak({ + url: authConfig.url, + realm: authConfig.realm, + clientId: authConfig.client + }) + } + return { isAuthenticated: computed(() => keycloak.authenticated), user, diff --git a/app/src/main.ts b/app/src/main.ts index 951554a..13c76b2 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -17,6 +17,7 @@ import { aliases, mdi } from 'vuetify/iconsets/mdi' import App from '@/App.vue' import router from '@/router' +import { getAuthConfig } from './utils/config' const vuetify = createVuetify({ components, @@ -30,17 +31,20 @@ const vuetify = createVuetify({ } }) -createApp(App) - .use(router) - .use(vuetify) - .use(VueCookies) - .use(hljsVuePlugin) - // Adhoc plugin to make the API docs view more testable. - .use({ - install(app: VueApp) { - app.component('ApiReference', ApiReference) - } - }) - .provide('$localStorage', localStorage) - .provide('$clipboard', navigator.clipboard) - .mount('#app') +getAuthConfig().then((config) => { + createApp(App) + .use(router) + .use(vuetify) + .use(VueCookies) + .use(hljsVuePlugin) + // Adhoc plugin to make the API docs view more testable. + .use({ + install(app: VueApp) { + app.component('ApiReference', ApiReference) + } + }) + .provide('$localStorage', localStorage) + .provide('$clipboard', navigator.clipboard) + .provide('$authConfig', config) + .mount('#app') +}) diff --git a/app/src/repos/__tests__/api-key-repo.spec.ts b/app/src/repos/__tests__/api-key-repo.spec.ts index 192e63a..9d7d9bb 100644 --- a/app/src/repos/__tests__/api-key-repo.spec.ts +++ b/app/src/repos/__tests__/api-key-repo.spec.ts @@ -46,7 +46,7 @@ describe('MonitorApiKeyRepositoryRepository', () => { it("getKeys() when there aren't any API keys", async () => { server.use( - http.get('http://127.0.0.1:8000/api/v1/keys', () => { + http.get('/api/v1/keys', () => { return HttpResponse.json({ data: [], paging: { total: 0 } diff --git a/app/src/repos/__tests__/monitor-repo.spec.ts b/app/src/repos/__tests__/monitor-repo.spec.ts index 6083133..d6b0c87 100644 --- a/app/src/repos/__tests__/monitor-repo.spec.ts +++ b/app/src/repos/__tests__/monitor-repo.spec.ts @@ -79,7 +79,7 @@ describe('MonitorRepository', () => { it("getMonitorInfos() when there aren't any monitors", async () => { server.use( - http.get('http://127.0.0.1:8000/api/v1/monitors', () => { + http.get('/api/v1/monitors', () => { return HttpResponse.json({ data: [], paging: { total: 0 } diff --git a/app/src/repos/api-repo.ts b/app/src/repos/api-repo.ts index a01caa6..9cb1e60 100644 --- a/app/src/repos/api-repo.ts +++ b/app/src/repos/api-repo.ts @@ -3,8 +3,7 @@ type ApiResponse = { } export class ApiRepository { - // TODO: Put API URL in env. - protected readonly baseUrl = 'http://127.0.0.1:8000' + private readonly baseUrl = import.meta.env.VITE_API_HOST protected readonly getAuthToken: () => string constructor(getAuthToken: () => string) { @@ -30,7 +29,7 @@ export class ApiRepository { let response: Response | null = null try { - response = await fetch(this.baseUrl + route, opts) + response = await fetch(`${this.baseUrl}${route}`, opts) } catch (e) { console.error('Failed to connect to the CronMon API:', e) throw new Error('Failed to connect to the CronMon API.') diff --git a/app/src/utils/__tests__/config.spec.ts b/app/src/utils/__tests__/config.spec.ts new file mode 100644 index 0000000..fa30be0 --- /dev/null +++ b/app/src/utils/__tests__/config.spec.ts @@ -0,0 +1,79 @@ +import { afterAll, afterEach, beforeAll, describe, it, expect } from 'vitest' +import { setupServer } from 'msw/node' +import { HttpResponse, http } from 'msw' + +import { getAuthConfig } from '../config' + +describe('getAuthConfig', () => { + const server = setupServer( + ...[ + http.get('/auth-config', () => { + return HttpResponse.json({ + url: 'http://localhost:8080', + realm: 'test', + client: 'test' + }) + }) + ] + ) + + // Start server before all tests + beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) + + // Close server after all tests + afterAll(() => server.close()) + + // Reset handlers after each test `important for test isolation` + afterEach(() => server.resetHandlers()) + + it('works as expected', async () => { + const config = await getAuthConfig() + expect(config).toEqual({ + url: 'http://localhost:8080', + realm: 'test', + client: 'test' + }) + }) + + it('throws an error when fetch fails', async () => { + server.use( + http.get('/auth-config', () => { + return new HttpResponse(null, { status: 404, statusText: 'Failed to fetch' }) + }) + ) + + await expect(getAuthConfig()).rejects.toThrowError('Failed to fetch auth config') + }) + + it('throws an error when response is not JSON', async () => { + server.use( + http.get('/auth-config', () => { + return HttpResponse.text('Not JSON') + }) + ) + + await expect(getAuthConfig()).rejects.toThrowError() + }) + + it('throws an error when response is missing required fields', async () => { + server.use( + http.get('/auth-config', () => { + return HttpResponse.json({}) + }) + ) + + await expect(getAuthConfig()).rejects.toThrowError('Invalid auth config') + }) +}) + +describe('getAuthConfig in development mode', () => { + it('returns the correct config', async () => { + import.meta.env.MODE = 'development' + const config = await getAuthConfig() + expect(config).toEqual({ + url: 'http://keycloak', + realm: 'keycloak-realm', + client: 'keycloak-client-id' + }) + }) +}) diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts new file mode 100644 index 0000000..bd0af88 --- /dev/null +++ b/app/src/utils/config.ts @@ -0,0 +1,37 @@ +export type AuthConfig = { + url: string + realm: string + client: string +} + +export async function getAuthConfig(): Promise { + // If we running in dev mode, there won't be an /auth-config endpoint. + if (import.meta.env.MODE === 'development') { + return { + url: import.meta.env.VITE_KEYCLOAK_URL, + realm: import.meta.env.VITE_KEYCLOAK_REALM, + client: import.meta.env.VITE_KEYCLOAK_CLIENT_ID + } + } + + const response = await fetch('/auth-config') + if (!response.ok) { + throw new Error('Failed to fetch auth config') + } + + const config = await response.json() + validateAuthConfig(config) + return config +} + +function validateAuthConfig(config: any): void { + if ( + !( + typeof config.url === 'string' && + typeof config.realm === 'string' && + typeof config.client === 'string' + ) + ) { + throw new Error('Invalid auth config') + } +} diff --git a/app/src/utils/testing/test-api.ts b/app/src/utils/testing/test-api.ts index 4a0b541..d03c726 100644 --- a/app/src/utils/testing/test-api.ts +++ b/app/src/utils/testing/test-api.ts @@ -144,13 +144,13 @@ export function setupTestAPI(expectedToken: string): SetupServer { return setupServer( ...[ - http.get('http://127.0.0.1:8000/api/v1/docs/openapi.yaml', () => { + http.get('/api/v1/docs/openapi.yaml', () => { return new HttpResponse('openapi: 3.0.0', { status: 201, headers: { 'Content-Type': 'application/yaml' } }) }), - http.get('http://127.0.0.1:8000/api/v1/monitors', ({ request }) => { + http.get('/api/v1/monitors', ({ request }) => { return ( assertAuth(request) || HttpResponse.json({ @@ -168,7 +168,7 @@ export function setupTestAPI(expectedToken: string): SetupServer { }) ) }), - http.get('http://127.0.0.1:8000/api/v1/monitors/:monitorId', ({ request, params }) => { + http.get('/api/v1/monitors/:monitorId', ({ request, params }) => { const authErroResponse = assertAuth(request) if (authErroResponse) { return authErroResponse @@ -200,7 +200,7 @@ export function setupTestAPI(expectedToken: string): SetupServer { } }) }), - http.post('http://127.0.0.1:8000/api/v1/monitors', async ({ request }) => { + http.post('/api/v1/monitors', async ({ request }) => { const authErroResponse = assertAuth(request) if (authErroResponse) { return authErroResponse @@ -228,44 +228,41 @@ export function setupTestAPI(expectedToken: string): SetupServer { } }) }), - http.patch( - 'http://127.0.0.1:8000/api/v1/monitors/:monitorId', - async ({ params, request }) => { - const authErroResponse = assertAuth(request) - if (authErroResponse) { - return authErroResponse - } + http.patch('/api/v1/monitors/:monitorId', async ({ params, request }) => { + const authErroResponse = assertAuth(request) + if (authErroResponse) { + return authErroResponse + } - const { monitorId } = params - const monitor = monitors.find((m) => m.monitor_id === monitorId) + const { monitorId } = params + const monitor = monitors.find((m) => m.monitor_id === monitorId) - if (!monitor) { - return HttpResponse.json( - { - error: { - code: 404, - reason: 'Not Found', - description: 'The requested resource could not be found.' - } - }, - { status: 404 } - ) - } + if (!monitor) { + return HttpResponse.json( + { + error: { + code: 404, + reason: 'Not Found', + description: 'The requested resource could not be found.' + } + }, + { status: 404 } + ) + } - const body = (await request.json()) as MonitorSummary + const body = (await request.json()) as MonitorSummary - return HttpResponse.json({ - data: { - monitor_id: monitor.monitor_id, - name: body.name, - expected_duration: body.expected_duration, - grace_duration: body.grace_duration, - jobs: monitor.jobs - } - }) - } - ), - http.delete('http://127.0.0.1:8000/api/v1/monitors/:monitorId', ({ request, params }) => { + return HttpResponse.json({ + data: { + monitor_id: monitor.monitor_id, + name: body.name, + expected_duration: body.expected_duration, + grace_duration: body.grace_duration, + jobs: monitor.jobs + } + }) + }), + http.delete('/api/v1/monitors/:monitorId', ({ request, params }) => { const authErroResponse = assertAuth(request) if (authErroResponse) { return authErroResponse @@ -291,7 +288,7 @@ export function setupTestAPI(expectedToken: string): SetupServer { return new HttpResponse(null, { status: 200 }) }), - http.get('http://127.0.0.1:8000/api/v1/keys', ({ request }) => { + http.get('/api/v1/keys', ({ request }) => { return ( assertAuth(request) || HttpResponse.json({ @@ -301,7 +298,7 @@ export function setupTestAPI(expectedToken: string): SetupServer { ) }), - http.post('http://127.0.0.1:8000/api/v1/keys', async ({ request }) => { + http.post('/api/v1/keys', async ({ request }) => { const authErroResponse = assertAuth(request) if (authErroResponse) { return authErroResponse @@ -325,7 +322,7 @@ export function setupTestAPI(expectedToken: string): SetupServer { } }) }), - http.delete('http://127.0.0.1:8000/api/v1/keys/:keyId', ({ request, params }) => { + http.delete('/api/v1/keys/:keyId', ({ request, params }) => { const authErroResponse = assertAuth(request) if (authErroResponse) { return authErroResponse diff --git a/app/src/views/docs/ApiView.vue b/app/src/views/docs/ApiView.vue index d0154d5..a6902ad 100644 --- a/app/src/views/docs/ApiView.vue +++ b/app/src/views/docs/ApiView.vue @@ -7,20 +7,12 @@