diff --git a/.dockerignore b/.dockerignore index 718cd06..4bd7ec3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,9 +20,9 @@ !directory_spec.md !docs/**/*.md -# Tests -nodejs/tests/ -nodejs/*.test.js +# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly) +# nodejs/tests/ +# nodejs/*.test.js # Host dependency tree — let the image run a clean `npm ci`. Also avoids # copying platform-wrong native modules (e.g. bcrypt built for the host OS). diff --git a/Dockerfile.test-runner b/Dockerfile.test-runner new file mode 100644 index 0000000..939b420 --- /dev/null +++ b/Dockerfile.test-runner @@ -0,0 +1,50 @@ +# Test-runner image for SSO Manager. +# +# Installs all dependencies (including dev) and bundles the app code plus +# the seed script. The entrypoint waits for LDAP + Redis, seeds the test +# user, then runs whatever command is given (default: npm test). + +FROM node:20-alpine + +# Install OpenLDAP clients (ldapadd, ldapsearch) and bash for the seed script +RUN apk add --no-cache openldap-clients bash + +WORKDIR /app + +# Copy and install dependencies (including devDependencies for jest/supertest) +COPY nodejs/package*.json ./ +RUN npm ci + +# Copy the application source +COPY nodejs/app.js ./ +COPY nodejs/bin ./bin +COPY nodejs/conf ./conf +COPY nodejs/controller ./controller +COPY nodejs/middleware ./middleware +COPY nodejs/models ./models +COPY nodejs/routes ./routes +COPY nodejs/services ./services +COPY nodejs/utils ./utils +COPY nodejs/views ./views +COPY nodejs/public ./public +COPY nodejs/tests ./tests + +# SQLite database directory (config/inventory.sqlite for Resource model's ORM) +RUN mkdir -p /app/config + +# Files expected at the flattened /app path (see Dockerfile.openldap notes) +COPY tos.md /tos.md +COPY README.md /README.md +COPY CHANGELOG.md /CHANGELOG.md +COPY DEPLOYMENT.md /DEPLOYMENT.md +COPY API.md /API.md +COPY directory_spec.md /directory_spec.md +COPY docs /docs + +# Seed script and utility +COPY test_seed.js ./test_seed.js +COPY test/seed-test-user.sh /usr/local/bin/seed-test-user +RUN chmod +x /usr/local/bin/seed-test-user + +# Default command: seed the test user, then run the test suite +CMD ["sh", "-c", "seed-test-user && npm test"] diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..cb40f4c --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,81 @@ +# Docker Compose for running the SSO Manager test suite. +# +# Spins up: +# ldap — OpenLDAP + Redis (all-in-one image, slapd + redis only, no app) +# redis — Standalone Redis for the app's model/token storage +# test-runner — Seeds the test user, then runs `npm test` +# +# Usage: +# docker compose -f docker-compose.test.yml up --build +# # Or to run a specific test file: +# docker compose -f docker-compose.test.yml run --rm test-runner npx jest tests/auth.test.js +# +# The LDAP service uses the same Dockerfile.openldap image as production but +# overrides the command to only start slapd + redis (the entrypoint handles +# slapd.conf generation, directory initialization, and Redis startup before +# running the given command — "sleep infinity" keeps it alive). +# +# The test-runner connects to ldap:389 and redis:6379 via Docker networking. +# app_* env vars override conf/secrets.js (highest precedence in +# @simpleworkjs/conf), so the production secrets.js is never read. + +services: + ldap: + build: + context: . + dockerfile: Dockerfile.openldap + environment: + - LDAP_BASE_DN=dc=test,dc=local + - LDAP_ADMIN_PASS=secret + - ORG_NAME=Test SSO + # The entrypoint starts slapd + redis, then runs whatever command is given. + # "sleep infinity" keeps the container alive so the test-runner can connect. + command: ["sleep", "infinity"] + healthcheck: + test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"] + interval: 2s + timeout: 3s + retries: 20 + start_period: 5s + volumes: + - ldap-data:/var/lib/ldap + - ldap-certs:/etc/openldap/certs + + redis: + image: redis:7-alpine + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 2s + timeout: 3s + retries: 15 + + test-runner: + build: + context: . + dockerfile: Dockerfile.test-runner + environment: + # Tell the app which environment it's in (loads conf/test.js for Redis prefix) + - NODE_ENV=test + # LDAP — point at the ldap service container + - app_ldap__url=ldap://ldap:389 + - app_ldap__bindDN=cn=admin,dc=test,dc=local + - app_ldap__bindPassword=secret + - app_ldap__userBase=ou=people,dc=test,dc=local + - app_ldap__groupBase=ou=groups,dc=test,dc=local + # Redis — point at the redis service container + - app_redis__redisConf__url=redis://redis:6379 + # Also used by tests/globalSetup.js (direct Redis client, not @simpleworkjs/conf) + - REDIS_URL=redis://redis:6379 + # JWT secret (required by the app, not sensitive in test) + - app_oauth__jwtSecret=test-jwt-secret-for-testing-only + # App name + - app_name=Test SSO + depends_on: + ldap: + condition: service_healthy + redis: + condition: service_healthy + +volumes: + ldap-data: + ldap-certs: diff --git a/nodejs/tests/globalSetup.js b/nodejs/tests/globalSetup.js index 1737431..a90a0db 100644 --- a/nodejs/tests/globalSetup.js +++ b/nodejs/tests/globalSetup.js @@ -5,7 +5,8 @@ const { createClient } = require('redis'); module.exports = async function() { - const client = createClient(); + const redisUrl = process.env.REDIS_URL || undefined; + const client = createClient(redisUrl ? { url: redisUrl } : {}); await client.connect(); const keys = await client.keys('sso_manager_test_*'); diff --git a/nodejs/tests/setup.js b/nodejs/tests/setup.js index 94bc528..b015af0 100644 --- a/nodejs/tests/setup.js +++ b/nodejs/tests/setup.js @@ -3,6 +3,19 @@ const crypto = require('crypto'); const request = require('supertest'); const app = require('../app'); +const { initORM } = require('../models'); + +beforeAll(async () => { + await initORM(); + const { Token } = require('../models/token'); + try { + if (Token.orm) { + await Token.orm.adapter(Token).Table.redisClient.flushDb(); + } + } catch (e) { + console.warn('Could not flush Redis:', e.message); + } +}); const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' }; diff --git a/test/seed-test-user.sh b/test/seed-test-user.sh new file mode 100644 index 0000000..e7aad18 --- /dev/null +++ b/test/seed-test-user.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# seed-test-user.sh — Create the test user in LDAP for the test suite. +# +# The test suite (tests/setup.js) logs in as uid=test / password=MyTestPassword!2. +# This script creates that user in the LDAP directory along with its personal +# posixGroup, and adds it to the app_sso_admin group so admin-gated tests pass. +# +# Environment variables (from docker-compose.test.yml): +# LDAP_HOST — LDAP server hostname (default: ldap) +# LDAP_PORT — LDAP server port (default: 389) +# BIND_DN — LDAP admin bind DN (default: cn=admin,dc=test,dc=local) +# BIND_PW — LDAP admin password (default: secret) +# BASE_DN — LDAP base DN (default: dc=test,dc=local) + +set -euo pipefail + +LDAP_HOST="${LDAP_HOST:-ldap}" +LDAP_PORT="${LDAP_PORT:-389}" +BIND_DN="${BIND_DN:-cn=admin,dc=test,dc=local}" +BIND_PW="${BIND_PW:-secret}" +BASE_DN="${BASE_DN:-dc=test,dc=local}" + +LDAP_URI="ldap://${LDAP_HOST}:${LDAP_PORT}" +USER_UID="test" +USER_PASSWORD="MyTestPassword!2" + +info() { echo "[INFO] $*"; } +error() { echo "[ERROR] $*" >&2; } + +# ── Wait for LDAP to be reachable ──────────────────────────────────────────── +info "Waiting for LDAP at ${LDAP_URI}..." +for i in $(seq 1 30); do + if ldapsearch -x -H "$LDAP_URI" -b '' -s base '(objectClass=*)' >/dev/null 2>&1; then + info "LDAP is reachable" + break + fi + if [ "$i" -eq 30 ]; then + error "LDAP not reachable after 30 attempts" + exit 1 + fi + sleep 1 +done + +# ── Check if the test user already exists ──────────────────────────────────── +if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \ + -b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then + info "Test user '${USER_UID}' already exists — skipping seed" + exit 0 +fi + +# ── Generate the SSHA512 password hash ─────────────────────────────────────── +# Inline the hash function to avoid requiring the full model chain (which +# tries to connect to Redis/LDAP during module loading and would hang). +info "Generating password hash..." +PASSWORD_HASH=$(node -e " + const crypto = require('crypto'); + const salt = crypto.randomBytes(8); + const hash = crypto.createHash('sha512').update('${USER_PASSWORD}').update(salt).digest(); + console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64')); +") + +info "Password hash generated" + +# ── Create a temporary LDIF file ───────────────────────────────────────────── +TMPFILE=$(mktemp /tmp/seed-test-user.XXXXXX) +trap 'rm -f "$TMPFILE"' EXIT + +cat > "$TMPFILE" << LDIF +# Personal posixGroup for the test user +dn: cn=${USER_UID},ou=groups,${BASE_DN} +objectClass: posixGroup +objectClass: top +cn: ${USER_UID} +gidNumber: 1500 +description: Personal group for test user + +# Test user posixAccount +dn: cn=${USER_UID},ou=people,${BASE_DN} +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: top +objectClass: theta42Person +objectClass: ldapPublicKey +objectClass: sudoRole +cn: ${USER_UID} +sn: Test +uid: ${USER_UID} +uidNumber: 1500 +gidNumber: 1500 +homeDirectory: /home/${USER_UID} +loginShell: /bin/bash +mail: test@test.local +userPassword: ${PASSWORD_HASH} +description: Test user for automated test suite +sudoHost: ALL +sudoCommand: ALL +sudoUser: ${USER_UID} +LDIF + +# ── Add the entries to LDAP ────────────────────────────────────────────────── +info "Creating test user '${USER_UID}' in LDAP..." +ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true +if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \ + -b "cn=${USER_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then + info "Test user '${USER_UID}' exists or was created" +else + error "Failed to create test user '${USER_UID}'" + exit 1 +fi + +# ── Add the test user to required SSO groups ───────────────────────────────── +info "Adding test user to SSO admin groups..." +for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do + ldapmodify -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" << EOF 2>/dev/null || true +dn: cn=${group},ou=groups,${BASE_DN} +changetype: modify +add: member +member: cn=${USER_UID},ou=people,${BASE_DN} +EOF +done +info "Test user added to SSO admin groups" + +# ── Create additional users needed by tests ───────────────────────────────── +# wmantly is referenced by OTP (otp.test.js) and impersonation (impersonate.test.js) +# tests as an existing non-admin user. +WMANTLY_UID="wmantly" +if ! ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \ + -b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then + + info "Creating additional test user '${WMANTLY_UID}'..." + + # Generate password hash for wmantly + WMANTLY_HASH=$(node -e " + const crypto = require('crypto'); + const salt = crypto.randomBytes(8); + const hash = crypto.createHash('sha512').update('testpass').update(salt).digest(); + console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64')); + ") + + cat > "$TMPFILE" << LDIF +dn: cn=${WMANTLY_UID},ou=groups,${BASE_DN} +objectClass: posixGroup +objectClass: top +cn: ${WMANTLY_UID} +gidNumber: 1501 + +dn: cn=${WMANTLY_UID},ou=people,${BASE_DN} +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: top +objectClass: theta42Person +objectClass: ldapPublicKey +objectClass: sudoRole +cn: ${WMANTLY_UID} +sn: Mantly +uid: ${WMANTLY_UID} +uidNumber: 1501 +gidNumber: 1501 +homeDirectory: /home/${WMANTLY_UID} +loginShell: /bin/bash +mail: wmantly@test.local +userPassword: ${WMANTLY_HASH} +sudoHost: ALL +sudoCommand: ALL +sudoUser: ${WMANTLY_UID} +LDIF + ldapadd -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" -f "$TMPFILE" 2>/dev/null || true + if ldapsearch -x -H "$LDAP_URI" -D "$BIND_DN" -w "$BIND_PW" \ + -b "cn=${WMANTLY_UID},ou=people,${BASE_DN}" -s base '(objectClass=*)' >/dev/null 2>&1; then + info "Additional test user '${WMANTLY_UID}' exists or was created" + else + error "Failed to create additional test user '${WMANTLY_UID}'" + exit 1 + fi +fi + +info "Seed complete — all test users are ready"